在编写基于 Python 的应用程序时,您不仅限于 Python 语言。在第 3 章中简要提到了 Hy 等工具,语法最佳实践–高于类级别。它允许您使用将在 Python 虚拟机中运行的其他语言(Lisp 方言)编写模块、包,甚至整个应用程序。尽管它能让你用完全不同的语法来表达程序逻辑,但它仍然是完全相同的语言,因为它编译成相同的字节码。这意味着它与普通 Python 代码具有相同的限制:
- 由于 GIL 的存在,线程可用性大大降低
- 它没有被编译
- 它不提供静态类型和可能的优化
帮助克服这些核心限制的解决方案是完全用不同语言编写的扩展,并通过 Python 扩展 API 公开其接口。
本章将讨论用其他语言编写自己的扩展的主要原因,并向您介绍帮助创建这些扩展的流行工具。你将学到:
- 如何使用 Python/CAPI 用 C 编写简单的扩展
- 如何使用 Cython 进行同样的操作
- 扩展带来的主要挑战和问题是什么
- 如何在不创建专用扩展和仅使用 Python 代码的情况下与已编译的动态库交互
当我们讨论在不同语言中的扩展时,我们几乎只考虑 C 和 C++。甚至像 Cython 或 Pyrex 这样只为扩展目的而提供 Python 语言超集的工具实际上也是源代码到源代码编译器,它们通过类似 Python 的扩展语法生成 C 代码。
确实,如果 Python 中的任何语言都可以使用动态/共享库,只要编译是可能的,那么它就超越 C 和 C++。但共享库本质上是通用的。它们可以在支持其加载的任何语言中使用。因此,即使您使用完全不同的语言(比如 Delphi 或 Prolog)编写这样一个库,如果它不使用 Python/capi,也很难将它命名为 Python 扩展。
不幸的是,使用 C 语言或 C++使用纯 Python/C API 编写自己的扩展是非常苛刻的。这不仅是因为它需要很好地理解这两种相对较难掌握的语言之一,而且还因为它需要大量的样板文件。必须编写大量重复代码,以提供将实现的逻辑与 Python 及其数据类型粘合在一起的接口。无论如何,知道纯 C 扩展是如何构建的是件好事,因为:
- 您将更好地理解 Python 的一般工作原理
- 有一天,您可能需要调试或维护本机 C/C++扩展
- 它有助于理解构建扩展的高级工具是如何工作的
Python 解释器能够从动态/共享库加载扩展,前提是它们使用 Python/CAPI 提供了适用的接口。此 API 必须使用与 Python 源代码一起分发的Python.h
C 头文件合并到扩展的源代码中。在许多 Linux 发行版中,此头文件包含在一个单独的包中(例如,Debian/Ubuntu 中的python-dev
),但在 Windows 下,它是默认分发的,可以在 Python 安装的includes/
目录中找到。
Python/capi 传统上会随着 Python 的每个版本而变化。在大多数情况下,这些只是对 API 的新特性的添加,因此它通常与源代码兼容。无论如何,在大多数情况下,由于应用程序二进制接口(ABI中的更改,它们不兼容二进制。这意味着必须为每个版本的 Python 分别构建扩展。还请注意,不同的操作系统具有不兼容的 ABI,因此实际上不可能为每个可能的环境创建二进制发行版。这就是大多数 Python 扩展以源代码形式分发的原因。
自 Python3.2 以来,Python/CAPI 的一个子集被定义为具有稳定的 ABI。然后可以使用这个有限的 API(带有稳定的 ABI)构建扩展,因此扩展只能构建一次,并且可以与任何高于或等于 3.2 的 Python 版本一起工作,而无需重新编译。无论如何,这限制了 API 特性的数量,并且不能解决较旧 Python 版本的问题,也不能解决以二进制形式将扩展分发到使用不同操作系统的环境中的问题。所以这是一种权衡,稳定 ABI 的价格似乎有点高,但收益很低。
您需要知道的一件事是,Python/CAPI 是一种仅限于 CPython 实现的特性。为了给 PyPI、Jython 或 IronPython 等替代实现带来扩展支持,做了一些努力,但目前似乎还没有可行的解决方案。唯一可以轻松处理扩展的替代 Python 实现是无堆栈 Python,因为它实际上只是 CPython 的一个修改版本。
Python 的 C 扩展需要编译到共享/动态库中,然后才能供使用,因为显然没有从源代码直接将 C/C++代码导入 Python 的本机方法。幸运的是,distutils
和setuptools
提供了将编译后的扩展定义为模块的帮助,因此可以像处理普通 Python 包一样使用setup.py
脚本来处理编译和分发。这是官方文档中的setup.py
脚本的一个示例,用于处理带有内置扩展的简单包的打包:
from distutils.core import setup, Extension
module1 = Extension(
'demo',
sources=['demo.c']
)
setup(
name='PackageName',
version='1.0',
description='This is a demo package',
ext_modules=[module1]
)
以这种方式准备好后,您的分发流程中还需要另外一个步骤:
python setup.py build
这将根据Extension()
调用提供的所有附加编译器设置编译作为ext_modules
参数提供的所有扩展。将使用的编译器是您环境的默认编译器。如果包将与源发行版一起发行,则不需要此编译步骤。在这种情况下,您需要确保目标环境具有所有编译先决条件,例如编译器、头文件和将链接到二进制文件的附加库(如果扩展需要)。打包 Python 扩展的更多细节将在后面的挑战部分中解释。
用 C/C++编写扩展什么时候是合理的决定并不容易。一般的经验法则是,永远不会,除非你别无选择。但这是一个非常主观的陈述,它为解释 Python 中不可行的东西留下了很大的空间。事实上,很难找到一件使用纯 Python 代码无法完成的事情,但在扩展可能特别有用的地方存在一些问题:
- 绕过 Python 线程模型中的GIL(全局解释器锁)
- 提高关键代码段的性能
- 集成第三方动态库
- 集成用不同语言编写的源代码
- 创建自定义数据类型
例如,核心语言约束(如 GIL)可以通过不同的并发方法(如绿色线程或多处理而不是线程模型)轻松克服。
老实说吧。由于性能原因,开发人员没有选择 Python。它不会快速执行,但允许您快速开发。尽管如此,无论我们作为程序员的表现如何,多亏了这种语言,我们有时可能会发现一个问题,而这个问题可能无法用纯 Python 有效地解决。
在大多数情况下,解决性能问题实际上只是选择合适的算法和数据结构,而不是限制语言开销的恒定因素。如果代码已经写得不好或者没有使用正确的算法,那么依靠扩展来减少一些 CPU 周期实际上不是一个好的解决方案。通常情况下,性能可以提高到可接受的水平,而无需通过在堆栈中循环使用另一种语言来增加项目的复杂性。如果可能的话,首先应该这样做。无论如何,即使有最先进的算法方法和最适合我们使用的数据结构,我们也很可能无法单独使用 Python 来适应一些任意的技术约束。
对应用程序的性能设置了一些明确限制的示例字段是实时竞价(RTB业务。简言之,整个 RTB 是以类似于真实拍卖或证券交易所的方式买卖广告存货(广告场所)。交易通常通过一些广告交换服务进行,该服务将可用库存信息发送给有兴趣购买的需求方平台(DSP)。这就是事情变得激动人心的地方。大多数广告交换使用 OpenRTB 协议(基于 HTTP)与潜在投标人进行通信,其中 DSP 是负责响应其 HTTP 请求的站点。而 ad 交换总是对从接收到的第一个 TPC 数据包到服务器写入的最后一个字节的整个过程施加非常有限的时间限制(通常在 50 到 100 毫秒之间)。为了增加趣味性,DSP 平台每秒处理数以万计的请求并不少见。能够将请求处理时间推几毫秒,这是该业务中的是或不是。这意味着,在这种情况下,将即使是微不足道的代码移植到 C 也可能是合理的,但前提是它是某些性能瓶颈的一部分,并且无法在算法上进一步改进。正如有人曾经说过:
“你无法打败用 C 写的循环。”
在计算机科学的短暂历史中,已经编写了许多有用的库。每当一种新的编程语言出现时,忘记所有这些遗产将是一个巨大的损失,但也不可能可靠地移植任何用任何可用语言编写的软件。
C 语言和 C++语言似乎是最重要的语言,它们提供了大量的库和实现,您希望在应用程序代码中集成它们,而不必将它们完全地移植到 Python。幸运的是,CPython 已经是用 C 编写的,因此集成此类代码最自然的方法就是通过自定义扩展。
使用不同技术编写的代码的集成不会以 C/C++结束。许多库,特别是具有封闭源代码的第三方软件,都以编译二进制文件的形式分发。在 C 中,加载这样的共享/动态库并调用它们的函数非常容易。这意味着您可以使用任何 C 库,只要使用 Python/CAPI 将其包装为扩展。
当然,这不是唯一的解决方案,还有一些工具,如ctypes
或 CFFI,允许您使用纯 Python 与动态库进行交互,而无需使用 C 编写扩展。通常情况下,Python/C API 仍然是一个更好的选择,因为它在集成层(用 C 编写)之间提供了更好的分离以及应用程序的其余部分。
Python 提供了多种多样的内置数据类型选择。其中一些确实使用了最先进的内部实现(至少在 CPython 中是这样),这些实现是专门为 Python 语言的使用而定制的。对于新手来说,开箱即用的基本类型和系列的数量可能会给人留下深刻印象,但很明显,它并不能满足我们所有可能的需求。
当然,您可以在 Python 中创建许多自定义数据结构,方法是完全基于某些内置类型,或者从头开始将它们构建为全新的类。不幸的是,对于一些可能严重依赖此类自定义数据结构的应用程序,性能可能还不够。像dict
或set
这样的复杂集合的全部功能都来自它们的底层 C 实现。为什么不做同样的事情,用 C 实现一些自定义数据结构呢?
如前所述,编写扩展不是一项简单的任务,但作为你努力工作的交换,它可以给你带来很多优势。对于您自己的扩展,最简单和推荐的方法是使用诸如 Cython 或 Pyrex 之类的工具,或者简单地将现有的动态库与ctypes
或cffi
集成。这些项目将提高您的生产率,并使代码更易于开发、阅读和维护。
无论如何,如果您是这个主题的新手,那么很高兴知道您可以通过使用裸 C 代码和 Python/C API 编写扩展来开始您的冒险。这将提高您对扩展如何工作的理解,并帮助您了解替代解决方案的优势。为了简单起见,我们将以一个简单的算法问题为例,尝试使用三种不同的方法来实现它:
- 编写纯 C 扩展
- 使用 Cython
- 使用 Pyrex
我们的问题是找到斐波那契序列的第n个数。您不太可能只为这个问题创建编译扩展,但它非常简单,因此它将作为将任何 C 函数连接到 Python/C API 的一个非常好的示例。我们唯一的目标是清晰和简单,因此我们不会试图提供最有效的解决方案。一旦我们知道了这一点,我们在 Python 中实现的斐波那契函数的参考实现如下所示:
"""Python module that provides fibonacci sequence function"""
def fibonacci(n):
"""Return nth Fibonacci sequence number computed recursively.
"""
if n < 2:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
请注意,这是fibonnaci()
函数最简单的实现之一,可以对其进行许多改进。我们拒绝改进我们的实现(例如使用记忆模式),因为这不是我们示例的目的。同样,在以后讨论 C 或 Cython 的实现时,我们不会优化代码,即使编译后的代码提供了更多这样做的可能性。
在我们深入研究用 C 编写的 Python 扩展的代码示例之前,这里有一个巨大的警告。如果您想用 C 扩展 Python,您需要已经熟悉这两种语言。这对于 C 尤其如此。缺乏对它的熟练程度可能会导致真正的灾难,因为它很容易被错误处理。
如果您已经决定需要为 Python 编写 C 扩展,我假设您已经对 C 语言有了一定的了解,这将使您能够完全理解所提供的示例。除了 Python/CAPI 的细节之外,这里将不做任何解释。这本书是关于 Python 而不是任何其他语言的。如果您根本不懂 C,在获得足够的经验和技能之前,绝对不应该尝试用 C 编写自己的 Python 扩展。把它留给其他人,坚持使用 Cython 或 Pyrex,因为从初学者的角度来看,它们更安全。这主要是因为 Python/capi 尽管经过精心编制,但绝对不是 C 的好入门。
如前所述,我们将尝试将fibonacci()
函数移植到 C,并将其作为扩展暴露在 Python 代码中。与前面的 Python 示例类似,没有连接到 Python/C API 的裸实现大致如下:
long long fibonacci(unsigned int n) {
if (n < 2) {
return 1;
} else {
return fibonacci(n - 2) + fibonacci(n - 1);
}
}
下面是一个完整的、功能齐全的扩展示例,它在编译模块中公开了这一单一功能:
#include <Python.h>
long long fibonacci(unsigned int n) {
if (n < 2) {
return 1;
} else {
return fibonacci(n-2) + fibonacci(n-1);
}
}
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
if (PyArg_ParseTuple(args, "l", &n)) {
result = Py_BuildValue("L", fibonacci((unsigned int)n));
}
return result;
}
static char fibonacci_docs[] =
"fibonacci(n): Return nth Fibonacci sequence number "
"computed recursively\n";
static PyMethodDef fibonacci_module_methods[] = {
{"fibonacci", (PyCFunction)fibonacci_py,
METH_VARARGS, fibonacci_docs},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef fibonacci_module_definition = {
PyModuleDef_HEAD_INIT,
"fibonacci",
"Extension module that provides fibonacci sequence function",
-1,
fibonacci_module_methods
};
PyMODINIT_FUNC PyInit_fibonacci(void) {
Py_Initialize();
return PyModule_Create(&fibonacci_module_definition);
}
前面的例子乍一看可能有点让人不知所措,因为我们必须添加四倍多的代码才能从 Python 访问fibonacci()
C 函数。我们将在稍后讨论该代码的每一部分,所以不要担心。但在我们这么做之前,让我们看看如何用 Python 打包和执行它。我们模块的最低setuptools
配置需要使用setuptools.Extension
类,以指导解释器如何编译我们的扩展:
from setuptools import setup, Extension
setup(
name='fibonacci',
ext_modules=[
Extension('fibonacci', ['fibonacci.c']),
]
)
扩展的构建过程可以用 Python 的setup.py
build 命令初始化,但也会在安装包时自动执行。下面的文字记录显示了在开发模式下安装的结果,以及一个简单的交互式会话,其中检查并执行了我们编译的fibonacci()
函数:
$ ls -1a
fibonacci.c
setup.py
$ pip install -e .
Obtaining file:///Users/swistakm/dev/book/chapter7
Installing collected packages: fibonacci
Running setup.py develop for fibonacci
Successfully installed Fibonacci
$ ls -1ap
build/
fibonacci.c
fibonacci.cpython-35m-darwin.so
fibonacci.egg-info/
setup.py
$ python
Python 3.5.1 (v3.5.1:37a07cee5969, Dec 5 2015, 21:12:44)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import fibonacci
>>> help(fibonacci.fibonacci)
Help on built-in function fibonacci in fibonacci:
fibonacci.fibonacci = fibonacci(...)
fibonacci(n): Return nth Fibonacci sequence number computed recursively
>>> [fibonacci.fibonacci(n) for n in range(10)]
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
>>>
既然我们知道如何正确地打包、编译和安装自定义 C 扩展,并且我们确信它能按预期工作,那么现在是详细讨论我们的代码的时候了。
extensions 模块从一个 C 预处理器指令开始,该指令包括Python.h
头文件:
#include <Python.h>
这包含了整个 Python/CAPI,是编写扩展所需的全部内容。在更实际的情况下,您的代码将需要更多的预处理器指令来利用 C 标准库函数或集成其他源文件。我们的示例很简单,因此不需要更多的指令。
接下来是我们模块的核心部分:
long long fibonacci(unsigned int n) {
if (n < 2) {
return 1;
} else {
return fibonacci(n - 2) + fibonacci(n - 1);
}
}
前面的fibonacci()
函数是我们代码中唯一有用的部分。这是 Python 默认无法理解的纯 C 实现。我们示例的其余部分将创建接口层,该层将通过 Python/CAPI 公开它。
向 Python 公开此代码的第一步是创建与 CPython 解释器兼容的 C 函数。在 Python 中,一切都是对象。这意味着在 Python 中调用的 C 函数也需要返回真实的 Python 对象。Python/CAPI 提供了一个PyObject
类型,每个可调用函数都必须返回指向它的指针。我们职能部门的签名为:
static PyObject* fibonacci_py(PyObject* self, PyObject* args)s
请注意,前面的签名没有指定参数的确切列表,只指定了PyObject* args
,它将保存指向包含所提供值元组的结构的指针。参数列表的实际验证必须在函数体内部执行,这正是fibonacci_py()
所做的。它解析args
参数列表,假设它是单个unsigned int
类型,并将该值用作fibonacci()
函数的参数,以检索斐波那契序列元素:
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
if (PyArg_ParseTuple(args, "l", &n)) {
result = Py_BuildValue("L", fibonacci((unsigned int)n));
}
return result;
}
前面的示例函数有一些严重的 bug,经验丰富的开发人员应该很容易发现这些 bug。尝试将其作为使用 C 扩展的练习。现在,为了简洁起见,我们将其保持原样。在异常处理部分讨论错误处理细节时,我们将尝试修复它。
PyArg_ParseTuple(args, "l", &n)
调用中的"l"
字符串意味着我们希望args
只包含一个long
值。如果失败,则返回NULL
并在每线程解释器状态下存储异常信息。异常处理的细节将在后面的异常处理一节中介绍。
解析函数的实际签名是int PyArg_ParseTuple(PyObject *args, const char *format, ...)
,在format
字符串后面的是一个可变长度的参数列表,表示解析后的值输出(作为指针)。这类似于 C 标准库中的scanf()
函数的工作方式。如果我们的假设失败,并且用户提供了一个不兼容的参数列表,那么PyArg_ParseTuple()
将引发适当的异常。一旦您习惯了,这是一种非常方便的对函数签名进行编码的方法,但与普通 Python 代码相比,它有一个巨大的缺点。这种由PyArg_ParseTuple()
调用隐式定义的 Python 调用签名无法在 Python 解释器内部轻松检查。当使用作为扩展提供的代码时,您需要记住这一事实。
如前所述,Python 期望从可调用对象返回对象。这意味着我们不能返回从fibonacci()
函数获得的原始long long
值作为fibonacci_py()
的结果。这样的尝试甚至不会编译,也不会自动将基本 C 类型转换为 Python 对象。必须改用Py_BuildValue(*format, ...)
功能。它是PyArg_ParseTuple()
的对应项,并接受一组类似的格式字符串。主要区别在于参数列表不是函数输出而是输入,因此必须提供实际值而不是指针。
fibonacci_py()
定义后,大部分繁重的工作都完成了。最后一步是执行模块初始化,并将元数据添加到我们的函数中,这将使用户的使用更加简单。这是我们的扩展代码的样板部分,对于一些简单的例子,比如这一个,可以比我们想要公开的实际函数发生更多的事情。在大多数情况下,它只是由一些静态结构和一个初始化函数组成,这些函数将由解释器在模块导入时执行。
首先,我们为fibonacci_py()
函数创建一个静态字符串,该字符串将作为 Python docstring 的内容:
static char fibonacci_docs[] =
"fibonacci(n): Return nth Fibonacci sequence number "
"computed recursively\n";
请注意,这可以在fibonacci_module_methods
后面的某个地方内联,但最好将 docstring 分离并存储在它们所引用的实际函数定义附近。
我们定义的下一部分是PyMethodDef
结构的数组,这些结构定义了我们模块中可用的方法(函数)。此结构正好包含四个字段:
char* ml_name
:这是方法的名称。PyCFunction ml_meth
:这是指向函数的 C 实现的指针。int ml_flags
:包括表示调用约定或绑定约定的标志。后者仅适用于类方法的定义。char* ml_doc
:指向方法/函数 docstring 内容的指针。
这样的数组必须始终以指示其结束的哨兵值{NULL, NULL, 0, NULL}
结束。在我们的简单示例中,我们创建了只包含两个元素(包括 sentinel 值)的static PyMethodDef fibonacci_module_methods[]
数组:
static PyMethodDef fibonacci_module_methods[] = {
{"fibonacci", (PyCFunction)fibonacci_py,
METH_VARARGS, fibonacci_docs},
{NULL, NULL, 0, NULL}
};
这就是第一个条目如何映射到PyMethodDef
结构:
ml_name = "fibonacci"
:这里,fibonacci_py()
C 函数将以fibonacci
名称作为 Python 函数公开ml_meth = (PyCFunction)fibonacci_py
:在这里,对PyCFunction
的转换只是 Python/CAPI 所要求的,并由ml_flags
后面定义的调用约定决定ml_flags = METH_VARARGS
:这里,METH_VARARGS
标志表示我们函数的调用约定接受变量列表中的参数,而不接受关键字参数ml_doc = fibonacci_docs
:这里将用fibonacci_docs
字符串的内容记录 Python 函数
当函数定义数组完成时,我们可以创建另一个包含整个模块定义的结构。使用PyModuleDef
类型描述,包含多个字段。其中一些仅适用于更复杂的场景,其中需要对模块初始化过程进行细粒度控制。在这里,我们只对其中的前五项感兴趣:
PyModuleDef_Base m_base
:应始终使用PyModuleDef_HEAD_INIT
进行初始化。char* m_name
:新建模块的名称。在我们的例子中,它是fibonacci
。char* m_doc
:这是指向模块的 docstring 内容的指针。我们通常在一个 C 源文件中只定义了一个模块,因此可以在整个结构中内联文档字符串。Py_ssize_t m_size
:这是为保持模块状态而分配的内存大小。这仅在需要支持多个子解释器或多阶段初始化时使用。在大多数情况下,您不需要它,它会获取值-1
。PyMethodDef* m_methods
:指向包含PyMethodDef
值描述的模块级函数的数组的指针。如果模块未公开任何功能,则可能为NULL
。在我们的例子中,它是fibonacci_module_methods
。
其他字段在官方 Python 文档中有详细说明(请参阅https://docs.python.org/3/c-api/module.html ),但在我们的示例扩展中不需要。如果不需要,则应将其设置为NULL
,如果未指定,则将使用该值隐式初始化。这就是为什么我们在fibonacci_module_definition
变量中包含的模块描述可以采用这种简单的五元素形式:
static struct PyModuleDef fibonacci_module_definition = {
PyModuleDef_HEAD_INIT,
"fibonacci",
"Extension module that provides fibonacci sequence function",
-1,
fibonacci_module_methods
};
最后一段代码是模块初始化函数。这必须遵循一个非常特定的命名约定,因此 Python 解释器可以在加载动态/共享库时轻松地选择它。应该命名为PyInit_name
,其中名称是您的模块名称。因此,它与用作PyModuleDef
定义中的m_base
字段和setuptools.Extension()
调用的第一个参数的字符串完全相同。如果您不需要对模块执行复杂的初始化过程,那么它将采用非常简单的形式,与我们的示例完全相同:
PyMODINIT_FUNC PyInit_fibonacci(void) {
return PyModule_Create(&fibonacci_module_definition);
}
PyMODINIT_FUNC
宏是一个预处理器宏,它将此初始化函数的返回类型声明为PyObject*
,并在平台需要时添加任何特殊的链接声明。
正如在深入了解 Python/CAPI一节中所解释的,PyMethodDef
结构的ml_flags
位字段包含用于调用和绑定约定的标志。呼叫约定标志为:
METH_VARARGS
:这是 Python 函数或方法的典型约定,它只接受参数作为参数。作为此类函数的ml_meth
字段提供的类型应为PyCFunction
。该函数将提供两个PyObject*
类型的参数。第一个是self
对象(用于方法)或module
对象(用于模块函数)。具有该调用约定的 C 函数的典型签名是PyObject* function(PyObject* self, PyObject* args)
。METH_KEYWORDS
:这是 Python 函数在调用时接受关键字参数的约定。其相关的 C 型为PyCFunctionWithKeywords
。C 函数必须接受三个PyObject*
类型的参数:self
、args
和一个关键字参数字典。如果与METH_VARARGS
组合,前两个参数的含义与前一个调用约定相同,否则args
将为NULL
。典型的 C 函数签名是:PyObject* function(PyObject* self, PyObject* args, PyObject* keywds)
。METH_NOARGS
:这是不接受任何其他参数的 Python 函数的约定。C 函数应该是PyCFunction
类型的,因此签名与METH_VARARGS
约定的签名相同(两个self
和args
参数)。唯一的区别是args
将始终是NULL
,因此无需调用PyArg_ParseTuple()
。这不能与任何其他调用约定标志组合。METH_O
:这是接受单对象参数的函数和方法的缩写。C 函数的类型也是PyCFunction
,所以它接受两个PyObject*
参数:self
和args
。它的区别from METH_VARARGS
是不需要调用PyArg_ParseTuple()
,因为PyObject*
提供的 asargs
已经表示 Python 调用该函数时提供的单个参数。这也不能与任何其他调用约定标志组合。
接受关键字的函数用METH_KEYWORDS
或调用约定标志的按位组合来描述,其形式为METH_VARARGS |``METH_KEYWORDS
。如果是这样,它应该使用PyArg_ParseTupleAndKeywords()
而不是PyArg_ParseTuple()
或PyArg_UnpackTuple()
解析其参数。下面是一个示例模块,其中有一个函数返回None
并接受打印在标准输出上的两个命名关键字参数:
#include <Python.h>
static PyObject* print_args(PyObject *self, PyObject *args, PyObject *keywds)
{
char *first;
char *second;
static char *kwlist[] = {"first", "second", NULL};
if (!PyArg_ParseTupleAndKeywords(args, keywds, "ss", kwlist,
&first, &second))
return NULL;
printf("%s %s\n", first, second);
Py_INCREF(Py_None);
return Py_None;
}
static PyMethodDef module_methods[] = {
{"print_args", (PyCFunction)print_args,
METH_VARARGS | METH_KEYWORDS,
"print provided arguments"},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef module_definition = {
PyModuleDef_HEAD_INIT,
"kwargs",
"Keyword argument processing example",
-1,
module_methods
};
PyMODINIT_FUNC PyInit_kwargs(void) {
return PyModule_Create(&module_definition);
}
Python/C API 中的参数解析非常灵活,在的官方文档中有大量描述 https://docs.python.org/3.5/c-api/arg.html 。PyArg_ParseTuple()
和PyArg_ParseTupleAndKeywords()
中的格式参数允许对参数数量和类型进行细粒度控制。Python 中已知的所有高级调用约定都可以使用此 API 用 C 编码,包括:
- 参数具有默认值的函数
- 参数仅指定为关键字的函数
- 参数数目可变的函数
绑定约定标志为METH_CLASS
、METH_STATIC
、METH_COEXIST
为方法预留,不能用于描述模块功能。前两个是不言自明的。它们是classmethod
和staticmethod
装饰符的 C 对应项,用于更改传递给 C 函数的self
参数的含义。
METH_COEXIST
允许加载方法来代替现有定义。它很少有用。这主要是在中,您希望提供一个 C 方法的实现,该方法将根据所定义类型的其他特性自动生成。Python 文档给出了一个__contains__()
包装器方法的示例,如果该类型定义了sq_contains
槽,则将生成该方法。不幸的是,使用 Python/CAPI 定义自己的类和类型超出了本介绍性章节的范围。稍后在讨论 Cython 时,我们将讨论在扩展中创建您自己的类型,因为在纯 C 中这样做需要太多的样板代码,并且会留下很多出错的空间。
C,不像 Python,甚至 C++也没有语法来提高和捕获异常。所有错误处理都是通常使用函数返回值和可选的全局状态来处理,以存储能够解释上次故障原因的详细信息。
Python/CAPI 中的异常处理就是围绕这个简单的原则构建的。有一个全局每线程指示符,指示在 C API 中发生并运行的最后一个错误。它被设置为描述问题的原因。如果调用过程中此状态发生更改,还有一种标准化的方法通知调用方函数:
- 如果函数应该返回指针,则返回
NULL
- 如果函数应该返回一个
int
类型,则返回-1
Python/C API 中上述规则的唯一例外是返回1
表示成功和0
表示失败的PyArg_*()
函数。
为了了解这在实践中是如何工作的,让我们回顾一下前面几节示例中的fibonacci_py()
函数:
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
if (PyArg_ParseTuple(args, "l", &n)) {
result = Py_BuildValue("L", fibonacci((unsigned int) n));
}
return result;
}
以某种方式参与错误处理的行将突出显示。它从初始化用来存储函数返回值的result
变量开始。我们已经知道,NULL
是错误的指示器。假设错误是代码的默认状态,这就是您通常编写扩展的方式。
稍后我们有一个PyArg_ParseTuple()
调用,它将在出现异常时设置错误信息并返回0
。这是if
声明的一部分,在这种情况下,我们不再做任何事情,而是返回NULL
。无论是谁调用我们的函数,都会收到有关错误的通知。
Py_BuildValue()
也可以引发异常。它应该返回PyObject*
(指针),所以如果失败,它会给出NULL
。我们可以简单地将其存储为结果变量,并作为返回值进一步传递。
但我们的工作并没有以处理 Python/CAPI 调用引发的异常而结束。您很可能需要通知扩展用户发生了其他类型的错误或故障。Python/CAPI 有多个函数可以帮助您引发异常,但最常见的是PyErr_SetString()
。它使用给定的异常类型设置错误指示器,并提供一个附加字符串作为错误原因解释。此功能的完整签名为:
void PyErr_SetString(PyObject* type, const char* message)
我已经说过我们fibonacci_py()
函数的实现有严重的 bug。现在是修复它的正确时机。幸运的是,我们有适当的工具来做到这一点。问题在于以下线路中long
型到unsigned int
型的铸造不安全:
if (PyArg_ParseTuple(args, "l", &n)) {
result = Py_BuildValue("L", fibonacci((unsigned int) n));
}
由于PyArg_ParseTuple()
调用,第一个也是唯一一个参数将被解释为long
类型("l"
说明符),并存储在本地n
变量中。然后将其强制转换为unsigned int
,因此如果用户使用负值从 Python 调用fibonacci()
函数,则会出现问题。例如,-1
作为有符号 32 位整数,在转换为无符号 32 位整数时将被解释为4294967295
。这样的值将导致深度递归,并将导致堆栈溢出和分段错误。请注意,如果用户给出任意大的正参数,也可能发生同样的情况。如果不彻底重新设计 Cfibonacci()
函数,我们无法解决这个问题,但我们至少可以尝试确保传递的参数满足一些先决条件。在这里,我们检查n
参数的值是否大于或等于零,如果不是这样,我们将引发ValueError
异常:
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
long long fib;
if (PyArg_ParseTuple(args, "l", &n)) {
if (n<0) {
PyErr_SetString(PyExc_ValueError,
"n must not be less than 0");
} else {
result = Py_BuildValue("L", fibonacci((unsigned int)n));
}
}
return result;
}
最后一点需要注意的是,全局错误状态本身并不清楚。有些错误可以在 C 函数中优雅地处理(与在 Python 中使用try ... except
子句相同),如果错误指示器不再有效,则需要能够清除它。其功能为PyErr_Clear()
。
我已经提到,扩展可以是绕过 Python GIL 的一种方式。CPython 实现有一个著名的限制,即一次只能有一个线程执行 Python 代码。虽然多处理是避免这个问题的建议方法,但对于一些高度并行化的算法来说,它可能不是一个好的解决方案,因为运行额外的进程会带来资源开销。
由于扩展主要用于在纯 C 中执行大部分工作而不调用 Python/CAPI 的情况,因此在某些应用程序部分中发布 GIL 是可能的(甚至是可取的)。多亏了这一点,您仍然可以从多 CPU 内核和多线程应用程序设计中获益。您需要做的唯一一件事是使用 Python/capi 提供的特定宏包装已知不使用任何 Python/capi 调用或 Python 结构的代码块。提供这两个预处理器宏是为了简化释放和重新获取全局解释器锁的整个过程:
Py_BEGIN_ALLOW_THREADS
:声明保存当前线程状态的隐藏局部变量,并释放 GILPy_END_ALLOW_THREADS
:这将重新获取 GIL 并从上一个宏声明的局部变量恢复线程状态
当我们仔细查看fibonacci
扩展示例时,我们可以清楚地看到fibonacci()
函数不执行任何 Python 代码,也不涉及任何 Python 结构。这意味着可以更新仅包装fibonacci(n)
执行的fibonacci_py()
函数,以释放该调用周围的 GIL:
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
long long fib;
if (PyArg_ParseTuple(args, "l", &n)) {
if (n<0) {
PyErr_SetString(PyExc_ValueError,
"n must not be less than 0");
} else {
Py_BEGIN_ALLOW_THREADS;
fib = fibonacci(n);
Py_END_ALLOW_THREADS;
result = Py_BuildValue("L", fib);
}}
return result;
}
最后,我们来讨论 Python 中的内存管理这一重要主题。Python 有自己的垃圾收集器,但它的设计只是为了解决引用计数算法中的循环引用问题。引用计数是管理不再需要的对象的释放的主要方法。
Python/CAPI 文档引入了引用的所有权,以解释它如何处理对象的释放。Python 中的对象从不被拥有,它们总是被共享的。对象的实际创建由 Python 的内存管理器管理。它是 CPython 解释器的组件,负责为存储在私有堆中的对象分配和释放内存。相反,可以拥有的是对对象的引用。
Python 中由引用(PyObject*
指针)表示的每个对象都有一个关联的引用计数。当它变为零时,意味着没有人持有对该对象的任何有效引用,并且可以调用与其类型关联的 deallocator。Python/CAPI 提供了两个宏来增加和减少引用计数:Py_INCREF()
和Py_DECREF()
。但在讨论其细节之前,我们需要了解更多与参考所有权相关的术语:
- 所有权的传递:每当我们说函数通过引用传递所有权时,这意味着它已经增加了引用计数,当不再需要对对象的引用时,调用者有责任减少计数。大多数返回新创建的对象的函数(如
Py_BuildValue
)都会这样做。如果该对象将从我们的函数返回给另一个调用方,那么所有权将再次传递。在这种情况下,我们不会减少参考数量,因为这不再是我们的责任。这就是为什么fibonacci_py()
函数不调用result
变量上的Py_DECREF()
。 - 借用引用:当函数接收到对某个 Python 对象的引用作为参数时,引用的借用发生。该函数中此类引用的引用计数不应减少,除非在其范围内显式增加。在我们的
fibonacci_py()
函数中,self
和args
参数是借用的引用,因此我们不调用PyDECREF()
。一些 Python/CAPI 函数也可能返回借用的引用。值得注意的例子有PyTuple_GetItem()
和PyList_GetItem()
。人们常说这样的引用是不受保护的。除非它将作为函数的返回值返回,否则不需要处理它的所有权。在大多数情况下,如果我们使用这些借用的引用作为其他 Python/CAPI 调用的参数,则应该格外小心。在某些情况下,可能需要使用额外的Py_INCREF()
来额外保护此类引用,然后将其用作其他函数的参数,并在不再需要时调用Py_DECREF()
。 - 偷取的引用:Python/C API 函数也可以偷取引用,而不是借用引用作为调用参数提供。这就是的两个功能:
PyTuple_SetItem()
和PyList_SetItem()
的情况。他们完全接管了转交给他们的参考的责任。它们不会自行增加引用计数,但在不再需要引用时会调用Py_DECREF()
。
在编写复杂的扩展时,关注引用计数是最困难的事情之一。在多线程设置中运行代码之前,可能不会注意到一些不太明显的问题。
另一个常见问题是由 Python 对象模型的本质以及某些函数返回借用的引用这一事实引起的。当引用计数变为零时,执行释放函数。对于用户定义的类,可以定义一个__del__()
方法,该方法将在此时被调用。这可以是任何 Python 代码,并且可能会影响其他对象及其引用计数。官方 Python 文档给出了以下可能受此问题影响的代码示例:
void bug(PyObject *list) {
PyObject *item = PyList_GetItem(list, 0);
PyList_SetItem(list, 1, PyLong_FromLong(0L));
PyObject_Print(item, stdout, 0); /* BUG! */
}
它看起来完全无害,但问题是我们无法知道list
对象包含哪些元素。当PyList_SetItem()
在list[1]
索引上设置新值时,先前存储在该索引中的对象的所有权将被释放。如果它是唯一存在的引用,则引用计数将变为 0,并且对象将被解除分配。它可能是某个用户定义的类,具有__del__()
方法的自定义实现。如果__del__()
执行的结果将item[0]
从列表中删除,则会发生严重问题。注意,PyList_GetItem()
返回一个借用的引用!在返回引用之前,它不会调用Py_INCREF()
。因此,在该代码中,PyObject_Print()
可能会被调用,引用一个不再存在的对象。这将导致分段错误并使 Python 解释器崩溃。
正确的方法是在我们需要它们的整个时间内保护借用的引用,因为中间的任何调用都有可能导致释放任何其他对象,即使它们看起来不相关:
void no_bug(PyObject *list) {
PyObject *item = PyList_GetItem(list, 0);
Py_INCREF(item);
PyList_SetItem(list, 1, PyLong_FromLong(0L));
PyObject_Print(item, stdout, 0);
Py_DECREF(item);
}
Cython 既是一个优化静态编译器,也是一种编程语言的名称,它是 Python 的超集。作为一个编译器,它可以使用 Python/C API 对本地 Python 代码及其 Cython 方言进行源到源的编译,以实现 Python C 扩展。它允许您将 Python 和 C 的功能结合起来,而无需手动处理 Python/CAPI。
对于使用 Cython 创建的扩展,您将获得的主要优势是使用它提供的超集语言。无论如何,可以使用源代码到源代码的编译从普通 Python 代码创建扩展。这是 Cython 最简单的方法,因为它几乎不需要更改代码,并且可以以非常低的开发成本提供一些显著的性能改进。
Cython 提供了一个简单的cythonize
实用函数,允许您轻松地将编译过程与distutils
或setuptools
集成。让我们假设我们想要将fibonacci()
函数的纯 Python 实现编译为 C 扩展。如果它位于fibonacci
模块中,则最小的setup.py
脚本可以如下所示:
from setuptools import setup
from Cython.Build import cythonize
setup(
name='fibonacci',
ext_modules=cythonize(['fibonacci.py'])
)
Cython 用作 Python 语言的源代码编译工具还有另一个好处。源代码到源代码的扩展编译可以是源代码分发安装过程中完全可选的一部分。如果需要安装包的环境没有 Cython 或任何其他构建必备组件,则可以将其作为正常的纯 Python包安装。用户不应该注意到以这种方式分发的代码行为中的任何功能差异。
分发使用 Cython 构建的扩展的一种常见方法是同时包含 Python/Cython 源代码和从这些源文件生成的 C 代码。通过这种方式,可以根据建筑先决条件的存在,以三种不同的方式安装软件包:
- 如果安装环境中有 Cython 可用,那么扩展 C 代码将从提供的 Python/Cython 源代码生成
- 如果 Cython 不可用,但有可用的构建先决条件(C 编译器、Python/C API 头),则扩展将从分布式预生成的 C 文件构建
- 如果前面的两个先决条件都不可用,但扩展是从纯 Python 源代码创建的,那么模块将像普通 Python 代码一样安装,编译步骤将被跳过
请注意,Cython 文档指出,包括生成的 C 文件和 Cython 源代码是分发 Cython 扩展的推荐方式。同一文档指出,默认情况下应禁用 Cython 编译,因为用户的环境中可能没有所需版本的 Cython,这可能会导致意外的编译问题。无论如何,随着环境隔离的到来,这似乎是一个不太令人担忧的问题。此外,Cython 是 PyPI 上可用的一个有效 Python 包,因此可以在特定版本中轻松地将其定义为您的项目需求。当然,包括这样一个先决条件是一个具有严重影响的决定,应该非常仔细地加以考虑。更安全的解决方案是利用setuptools
包中extras_require
功能的强大功能,允许用户决定是否将 Cython 与特定环境变量一起使用:
import os
from distutils.core import setup
from distutils.extension import Extension
try:
# cython source to source compilation available
# only when Cython is available
import Cython
# and specific environment variable says
# explicitely that Cython should be used
# to generate C sources
USE_CYTHON = bool(os.environ.get("USE_CYTHON"))
except ImportError:
USE_CYTHON = False
ext = '.pyx' if USE_CYTHON else '.c'
extensions = [Extension("fibonacci", ["fibonacci"+ext])]
if USE_CYTHON:
from Cython.Build import cythonize
extensions = cythonize(extensions)
setup(
name='fibonacci',
ext_modules=extensions,
extras_require={
# Cython will be set in that specific version
# as a requirement if package will be intalled
# with '[with-cython]' extra feature
'cython': ['cython==0.23.4']
}
)
pip
安装工具通过在包名中添加[extra-name]
后缀,支持安装带有附加选项的包。对于前面的示例,可以使用以下命令启用从本地源安装期间的可选 Cython 要求和编译:
$ USE_CYTHON=1 pip install .[with-cython]
Cython 不仅是一个编译器,而且还是 Python 语言的超集。超集意味着允许使用任何有效的 Python 代码,并且可以使用其他特性对其进行进一步更新,例如支持调用 C 函数或在变量和类属性上声明 C 类型。因此,任何用 Python 编写的代码都是用 Cython 编写的。这解释了为什么使用 Cython 编译器可以如此轻松地将普通 Python 模块编译成 C。
但我们不会停留在这个简单的事实上。我们不会说我们的引用fibonacci()
函数也是 Python 超集中有效扩展的代码,我们将尝试对其进行一些改进。这不会对我们的函数设计进行任何真正的优化,但会有一些小的更新,使它能够从用 Cython 编写的代码中获益。
Cython 源使用不同的文件扩展名。它是.pyx
而不是.py
。假设我们仍然想要实现斐波那契序列。fibonacci.pyx
的内容可能如下:
"""Cython module that provides fibonacci sequence function."""
def fibonacci(unsigned int n):
"""Return nth Fibonacci sequence number computed recursively."""
if n < 2:
return n
else:
return fibonacci(n - 1) + fibonacci(n - 2)
正如您所看到的,唯一真正改变的是fibonacci()
函数的签名。由于 Cython 中的可选静态类型,我们可以将n
参数声明为unsigned int
,这应该会稍微改进我们函数的工作方式。此外,它比我们以前手工编写扩展时做的多得多。如果 Cython 函数的参数声明为静态类型,则扩展将通过引发适当的异常自动处理转换和溢出错误:
>>> from fibonacci import fibonacci
>>> fibonacci(5)
5
>>> fibonacci(-1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "fibonacci.pyx", line 21, in fibonacci.fibonacci (fibonacci.c:704)
OverflowError: can't convert negative value to unsigned int
>>> fibonacci(10 ** 10)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "fibonacci.pyx", line 21, in fibonacci.fibonacci (fibonacci.c:704)
OverflowError: value too large to convert to unsigned int
我们已经知道 Cython 只编译源代码到源代码,生成的代码使用的 Python/C API 与我们手工编写扩展 C 代码时使用的相同。注意,fibonacci()
是一个递归函数,所以它经常调用自己。这意味着,尽管我们为输入参数声明了一个静态类型,但在递归调用期间,它将像对待任何其他 Python 函数一样对待自己。因此,n-1
和n-2
将被打包回 Python 对象,然后传递到内部fibonacci()
实现的隐藏包装层,该层将再次将其恢复为unsigned int
类型。这将一次又一次地发生,直到我们达到递归的最终深度。这不一定是个问题,但涉及的参数处理比实际需要的多得多。
我们可以通过将更多的工作委托给一个对 Python 结构一无所知的纯 C 函数来减少 Python 函数调用和参数处理的开销。我们以前在用纯 C 创建 C 扩展时就这样做了,我们也可以在 Cython 中这样做。我们可以使用cdef
关键字来声明只接受和返回 C 类型的 C 风格函数:
cdef long long fibonacci_cc(unsigned int n):
if n < 2:
return n
else:
return fibonacci_cc(n - 1) + fibonacci_cc(n - 2)
def fibonacci(unsigned int n):
""" Return nth Fibonacci sequence number computed recursively
"""
return fibonacci_cc(n)
我们可以走得更远。通过一个简单的 C 示例,我们最终展示了如何在调用纯 C 函数的过程中释放 GIL,因此对于多线程应用程序来说,扩展稍微好一些。在前面的示例中,我们使用 Python/CAPI 头中的Py_BEGIN_ALLOW_THREADS
和Py_END_ALLOW_THREADS
预处理器宏将代码部分标记为不受 Python 调用。Cython 语法要短得多,更容易记住。GIL 可以使用一个简单的with nogil
语句在代码部分发布:
def fibonacci(unsigned int n):
""" Return nth Fibonacci sequence number computed recursively
"""
with nogil:
result = fibonacci_cc(n)
return fibonacci_cc(n)
您还可以将整个 C 风格函数标记为无需 GIL 即可安全调用:
cdef long long fibonacci_cc(unsigned int n) nogil:
if n < 2:
return n
else:
return fibonacci_cc(n - 1) + fibonacci_cc(n - 2)
必须知道,此类函数不能将 Python 对象作为参数或返回类型。当标记为nogil
的函数需要执行任何 Python/C API 调用时,它必须使用with gil
语句获取 GIL。
老实说,我开始用 Python 冒险只是因为我厌倦了在 C 和 C++中编写软件的所有困难。事实上,当程序员意识到其他语言不能满足用户的需求时,他们开始学习 Python 是很常见的。与 C、C++或 java 相比,在 Python 中编程是轻而易举的事。一切似乎都很简单,设计得很好。您可能会认为,已经没有地方可以旅行,也不再需要其他编程语言。
当然,没有比这更糟糕的了。是的,Python 是一种了不起的语言,它有很多很酷的特性,并且在许多领域都有应用。但这并不意味着它是完美的,没有任何缺点。它很容易理解和书写,但这种简单是有代价的。它不像许多人想象的那么慢,但永远不会像 C 那么快。它具有很高的可移植性,但它的解释器在许多体系结构上并不像其他语言的编译器那样可用。我们可以永远使用这个列表。
解决这个问题的一个方法是编写扩展,这样我们就可以将好的旧 C的一些优点带回 Python。在大多数情况下,它运行良好。问题是:我们真的使用 Python 是因为我们想用 C 扩展它吗?答案是否。在我们没有更好的选择的情况下,这只是一种不方便的必要。
用许多不同的语言开发应用程序并非易事,这已不是秘密。Python 和 C 是完全不同的技术,很难找到它们的共同点。同样,没有一个应用程序是没有 bug 的。如果扩展在代码库中变得很常见,调试可能会变得很痛苦。这不仅是因为调试 C 代码需要完全不同的工作流和工具,还因为您需要经常在两种不同的语言之间切换上下文。
我们都是人,都有有限的认知能力。当然,有些人可以同时有效地处理多层抽象和技术堆栈,但他们似乎是非常罕见的样本。无论您有多熟练,维护这种混合解决方案总是要付出额外的代价。这要么需要额外的精力和时间在 C 和 Python 之间切换,要么需要额外的压力,最终会降低效率。
根据 TIOBE 索引,C 仍然是最流行的编程语言之一。尽管如此,Python 程序员对它知之甚少或几乎一无所知是很常见的。就我个人而言,我认为 C 应该是编程界的通用语言,但我的观点不太可能改变这一点。Python 也是如此诱人且易于学习,以至于许多程序员忘记了他们以前的所有经验,完全转向了新技术。编程不像骑自行车。如果不充分使用和打磨,这种特殊技能的侵蚀速度会更快。即使是拥有强大 C 语言背景的程序员,如果他们决定长时间钻研 Python,也有可能逐渐失去他们以前的知识。所有这些导致了一个简单的结论:很难找到能够理解和扩展您的代码的人。对于开源软件包,这意味着更少的自愿贡献者。在封闭源代码中,这意味着不是所有的团队成员都能够在不破坏东西的情况下开发和维护扩展。
当涉及到故障时,扩展可能会中断,非常严重。与 Python 相比,静态类型给了您很多优势,并允许您在编译步骤中发现许多问题,如果没有严格的测试例程和完整的测试覆盖率,在 Python 中很难发现这些问题。另一方面,所有内存管理都必须手动执行。错误的内存管理是 C 语言中大多数编程错误的主要原因。在最好的情况下,这样的错误只会导致一些内存泄漏,这将逐渐消耗您的所有环境资源。最好的情况并不意味着容易处理。如果不使用适当的外部工具(如 Valgrind),内存泄漏很难发现。无论如何,在大多数情况下,扩展代码中的内存管理问题将导致在 Python 中无法恢复的分段错误,并将导致解释器崩溃而不会引发任何异常。这意味着您最终需要配备大多数 Python 程序员不需要使用的其他工具。这会增加开发环境和工作流程的复杂性。
多亏了ctypes
(标准库中的一个模块)或cffi
(一个外部包),您可以在 Python 中集成几乎所有编译的动态/共享库,无论它是用什么语言编写的。您可以在纯 Python 中实现这一点,而无需任何编译步骤,因此这是用 C 编写扩展的一个有趣的替代方法。
这并不意味着您不需要了解任何关于 C 的知识。这两种解决方案都要求您对 C 以及动态库的工作原理有一个合理的了解。另一方面,它们消除了处理 Python 引用计数的负担,并大大降低了犯痛苦错误的风险。通过ctypes
或cffi
与 C 代码接口比编写和编译 C 扩展模块更具可移植性。
ctypes
是最流行的模块,可以从动态或共享库调用函数,而无需编写自定义 C 扩展。原因是显而易见的。它是标准库的一部分,因此它始终可用,不需要任何外部依赖项。它是一个外部函数接口(FFI)库,提供创建 C 兼容数据类型的 API。
ctypes
中有四种类型的动态库加载器可用,使用它们有两种约定。表示动态和共享库的类有ctypes.CDLL
、ctypes.PyDLL
、ctypes.OleDLL
和ctypes.WinDLL
。最后两个仅在 Windows 上可用,因此我们在这里不讨论它们。CDLL
与PyDLL
的区别如下:
ctypes.CDLL
:此类表示加载的共享库。这些库中的函数使用标准调用约定,并假定返回int
。吉尔在通话中被释放。ctypes.PyDLL
:该类的工作原理与CDLL
类似,但调用过程中不会释放 GIL。执行后,将检查 Python 错误标志,如果设置了该标志,将引发异常。它仅在直接从 Python/CAPI 调用函数时有用。
要加载库,您可以使用正确的参数实例化前面的一个类,或者从与特定类关联的子模块调用LoadLibrary()
函数:
ctypes.cdll.LoadLibrary()
用于ctypes.CDLL
ctypes.pydll.LoadLibrary()
用于ctypes.PyDLL
ctypes.windll.LoadLibrary()
用于ctypes.WinDLL
ctypes.oledll.LoadLibrary()
用于ctypes.OleDLL
加载共享库时的主要挑战是如何以可移植的方式查找它们。不同的系统对共享库使用不同的后缀(Windows 上为.dll
,OS X 上为.dylib
,Linux 上为.so
),并在不同的位置搜索它们。这方面的主要问题是 Windows,它没有预定义的库命名方案。因此,我们不会讨论在这个系统上使用ctypes
加载库的细节,主要集中在 Linux 和 Mac OS X 上,它们以一致且类似的方式处理这个问题。如果您对 Windows 平台感兴趣,请参阅官方的ctypes
文档,其中包含大量关于支持该系统的信息(请参阅https://docs.python.org/3.5/library/ctypes.html 。
两种库加载约定(“T0”函数和特定库类型类)都要求您使用完整的库名称。这意味着需要包括所有预定义的库前缀和后缀。例如,要在 Linux 上加载 C 标准库,需要编写以下代码:
>>> import ctypes
>>> ctypes.cdll.LoadLibrary('libc.so.6')
<CDLL 'libc.so.6', handle 7f0603e5f000 at 7f0603d4cbd0>
在这里,对于 Mac OS X,这将是:
>>> import ctypes
>>> ctypes.cdll.LoadLibrary('libc.dylib')
幸运的是,ctypes.util
子模块提供了一个find_library()
函数,该函数允许使用其名称加载库,而无需任何前缀或后缀,并且可以在任何具有预定义共享库命名方案的系统上工作:
>>> import ctypes
>>> from ctypes.util import find_library
>>> ctypes.cdll.LoadLibrary(find_library('c'))
<CDLL '/usr/lib/libc.dylib', handle 7fff69b97c98 at 0x101b73ac8>
>>> ctypes.cdll.LoadLibrary(find_library('bz2'))
<CDLL '/usr/lib/libbz2.dylib', handle 10042d170 at 0x101b6ee80>
>>> ctypes.cdll.LoadLibrary(find_library('AGL'))
<CDLL '/System/Library/Frameworks/AGL.framework/AGL', handle 101811610 at 0x101b73a58>
当库成功加载时,常见的模式是将其存储为与库同名的模块级变量。这些函数可以作为对象属性访问,因此调用它们就像从任何其他导入模块调用 Python 函数一样:
>>> import ctypes
>>> from ctypes.util import find_library
>>>
>>> libc = ctypes.cdll.LoadLibrary(find_library('c'))
>>>
>>> libc.printf(b"Hello world!\n")
Hello world!
13
不幸的是,除了整数、字符串和字节之外,所有内置 Python 类型都与 C 数据类型不兼容,因此必须包装在ctypes
模块提供的相应类中。以下是来自ctypes
文档的兼容数据类型的完整列表:
ctypes 类型
|
C 型
|
Python 类型
|
| --- | --- | --- |
| c_bool
| _Bool
| bool
(1) |
| c_char
| char
| 单字符bytes
对象 |
| c_wchar
| wchar_t
| 1 个字符string
|
| c_byte
| char
| int
|
| c_ubyte
| unsigned char
| int
|
| c_short
| short
| int
|
| c_ushort
| unsigned short
| int
|
| c_int
| int
| int
|
| c_uint
| unsigned int
| int
|
| c_long
| long
| int
|
| c_ulong
| unsigned long
| int
|
| c_longlong
| __int64 or long long
| int
|
| c_ulonglong
| unsigned __int64 or unsigned long long
| int
|
| c_size_t
| size_t
| int
|
| c_ssize_t
| ssize_t or Py_ssize_t
| int
|
| c_float
| float
| float
|
| c_double
| double
| float
|
| c_longdouble
| long double
| float
|
| c_char_p
| char * (NUL terminated)
| bytes
对象或None
|
| c_wchar_p
| wchar_t * (NUL terminated)
| string
或None
|
| c_void_p
| void *
| int
或None
|
如您所见,上表不包含将任何 Python 集合反映为 C 数组的专用类型。为 C 数组创建类型的推荐方法是简单地使用乘法运算符和所需的基本ctypes
类型:
>>> import ctypes
>>> IntArray5 = ctypes.c_int * 5
>>> c_int_array = IntArray5(1, 2, 3, 4, 5)
>>> FloatArray2 = ctypes.c_float * 2
>>> c_float_array = FloatArray2(0, 3.14)
>>> c_float_array[1]
3.140000104904175
将部分功能实现工作委托给用户提供的自定义回调是一种非常流行的设计模式。接受此类回调的 C 标准库中最有名的函数是一个qsort()
函数,它提供了快速排序算法的通用实现。您不太可能希望使用此算法而不是更适合排序 Python 集合的默认 PythonTimsort。无论如何,qsort()
似乎是一个高效排序算法和使用回调机制的 C API 的典型例子,这在许多编程书籍中都可以找到。这就是为什么我们将尝试使用它作为将 Python 函数作为 C 回调传递的示例。
普通 Python 函数类型与qsort()
规范要求的回调函数类型不兼容。以下是来自 BSDman
页面的qsort()
签名,也包含接受回调类型的类型(compar
参数):
void qsort(void *base, size_t nel, size_t width,
int (*compar)(const void *, const void *));
所以要从libc
执行qsort()
,您需要通过:
base
:这是需要排序为void*
指针的数组。nel
:这是作为size_t
的元素数。width
:数组中单个元素的大小为size_t
。compar
:这是指向应该返回int
并接受两个void*
指针的函数的指针。它指向一个函数,该函数比较被排序的两个元素的大小。
在使用 ctypes调用 C 函数一节中,我们已经知道如何使用乘法运算符从其他ctypes
类型构造 C 数组。nel
应该是size_t
,并且映射到 Pythonint
,所以不需要任何额外的包装,可以作为len(iterable)
传递。一旦我们知道base
数组的类型,就可以使用ctypes.sizeof()
函数获得width
值。我们需要知道的最后一件事是如何创建指向与compar
参数兼容的 Python 函数的指针。
ctypes
模块包含一个CFUNTYPE()
工厂函数,允许我们包装 Python 函数,并将它们表示为 C 可调用函数指针。第一个参数是包装函数应该返回的 C 返回类型。它后面是函数接受作为其参数的 C 类型变量列表。与qsort()
的compar
参数兼容的函数类型为:
CMPFUNC = ctypes.CFUNCTYPE(
# return type
ctypes.c_int,
# first argument type
ctypes.POINTER(ctypes.c_int),
# second argument type
ctypes.POINTER(ctypes.c_int),
)
CFUNTYPE()
使用cdecl
调用约定,因此仅与CDLL
和PyDLL
共享库兼容。加载了WinDLL
或OleDLL
的 Windows 上的动态库使用stdcall
调用约定。这意味着必须使用另一个工厂将 Python 函数包装为 C 可调用函数指针。在ctypes
中,它是WINFUNCTYPE()
。
总结一下,我们假设我们想要使用标准 C 库中的qsort()
函数对随机排列的整数列表进行排序。下面是一个示例脚本,它展示了如何使用到目前为止我们所了解的关于ctypes
的所有内容来完成这项工作:
from random import shuffle
import ctypes
from ctypes.util import find_library
libc = ctypes.cdll.LoadLibrary(find_library('c'))
CMPFUNC = ctypes.CFUNCTYPE(
# return type
ctypes.c_int,
# first argument type
ctypes.POINTER(ctypes.c_int),
# second argument type
ctypes.POINTER(ctypes.c_int),
)
def ctypes_int_compare(a, b):
# arguments are pointers so we access using [0] index
print(" %s cmp %s" % (a[0], b[0]))
# according to qsort specification this should return:
# * less than zero if a < b
# * zero if a == b
# * more than zero if a > b
return a[0] - b[0]
def main():
numbers = list(range(5))
shuffle(numbers)
print("shuffled: ", numbers)
# create new type representing array with length
# same as the length of numbers list
NumbersArray = ctypes.c_int * len(numbers)
# create new C array using a new type
c_array = NumbersArray(*numbers)
libc.qsort(
# pointer to the sorted array
c_array,
# length of the array
len(c_array),
# size of single array element
ctypes.sizeof(ctypes.c_int),
# callback (pointer to the C comparison function)
CMPFUNC(ctypes_int_compare)
)
print("sorted: ", list(c_array))
if __name__ == "__main__":
main()
作为回调提供的比较函数有一个额外的print
语句,所以我们可以看到排序过程中是如何执行的:
$ python ctypes_qsort.py
shuffled: [4, 3, 0, 1, 2]
4 cmp 3
4 cmp 0
3 cmp 0
4 cmp 1
3 cmp 1
0 cmp 1
4 cmp 2
3 cmp 2
1 cmp 2
sorted: [0, 1, 2, 3, 4]
CFFI 是 Python 的一个外部函数接口,它是ctypes
的一个有趣的替代品。它不是标准库的一部分,但在 PyPI 上可以作为cffi
包轻松获得。它与ctypes
不同,因为它更强调重用普通 C 声明,而不是在单个模块中提供广泛的 Python API。它要复杂得多,还具有一个特性,允许您使用 C 编译器自动将集成层的某些部分编译成扩展。因此,它可以作为一种混合解决方案,填补 C 扩展和ctypes
之间的空白。
因为这是一个非常大的项目,所以不可能很快在几个段落中介绍它。另一方面,如果不多说一些,那将是一种耻辱。我们已经讨论了使用ctypes
集成标准库中qsort()
函数的一个示例。因此,显示这两种解决方案之间主要差异的最佳方法是使用cffi
重新实现相同的示例。我希望一段代码比几段文字更有价值:
from random import shuffle
from cffi import FFI
ffi = FFI()
ffi.cdef("""
void qsort(void *base, size_t nel, size_t width,
int (*compar)(const void *, const void *));
""")
C = ffi.dlopen(None)
@ffi.callback("int(void*, void*)")
def cffi_int_compare(a, b):
# Callback signature requires exact matching of types.
# This involves less more magic than in ctypes
# but also makes you more specific and requires
# explicit casting
int_a = ffi.cast('int*', a)[0]
int_b = ffi.cast('int*', b)[0]
print(" %s cmp %s" % (int_a, int_b))
# according to qsort specification this should return:
# * less than zero if a < b
# * zero if a == b
# * more than zero if a > b
return int_a - int_b
def main():
numbers = list(range(5))
shuffle(numbers)
print("shuffled: ", numbers)
c_array = ffi.new("int[]", numbers)
C.qsort(
# pointer to the sorted array
c_array,
# length of the array
len(c_array),
# size of single array element
ffi.sizeof('int'),
# callback (pointer to the C comparison function)
cffi_int_compare,
)
print("sorted: ", list(c_array))
if __name__ == "__main__":
main()
本章解释了书中最高级的主题之一。我们讨论了构建 Python 扩展的原因和工具。我们从编写只依赖 Python/CAPI 的纯 C 扩展开始,然后用 Cython 重新实现它们,以展示如果您只选择合适的工具,它是多么容易。
仍然有一些原因需要以艰难的方式进行工作并且只使用纯 C 编译器和Python.h
头。无论如何,最好的建议是使用 Cython 或 Pyrex 之类的工具(这里没有介绍),因为这将使您的代码库更具可读性和可维护性。它还可以避免由于不谨慎的引用计数和内存管理而导致的大多数问题。
我们对扩展的讨论以ctypes
和 CFFI 作为解决集成共享库问题的替代方法而结束。因为它们不需要编写自定义扩展来从已编译的二进制文件调用函数,所以它们应该是实现这一点的首选工具,特别是在不需要使用自定义 C 代码的情况下。
在下一章中,我们将从低级编程技术中稍作休息,深入研究同样重要的代码管理和版本控制系统。