使用 C 库

原文: http://docs.cython.org/en/latest/src/tutorial/clibraries.html

除了编写快速代码之外,Cython 的一个主要用例是从 Python 代码调用外部 C 库。由于 Cython 代码编译为 C 代码本身,因此直接在代码中调用 C 函数实际上是微不足道的。下面给出了在 Cython 代码中使用(和包装)外部 C 库的完整示例,包括适当的错误处理和有关为 Python 和 Cython 代码设计合适 API 的注意事项。

想象一下,您需要一种有效的方法来将整数值存储在 FIFO 队列中。由于内存非常重要,并且值实际上来自 C 代码,因此您无法在列表或双端队列中创建和存储 Python int对象。所以你要注意 C 中的队列实现。

经过一些网络搜索,你会发现 C 算法库 [CAlg] 并决定使用它的双端队列实现。但是,为了使处理更容易,您决定将其包装在可以封装所有内存管理的 Python 扩展类型中。

| [CAlg] | Simon Howard,C 算法库, http://c-algorithms.sourceforge.net/ |

定义外部声明

你可以在这里下载 CAlg

队列实现的 C API,在头文件c-algorithms/src/queue.h中定义,基本上如下所示:

  1. /* queue.h */
  2. typedef struct _Queue Queue;
  3. typedef void *QueueValue;
  4. Queue *queue_new(void);
  5. void queue_free(Queue *queue);
  6. int queue_push_head(Queue *queue, QueueValue data);
  7. QueueValue queue_pop_head(Queue *queue);
  8. QueueValue queue_peek_head(Queue *queue);
  9. int queue_push_tail(Queue *queue, QueueValue data);
  10. QueueValue queue_pop_tail(Queue *queue);
  11. QueueValue queue_peek_tail(Queue *queue);
  12. int queue_is_empty(Queue *queue);

首先,第一步是在.pxd文件中重新定义 C API,例如cqueue.pxd

  1. # cqueue.pxd
  2. cdef extern from "c-algorithms/src/queue.h":
  3. ctypedef struct Queue:
  4. pass
  5. ctypedef void* QueueValue
  6. Queue* queue_new()
  7. void queue_free(Queue* queue)
  8. int queue_push_head(Queue* queue, QueueValue data)
  9. QueueValue queue_pop_head(Queue* queue)
  10. QueueValue queue_peek_head(Queue* queue)
  11. int queue_push_tail(Queue* queue, QueueValue data)
  12. QueueValue queue_pop_tail(Queue* queue)
  13. QueueValue queue_peek_tail(Queue* queue)
  14. bint queue_is_empty(Queue* queue)

请注意这些声明与头文件声明几乎完全相同,因此您通常可以将它们复制过来。但是,您不需要像上面那样提供 所有 声明,只需要在代码或其他声明中使用那些声明,这样 Cython 就可以看到它们的足够和一致的子集。然后,考虑对它们进行一些调整,以使它们在 Cython 中更舒适。

具体来说,您应该注意为 C 函数选择好的参数名称,因为 Cython 允许您将它们作为关键字参数传递。稍后更改它们是向后不兼容的 API 修改。立即选择好的名称将使这些函数更适合使用 Cython 代码。

我们上面使用的头文件的一个值得注意的差异是第一行中Queue结构的声明。在这种情况下,Queue用作 不透明手柄 ;只有被调用的库才知道里面是什么。由于没有 Cython 代码需要知道结构的内容,我们不需要声明它的内容,所以我们只提供一个空的定义(因为我们不想声明 C 头中引用的_Queue类型) [1]

| [1] | cdef struct Queue: passctypedef struct Queue: pass之间存在细微差别。前者声明一种在 C 代码中引用为struct Queue的类型,而后者在 C 中引用为Queue。这是 Cython 无法隐藏的 C 语言怪癖。大多数现代 C 库使用ctypedef类型的结构。 |

另一个例外是最后一行。 queue_is_empty()函数的整数返回值实际上是一个 C 布尔值,即关于它的唯一有趣的事情是它是非零还是零,表明队列是否为空。这最好用 Cython 的bint类型表示,它在 C 中使用时是普通的int类型,但在转换为 Python 对象时映射到 Python 的布尔值TrueFalse。这种在.pxd文件中收紧声明的方法通常可以简化使用它们的代码。

最好为您使用的每个库定义一个.pxd文件,如果 API 很大,有时甚至为每个头文件(或功能组)定义。这简化了它们在其他项目中的重用。有时,您可能需要使用标准 C 库中的 C 函数,或者想直接从 CPython 调用 C-API 函数。对于像这样的常见需求,Cython 附带了一组标准的.pxd文件,这些文件以易于使用的方式提供这些声明,以适应它们在 Cython 中的使用。主要包装是cpythonlibclibcpp。 NumPy 库还有一个标准的.pxd文件numpy,因为它经常在 Cython 代码中使用。有关提供的.pxd文件的完整列表,请参阅 Cython 的Cython/Includes/源包。

编写包装类

在声明我们的 C 库的 API 之后,我们可以开始设计应该包装 C 队列的 Queue 类。它将存在于名为queue.pyx的文件中。 [2]

| [2] | 请注意,.pyx文件的名称必须与带有 C 库声明的cqueue.pxd文件不同,因为两者都没有描述相同的代码。具有相同名称的.pyx文件旁边的.pxd文件定义.pyx文件中代码的导出声明。由于cqueue.pxd文件包含常规 C 库的声明,因此不得有与 Cython 关联的.pyx文件具有相同名称的文件。 |

这是 Queue 类的第一个开始:

  1. # queue.pyx
  2. cimport cqueue
  3. cdef class Queue:
  4. cdef cqueue.Queue* _c_queue
  5. def __cinit__(self):
  6. self._c_queue = cqueue.queue_new()

请注意,它表示__cinit__而不是__init__。虽然__init__也可用,但不保证可以运行(例如,可以创建子类并忘记调用祖先的构造函数)。因为没有初始化 C 指针经常导致 Python 解释器的硬崩溃,所以在 CPython 甚至考虑调用__init__之前,Cython 提供__cinit__ 始终 在构造时立即被调用,因此它是正确的位置初始化新实例的cdef字段。但是,在对象构造期间调用__cinit__时,self尚未完全构造,并且必须避免对self执行任何操作,而是分配给cdef字段。

另请注意,上述方法不带参数,但子类型可能需要接受一些参数。无参数__cinit__()方法是一种特殊情况,它只是不接收传递给构造函数的任何参数,因此它不会阻止子类添加参数。如果参数在__cinit__()的签名中使用,则它们必须与用于实例化类型的类层次结构中任何声明的__init__类方法相匹配。

内存管理

在我们继续实施其他方法之前,重要的是要了解上述实现并不安全。如果在queue_new()调用中出现任何问题,此代码将简单地吞下错误,因此我们稍后可能会遇到崩溃。根据queue_new()功能的文档,上述可能失败的唯一原因是内存不足。在这种情况下,它将返回NULL,而它通常会返回指向新队列的指针。

Python 的方法是提出MemoryError [3] 。因此我们可以更改 init 函数,如下所示:

  1. # queue.pyx
  2. cimport cqueue
  3. cdef class Queue:
  4. cdef cqueue.Queue* _c_queue
  5. def __cinit__(self):
  6. self._c_queue = cqueue.queue_new()
  7. if self._c_queue is NULL:
  8. raise MemoryError()

| [3] | 在MemoryError的特定情况下,为了引发它而创建一个新的异常实例实际上可能会失败,因为我们的内存不足。幸运的是,CPython 提供了一个 C-API 函数PyErr_NoMemory(),可以安全地为我们提出正确的例外。只要您编写raise MemoryErrorraise MemoryError(),Cython 就会自动替换此 C-API 调用。如果您使用的是旧版本,则必须从标准软件包cpython.exc中导入 C-API 函数并直接调用它。 |

接下来要做的是在不再使用 Queue 实例时清理(即删除了对它的所有引用)。为此,CPython 提供了 Cython 作为特殊方法__dealloc__()提供的回调。在我们的例子中,我们所要做的就是释放 C Queue,但前提是我们在 init 方法中成功初始化它:

  1. def __dealloc__(self):
  2. if self._c_queue is not NULL:
  3. cqueue.queue_free(self._c_queue)

编译和链接

在这一点上,我们有一个可以测试的工作 Cython 模块。要编译它,我们需要为 distutils 配置一个setup.py脚本。这是编译 Cython 模块的最基本脚本:

  1. from distutils.core import setup
  2. from distutils.extension import Extension
  3. from Cython.Build import cythonize
  4. setup(
  5. ext_modules = cythonize([Extension("queue", ["queue.pyx"])])
  6. )

要构建针对外部 C 库,我们需要确保 Cython 找到必要的库。存档有两种方法。首先,我们可以告诉 distutils 在哪里找到 c-source 来自动编译queue.c实现。或者,我们可以构建和安装 C-Alg 作为系统库并动态链接它。如果其他应用也使用 C-Alg,后者是有用的。

静态链接

要自动构建 c 代码,我们需要在 <cite>queue.pyx</cite> 中包含编译器指令:

  1. # distutils: sources = c-algorithms/src/queue.c
  2. # distutils: include_dirs = c-algorithms/src/
  3. cimport cqueue
  4. cdef class Queue:
  5. cdef cqueue.Queue* _c_queue
  6. def __cinit__(self):
  7. self._c_queue = cqueue.queue_new()
  8. if self._c_queue is NULL:
  9. raise MemoryError()
  10. def __dealloc__(self):
  11. if self._c_queue is not NULL:
  12. cqueue.queue_free(self._c_queue)

sources编译器指令给出了 distutils 将编译和链接(静态)到生成的扩展模块的 C 文件的路径。通常,所有相关的头文件都应该在include_dirs中找到。现在我们可以使用以下方法构建项目

  1. $ python setup.py build_ext -i

并测试我们的构建是否成功:

  1. $ python -c 'import queue; Q = queue.Queue()'

动态链接

如果我们要打包的库已经安装在系统上,则动态链接很有用。要执行动态链接,我们首先需要构建和安装 c-alg。

要在您的系统上构建 c 算法:

  1. $ cd c-algorithms
  2. $ sh autogen.sh
  3. $ ./configure
  4. $ make

安装 CAlg 运行:

  1. $ make install

之后文件/usr/local/lib/libcalg.so应该存在。

注意

此路径适用于 Linux 系统,在其他平台上可能有所不同,因此您需要根据系统中libcalg.solibcalg.dll的路径调整本教程的其余部分。

在这种方法中,我们需要告诉安装脚本与外部库链接。为此,我们需要扩展安装脚本以安装更改扩展设置

  1. ext_modules = cythonize([Extension("queue", ["queue.pyx"])])

  1. ext_modules = cythonize([
  2. Extension("queue", ["queue.pyx"],
  3. libraries=["calg"])
  4. ])

现在我们应该能够使用以下方法构建项目:

  1. $ python setup.py build_ext -i

如果 <cite>libcalg</cite> 未安装在“普通”位置,用户可以通过传递适当的 C 编译器标志在外部提供所需的参数,例如:

  1. CFLAGS="-I/usr/local/otherdir/calg/include" \
  2. LDFLAGS="-L/usr/local/otherdir/calg/lib" \
  3. python setup.py build_ext -i

在运行模块之前,我们还需要确保 <cite>libcalg</cite> 在 <cite>LD_LIBRARY_PATH</cite> 环境变量中,例如通过设置:

  1. $ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib

一旦我们第一次编译模块,我们现在可以导入它并实例化一个新的队列:

  1. $ export PYTHONPATH=.
  2. $ python -c 'import queue; Q = queue.Queue()'

但是,这是我们所有 Queue 类到目前为止所能做到的,所以让它更有用。

映射功能

在实现此类的公共接口之前,最好先查看 Python 提供的接口,例如在listcollections.deque类中。由于我们只需要 FIFO 队列,因此足以提供方法append()peek()pop(),另外还有extend()方法一次添加多个值。此外,由于我们已经知道所有值都来自 C,因此最好现在只提供cdef方法,并为它们提供直接的 C 接口。

在 C 中,数据结构通常将数据作为void*存储到任何数据项类型。由于我们只想存储通常适合指针类型大小的int值,我们可以通过一个技巧避免额外的内存分配:我们将int值转换为void*,反之亦然,并存储值直接作为指针值。

这是append()方法的简单实现:

  1. cdef append(self, int value):
  2. cqueue.queue_push_tail(self._c_queue, <void*>value)

同样,适用与__cinit__()方法相同的错误处理注意事项,因此我们最终会使用此实现:

  1. cdef append(self, int value):
  2. if not cqueue.queue_push_tail(self._c_queue,
  3. <void*>value):
  4. raise MemoryError()

现在添加extend()方法应该是直截了当的:

  1. cdef extend(self, int* values, size_t count):
  2. """Append all ints to the queue.
  3. """
  4. cdef int value
  5. for value in values[:count]: # Slicing pointer to limit the iteration boundaries.
  6. self.append(value)

例如,当从 C 数组读取值时,这变得很方便。

到目前为止,我们只能将数据添加到队列中。下一步是编写两个方法来获取第一个元素:peek()pop(),它们分别提供只读和破坏性读访问。为了避免在直接将void*转换为int时出现编译器警告,我们使用的中间数据类型足以容纳void*。在这里,Py_ssize_t

  1. cdef int peek(self):
  2. return <Py_ssize_t>cqueue.queue_peek_head(self._c_queue)
  3. cdef int pop(self):
  4. return <Py_ssize_t>cqueue.queue_pop_head(self._c_queue)

通常,在 C 中,当我们将较大的整数类型转换为较小的整数类型而不检查边界时,我们冒着丢失数据的风险,并且Py_ssize_t可能是比int更大的类型。但由于我们控制了如何将值添加到队列中,我们已经知道队列中的所有值都适合int,因此上面的转换从void*Py_ssize_tint(返回类型) )设计安全。

处理错误

现在,当队列为空时会发生什么?根据文档,函数返回NULL指针,该指针通常不是有效值。但由于我们只是简单地向内和外输入,我们无法区分返回值是否为NULL,因为队列为空或者队列中存储的值为0。在 Cython 代码中,我们希望第一种情况引发异常,而第二种情况应该只返回0。为了解决这个问题,我们需要特殊情况下这个值,并检查队列是否真的是空的:

  1. cdef int peek(self) except? -1:
  2. cdef int value = <Py_ssize_t>cqueue.queue_peek_head(self._c_queue)
  3. if value == 0:
  4. # this may mean that the queue is empty, or
  5. # that it happens to contain a 0 value
  6. if cqueue.queue_is_empty(self._c_queue):
  7. raise IndexError("Queue is empty")
  8. return value

请注意我们如何在希望常见的情况下有效地创建了通过该方法的快速路径,返回值不是0。如果队列为空,则只有该特定情况需要额外检查。

方法签名中的except? -1声明属于同一类别。如果函数是返回 Python 对象值的 Python 函数,CPython 将在内部返回NULL而不是 Python 对象来指示异常,该异常将立即由周围的代码传播。问题是返回类型是int并且任何int值都是有效的队列项值,因此无法向调用代码显式地发出错误信号。实际上,如果没有这样的声明,Cython 就没有明显的方法可以知道在异常上返回什么以及调用代码甚至知道这个方法 可能 以异常退出。

调用代码可以处理这种情况的唯一方法是在从函数返回时调用PyErr_Occurred()以检查是否引发了异常,如果是,则传播异常。这显然有性能损失。因此,Cython 允许您声明在异常的情况下它应隐式返回的值,以便周围的代码只需在接收到此精确值时检查异常。

我们选择使用-1作为异常返回值,因为我们期望它是一个不太可能被放入队列的值。 except? -1声明中的问号表示返回值不明确(毕竟, 可能 可能是队列中的-1值)并且需要使用PyErr_Occurred()进行额外的异常检查在调用代码。没有它,调用此方法并接收异常返回值的 Cython 代码将默默地(有时不正确地)假定已引发异常。在任何情况下,所有其他返回值将几乎没有惩罚地通过,因此再次为“正常”值创建快速路径。

既然实现了peek()方法,pop()方法也需要适应。但是,由于它从队列中删除了一个值,因此仅在删除后测试队列是否为空 是不够的。相反,我们必须在进入时测试它:

  1. cdef int pop(self) except? -1:
  2. if cqueue.queue_is_empty(self._c_queue):
  3. raise IndexError("Queue is empty")
  4. return <Py_ssize_t>cqueue.queue_pop_head(self._c_queue)

异常传播的返回值与peek()完全相同。

最后,我们可以通过实现__bool__()特殊方法以正常的 Python 方式为 Queue 提供空白指示符(注意 Python 2 调用此方法__nonzero__,而 Cython 代码可以使用任一名称):

  1. def __bool__(self):
  2. return not cqueue.queue_is_empty(self._c_queue)

请注意,此方法返回TrueFalse,因为我们在cqueue.pxd中将queue_is_empty()函数的返回类型声明为bint

测试结果

既然实现已经完成,您可能需要为它编写一些测试以确保它正常工作。特别是 doctests 非常适合这个目的,因为它们同时提供了一些文档。但是,要启用 doctests,您需要一个可以调用的 Python API。从 Python 代码中看不到 C 方法,因此无法从 doctests 中调用。

为类提供 Python API 的一种快速方法是将方法从cdef更改为cpdef。这将让 Cython 生成两个入口点,一个可以使用 Python 调用语义和 Python 对象作为参数从普通 Python 代码调用,另一个可以使用快速 C 语义从 C 代码调用,不需要从 Python 进行中间参数转换。类型。请注意,cpdef方法确保 Python 方法可以适当地覆盖它们,即使从 Cython 调用它们也是如此。与cdef方法相比,这增加了很小的开销。

现在我们已经为我们的类提供了 C 接口和 Python 接口,我们应该确保两个接口都是一致的。 Python 用户期望extend()方法接受任意迭代,而 C 用户希望有一个允许传递 C 数组和 C 内存的方法。两个签名都不兼容。

我们将通过考虑在 C 中,API 也可能想要支持其他输入类型来解决此问题,例如, longchar的数组,通常支持不同名称的 C API 函数,如extend_ints()extend_longs(),extend_chars()`等。这允许我们释放方法名extend()` duck typed Python 方法,可以接受任意迭代。

以下清单显示了尽可能使用cpdef方法的完整实现:

  1. # queue.pyx
  2. cimport cqueue
  3. cdef class Queue:
  4. """A queue class for C integer values.
  5. >>> q = Queue()
  6. >>> q.append(5)
  7. >>> q.peek()
  8. 5
  9. >>> q.pop()
  10. 5
  11. """
  12. cdef cqueue.Queue* _c_queue
  13. def __cinit__(self):
  14. self._c_queue = cqueue.queue_new()
  15. if self._c_queue is NULL:
  16. raise MemoryError()
  17. def __dealloc__(self):
  18. if self._c_queue is not NULL:
  19. cqueue.queue_free(self._c_queue)
  20. cpdef append(self, int value):
  21. if not cqueue.queue_push_tail(self._c_queue,
  22. <void*> <Py_ssize_t> value):
  23. raise MemoryError()
  24. # The `cpdef` feature is obviously not available for the original "extend()"
  25. # method, as the method signature is incompatible with Python argument
  26. # types (Python does not have pointers). However, we can rename
  27. # the C-ish "extend()" method to e.g. "extend_ints()", and write
  28. # a new "extend()" method that provides a suitable Python interface by
  29. # accepting an arbitrary Python iterable.
  30. cpdef extend(self, values):
  31. for value in values:
  32. self.append(value)
  33. cdef extend_ints(self, int* values, size_t count):
  34. cdef int value
  35. for value in values[:count]: # Slicing pointer to limit the iteration boundaries.
  36. self.append(value)
  37. cpdef int peek(self) except? -1:
  38. cdef int value = <Py_ssize_t> cqueue.queue_peek_head(self._c_queue)
  39. if value == 0:
  40. # this may mean that the queue is empty,
  41. # or that it happens to contain a 0 value
  42. if cqueue.queue_is_empty(self._c_queue):
  43. raise IndexError("Queue is empty")
  44. return value
  45. cpdef int pop(self) except? -1:
  46. if cqueue.queue_is_empty(self._c_queue):
  47. raise IndexError("Queue is empty")
  48. return <Py_ssize_t> cqueue.queue_pop_head(self._c_queue)
  49. def __bool__(self):
  50. return not cqueue.queue_is_empty(self._c_queue)

现在我们可以使用 python 脚本测试我们的 Queue 实现,例如这里test_queue.py

  1. from __future__ import print_function
  2. import time
  3. import queue
  4. Q = queue.Queue()
  5. Q.append(10)
  6. Q.append(20)
  7. print(Q.peek())
  8. print(Q.pop())
  9. print(Q.pop())
  10. try:
  11. print(Q.pop())
  12. except IndexError as e:
  13. print("Error message:", e) # Prints "Queue is empty"
  14. i = 10000
  15. values = range(i)
  16. start_time = time.time()
  17. Q.extend(values)
  18. end_time = time.time() - start_time
  19. print("Adding {} items took {:1.3f} msecs.".format(i, 1000 * end_time))
  20. for i in range(41):
  21. Q.pop()
  22. Q.pop()
  23. print("The answer is:")
  24. print(Q.pop())

作为在作者的机器上使用 10000 个数字的快速测试表明,使用来自 Cython 代码的 C int值的这个队列大约是使用 Cython 代码和 Python 对象值的速度的五倍,几乎是使用它的速度的八倍。 Python 循环中的 Python 代码,仍然比使用 Python 整数的 Cython 代码中的 Python 高度优化的collections.deque类型快两倍。

回调

假设您希望为用户提供一种方法,可以将队列中的值弹出到某个用户定义的事件。为此,您希望允许它们传递一个判断何时停止的谓词函数,例如:

  1. def pop_until(self, predicate):
  2. while not predicate(self.peek()):
  3. self.pop()

现在,让我们假设为了参数,C 队列提供了一个将 C 回调函数作为谓词的函数。 API 可能如下所示:

  1. /* C type of a predicate function that takes a queue value and returns
  2. * -1 for errors
  3. * 0 for reject
  4. * 1 for accept
  5. */
  6. typedef int (*predicate_func)(void* user_context, QueueValue data);
  7. /* Pop values as long as the predicate evaluates to true for them,
  8. * returns -1 if the predicate failed with an error and 0 otherwise.
  9. */
  10. int queue_pop_head_until(Queue *queue, predicate_func predicate,
  11. void* user_context);

C 回调函数具有通用void*参数是正常的,该参数允许通过 C-API 将任何类型的上下文或状态传递到回调函数中。我们将使用它来传递我们的 Python 谓词函数。

首先,我们必须定义一个带有预期签名的回调函数,我们可以将其传递给 C-API 函数:

  1. cdef int evaluate_predicate(void* context, cqueue.QueueValue value):
  2. "Callback function that can be passed as predicate_func"
  3. try:
  4. # recover Python function object from void* argument
  5. func = <object>context
  6. # call function, convert result into 0/1 for True/False
  7. return bool(func(<int>value))
  8. except:
  9. # catch any Python errors and return error indicator
  10. return -1

主要思想是将指针(a.k.a。借用的引用)作为用户上下文参数传递给函数对象。我们将调用 C-API 函数如下:

  1. def pop_until(self, python_predicate_function):
  2. result = cqueue.queue_pop_head_until(
  3. self._c_queue, evaluate_predicate,
  4. <void*>python_predicate_function)
  5. if result == -1:
  6. raise RuntimeError("an error occurred")

通常的模式是首先将 Python 对象引用转换为void*以将其传递给 C-API 函数,然后将其转换回 C 谓词回调函数中的 Python 对象。对void*的强制转换创建了借用的引用。在转换为&lt;object&gt;时,Cython 递增对象的引用计数,从而将借用的引用转换回拥有的引用。在谓词函数结束时,拥有的引用再次超出范围,Cython 丢弃它。

上面代码中的错误处理有点简单。具体而言,谓词函数引发的任何异常将基本上被丢弃,并且只会导致在事实之后引发普通RuntimeError()。这可以通过将异常存储在通过 context 参数传递的对象中并在 C-API 函数返回-1以指示错误之后重新引发它来改进。