Cython 的异常处理

简介: Cython 的异常处理

def 定义的函数在 C 的级别总是会返回一个 PyObject *,这个是恒定的,不会改变,因为 Python 的变量本质上就是一个 PyObject *。因此正常调用一个函数时,它的返回值一定指向了一个合法的 Python 对象。

但如果报错了,在 C 的层面返回的就不再是 PyObject *,而是一个 NULL。解释器在返回 NULL 之前,会将异常信息写入到 stderr(标准错误输出)里面。对于 Python 使用者而言,表现就是:先输出一堆错误信息,然后解释器中止运行。

cpdef test():
    lst = [0]
    # 显然会索引越界
    lst[3]
    return None

文件名仍叫 cython_test.pyx,我们测试一下。

import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.test())
"""
Traceback (most recent call last):
  File "...", line 5, in <module>
    print(cython_test.test())
  File "cython_test.pyx", line 1, in cython_test.test
    cpdef test():
  File "cython_test.pyx", line 4, in cython_test.test
    lst[3]
IndexError: list index out of range
"""

如果程序正常执行完毕,返回的状态码为 0;执行的时候报错了,返回的状态码为 1;如果返回的状态码很古怪,是一个乱七八糟的数字,那么说明解释器内部出现了异常,这种情况基本只有在写 C 扩展的时候才会发生。

总之,解释器抛出异常的本质就是,发现程序出错了,然后将错误信息写入到标准错误输出当中,return NULL 并停止运行。

所以异常输出在 Cython 中的表现也是一样的,它允许 Cython 正确地从函数中抛出异常。但这有一个前提,那就是函数的返回值必须是 Python 类型,显然对于 def 函数是没有问题的。而 cdef 和 cpdef 可能会返回一个非 Python 类型,那么此时则需要一些其它的异常提示机制。

我们举个例子:

cpdef long test():
    lst = [0]
    # 显然会索引越界
    lst[3]
    return 123

此时 test1 的返回值类型是 C 的类型,并且里面的代码会报错,那么 Python 在调用的时候会不会将异常抛出来呢?

import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.test())
"""
IndexError: list index out of range
Exception ignored in: 'cython_test.test'
Traceback (most recent call last):
  File "...", line 5, in <module>
    print(cython_test.test())
IndexError: list index out of range
0
"""
print("我会执行吗?")
"""
我会执行吗?
"""

我们看到了神奇的一幕,程序报错了,但是没有停止运行,而是将异常忽略掉了。并且当函数内部出异常的时候,自动返回零值。

CPython 在判断是否出现异常的时候,首先会根据返回值来判断。如果返回值的类型是 PyObject *,那么正常执行一定会返回一个非 NULL 指针,因为要指向一个合法的 Python 对象;可要是出现异常,那么就会返回一个空指针 NULL,代表函数执行失败,应该将异常抛出来。

因此在调用一个返回值为 Python 类型的函数时,根据返回值是否为 NULL 可以很轻松地判断调用是否出异常。所以当异常发生在 def 函数、或者返回值为 Python 类型的 cdef、cpdef 函数中,那么表现和 Python 代码是一致的。

但如果返回值是一个 C 的类型(针对 cdef 和 cpdef 函数),比如这里返回的是 long 类型,那么执行出错时会设置异常、并返回 0,也就是对应类型的零值。但问题是解释器不知道此时的 0,是因为调用出错返回的 0,还是正常执行完毕后返回的 0,因为返回值也可能是 0。

所以当返回值是 C 的类型时,如果函数调用出错,那么异常没有办法传递给它的调用方。换句话说就是异常没有办法向上抛,最终的结果就是将异常输出到 stderr 当中,但是却无法停止运行。

而为了正确传递异常,Cython 提供了一个 except 字句,允许 cdef、cpdef 函数和调用方通信,如果函数在执行过程中发生了 Python 异常,要将它抛出来。

cpdef long test() except -1:
    lst = [0]
    # 显然会索引越界
    lst[3]
    return 123

此时再调用的话,会有什么结果呢?异常能正常抛出来吗?

import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.test())
print("我会执行吗?")
"""
Traceback (most recent call last):
  File "...", line 5, in <module>
    print(cython_test.test())
  File "cython_test.pyx", line 1, in cython_test.test
    cpdef long test() except -1:
  File "cython_test.pyx", line 4, in cython_test.test
    lst[3]
IndexError: list index out of range
"""

我们看到此时异常就正确地传递给调用方了,当出现异常时,将异常信息打印出来,然后中止运行。

所以 except -1 相当于充当异常发生时的哨兵,当然啦不仅是 -1,任何整数都是可以的。但是还有一个问题,如果返回值恰好也是 -1 怎么办?我们举个例子:

cpdef long test(long i, long j) except -1:
    return i // j

这里需要写成 i // j,不能写 i / j,因为返回值是 long 类型,而 i / j 得到的是浮点数。虽然在变量赋值的时候,表达式计算得到的浮点数会自动向下取整,但此处不行,我们必须显式地返回一个整数。

然后测试一下:

import pyximport
pyximport.install(language_level=3)
import cython_test
try:
    cython_test.test(3, 0)
except ZeroDivisionError as e:
    print(e)
"""
integer division or modulo by zero
"""
print(ext.test(3, -3))
"""
Traceback (most recent call last):
  File "...", line 14, in <module>
    print(cython_test.test(3, -3))
SystemError: <built-in function test> returned NULL without setting an error
"""

神奇的地方出现了,虽然异常依旧能够正常抛出来,但是当返回值为 -1 时居然又抛出了一个 SystemError。

如果你使用 C 编写过扩展模块的话,应该会遇见过这个问题。前面说了,Python 的函数一定会返回一个 PyObject *,但如果函数执行出错了,那么在 C 一级就会返回一个 NULL,并且将发生的异常设置进去。

如果返回了 NULL 但是没有设置异常的话,就会抛出上面的那个错误。因为解释器发现函数返回了 NULL,所以知道出现异常了,于是会将回溯栈里设置好的异常信息打印出来,告诉开发者出现异常的具体原因,到底是哪部分代码出错了。但问题是解释器发现异常回溯栈是空的,所以会抛出一个 SystemError,表示函数返回了 NULL,但却没有设置异常。

而出现上述结果的原因就是我们这里的 except -1,它允许 -1 充当异常发生时的哨兵。但如果函数正常执行、只是返回的恰好是 -1,那么也表示发生异常了,于是底层会返回NULL。然而实际上异常并没有发生,所以没有设置异常,而解释器又发现返回值为 NULL,所以提示我们 returned NULL without setting an error。

一个比较笨的解决办法是将 except -1 换成 except -2,显然这是治标不治本,因为当返回值为 -2 的时候还是会出现上面的结果。所以能不能有这样一种机制,就是当返回值恰好和哨兵相等时,让解释器去看一眼异常回溯栈。如果回溯栈为空,证明返回的 NULL 不是因为执行出错返回的 NULL,而是返回值和哨兵恰好相等,那么此时就不要返回 NULL 了,因为没有报错,所以应该将返回值正常返回。

cpdef long test(long i, long j) except ? -1:
    return i // j

我们在 except 后面加上了一个问号,来看看执行结果。

import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.test(3, -3))  # -1

此时就没有问题了。

总结一下,except ? -1 只是单纯为了在发生异常的时候能够往上抛,这里可以是 -1、也可以是其它的什么值。而函数如果也返回了相同的值,那么就会检测异常回溯栈,如果为空(表示没有报错)就会正常返回。而触发检测的条件就是中间的那个 ?,如果不指定 ?,那么当函数返回了和哨兵相同的值,也是会报错的,因此这个时候你应该确保函数不可能返回 except 后面指定的值(哨兵)。

但很明显,这样的逻辑不具备可靠性,还是在 except 后面加上 ? 要更保险一些。

事实上,在 CPython 源码内部也有大量相似的判断逻辑。

调用 new_values 如果成功,那么 values 一定指向一个合法的 Python 对象,但如果调用失败,values 则为 NULL。所以下一步就要判断 values 是不是等于 NULL,如果等于 NULL,证明执行失败了,应该设置异常、然后返回 NULL。而解释器发现为 NULL 了,证明执行出现异常了,于是会将回溯栈里的异常信息打印出来,然后中止运行。

所以当返回值是 PyObject * 时,根据返回值是否为 NULL 即可判断执行是否出现了异常。但如果返回值不是 PyObject *、或者说不是 Python 类型呢?比如是 C 的整型。

我们看到 CPython 内部也是使用 -1  充当的哨兵,如果返回值不是 -1,证明正常执行。如果返回值是 -1,则说明有可能出异常了,此时需要调用 PyErr_Occurred 检测回溯栈是否为空,如果不为空,证明确实出现异常了;如果为空则证明没有出现异常,只是返回值恰好是 -1。

另外哨兵的值要和返回值类型相匹配,返回值类型为整型,那么哨兵可以是任意的整数;返回值类型是浮点型,那么哨兵可以是任意的浮点数。如果我们将 -1 改成 -1.0 的话:

编译的时候报错了,因为哨兵的值的类型和返回值类型不兼容

所以工作中建议加上 except ? val 作为异常传递的哨兵,val 的值任意,只要和返回值类型匹配即可。但只有返回值是 C 的类型时,才需要这么做。如果返回值是 Python 类型,那么使用 except 子句会报错,比如下面的代码就是不合法的。

cpdef tuple test(long i, long j) except ? ():
    pass

我们的本意是使用空元组作为异常传递的哨兵,但当返回值为 Python 类型时,异常是可以正常抛的,它的表现和 Python 是一致的。只有当返回值是 C 的类型,才需要哨兵,所以上面的代码属于画蛇添足,反而会编译错误。

报错信息很明显,当返回值是 Python 类型时,不允许使用 except 子句。

以上就是 Cython 中的异常处理,准确来说是异常在 Cython 中的一个坑,因为当返回值是 C 的类型时,异常无法正常抛给调用方,需要使用哨兵。至于异常处理本身(try),在 Cython 和 Python 中的表现都是一致的。

相关文章
|
存储 并行计算 Java
Python读取.nc文件的方法与技术详解
本文介绍了Python中读取.nc(NetCDF)文件的两种方法:使用netCDF4和xarray库。netCDF4库通过`Dataset`函数打开文件,`variables`属性获取变量,再通过字典键读取数据。xarray库利用`open_dataset`打开文件,直接通过变量名访问数据。文中还涉及性能优化,如分块读取、使用Dask进行并行计算以及仅加载所需变量。注意文件路径、变量命名和数据类型,读取后记得关闭文件(netCDF4需显式关闭)。随着科学数据的增长,掌握高效处理.nc文件的技能至关重要。
2447 0
|
JavaScript 前端开发 Java
正则表达式深度解析:匹配任意字符串
【4月更文挑战第1天】
7076 0
|
计算机视觉 Python
最快速度写出一个识别效果——OpenCV模板匹配(含代码)
最快速度写出一个识别效果——OpenCV模板匹配(含代码)
1174 0
|
C语言 C++ Python
在 Cython 中声明结构体、共同体、枚举
在 Cython 中声明结构体、共同体、枚举
190 0
|
存储 Python
Cython 中的类型转换
Cython 中的类型转换
172 0
|
并行计算 算法 编译器
使用 prange 实现 for 循环的并行
使用 prange 实现 for 循环的并行
350 1
使用 prange 实现 for 循环的并行
|
Python
《Cython 从入门到精通》PDF 版本新鲜出炉啦!!!
《Cython 从入门到精通》PDF 版本新鲜出炉啦!!!
307 1
|
Python
详解历时五年的 Cython3.0 都发生了哪些变化(一)
详解历时五年的 Cython3.0 都发生了哪些变化(一)
244 1
|
存储 索引 Python
深度解密 Python 列表的实现原理
深度解密 Python 列表的实现原理
209 14
|
网络协议 算法 Linux
深度解密 TCP 三次握手与四次挥手
深度解密 TCP 三次握手与四次挥手
326 9