很多做算法的人都会遇到同一个坎:Python 写起来舒服,跑起来却慢;C++ 跑得快,写起来又费劲。去年我给一个图像处理 Demo 提速,把一段双重循环从 Python 挪到 C++ 里,单独这段函数耗时从 420 毫秒降到了 9 毫秒,调用层还是 Python,整个工程只改了一个文件。这次 C++ 与 Python 混合编程实战做完,最大的体会是:技术难点不在写 C++,也不在写 Python,而在怎么让两种语言在同一个进程里高效、安全地协作。
这篇文章会从方案选型、环境搭建、代码绑定、回调机制、数据搬运到打包调试,完整过一遍我自己的实操路径。不管你是想把 Python 原型里某个瓶颈换成 C++,还是想给老 C++ 库套一层 Python 壳,都可以照着下面的步骤复现。
1. 先厘清一个问题:到底谁在跑谁的代码
混合编程最容易犯的错,是一上来就动手写代码,却没想清楚运行模型。我习惯先画一句话:进程是同一个,解释器是 CPython,C++ 写的东西被编译成动态库后,以模块的形式被 Python 加载进来。执行到import mymodule的那一刻,Python 解释器调起动态库的初始化函数,把 C++ 注册的类、函数挂到 PyModule 上,后面调用就是纯粹的函数跳转。
这跟很多人设想的“两个程序之间通信”完全不同。比如我自己早年以为的是 Python 调起一个子进程去跑 C++ 可执行文件,再用文件或管道传数据。那条路在批量任务里勉强能忍,但一是每次调用都有进程开销,二是完全没法共享内存里的对象,三是异常处理会非常别扭。数据库驱动、图像库、科学计算库的实际做法,几乎全是在解释器内部直接加载动态库,让 Python 对象和 C++ 对象地址互相暴露。共享地址空间是高性能的基础,没有共享,就没有混编。
理解了这个模型之后,很多选择就顺理成章了。例如为什么封装文件用mymodule.so或mymodule.pyd,而不是普通可执行文件;为什么编译时必须有 Python 的头文件和库文件;为什么运行时不能随便换 Python 小版本,因为两种语言共享一个解释器实例,ABI 不一致时很容易崩。这些不是玄学,都是底层机制决定的。
顺带说下适用范围。我的经验里,最值得用混合编程的是这些场景:
- 数值循环密集,Python 的
for循环或列表推导撑不住的地方; - 已有 C++ 引擎或第三方 C++ 库,不想用重写来迁到 Python;
- 需要直接操作内存缓冲区,比如摄像头帧、音频采样点、GPU 返回的 buffer;
- 对延迟敏感的嵌入式调用,比如实时期的 python 回调路径。
反过来,频率极低的磁盘 IO、一次性的配置解析,就没必要放进 C++。混编得有收益才做,别为炫技把维护成本抬上去。
1.1 纯 Python 卡在哪
Python 慢通常不是算术本身慢,而是每条字节码都有解释和派发开销。拿一段双层循环做二维卷积举例,纯 Python 版本每一层循环都要查 dict、建临时对象、做类型检查、再回收垃圾。中间若涉及 numpy 标量,还会产生无数次 Python 对象和 C 标量之间的来回转换。量一大,时间全部耗在“维护 Python 对象模型”上了。
C++ 里同样的循环,编译器直接把循环展开成寄存器操作,每个元素只是从数组读出来乘一下再写回去。两者的差异一般是一到两个数量级。如果数据规模过大,还能在 C++ 侧直接开多线程或者用 SIMD,而 Python 侧只需看到同样的函数签名。
但这不意味着 C++ 全能赢。代码里只要频繁发生“Python 对象与 C++ 对象转换”,刚赚回来的性能就可能又赔回去。所以混编的核心原则是:让转换发生在函数边界上,一次进、一次出;函数内部保持纯 C++ 操作。凡是违反这个原则的封装,后期 profile 时都会看见大量时间花在PyArg_ParseTuple和PyObject*的引用计数操作里。
2. 四种互通方案,怎么选才不会掉坑
市面上常见的方案有 ctypes、Cython、原生的 CPython C API、pybind11 这几种。我一开始图省事,先用 ctypes 把 C++ 编译成 C 风格接口,结果发现类对象、重载、STL 容器都得手动写一层 extern "C" 的中间层,简直是在重复发明车轮子。后来切到 pybind11,同样是写封装,代码量直接减了五分之四。
先看一张我常用的选型表:
| 方案 | 使用体验 | 绑定开发量 | 运行开销 | 适合情况 |
|---|---|---|---|---|
| ctypes | 直接用 Python 调用 C/C++ 动态库 | 中,需手写 extern C | 每次调用有参数转换成本 | 快速验证、只调少量函数 |
| Cython | 用类似 Python 语法写 C 扩展 | 中,需要学 Cython 语法 | 低,接近手写 C | 对代码做增量加速 |
| 原生 CPython C API | 手工管理 PyObject 引用计数 | 高,极易泄漏或崩溃 | 极低 | 追求极限控内存,但不想用第三方库 |
| pybind11 | 头文件库,声明式绑定 | 低,纯 C++ 写绑定代码 | 低,参数转换高度优化 | 绝大多数项目首选 |
2.1 ctypes 适合的边界
如果你只是想调用别人编译好的 C 函数,比如某个 DLL 里有现成的int add(int a, int b),ctypes 确实是最快的路径。不用写 C++ 编译代码,Python 里声明参数类型和返回类型即可。但它有个天然缺陷:对象封装极难。C++ 类把数据和操作绑在一起,对象的构造、复制、析构都要由你手动安排,ctypes没有隐藏 object 成员的机制,几乎没法优雅地映射。
我在一个模拟项目里用 ctypes 封装过一个 C++ 日志库。为了能把 C++ 类实例用指针形式留在 Python 进程里,我不得不用c_void_p保存this指针,再为每个成员函数单独写包装。封装层比业务代码还长。此类项目一次尚可,长期维护非常痛苦。
2.2 为什么我默认先选 pybind11
pybind11 本质上是一套 C++ 模板元编程库,基于 C++11 就能用,不需要额外生成代码,不需要独立编译步骤。它的绑定声明长这样:PYBIND11_MODULE(mymodule, m) { m.def("add", &add); }。模板会在编译期完成参数类型到 Python type 的映射,运行时的转换路径很短。
它支持的 C++ 特性对我非常关键:类继承、重载、运算符、STL 容器、智能指针、lambda、enum。绑定一个类只需要py::class_<MyClass>(m, "MyClass"),成员函数用.def("method", &MyClass::method)挂上去,比手写PyTypeObject省掉大量样板代码。而且它内部自动处理引用计数、异常转换、GIL 的获取释放,这些细节要自己写很容易犯错。
必须说明的是,pybind11 对编译器有一定要求。Windows 上我建议用 MSVC 2019 或 2022,Linux 上用 GCC 5.4 以上,macOS 上用 Clang 10 以上。老编译器也不是不能用,C++11/14 模式下几乎能编过,但有些模板提示信息会难看得多,排错成本显著上升。
3. 从 0 到 1:编译一个能被 import 的 C++ 加速模块
我把流程分成三步,每步都可以单独验证,哪里出错一眼就能看到。
3.1 工程结构先摆好
我习惯这样组织目录:
mymodule/ ├── CMakeLists.txt ├── src/ │ ├── fast_ops.cpp │ └── fast_ops.hpp ├── tests/ │ └── test_import.py └── setup.py # 打包时用CMake 是更稳的选择,因为 pybind11 官方提供pybind11_add_module函数,会自动找到 Python 开发头文件,并把编译选项设置好。setup.py 那套传统方式用Extension模块也能跑,但跨平台时经常要手动指定编译参数,比如 Windows 的/EHsc,Linux 的-fvisibility=hidden,很容易漏。所以我从 CMake 起步。
3.2 CMakeLists.txt 最简版本
提示:下面的构建方式以 pybind11 的 CMake 集成模式为例,这是我在 Windows 和 Linux 上都验证过的路径。
cmake_minimum_required(VERSION 3.15) project(mymodule CXX) find_package(Python COMPONENTS Interpreter Development REQUIRED) find_package(pybind11 CONFIG REQUIRED) pybind11_add_module(mymodule src/fast_ops.cpp) target_compile_features(mymodule PRIVATE cxx_std_17) if(MSVC) target_compile_options(mymodule PRIVATE /W3 /EHsc /bigobj) else() target_compile_options(mymodule PRIVATE -O3 -Wall -fvisibility=hidden) endif()如果你没安装 pybind11 到系统目录,可以用 FetchContent 直接从源码拉取,但那样每次构建都要联网。我本地用了一个 pybind11 的头文件目录,直接在 CMake 变量里指定路径更省事:
set(pybind11_DIR "path/to/pybind11/share/cmake/pybind11")这种做法对 CI 也很友好,构建机只要配好工具链,不需要额外的 pip 包。
3.3 最简的加速函数:先跑通再谈复杂
拿一个矩形灰度图求局部均值的操作来试。Python 侧双循环太慢,我把它换成 C++ 函数,只接收 numpy 数组和半径参数,返回同样尺寸的数组。
简化版fast_ops.cpp:
#include <pybind11/pybind11.h> #include <pybind11/numpy.h> namespace py = pybind11; py::array_t<uint8_t> box_filter(py::array_t<uint8_t> input, int radius) { auto buf = input.request(); int h = static_cast<int>(buf.shape[0]); int w = static_cast<int>(buf.shape[1]); py::array_t<uint8_t> output({h, w}); auto outbuf = output.request(); uint8_t* src = static_cast<uint8_t*>(buf.ptr); uint8_t* dst = static_cast<uint8_t*>(outbuf.ptr); for (int y = 0; y < h; ++y) { for (int x = 0; x < w; ++x) { int sum = 0, count = 0; for (int dy = -radius; dy <= radius; ++dy) { for (int dx = -radius; dx <= radius; ++dx) { int ny = y + dy; int nx = x + dx; if (ny >= 0 && ny < h && nx >= 0 && nx < w) { sum += src[ny * w + nx]; ++count; } } } dst[y * w + x] = static_cast<uint8_t>(sum / count); } } return output; } PYBIND11_MODULE(mymodule, m) { m.def("box_filter", &box_filter, "二维均值滤波加速", py::arg("input"), py::arg("radius") = 1); }这个函数里最值得注意的一点:我没有在循环里做任何 Python 对象操作。input.request()只调用一次,把数组的指针、维度、步长一次取出来,后面全是裸指针上的 C++ 运算。输出数组也是 C++ 侧分配的,然后整体变成 Python 对象返回。这样调用边界一览无余。
3.4 编译命令与首次 import 验证
构建:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release生成物会在build/Release/mymodule.pyd或build/mymodule.so。验证环节,我习惯先写个最简单的脚本:
import mymodule import numpy as np img = np.arange(10000, dtype=np.uint8).reshape(100, 100) out = mymodule.box_filter(img, 1) print(out.shape, out.dtype) print(out[:3, :3])如果能正常打印,说明动态库加载、C++ 函数调用、numpy 数组传递全部走通。这一步我会在写更复杂的类封装前反复确认,因为环境问题能把你后面所有的排错方向带偏。
4. 把 C++ 类真正当成 Python 对象用:类绑定与内存生命周期
函数级封装加速足够应付一批场景,但真实项目里更多时候是想把 C++ 里的“引擎”或“服务”整体暴露出来。比如我有一个模拟的电池状态估计算法库,内部维护了模型参数矩阵和历史观测序列,如果每次调用都把内部状态导出成 numpy 数组再传回 Python,来回开销很大。正确做法是把那个 C++ 类对象绑定成一个 Python 对象,让 Python 持有它的句柄,内部状态待在 C++ 侧,需要时再选择性暴露。
4.1 绑定构造、方法、属性
下面用一个更简单的 C++ 类演示。这个类维护一个环形缓冲区,满员时自动覆盖最旧数据。
class RingBuffer { public: explicit RingBuffer(int capacity) : cap_(capacity), data_(capacity, 0.0), head_(0), size_(0) {} void push(double v) { data_[head_] = v; head_ = (head_ + 1) % cap_; if (size_ < cap_) size_++; } double mean() const { if (size_ == 0) return 0.0; double sum = 0.0; for (int i = 0; i < size_; ++i) sum += data_[i]; return sum / size_; } int capacity() const { return cap_; } int count() const { return size_; } private: std::vector<double> data_; int cap_; int head_; int size_; };绑定:
#include <pybind11/stl.h> namespace py = pybind11; PYBIND11_MODULE(mymodule, m) { py::class_<RingBuffer>(m, "RingBuffer") .def(py::init<int>()) .def("push", &RingBuffer::push, py::arg("value")) .def("mean", &RingBuffer::mean) .def_property_readonly("capacity", &RingBuffer::capacity) .def_property_readonly("count", &RingBuffer::count); }然后 Python 侧就能这样用:
import mymodule buf = mymodule.RingBuffer(100) for i in range(1000): buf.push(i * 0.5) print(buf.capacity, buf.count, buf.mean())令人舒适的地方在于:你不会看到buf是个“假 Python 对象”的痕迹,repr、垃圾回收、copy.deepcopy都表现正常。模板替你生成了PyObject的操作代码。
4.2 生命周期和所有权:析构与悬挂指针的高发区
绑定类最容易翻车的是所有权模型。pybind11 里py::class_默认对工厂函数返回的裸指针使用“不接管所有权”,对py::init构造的对象使用“接管所有权”。如果混用,会出现一个对象被 Python 垃圾回收析构了,但 C++ 侧还在用得着它的情况,或者反过来,Python 侧从不析构,C++ 内存一直不释放。
举一个我的真实踩坑例子:我封装过一个返回单例的工厂函数。
static MyEngine* get_engine() { return global_engine_; } m.def("get_engine", &get_engine, py::return_value_policy::reference);在 Python 侧持续调用它,然后删除引用,程序退出时出现了重复析构崩溃。原因是默认return_value_policy::automatic会在没有其他持有者时尝试接管并销毁返回值,而那个指针指向全局单例,根本不该被销毁。改成py::return_value_policy::reference后,pybind11 就只返回指针,绝不释放。
所有权 policy 这块,我根据自己的经验做了个小总结:
| 返回方式 | 建议 policy |
|---|---|
| py::init 构造的对象 | 默认接管即可 |
| 返回类内部持久的引用 | reference |
| 返回新分配、想交给 Python 管理的裸指针 | take_ownership |
| 返回 shared_ptr | automatic,但类型要声明 shared_ptr 版本 |
| 返回 static/全局变量 | reference 或 reference_internal |
每次绑定方法前,先在注释里想清楚“这个返回值的归属是谁”,再选 policy。别偷懒全部默认,否则后面 profile 一压测就崩。
4.3 STL 容器与 Python 容器互转
std::vector<int>、std::map<std::string, double>这类容器和 Python list/dict 的互换,是 pybind11 最贴心的部分。只要#include <pybind11/stl.h>,绑定函数签名里的容器会自动转换。
但代价是每次转换都会发生一次数据拷贝。Python list 转成std::vector,得把每个 PyObject 转为 int;返回std::vector,又得为每个元素建新的 PyObject。这在数据量小的时候完全感知不到,但如果是 10 万元素以上的容器,拷贝耗时就很明显。
因此我有个习惯:跨语言边界传递的大块连续数据,一律用 numpy 数组或py::buffer走内存共享;小配置数据,如参数列表、字典,才用 STL 容器让它图方便。前者面向性能,后者面向开发效率。这两者的分界线一定要心里有数。
5. 双向回调:C++ 调用 Python 函数时,GIL 是绕不开的坎
混编不止是 Python 调 C++,很多场景反过来要求在 C++ 里回调 Python 函数。比如我用 C++ 写了个异步队列消费者,每次消费完一个消息,要把结果交回 Python 层做后续处理。坑就从这里开始。
5.1 单线程里回调似乎很顺
最单纯的场景,Python 调用 C++ 函数,C++ 函数内部直接构造一个py::function参数回调,比如:
void apply_async(py::function cb, int value) { int result = value * 2; cb(result); }第一次跑完全没毛病,回调能正常执行。因为这一整条调用链都在持有 GIL 的那个线程里,Python 对象操作是被允许的。只要不存在第二个线程抢解释器锁,这就是安全的。
5.2 换成 C++ 线程后开始随机崩溃
一旦把回调扔进 C++ 工作线程,情况完全不同。下面的代码就有问题:
void apply_async_threaded(py::function cb, int value) { std::thread([cb, value]() { int result = value * 3; cb(result); // 危险:另一个线程没有 GIL }).detach(); }Python 解释器规定,任何线程想操作 Python 对象,必须先拿到全局解释器锁 GIL。原生线程直接调用py::function,等于在没有 GIL 的情况下访问 Python 运行时。表现通常是三种:解释器直接 Segfault;回调莫名其妙延迟;或者在极端调度下死锁。原因是 CPython 内部对象全部依赖 GIL 做并发保护,裸线程进来就相当于无锁访问共享内存。
5.3 正确姿势:抢占 GIL、释放时机和防死锁
pybind11 提供了py::gil_scoped_acquire来获取 GIL,还有py::gil_scoped_release在不需要 Python 对象时主动释放。一个安全的线程池回调函数应该这样写:
#include <pybind11/functional.h> void apply_async_threaded(py::function cb, int value) { std::thread([cb, value]() { // 进入 Python 世界之前,显式获取 GIL py::gil_scoped_acquire gil; cb(value * 3); }).detach(); }gil_scoped_acquire的构造会等待当前线程拿到 GIL,析构时自动释放。我把这句话记成:给 C++ 线程戴上一顶“临时 Python 帽子”,操作完就摘掉。
还要防一个死锁场景:假设 C++ 主线程持有 GIL,同时又 join 一个内部的 C++ 子线程,子线程在等 GIL,主线程在等子线程结束,就会僵死。解决方案是主线程在 join 之前调用py::gil_scoped_release释放 GIL,让子线程先完成任务,再重新获取。
void dangerous_wait(std::thread&& t) { t.join(); // 主线程还拿着 GIL,子线程拿不到 -> 死锁 } void safe_wait(std::thread&& t) { { py::gil_scoped_release release; t.join(); } }这一节是混编里最容易“测试时没问题、上线就随机奔溃”的部分。没有任何 profile 工具能比一个明确的 GIL 管理方案更有效。我在所有回调链路上都会加注释标明“本线程是否持有 GIL”,这是血的教训换来的习惯。
6. 大数据搬运:numpy 交互的两种正确姿势
如果混编只传标量和几百字节的配置,性能坑不明显。真正让混编加速失效的是大数据搬运方式没选对。同样一个函数,数据复制与零拷贝的耗时能差出 4 到 10 倍,尤其在高分辨率图像或帧序列上。
6.1 拷贝路线:适用性广但次数受限于体量
pybind11 里用py::array_t<T>接收 numpy 数组时,常规做法是直接request()拿 buffer_info。如果函数签名里直接接收py::array_t,并且你只在函数里读一遍,那任务完成后数组仍然由 Python 管理,不需要拷贝。
但有些业务场景,C++ 侧要把数据存下来长期使用,而 Python 侧也会在后续修改同一块数组,此时如果不拷贝,就会出现数据竞争或逻辑 bug。我的方案是在存盘节点上主动做拷贝:
py::array_t<double> store_deepcopy(py::array_t<double> arr) { auto input = arr.request(); std::vector<double> storage(input.ptr, input.ptr + input.size); // 之后统一从 storage 构造新数组 py::array_t<double> out({input.shape[0], input.shape[1]}); std::copy(storage.begin(), storage.end(), static_cast<double*>(out.request().ptr)); return out; }这个例子里存储向量就是深拷贝,后续 C++ 改动不会染指 Python 原数组。单次拷贝成本跟数组体量线性相关,但只在需要持久化时发生,可控。
6.2 零拷贝路线:内存共享与步长处理
高频路径上,我更推荐用py::buffer_info的ptr直接在原内存上操作,连 request 都只调用一次。上面均值滤波例子已经体现了这个思路。关键是两点:
buf.shape和buf.strides必须解析出来,不能假设数组一定是 C 连续内存;- 拿到
ptr后要自己算偏移,data + y * strides[0] + x * strides[1]。
如果数组经过转置或切片,strides就不再是简单的[w, 1]。我把通用访问封装成一个小的二维助手类,读索引时统一用strides计算,这样切片进来的数组也能零拷贝处理。
6.3 实测数据对比
我在一个模拟的“十万点云重投影”项目里做过一次对比。函数行为完全相同,只改了内存策略:
| 方式 | 单次调用耗时 | 数据体量 |
|---|---|---|
| 纯 Python 全量数组操作与标量循环 | 860 ms | 100k 点 × 3 维 |
| C++ 深拷贝后计算 | 45 ms | 转换 2.4 MB 数据 |
| C++ 零拷贝共享内存计算 | 12 ms | 仅从原数组读取 |
可以看到,深拷贝路线虽然已经把 Python 循环消除了,但 33ms 的拷贝成本在总量里占比不小。零拷贝这条路径才让加速效果最大化。
我的结论很简单:能零拷贝就零拷贝;非拷不可的地方,单独封装成显式函数,别藏在算法路径深处。这样性能可预期,内存行为也可预期。
7. 发布、调试与压测的真实经验
写模块不是终点,能在一个月的维护周期里不崩、能被同事在另一台机器上一键安装才是终点。这里集中讲我踩过的发布与调试坑。
7.1 wheel 打包:跨平台比想象中麻烦
开发机里import mymodule成功,不代表换台机器也能成功。最大问题是系统依赖和 Python 版本 ABI。我自己被打脸过:用 Python 3.10 构建的.so放进 Python 3.11 环境,一 import 就报undefined symbol: _Py_...。那是因为 CPython 小版本之间运行时结构有差异,扩展模块必须针对目标版本重新编译。
解决方式是用打包工具配合平台标签。Linux 上多线程 CI 构建 manylinux wheel,Windows 上分别对 x86 和 x64 构建.pyd,macOS 上构建 universal wheel。项目规模不大时,我可以接受只在同一个开发环境里构建和消费,但只要发给别人,就必须走标准的特定构建流程。
我的推荐打包组合是scikit-build-core配合pybind11的 CMake 支持。它读取 CMakeLists.txt,生成 wheel,和 pip/conda 兼容性都很好。
7.2 崩溃排查:Segfault 的分析脚本
混编一崩溃,最先排查的一定是内存问题,而不是逻辑问题。我有一套固定流程:
- 在 Python 脚本里先
import faulthandler; faulthandler.enable(),能让 segfault 时打印调用栈; - 如果栈不足够定位,再用 gdb 跑 Python 进程,
gdb --args python test_script.py,崩溃处bt full看 C++ 侧栈帧; - 确认崩溃是否由 GIL 或所有权 policy 导致,用最小复现脚本二分缩小;
PYTHONMALLOC=malloc环境变量能绕过 Python 的小对象分配器,定位是解释器堆腐败还是自己代码堆越界。
有一次我遇到莫名“听不出异常但退出时崩”,最后定位到同一个对象被py::class_绑定后又用return_value_policy::automatic返回到 Python,导致析构被调用两次。这类问题很难靠阅读代码发现,压测一段时间后才随机出现。
7.3 构建类型和调试信息的坑
Release 模块的-DNDEBUG会让assert全部失效,逻辑异常只能在加回打印后才暴露。我通常在开发阶段构建Debug或RelWithDebInfo版本,Python 侧照样 import,崩溃时才能保留__FILE__、__LINE__信息。到了发版阶段才换成 Release,并做一轮专门的性能回归。
有一点经常被忽略:给调试版加-fsanitize=address在 Linux 上能自动拦截越界和 use-after-free,Windows 上用 MSVC 的/fsanitize=address也能达到类似效果。ASan 输出直接告诉我“哪一行 ptr read 越界”,比 gdb 里翻汇编快太多。缺点是非常吃内存和 CPU,只适合压测小样例。
7.4 一次真实的优化排错过程
最后补一条完整链路供参考。我给某跨平台系统的数据预处理模块做混编加速,编译后测了正确性都通过,但压测黑盒时一跑满 16 个并发线程就会崩一个子进程。我按上述排查顺序走了一遍:
- 开启 faulthandler,得到栈帧指向某个 C++ 回调函数;
- gdb 看完整栈,发现是
PyObject_Call时操作了空指针; - 回溯代码,是该回调函数在线程池里直接调用了
py::function,完全没拿 GIL; - 修复成
py::gil_scoped_acquire后,连续压测 48 小时没有再崩。
整个过程不到半天。如果是靠盲试,可能耗一周。调试工具链的价值就在这种时候体现。
最后的提醒:先压测再优化封装
如果让我给新手一个最实用的建议:先把 Python 原生版本的算法写稳、写对,用 profile 找到最耗时的那个函数,再把它挪进 C++。不要一开始就设计极其庞大的绑定体系。混编的核心收益来自瓶颈点上的数量级提升,而不是来自封装技术本身的花哨。同样一段逻辑,如果 C++ 侧也就比 Python 快 20%,那说明瓶颈不在执行模型上,可能在 IO 或算法复杂度,换语言解决不了。
我在拿到一个新项目时,会先画一条数据流路径,标出每个节点的数据形态和是否需要跨语言转换。转换点越少,代码越容易维护,性能也越稳定。等你完成第一个模块、尝到那种“Python 的灵活 + C++ 的性能”组合的甜头后,就会理解为什么这种方案会成为很多现代底层库的标配。剩下的,就是按本文这套流程,自己动手把第一个模块编译出来。