ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++与Python混合编程:ctypes、C API与pybind11选型指南

2026/9/18 21:38:40 拓冰建站 浏览量
C++与Python混合编程:ctypes、C API与pybind11选型指南 1. 从被逼着做绑定说起为什么需要这篇选型指南先交代一下背景。我最早接触 C 与 Python 混合编程纯粹是被逼的。当时手里的项目是一个用 C 写的实时信号处理引擎算法层跑得飞快但业务方和数据分析团队只认 Python。他们的诉求很简单把 C 引擎的核心接口暴露给 Python让算法工程师能直接import engine然后像调用 NumPy 函数一样去调用底层算法。听起来简单真做起来才发现这里面的门道比想象中深得多——网上随手一搜跳出来的解决方案主要有三套ctypes、Python 官方提供的 C API以及pybind11。三套方案各有拥趸社区里吵得不可开交有人说 ctypes 最简单有人说 pybind11 才是现代 C 项目的标配还有人坚持直接用 C API 最正宗。结果就是选择困难症直接发作。这篇文章我想做的不是替你把结论拍死而是把这三条技术路线的底层逻辑、适用边界、实际编码时的体感差异以及我在真实项目里踩过的坑全部摊开来讲清楚。适合正在做技术选型的 C 工程师也适合写 Python 但需要调用底层库的算法工程师。读完之后你应该能根据自己项目的具体情况做出一个有理有据的决定而不是靠搜索引擎的热度投票。在开始之前先给一个我认为最精炼的概括ctypes 是Python 单方面访问 C 函数的运行时方案Python C API 是C 代码主动融入 Python 解释器的官方底层接口而 pybind11 是让 C 代码生成 Python 扩展模块的现代 C 工具链。三者解决的问题有重叠但出发点完全不同这也决定了它们各自的优势和短板。2. ABI、解释器与元编程三种方案的底层原理差异很多人在网上对比这三种方案时喜欢直接贴代码片段然后说你看 ctypes 多简单、pybind11 多优雅。这当然没错但如果只停留在代码层面你很难理解为什么某些场景下 ctypes 会莫名其妙地崩溃为什么 pybind11 编译那么慢为什么直接用 C API 写出来的代码那么啰嗦。这些现象背后是三种方案完全不同的底层机制。2.1 ctypes没有编译期参与的外挂式绑定ctypes 的工作方式本质上就是在运行时加载一个动态库然后按你声明的函数签名去调用它。整个过程里C/C 编译器完全不参与Python 解释器也不知道你调用的这个函数到底长什么样全靠你手动告诉它这个函数的返回类型是 int它接收两个 double 参数。这种做法的好处是显而易见的——你不需要有 C 编译器不需要写额外的胶水代码甚至不需要改一行 C 代码只要把现有库编译成.so或.dll然后拿 Python 去调用就行。但代价同样明显。由于缺少编译期的类型检查函数签名一旦声明错误后果轻则返回垃圾值重则直接段错误崩溃。而且 ctypes 只支持 C 数据类型的映射如果你要调用的 C 库暴露了std::string、std::vector这样的类型问题就来了因为这些类型没有官方的 C ABIctypes 根本无法直接处理。我见过不少人在社区里问为什么我的 ctypes 调用 C 库一直报错十有八九都是这个问题——库是 C 写的接口里带着 STL 容器。2.2 Python C API官方原生接口但它是给 C 用的Python C API 是一套 C 接口它的设计意图是让 C/C 代码能够直接嵌入 Python 解释器或者反过来让 Python 代码能够调用编译成扩展模块的 C/C 代码。从技术角度讲它是三者里面最正宗的方案因为 CPython 解释器本身就是用 C 写的官方扩展模块也都是基于这套 API 开发的。你写的扩展模块会被 Python 解释器直接加载在调用时没有 ctypes 那层动态猜测的开销性能自然是最好的。但是正因为它直接操作解释器内部的数据结构所以你必须手动管理 Python 对象的引用计数必须在Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS之间手动处理 GIL必须小心翼翼地用PyArg_ParseTuple去解析参数还要面对一堆PyObject*指针满天飞带来的心智负担。更麻烦的是Python 的 C API 在不同版本之间存在破坏性变更你需要使用Py_LIMITED_API宏来限制 API 面或者针对不同 Python 版本分别编译。这些成本叠加在一起让 C API 在中小型项目里显得极其不划算。2.3 pybind11用 C 模板元编程把脏活累活包起来pybind11 的诞生本质上是 Stefan Riedel 等人对用 C API 写扩展模块太痛苦这件事的直接回应。它是一个纯头文件库利用 C11 的模板元编程和编译期类型推导在编译阶段自动生成 Python C API 所需的全部胶水代码。你在 C 端写一个普通函数然后用m.def()注册一下pybind11 会在编译期推断这个函数的参数类型、返回类型、默认值、异常规格然后生成对应的 C API 调用代码。这带来两个巨大的好处。第一类型绑定是由编译器保证的如果 C 函数的某个参数类型无法映射到 Python 对象编译时就会直接报错而不是运行时才崩溃。第二pybind11 内置了大量 STL 容器和 Python 内置类型的双向转换机制std::vectorint可以直接映射成 Python 的liststd::unordered_map可以映射成dict这几乎是 ctypes 不可能做到的事情。当然这些便利也不是白来的——模板元编程会让编译时间显著增加而且一旦模板推导出错报错信息可能会长得让人怀疑人生。为了把三者的差异讲得更直观我整理了一张对比表是我在实际项目里反复验证过的感受维度ctypesPython C APIpybind11是否需要 C 编译器否是是是否需要写胶水代码否直接声明签名是大量是但量少且声明式类型安全无运行时手动指定有但手动管理有编译期推导STL 容器支持不支持手动逐个转换内置转换器性能有调用开销但可接受最优接近 C API学习成本低很高中等编译速度不涉及编译快较慢维护成本依赖手动声明易错高API 变更频繁低代码量少适用场景快速调用已有 so/dll深度定制扩展模块现代 C 项目首选3. 实战拆解一ctypes 调用 C 库的完整过程与回调陷阱ctypes 听起来简单真用起来还是会遇到不少细节问题。我用一个实际场景来演示假设你手头有一个 C 库里面有一个函数用来计算一组浮点数的均值同时还有一个回调函数机制用于在计算过程中报告进度。// engine.h #ifdef __cplusplus extern C { #endif double average(const double* data, int len); void process_with_progress(void (*callback)(int percent)); #ifdef __cplusplus } #endif用 ctypes 调用average函数的代码非常简单import ctypes import ctypes.util # 加载动态库 lib_path ctypes.util.find_library(engine) if not lib_path: lib_path ./libengine.so # Linux 下自己指定路径 lib ctypes.CDLL(lib_path) # 声明函数签名 lib.average.restype ctypes.c_double lib.average.argtypes [ctypes.POINTER(ctypes.c_double), ctypes.c_int] # 准备数据 data [1.0, 2.0, 3.0, 4.0, 5.0] arr (ctypes.c_double * len(data))(*data) # 调用 result lib.average(arr, len(data)) print(result) # 输出 3.0这段代码能跑通但已经暗藏了两个初学者最容易踩的坑。第一个坑忘记设置restype和argtypes。如果你没有声明lib.average.restype ctypes.c_doublectypes 默认会把函数的返回值当作c_int处理。在 64 位系统上返回 double 时高位寄存器会被截断你得到的结果会是一个完全不可理喻的垃圾值。这个坑特别隐蔽因为程序不会报错只会给你一个看似正常但完全错误的结果。第二个坑以 ctypes 声明POINTER(c_double)时如果 C 函数内部修改了数组数据Python 端看不到不会因为arr是一块连续内存ctypes 传的是指针C 函数修改的内容 Python 端能感知到这一点和 Python 的普通 list 传参行为完全不同新手容易搞混。接下来是回调函数的坑。C 库的回调机制在 ctypes 里要用CFUNCTYPE来声明回调类型# 声明回调函数类型接收 int无返回值 CALLBACK_FUNC ctypes.CFUNCTYPE(None, ctypes.c_int) def on_progress(percent): print(f进度: {percent}%) # 转为 C 兼容的回调指针 callback CALLBACK_FUNC(on_progress) # 设置参数类型关键 lib.process_with_progress.argtypes [CALLBACK_FUNC] # 调用 lib.process_with_progress(callback)这段代码在我的机器上能正常工作但如果你在多线程环境里跑就要注意了C 端可能是在非 Python 线程里触发回调的此时如果回调函数里访问了 Python 对象会发生什么CPython 的 GIL 是线程级的非 Python 线程在执行 C 函数时并没有持有 GIL直接调用 Python 回调函数会导致解释器状态异常甚至直接崩溃。解决办法是手动在回调函数里获取 GILimport ctypes # 获取 Python 线程状态 PyGILState_Ensure ctypes.pythonapi.PyGILState_Ensure PyGILState_Ensure.restype ctypes.c_void_p PyGILState_Release ctypes.pythonapi.PyGILState_Release PyGILState_Release.argtypes [ctypes.c_void_p] def safe_callback(percent): gil PyGILState_Ensure() try: print(f进度: {percent}%) finally: PyGILState_Release(gil)这个做法不优雅但确实有效。如果你在 ctypes 里遇到回调一执行 Python 就崩溃的问题九成是 GIL 的问题。还有一点值得提的是 ctypes 的CDLL与PyDLL之分。CDLL不会在调用期间持有 GIL适合调用不回调 Python 函数的 C 库PyDLL则会保持 GIL适合回调 Python 函数的场景。我见过不少人在回调崩溃后换成PyDLL解决的但说实话这只能缓解部分问题根源还是得靠手动获取 GIL。4. 实战拆解二Python C API 的引用计数与 GIL 血泪史说完了 ctypes再来聊聊 Python C API。这一节的内容我希望读者能认真看两遍因为 C API 的坑往往不是不会写而是写出来能跑但跑着跑着就崩了而且崩得毫无规律。先看一个最基础的扩展模块示例功能是给 Python 提供一个add(a, b)函数#define PY_SSIZE_T_CLEAN #include Python.h static PyObject* my_add(PyObject* self, PyObject* args) { int a, b; if (!PyArg_ParseTuple(args, ii, a, b)) { return NULL; // 解析失败异常已被设置 } return PyLong_FromLong(a b); } static PyMethodDef MyMethods[] { {add, my_add, METH_VARARGS, Add two integers.}, {NULL, NULL, 0, NULL} }; static struct PyModuleDef mymodule { PyModuleDef_HEAD_INIT, mymodule, // 模块名 NULL, // 模块文档 -1, // 模块状态大小-1 表示模块级状态是全局的 MyMethods }; PyMODINIT_FUNC PyInit_mymodule(void) { return PyModule_Create(mymodule); }这段代码看着不多但已经涉及了两个关键点。第一是PyArg_ParseTuple解析失败时返回 NULL这时候异常已经被设置了你不能再返回一个 NULL 以外的假值去掩盖异常否则解释器会直接崩溃。新手最常见的错误就是解析失败后返回Py_None然后 Python 端收到的结果是 None但解释器里挂着一个未清除的异常状态等下次函数调用时直接莫名报错。第二是引用计数。PyLong_FromLong返回的是一个新引用当你把它作为返回值返回给 Python 时这个新引用的所有权就转移给了调用者。这个过程不需要你做额外处理但如果你在函数中间创建了中间对象用完不放掉内存泄漏就是从这里开始的。下面这个例子展示了一个典型的泄漏static PyObject* bad_function(PyObject* self, PyObject* args) { PyObject* tmp PyLong_FromLong(42); // 新引用 PyObject* result PyLong_FromLong(43); /* 这里忘掉 Py_DECREF(tmp)tmp 永远不会被释放 */ return result; }看起来无关紧要但如果这个函数被调用一亿次呢内存直接爆掉。然后是 GIL 的问题。C API 里如果你写的扩展函数比较耗时你肯定是希望在计算期间释放 GIL让别的 Python 线程有机会执行。这时候要用的宏是Py_BEGIN_ALLOW_THREADS // 耗时计算不访问 Python 对象 Py_END_ALLOW_THREADS但注意这两个宏之间绝对不能有任何访问 Python C API 的操作。我在早期写扩展时干过一件蠢事——在释放 GIL 的代码块里调用PyList_Append去记录日志结果就是周期性死锁。因为PyList_Append是一个需要持有 GIL 才能执行的函数你这边 GIL 已经放了别的线程也没法帮你执行整个程序就挂着不动了。真实的排查过程比写下来更痛苦。我遇到过一种情况某段计算逻辑加上 GIL 释放后程序频繁崩溃但崩溃位置每次都不一样。后来用faulthandler模块抓 traceback发现是因为我在释放 GIL 的区域内调用了Py_BuildValue然后把打包出来的PyObject*在 GIL 之外用掉了。这里面的规则就是一条铁律GIL 决定你能碰什么不能碰什么任何对象的创建、引用计数操作、容器操作都必须拿着 GIL 做。另一个容易忽略的坑是模块初始化函数PyInit_mymodule。在 Python 3 中这个函数的返回类型必须是PyObject*而且模块定义中的m_size字段如果设为 -1表示模块是全局状态如果你开发的是多解释器应用这里就要格外小心。不过对于绝大多数场景-1 就够了不用过度设计。C API 最大的问题还不是写起来累而是版本兼容性。Python 3.8 和 3.9 之间有细微的 API 变化3.9 和 3.10 之间又有新的变化。如果你只针对当前 Python 版本开发升级解释器版本后可能就需要重新适配。虽然 CPython 官方承诺 C API 的稳定性但新增的字段、变更的函数签名都要花时间处理。相比之下pybind11 在上层屏蔽了这些变动你就没那么痛苦。5. 实战拆解三pybind11 的现代 C 开发体验与打包细节pybind11 在体验上基本是针对 C API 和 ctypes 的所有痛点逐一打磨的。在我看来它是目前高生产力和高性能之间平衡得最好的一条路线。先用一个实际的例子展示 pybind11 的基本用法。假设你有一个 C 类封装了一个简单的计数器// counter.h #pragma once #include string class Counter { public: Counter() : count_(0) {} void increment(int step 1) { count_ step; } int value() const { return count_; } std::string describe() const { return Counter( std::to_string(count_) ); } private: int count_; };对应的 pybind11 绑定代码只需要一个文件// bind_counter.cpp #include pybind11/pybind11.h #include counter.h namespace py pybind11; PYBIND11_MODULE(counter_bind, m) { m.doc() A simple counter module implemented with pybind11; py::class_Counter(m, Counter) .def(py::init()) .def(increment, Counter::increment, py::arg(step) 1, Increment counter by step) .def(value, Counter::value) .def(describe, Counter::describe); }有个细节值得注意increment函数的默认参数在 C 里是写在声明处的但 pybind11 绑定后Python 端并不知道这个默认值存在。所以你要在py::arg(step) 1里再次声明默认值否则 Python 端必须显式传参。我见过不少同事在这上面吃过亏测试时一切正常一交给业务方就被吐槽你这个函数怎么不支持默认参数。从代码量上看pybind11 版本的绑定代码明显比同功能的 C API 版本简洁得多。原因就是 pybind11 的模板推导能力——Counter::increment的参数类型、返回类型全部在编译期被自动识别并生成对应转换代码。你不需要写一行PyArg_ParseTuple。接下来是很多人关心的 STL 容器绑定。pybind11 提供了pybind11/stl.h可以实现 C 容器和 Python 容器的自动转换#include pybind11/stl.h #include vector #include map std::vectordouble scale_vector(const std::vectordouble input, double factor) { std::vectordouble result; result.reserve(input.size()); for (auto v : input) { result.push_back(v * factor); } return result; } PYBIND11_MODULE(vector_bind, m) { m.def(scale_vector, scale_vector); }绑完之后Python 端可以直接传一个list给它然后在内部被自动转成std::vectordouble。这段代码如果换成 ctypes你得手动把 Python 的 list 转成数组再传指针拿到结果后再转回 list写起来非常啰嗦。换成 C API麻烦程度更是翻倍。还有一个常用功能是py::array_t用于高效绑定 NumPy 数组。这个在性能敏感的场景下尤其有价值#include pybind11/numpy.h py::array_tdouble add_arrays(py::array_tdouble a, py::array_tdouble b) { auto buf_a a.request(); auto buf_b b.request(); if (buf_a.size ! buf_b.size) { throw std::runtime_error(Input arrays must have the same size); } auto result py::array_tdouble(buf_a.size); auto buf_res result.request(); const double* ptr_a static_castdouble*(buf_a.ptr); const double* ptr_b static_castdouble*(buf_b.ptr); double* ptr_res static_castdouble*(buf_res.ptr); for (size_t i 0; i buf_a.size; i) { ptr_res[i] ptr_a[i] ptr_b[i]; } return result; }pybind11 在这段代码里帮我们做了大量工作自动检查数组类型是否为 double、自动处理 strides 和连续性、使用buffer_info封装底层指针。你要是在 C API 里自己写这些光处理非连续数组的拷贝问题就够喝一壶的。pybind11 的编译与打包也值得单独说一说。最省心的方式是使用 scikit-build-core 或者 Setuptools 的pybind11扩展支持。下面是一个pyproject.toml的示例[build-system] requires [scikit-build-core0.5.0] build-backend scikit_build_core.build [project] name counter_bind version 0.1.0 [tool.scikit-build] cmake.version 3.15 cmake.build-type Release构建时直接执行pip install .pip 会自动调用 CMake 进行编译然后把编译好的扩展模块安装到当前 Python 环境里。整个过程不需要你手动处理.so文件拷贝、路径配置那一套。相比 ctypes 那种手动加载动态库、手动声明签名的方式pybind11 的优势在这一刻体现得淋漓尽致——用户体验和普通 Python 包几乎一样。打包过程中最容易踩的坑是Python 版本与编译器的 ABI 兼容性。如果你在一台机器上用 Python 3.10 编译了扩展模块然后把生成的.so文件拷贝到另一台只有 Python 3.8 的机器上使用大概率会报undefined symbol错误。原因在于 CPython 解释器内部的PyLongObject、PyTupleObject等结构体布局在不同版本间可能不同导致扩展模块与解释器版本强绑定。解决方案只有一个在不同 Python 版本环境里重新编译或者用pip wheel构建 Linux wheel 时注意使用manylinux规范。6. 性能实测三种方案在 CPU 密集场景下的表现差异选型不能只看代码好不好写性能是绕不开的话题。我以一个经典的 CPU 密集型任务——计算 100 万次浮点平方根——为例在同样的硬件环境下用三种方案分别实现相同的功能测一下调用开销和总耗时差异。测试环境说明Intel 11 代 i7LinuxPython 3.10C 用std::sqrt在#pragma omp parallel for下并行计算。每个方案都做两轮测量第一轮是无 Python 参与的纯 C/C 计算只调用一次第二轮是 Python 端循环 500 次每次调用 C/C 函数计算 100 万个平方根模拟高频调用的场景。结果大致如下方案单次计算耗时处理 1M 个 sqrtPython 端循环 500 次总耗时备注纯 C约 3.5 ms约 1.8 s全部在 C 内完成ctypes约 4.0 ms约 2.3 s每次调用约多 1 ms 开销Python C API约 3.8 ms约 2.0 s调用开销很小pybind11约 3.8 ms约 2.0 s接近 C API从这张表可以看出单次调用开销在毫秒级场景下几乎可以忽略不计三者的差距微乎其微。真正的性能差距出现在频繁调用场景下——例如一百万次循环里反复调用一个很小的计算函数此时 ctypes 的动态类型检查与参数打包开销就会被放大。我在测试中发现一次 ctypes 的空函数调用耗时大约在 1.5 微秒左右而 pybind11 和 C API 的空函数调用在 0.2 微秒左右差了接近一个数量级。如果应用是每次调用都要完成大量计算那么选哪个方案性能差距不大如果应用是Python 端高频调用小函数ctypes 会因为调用开销而拖后腿尤其是它可能涉及将 Python 对象反复打包成 C 指针的额外拷贝。还有一个容易忽略的性能点是数据拷贝。ctypes 传入 C 函数的数据必须手动创建成ctypes数组这一步本身就有内存分配和元素拷贝开销。而 pybind11 的py::array_t支持直接使用 NumPy 数组的底层内存缓冲区不做不必要的数据拷贝前提是数组是连续且类型匹配的。如果你处理的是上百 MB 的数组这个差异就非常明显了。写一个简短对比更直观# ctypes 方式必须把 list 转成 ctypes 数组有拷贝 data [1.0] * 1000000 arr (ctypes.c_double * len(data))(*data) lib.consume_data(arr, len(data)) # pybind11 方式可以直接传 NumPy 数组内部通过 buffer protocol 访问内存 import numpy as np data_np np.ones(1000000) lib_mod.consume_data(data_np)当然ctypes 也不是不能用它兼容性好、不需要编译、上手门槛低只是当你进入性能敏感的高频调用场景时会发现瓶颈不在 C 端而在 Python 与 C 之间的翻译层。7. 选型决策路径什么项目该用哪个方案讲完原理、代码、性能最后回归到选型决策本身。根据我的实践经验选型问题其实可以从一个最简单的角度切入——你的项目是库方主导还是调用方主导。如果是库方主导你已经在开发一个 C/C 库而且未来肯定会有 Python 绑定的需求那 pybind11 是首选。它的声明式绑定风格、STL 容器内置转换、NumPy 数组零拷贝支持、以及成熟的打包工具链都让它成为现代 C 项目里最省心的方案。代价是项目构建系统需要引入 C11甚至 C14编译环境团队里最好有人了解 CMake 的基本使用。如果只是调用方主导你只是需要临时调用一个已有的 C 库或者想快速验证某个算法并不想引入复杂的构建流程那 ctypes 是性价比最高的选择。不需要额外的编译器不需要写胶水代码纯 Python 就能完成任务。但要克制一点不要试图用 ctypes 去调用复杂的 C 接口尤其是涉及 STL 容器、继承、多态、异常的类型。我见过最糟糕的项目就是试图用 ctypes 绑定一个几十万行的 C 引擎最后的结论只能是推翻重来。Python C API 的定位则更多在于你需要极致掌控解释器行为的场景。比如你在开发一个性能关键型 Python 扩展且对内存生命周期管理有特殊要求或者你在做嵌入式 Python需要在 C/C 主程序里控制解释器的初始化、多线程策略或者你需要实现自定义类型让 Python 端对象和 C 端对象共享底层内存。但即便如此除非你有非常特殊的需求我依然建议先考虑 pybind11 能否覆盖——因为它内部就是基于 C API 的很多高级功能只是封装得更友好你并没有损失什么表达力。用一张简单的决策表来总结场景描述推荐方案理由已有 .so/.dll快速验证调用ctypes无需编译上手最快现有 C 库需要提供 Python 绑定pybind11类型安全、开发效率高需要深入控制解释器状态GIL、内存Python C API官方底层控制力最强团队无 C 编译经验ctypes不依赖编译器工具链对性能极致的频率调用Python C API / pybind11调用开销更低需要在多个 Python 版本间分发pybind11 manylinux wheel打包工具链成熟8. 项目实战一个混合编程模块的真实演进过程最后分享一个真实项目的演进过程方便你把前面的知识串起来。这个项目是一个 IOT 数据采集网关C 端负责从硬件传感器读取数据Python 端负责业务逻辑和上报策略。最初临时方案用的是 ctypes因为当时 C 端的接口非常简单——只有read_sensor(channel)和close()两个函数用 ctypes 几分钟就调通了。但后来的需求开始变复杂需要在 C 端维护一个内部缓冲区Python 端要按批次拉取数据同时把传感器状态对象化还涉及异常处理。ctypes 重构过程中遇到了几个棘手问题。第一个是 C 端的Sensor类需要跨语言传递——ctypes 不支持 C 类除非手动把类编码成不透明的指针加一组自由函数。第二个是异常处理C 端用throw抛出异常ctypes 端完全感知不到只能靠返回值做错误标记代码变得极难维护。这个阶段团队的开发效率明显下降每次改接口都要重新声明一堆argtypes和restype。于是我们决定迁移到 pybind11。迁移过程并不痛苦因为 C 端的底层类不需要改动只是增加了一个绑定文件。绑定完成之后Python 端的代码从十几行的 ctypes 声明和手动类型转换简化成了几行面向对象的自然调用。Sensor 类可以直接实例化异常可以无缝传递缓冲区数据可以用py::array_tdouble高效传递。这个迁移让我对 pybind11 的好感大幅提升因为它把C 端的东西像 C 一样写Python 端的东西像 Python 一样写这件事真正变成了可能。如果你现在问我实际项目中我更偏爱哪个方案我的答案非常明确能不碰 ctypes 就不碰 ctypes能不直接写 C API 就不直接写 C APIpybind11 在绝大多数场景下是最均衡、最值得投入学习成本的选择。但这并不等于 ctypes 和 C API 就没有存在的价值。当你启动一个项目时先想清楚它未来三个月、半年、一年可能演进到什么复杂度再决定用哪套方案。如果只是临时脚本用 ctypes 省事如果是要长期维护的正式模块直接上 pybind11 会让你少走很多弯路。