分享

使用 def、cdef、cpdef 创建函数

 古明地觉O_o 2022-12-08 发布于北京

楔子


我们前面所学的关于动态变量和静态变量的内容也适用于函数,Python 的函数和 C 的函数都有一些共同的属性:函数名称、接收参数、返回值,但是 Python 中的函数更加的灵活和强大。因为 Python 中一切皆对象,所以函数也是一等公民,可以随意赋值、并具有相应的状态和行为,这种抽象是非常有用的。

一个Python函数可以:

  • 在运行时动态创建;

  • 使用 lambda 关键字匿名创建;

  • 在另一个函数(或其它嵌套范围)中定义;

  • 从其它函数中返回;

  • 作为一个参数传递给其它函数;

  • 使用位置参数和关键字参数调用;

  • 函数参数可以使用默认值;

C 函数调用开销最小,比Python函数快几个数量级。一个C函数可以:

  • 作为一个参数传递给其它函数,但这样做比 Python 麻烦的多;

  • 不能在其它函数内部定义,但这在 Python 中不仅可以、而且还非常常见,毕竟 Python 的装饰器就是通过高阶函数加上闭包实现的,而闭包则可以理解为是函数的内部嵌套其它函数;

  • 具有不可修改的静态分配名称;

  • 只能接受位置参数;

  • 函数参数不支持默认值;

正所谓鱼和熊掌不可兼得,Python 的函数调用虽然慢几个数量级(即使没有参数),但是它的灵活性和可扩展性都比 C 强大很多,这是以效率为代价换来的。而 C 的效率虽然高,但是灵活性没有 Python 好,这便是各自的优缺点。

那么说完 Python 函数和 C 函数各自的优缺点之后该说啥啦,对啦,肯定是 Cython 如何将它们组合起来、吸取精华剔除糟粕的啦。


使用 def 关键字定义 Python 函数


Cython 支持使用 def 关键字定义一个通用的 Python 函数,并且还可以按照我们预期的那样工作。比如:

def rec(n):
    if n == 1:
        return 1
    return n * rec(n - 1)

文件名为 cython_test.pyx,我们来导入它。

import pyximport
pyximport.install(language_level=3)

import cython_test

print(cython_test.rec(20))  
"""
2432902008176640000
"""

显然这是一个 Python 语法的函数,参数 n 接收一个动态的 Python 变量,但它在 Cython 中也是合法的,并且表现形式是一样的。

我们知道即使是普通的 Python 函数,也可以通过 Cython 进行编译,但是就调用而言,这两者是没有任何区别的。不过执行扩展里面的代码时,已经绕过了解释器解释字节码这一过程;但是 Python 代码则不一样,它是需要被解释执行的,因此在运行期间可以随便动态修改内部的属性。我们举个栗子就很清晰了:

Python 版本:

# 文件名:a.py
def foo():
    return 123

# 另一个文件
from a import foo

print(foo())  # 123

print(foo.__name__)  # foo
foo.__name__ = "哈哈"
print(foo.__name__)  # 哈哈

Cython 版本:

# 文件名:cython_test.pyx
def foo():
    return 123
import pyximport
pyximport.install(language_level=3)

from cython_test import foo
print(foo())  # 123
print(foo.__name__)  # foo

try:
    foo.__name__ = "哈哈"
except AttributeError as e:
    print(e)
"""
attribute '__name__' of 'builtin_function_or_method' objects is not writable
"""

我们看到报错了,报错信息告诉我们 builtin_function_or_method 的属性 __name__ 不可写。Python 的函数是一个动态类型函数,所以它可以修改自身的一些属性。

但是 Cython 代码在编译之后,函数变成了 builtin_function_or_method,绕过了解释这一步,因为不能对它自身的属性进行修改。事实上,Python 的内置函数也是不能修改的。

try:
    getattr.__name__ = "xxx"
except Exception as e:
    print(e)  
"""
attribute '__name__' of 'builtin_function_or_method' objects is not writable
"""

内置函数和扩展模块里的函数都是直接指向了底层 C 一级的结构,因此它们的属性是不能够被修改的。

Python 的动态性是解释器将字节码翻译成 C 代码的时候动态赋予的,而 Cython 代码在被编译成扩展模块时,内部已经是机器码了,所以解释器无法再对其动手脚,或者说失去了相应的动态性,但换来的是速度的提升。但很明显,当前速度的提升是不大的,因为我们没有做类型标注,也就是没有基于类型进行优化。

回到上面用递归计算阶乘的例子上来,显然 rec 函数里面的 n 是一个动态变量,如果想要加快速度,就要使用静态变量,也就是规定好类型。

def rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

此时当我们传递的时候,会将值转成 C 中的 long,如果无法转换则会抛出异常。

另外在 Cython 中定义任何函数,我们都可以将动态类型的参数和静态类型的参数混合使用。Cython 还允许静态参数具有默认值,并且可以按照位置参数或者关键字参数的方式传递。

# 这样的话就可以不传参了,默认 n 是 20
def rec(long n=20):
    if n == 1:
        return 1
    return n * rec(n - 1)

这么做虽然可以提升效率,但提升幅度有限。因为这里的 rec 还是一个 Python 函数,它的返回值也是一个 Python 的整数,而不是静态的 C long。

因此在计算 n * rec(n - 1) 的时候,Cython 必须生成大量代码,将返回的 Python 整型转成 C long,然后乘上静态类型的变量 n。最后再将结果得到的 C long 打包成 Python 的整型,所以整个过程还是存在可以优化的地方。

那么如何才能提升性能呢?显然可以不使用递归、而是使用循环的方式,当然这个我们不谈,因为这个 Cython 没啥关系。我们想做的是告诉 Cython:"函数返回的是一个 C long,你在计算的时候不要有 Python 的整型参与。"

那么要如何完成呢?往下看。


使用 cdef 关键字定义 C 函数


cdef 关键字除了创建变量之外,还可以创建具有 C 语义的函数。cdef 定义的函数,其参数和返回值通常都是静态类型的,它们可以处理 C 指针、结构体、以及其它一些无法自动转换为 Python 类型的 C 类型。

所以把 cdef 定义的函数看成是长得像 Python 函数的 C 函数即可。

cdef long rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

我们之前的例子就可以改写成上面这种形式,我们看到结构非常相似,主要区别就是指定了返回值的类型。

因为指定了返回值的类型,此时的函数是没有任何 Python 对象参与的,因此不需要从 Python 类型转化成 C 类型。该函数和纯 C 函数一样有效,调用函数的开销最小。

所以在使用 cdef 定义函数时,格式为:cdef 类型 函数名。并且 cdef 函数返回的类型可以是任何的静态类型(如:指针、结构体、C数组、静态 Python 类型),如果不指定类型,也就是  cdef 函数名 的格式,那么返回值类型默认为 object。

另外,即便是 cdef 定义的函数,我们依旧可以创建 Python 对象和动态变量,或者接收它们作为参数也是可以的。

# 合法,返回的是一个 list 对象
cdef list f1():
    return []

# 等价于 cdef object f2()
# 而 Python 中任何对象都是 object 类型
cdef f2():
    pass

# 虽然要求返回列表
# 但是返回 None 也是可以的(None特殊,后面会说)
cdef list f3():
    pass

# 同样道理
cdef list f4():
    return None

# 这里是会报错的
# TypeError: Expected list, got tuple
cdef list f5():
    return 123

使用 cdef 定义的函数,可以被其它的函数(cdef 和 def 都行)调用,但是 Cython 不允许从外部的 Python 代码来调用 cdef 函数,我们之前使用 cdef 定义的变量也是如此。因为 cdef 定义的函数相当于是 C 函数,可以返回任意的 C 类型,而某些 C 类型在 Python 中无法与之对应。

所以我们通常会定义一个 Python 函数,然后让 Python 函数来调用 cdef 定义的函数,所以此时的 Python 函数就类似于一个包装器,用于向外界提供一个访问的接口。

cdef long _rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

def rec(n):
    return _rec(n)

def 函数效率不高,但它可以被 Python 代码访问;cdef 函数效率虽然高,但是无法被 Python 代码访问。于是我们可以定义一个 cdef 函数,用来执行具体的代码逻辑,然后再定义一个 def 函数,负责调用 C 函数,也就是给外部的 Python 代码提供一个接口。

通过 cdef 和 def 结合,从而实现最优的效果。因为计算逻辑都发生在 C 函数中,Python 函数只是提供一个包装而已,不负责实际代码的执行。这样就既保证了效率,又保证了外部的 Python 代码可以访问。

可能有人觉得,调用一个 Python 函数的开销会比调用 C 函数要大吧。这里的开销不包括函数体内部代码的执行时间,而是指调用函数本身的开销,也就是从调用函数、到开始执行函数内部代码之间的这段开销。很明显,调用 def 函数的开销是要比 cdef 函数大的。

可能有小伙伴觉得能不能把函数调用本身的开销也给优化一下,答案是不能,因为 cdef 定义的函数无法被外部的 Python 访问。如果你希望 Cython 里面的函数能够被外部的 Python 调用,要么将逻辑使用 def 函数实现,要么交给 cdef 函数实现、然后再定义一个 def 函数作为包装器。

总之我们可以对函数体内部的代码逻辑进行优化,但函数调用本身的开销是无法避免的。正如之前说的,Python 程序再怎么优化,在极限上也不可能和静态语言相媲美。而且 Cython 是为 Python 服务的,在函数调用时,Python 数据要转成 C 数据、在函数返回时,C 数据还要再转成 Python 数据,这些也是有开销的。

因此即便引入了 Cython,在极限上,Python 还是无法与 C++、Rust 之类的静态语言相抗衡。

当然啦,相比函数调用和返回时的数据类型转换,以及函数调用本身,这些开销实际上微不足道,重点是函数体内部代码的执行逻辑,它们才是需要优化的地方。如果函数体内部的代码已经优化到极致了,还达不到内心的预期,甚至连函数调用本身的开销都需要成为优化的地方(比如一个 Python 函数需要调用一百万次),那最好的方式就是换一门静态语言,比如 Rust。


使用 cpdef 结合 def、cdef


我们在 Cython 定义一个函数可以使用 def 和 cdef,但是还有第三种定义函数的方式,也就是使用 cpdef 关键字声明。cpdef 是 def 和 cdef 的混合体,结合了这两种函数的特性,并解决了局限性。

我们之前使用 cdef 定义了一个函数 _rec,但是它无法被外部访问,因此又定义了一个 Python 函数 rec 供外部调用,相当于提供了一个接口。所以我们需要定义两个函数,一个是用来执行逻辑的(C 版本),另一个是让外部访问的(Python版本),一般这种函数我们称之为 Python 包装器。很形象,C 版本不能被外部访问,因此定义一个 Python 函数将其包起来。

但是 cpdef 定义的函数会同时具备这两种身份,也就是说,一个 cpdef 定义的函数会自动为我们提供上面那两种函数的功能,怎么理解呢?从 Cython 中调用函数时,会调用 C 的版本,在外部的 Python 中导入并访问时,会调用包装器。这样的话,cpdef 函数就可以将 cdef 函数的性能和 def 函数的可访问性结合起来了。

因此上面那个例子,我们就可以改写成如下:

cpdef long rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

如果是之前的方式,则需要两个函数,这两个函数还不能重名,但是使用 cpdef 就不需要关心了,使用起来会更方便。

需要注意,cpdef 和 cdef 一样,支持定义函数的时候指定返回值类型,从而实现基于类型的优化(def 函数不可以指定返回值类型,但参数类型可以指定)。但 cpdef 函数毕竟是可以被外部的 Python 访问的,因此在指定返回值类型的时候就会受到限制,cpdef 函数指定的返回值类型要和 Python 的某个类型能够对应。举个例子:

cdef int* test1():
    pass

cpdef int* test2():
    pass

这段代码编译的时候会报错,原因在于 test2 函数的返回值类型声明有问题。首先 test1 是一个 cdef 函数,它的返回值类型不受限制,因为外部的 Python 代码无法访问。但 test2 不行,它支持外部的 Python 代码访问,所以返回值类型要能和 Python 的某个类型相对应,但很明显,Python 里面没有哪个类型可以和 C 的指针相对应,于是编译错误。

所以使用 cpdef 定义函数的时候,返回值类型有一些限制,当然还有参数类型。因为 cpdef 函数要同时兼容 Python 和 C,这意味着它的参数和返回值类型必须同时兼容 Python 类型和 C 类型。但我们知道,并非所有的 C 类型都可以用 Python 类型表示,比如:C 指针、C 数组等等,所以它们不可以作为 cpdef 函数的参数类型和返回值类型。

除此之外,cpdef 函数还有一个局限性,就是它的内部不可以出现闭包。 

# 不指定返回值类型,默认为 object
cpdef func():
    lam = lambda x: x

显然上述逻辑在 def 定义的函数中再正常不过了,但如果是 cpdef 的话,那么编译的时候会报错。

报错信息很直观,在 cpdef 函数内部定义闭包还不支持,说白了就是我们不可以在 cpdef 函数里面再定义函数,包括匿名函数。所以如果需要使用闭包,那么还是建议通过 cdef 函数加上 def 函数作为包装器的方式,def 和 cdef 都是支持闭包的。另外,使用闭包时,内层函数必须是 Python 的 def 函数或者匿名函数。

cdef deco():
    cdef inner():
        pass

上面的代码会报错,虽然 cdef 支持闭包,但是内层函数必须是 def 函数或者匿名函数。我们不能在一个函数里面去定义一个 cdef 函数,也就是说,cdef 函数在定义的时候,位置是有讲究的。

报错信息也很明显,cdef 定义的 C 函数不可以出现在当前的位置,cpdef 也是同理。当然不光是函数,像 if, for, while 等语句的内部也不可以。

if 2 > 1:
    cdef f1():
        pass

while 1:
    cdef f2():
        pass

for i in range(10):
    cdef f3():
        pass

上面三个 cdef 函数的出现位置都是不允许的,在编译的时候就会报错:cdef statement not allowed here,cpdef 函数也是同理。

所以 cdef 定义的 C 函数应该出现在最外层,或者说没有缩进的地方。如果有缩进,那么应该是在类里面,作为类的成员函数,关于类我们后面会说。

总结一下:

1)cdef 和 def 一样,不会受到闭包的限制,但 def 起不到加速效果,cdef 无法被外界访问;

2)cpdef 是两者的结合体,既能享受加速带来的收益,又能自动提供包装器给外界;

3)但 cpdef 在闭包语法上会受到限制,内部无法定义函数,因此最完美的做法是使用 cdef 定义函数之后再手动提供包装器。但是当不涉及到闭包的时候,还是推荐使用 cpdef 定义的;


内联函数


在 C 和 C++ 中,定义函数时还可以使用一个可选的关键字 inline,这个 inline 是做什么的呢?我们知道 C 和 C++ 的函数调用也是有开销的(即使很微小),因为要涉及到跳转、压栈、创建栈帧等一般性操作。而定义函数时使用 inline 关键字,那么代码会被放在符号表中,在使用时直接进行替换(像宏一样展开),这样就没有了调用的开销,提高效率。

Cython 同样支持 inline(但在 Cython 中不是关键字),使用时只需要将 inline 放在 cdef 或者 cpdef 后面即可,但是不能放在 def 后面。

cdef inline ssize_t get_square(ssize_t n):
    return n * n

当调用 get_square 函数的时候,会将函数内部的代码直接贴过来,此时不涉及函数的调用,从而减少开销。

inline 如果使用得当,可以提高性能,特别是在深度嵌套循环中调用的小型内联函数。因为它们会被多次调用,这个时候通过 inline 可以省去函数调用的开销。

可能有人觉得,既然 inline 可以省去函数调用时的开销,并且使用上还能像函数一样,那能不能每次声明函数的时候都加上 inline 呢?显然这种做法不可取,因为内联函数是以代码膨胀为代价的,你在任何地方调用内联函数都会把函数内的代码拷贝一份,这样会消耗很多的内存空间。如果函数体内的代码执行时间比较长,那么节省下来的函数调用的开销,与之相比意义不是很大。

因为函数调用本身的开销非常微小,所以只有当函数体逻辑简单、并且还要在深度嵌套循环中反复调用的情况下,才会使用内联函数。

另外 inline 只是一个对编译器的建议,至于最后到底是否内联,还要看编译器的意思。如果编译器认为函数不复杂、以及不涉及递归,可以在调用点展开,就会真正内联。所以并不是使用了 inline 就会内联,使用 inline 只是给编译器一个建议。


小结


以上就是如何在 Cython 中定义函数,总共提供了三种方式,都各有优缺点,需要在工作中搭配结合使用。

下一篇文章来聊一聊 Cython 中的异常处理。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多