在Python中封装C库:C、Cython还是ctypes?
我想在一个Python应用程序中调用一个C语言库。我不想把整个API都封装起来,只想封装那些对我来说有用的函数和数据类型。根据我的理解,我有三个选择:
- 创建一个真正的C语言扩展模块。这可能有点过于复杂,而且我也不想花时间去学习如何写扩展。
- 使用Cython来把C库中相关的部分暴露给Python。
- 完全用Python来做,使用
ctypes
来和外部库进行沟通。
我不太确定选择2)还是3)更好。选择3)的好处是ctypes
是Python标准库的一部分,最终的代码将是纯Python的——不过我不太确定这个好处到底有多大。
这两种选择各自还有更多的优缺点吗?你推荐哪种方法?
编辑:感谢大家的回答,这些内容对任何想做类似事情的人来说都是很好的资源。当然,最终的决定还是要根据具体情况来做——没有一个“这是正确的选择”的答案。就我个人而言,我可能会选择ctypes,但我也期待在其他项目中尝试Cython。
因为没有一个绝对正确的答案,所以接受某个答案有点随意;我选择了FogleBird的回答,因为它对ctypes提供了一些很好的见解,而且目前也是投票最高的回答。不过,我建议大家阅读所有的回答,以便获得一个全面的了解。
再次感谢大家。
12 个回答
Cython 是一个非常不错的工具,值得学习,而且它的语法和 Python 非常接近。如果你在做科学计算,特别是用 Numpy 的话,Cython 是个不错的选择,因为它能和 Numpy 很好地结合,进行快速的矩阵运算。
Cython 是 Python 语言的一个扩展。你可以把任何有效的 Python 文件给它,它会生成一个有效的 C 程序。在这个过程中,Cython 会把 Python 的调用映射到底层的 CPython 接口上。这意味着你的代码不再是被解释执行的,速度可能会提高大约 50%。
为了获得一些优化,你需要开始告诉 Cython 关于你代码的更多信息,比如变量的类型声明。如果你提供的信息足够多,它可以把代码简化成纯 C 代码。也就是说,Python 中的 for 循环会变成 C 中的 for 循环。在这种情况下,你会看到巨大的速度提升。你还可以在这里链接外部的 C 程序。
使用 Cython 代码也非常简单。我觉得手册上说得有点复杂。你只需要这样做:
$ cython mymodule.pyx
$ gcc [some arguments here] mymodule.c -o mymodule.so
然后你就可以在你的 Python 代码中 import mymodule
,完全不需要担心它是编译成 C 的。
总之,由于 Cython 设置和使用起来都很简单,我建议你试试看,看看它是否符合你的需求。如果最终发现它不是你想要的工具,也不会浪费时间。
警告:接下来是一个Cython核心开发者的观点。
我几乎总是推荐使用Cython,而不是ctypes。原因是Cython的升级过程要顺畅得多。如果你使用ctypes,刚开始很多事情会很简单,确实可以用普通的Python写你的外部函数接口(FFI)代码,不需要编译、构建依赖等等,这听起来很不错。然而,到了某个时候,你几乎肯定会发现你需要频繁调用你的C库,可能是在一个循环中,或者是一系列相互依赖的调用中,这时你就会想要加快速度。这时你会发现用ctypes无法做到这一点。或者,当你需要回调函数时,你会发现你的Python回调代码变成了瓶颈,你想要加快它的速度,或者把它移到C中去。同样,ctypes也无法满足这个需求。所以在这个时候,你就得换语言,开始重写你代码的某些部分,可能还要把你的Python/ctypes代码反向工程成普通的C代码,这样一来,最开始用普通Python写代码的好处就没了。
而使用Cython,你可以完全自由地决定包装和调用代码的复杂程度。你可以从普通的Python代码简单调用你的C代码开始,Cython会把这些调用转换成原生的C调用,没有额外的调用开销,而且对于Python参数的转换开销也非常低。当你发现需要更高性能的时候,尤其是当你频繁调用C库时,你可以开始在周围的Python代码中添加静态类型,让Cython直接优化成C代码。或者,你可以开始用Cython重写你的一部分C代码,以避免调用,并在算法上专门化和优化你的循环。如果你需要快速的回调,只需写一个合适的函数签名,然后直接把它传入C的回调注册表中。同样,没有额外的开销,这样你就能获得原生C的调用性能。如果在极少数情况下,你真的无法在Cython中让代码快到满意的程度,你仍然可以考虑把真正关键的部分用C(或C++或Fortran)重写,然后自然地从你的Cython代码中调用它。但这时,这就成了最后的选择,而不是唯一的选项。
所以,ctypes适合做一些简单的事情,快速让某些功能运行起来。然而,一旦事情开始变得复杂,你很可能会意识到,最好从一开始就使用Cython。
ctypes
是一个很好的选择,可以快速完成任务,而且使用起来很舒服,因为你仍然是在写 Python!
我最近用 ctypes
封装了一个 FTDI 驱动程序,用来和 USB 芯片通信,效果非常好。我在不到一天的工作时间里就完成并且让它正常工作了。(我只实现了我们需要的功能,大约 15 个函数)。
之前我们使用的是一个第三方模块 PyUSB,也是为了同样的目的。PyUSB 是一个真正的 C/Python 扩展模块。但 PyUSB 在进行阻塞读写时没有释放全局解释器锁(GIL),这给我们带来了问题。所以我用 ctypes
写了自己的模块,它在调用本地函数时会释放 GIL。
需要注意的是,ctypes
不会知道你使用的库中的 #define
常量等,只能识别函数,所以你需要在自己的代码中重新定义这些常量。
下面是代码的一个示例(很多部分省略了,只是想给你展示大概的样子):
from ctypes import *
d2xx = WinDLL('ftd2xx')
OK = 0
INVALID_HANDLE = 1
DEVICE_NOT_FOUND = 2
DEVICE_NOT_OPENED = 3
...
def openEx(serial):
serial = create_string_buffer(serial)
handle = c_int()
if d2xx.FT_OpenEx(serial, OPEN_BY_SERIAL_NUMBER, byref(handle)) == OK:
return Handle(handle.value)
raise D2XXException
class Handle(object):
def __init__(self, handle):
self.handle = handle
...
def read(self, bytes):
buffer = create_string_buffer(bytes)
count = c_int()
if d2xx.FT_Read(self.handle, buffer, bytes, byref(count)) == OK:
return buffer.raw[:count.value]
raise D2XXException
def write(self, data):
buffer = create_string_buffer(data)
count = c_int()
bytes = len(data)
if d2xx.FT_Write(self.handle, buffer, bytes, byref(count)) == OK:
return count.value
raise D2XXException
有人对各种选项做了一些 性能基准测试。
如果我需要封装一个有很多类/模板等的 C++ 库,我可能会更犹豫。但 ctypes
在处理结构体时效果很好,甚至可以 回调 到 Python 中。