分享

静态整型和静态字符串类型

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

静态整型


Cython 的变量和 Python 的变量是等价的,只不过前者是静态的,后者是动态的,而动态变量可以使用的 API,静态变量都可以使用。只不过对于 int 和 float 来说,C 里面也存在同名的类型,而且会优先使用 C 的类型,这也是我们期望的结果。

而一旦使用的是 C 里面的 int 和 float,比如 cdef int a = 1, cdef float b = 22.33,那么 a 和 b 就不再是指针了,它们代表的就是 C 的整数和浮点数。

那为什么在使用 int 和 float 的时候,要选择 C 的 int 和 float 呢?答案很好理解,因为 Cython 本身就是用来加速计算的,而提到计算,显然避不开 int 和 float,因此这两位老铁默认使用 C 里面的类型。

事实上单就 Python 的整数和浮点数来说,在运算时底层也是先转化成 C 的类型,然后再操作,最后将操作完的结果再转回 Python 的类型。而如果默认使用 C 的类型,就少了转换这一步,可以极大地提高效率。

但我们要知道 C 的整型是有范围的,我们在使用的时候要确保数值的大小不会溢出,这一点前面已经说过了。但是除此之外,还有一个重要的区别,就是除法和取模,在除法和取模上,C 的整型使用的却不是 C 的标准。

当使用有符号整数计算模的时候,C 和 Python 有着明显不同的行为:比如 -7 % 5,如果是 Python 的话那么结果为 3,C 的话结果为 -2。显然 C 的结果是符合我们正常人思维的,但是为什么 Python 得到的结果这么怪异呢?

事实上不光是 C,Go、Js 也是如此,计算 -7 % 5 的结果都是 -2,但 Python 得到 3,原因就是因为其内部的机制不同。我们知道 a % b,等于 a - (a / b) * b,其中 a / b 表示两者的商。比如 7 % 2,等于 7 - (7 / 2) * 2 = 7 - 3 * 2 = 1,对于正数,显然以上所有语言计算的结果都是一样的。

而负数出现差异的原因就在于:C 在计算 a / b 的时候是截断小数点,而 Python 是向下取整。比如上面的 -7 % 5,等于 -7 - (-7 / 5) * 5。-7 / 5 得到的结果是负的一点多,C的话直接截断得到 -1,因此结果是 -7 - (-1) * 5 = -2;但 Python 是向下取整,负的一点多变成 -2,因此结果变成了 -7 - (-2) * 5 = 3

# Python 的 / 默认是得到浮点数
# 整除的话使用 //
# 我们看到得到的是 -2
print(-7 // 5)  # -2

因此在除法和取模方面,尤其需要注意。另外即使在 Cython 中,也是一样的。

cdef int a = -7
cdef int b = 5
cdef int c1 = a / b
cdef int c2 = a // b
print(c1) # -2
print(c2) # -2
print(-7 // 5) # -2

以上打印的结果都是 -2,说明 Cython 默认使用 Python 的语义执行除法操作,当然还有取模,即使操作的对象是静态类型的 C 标量。这么做原因就在于为了最大程度的和 Python 保持一致,如果想要启动 C 语义都需要显式地进行开启。

然后我们看到 a 和 b 是静态类型的 C 变量,它们也是可以使用 // 的,因为 Cython 的目的就像写 Python 一样。但我们看到无论是 c1 还是 c2,打印的结果都是 -2,这很好理解。

首先 c1 和 c2 都是静态的 int,在赋值的时候会将浮点数变成整数,至于是直接截断还是向下取整则是和 Python 保持一致的,是按照 Python 的标准来的。

a / b 得到的是 -1.4,在赋值给 int 类型的 c1 时会向下取整。至于 a // b 就更不用说了,a // b 本身就表示整除,因此 c2 也是 -2。然后我们再来举个浮点数的例子。

cdef float a = -7.
cdef float b = 5.
cdef float c1 = a / b
cdef float c2 = a // b
print(c1)  # -1.399999976158142
print(c2)  # -2.0

a / b 是 -1.4,但此时的 c1 是浮点数,所以没有必要取整了,小数位会保留;而 a // b虽然得到的也是浮点(只要 a 和 b 中有一个是浮点,那么 a / b 和 a // b 得到的也是浮点),但它依旧具备整除的意义,所以 a // b 得到结果是 -2.0,然后赋值给一个 float 变量,还是 -2.0。

关于 Python 中 / 和 // 在不同操作数之间的差异,我们再举个栗子看一下:

# 3.5, 很好理解
7 / 2 == 3.5 
# // 表示整除,因此 3.5 会向下取整, 得到 3
7 // 2 == 3  
# -3.5,很好理解
-7 / 2 == -3.5 
# // 表示取整,因此 -3.5 会向下取整,得到 -4
-7 // 2 == -4  

# 3.5, 依旧没问题
7.0 / 2 == 3.5  
# // 两边出现了浮点,结果也是浮点,但 // 又代表整除
# 所以你可以简单认为是先取整(得到 3), 然后变成浮点(得到3.0)
7.0 // 2 == 3.0  
# -3.5,依旧很简单
-7.0 / 2 == -3.5  
# -3.5 和 -3.9 都会向下取整,然后得到-4
# 但结果是浮点,所以是-4.0
-7.0 // 2 == -7.8 // 2 == -4.0  

# 3.5,没问题
-7.0 / -2 == 3.5 
# 3.5向下取整,得到3
-7.0 // -2 == 3  

所以 Python 的整除或者说地板除还是比较奇葩的,主要原因就在于其它语言是截断(小数点后面直接不要了),而 Python 是向下取整。

如果是结果为正数的话,截断和向下取整是等价的,所以此时基本所有语言都是一样的。

而结果为负数的话,那么截断和向下取整就不同了,因为 -3.14 截断得到的是 -3、但向下取整得到的不是 -3,而是 -4。因此这一点务必要记住,算是 Python 的一个坑吧。话说如果没记错的话,好像只有 Python 采用了向下取整这种方式,别的语言(至少C、JS、Go)都是截断的方式。

还有一个问题,那就是整数和浮点数之间可不可以相互赋值呢?先说结论:

  • 整数赋值给浮点数是可以的;

  • 浮点数赋值给整数不行;

# 7 是一个纯数字,那么它既可以在赋值给 int 类型变量时表示整数 7
# 也可以在赋值给 float 类型变量时表示 7.0
cdef int a = 7
cdef float b = 7

# 但如果是下面这种形式,虽然也是可以的,但是会弹出警告
cdef float c = a
# 提示: '=': conversion from 'int' to 'float', possible loss of data
# 因为 a 的值虽然也是 7,但它已经具有相应的类型了
# a 是一个 int,将 int 赋值给 float 会警告

# 而将浮点数赋值给整数则不行
# 这行代码在编译的时候会报错:Cannot assign type 'double' to 'int'
cdef int d = 7.0 

前面说了,使用 cdef int、cdef float 声明的变量不再是指向 Python 整数对象、浮点数对象的指针,而是 C 在栈上分配的整数和浮点数。尽管 C 整数没有考虑溢出,但是它在做运算的时候是遵循 Python 的规则(主要是除法),那么可不可以让其强制遵循 C 的规则呢?

cimport cython

# 使用@cython.cdivision(True)装饰器
@cython.cdivision(True)
def divides(int a, int b):
    return a / b

文件名还是叫 cython_test.pyx,我们来测试一下。

import cython_test
print(-7 // 2)  # -4
# 函数参数 a 和 b 都是整型,相除得到还是整型
# 如果是 Python 语义,那么在转化的时候会向下取整得到 -4
# 但这里是 C 语义,所以是截断得到 -3
print(cython_test.divides(-72))  # -3

除了装饰器的方式,还可以用下面两种方式来指定。

1)通过上下文管理器的方式

cimport cython

def divides(int a, int b):
    with cython.cdivision(True):
        return a / b

2)通过注释的方式进行全局声明

# cython: cdivision=True

def divides(int a, int b):
    return a / b

通过这三种方式,在 Cython 中可以让 C 整型变量的除法遵循 C 的语义。

这里再选择不使用 cython.cdivision,执行一下看看。

def divides(int a, int b):
    return a / b
import cython_test

print(-7 // 2)  # -4
print(cython_test.divides(-72))  # -4

a 和 b 都是 C 的 int,相除得到的默认还是 int,而我们没有使用 cython.cdivision,那么默认使用 Python 的语义。相除之后的 -3.5 会向下取整,所以结果不是 -3,而是 -4。

总结:

  • 使用 cdef int、cdef float 声明的变量的类型不再是 Python 的 int、float,也不再表示 CPython 的 PyLongObject * 和 PyFloatObject *,而就是 C 的整数和浮点数;

  • 虽然是 C 的 int 和 float,但在进行运算的时候是遵循 Python 语义的。因为 Cython 就是为了优化 Python 而生的,因此在各个方面都要和 Python 保持一致;

  • 但是也提供了一些方式,禁用掉 Python 的语义,而采用 C 的语义。方式就是上面说的那三种,它们专门针对于整除和取模,因为加减乘都是一样的,只有除和取模会有歧义;

另外 Cython 中还有一个 cdivision_warnings,使用方式和 cdivision 完全一样,表示:当取模的时候如果两个操作数中有一个是负数,那么会抛出警告。

cimport cython

@cython.cdivision_warnings(True)
def mod(int a, int b):
    return a % b

测试一下:

import cython_test

# -7 - (2 * -4) == 1
print(cython_test.mod(-72))  
# 提示我们取整操作在 C 和 Python 中有着不同的语义
# 同理 cython_test.mod(7, -2) 也会警告
"""
RuntimeWarning: division with oppositely signed operands, C and Python semantics differ
  return a % b
1
"""



# -7 - (-2 * 3) = -1
print(cython_test.mod(-7-2))  # -1

# 但是这里的 cython_test.mod(-7, -2) 却没有弹出警告,这是为什么呢?
# 很好理解,我们说只有商是负数的时候才会存在歧义
# 但是 -7 除以 -2 得到的商是 3.5,是个正数
# 而正数的表现形式对于截断和向下取整都是一致的,所以不会警告
# 同理 cython_test.mod(7, 2) 一样不会警告

另外这里的警告同时针对 Python 和 C,即使我们事先使用 @cython.cdivision(True) 装饰、将其改变为 C 的语义,也一样会弹出警告的。个人觉得 cdivision_warnings 意义不是很大,了解一下即可。


引用计数和静态字符串类型


我们知道解释器会自动管理内存,方法是通过引用计数来判断一个对象是否应该被回收,引入计数为 0 则对象回收,否则不回收。但是引用计数无法解决循环引用,于是又引入了垃圾回收来弥补引用计数的缺陷。

而 Cython 也会为我们处理所有的引用计数问题,确保 Python 对象(无论是 Cython 静态声明、还是 Python 动态声明)在引用计数为 0 时被销毁。

很好理解,就是说内存管理的问题 Cython 也会负责的。其实不用想也大概能猜到 Cython 会这么做,毕竟 cdef tuple a = (1, 2, 3) 和 a = (1, 2, 3) 底层都指向 PyTupleObject,只不过后者在操作的时候需要先通过 PyObject * 获取类型然后再转化,而前者则省略了这一步。

但它们底层都是 CPython 中的结构体,所以内存都由解释器管理。还是那句话,Cython 代码是要被翻译成 C 代码的,在翻译的时候会自动处理内存的问题,当然这点和 Python 也是一样的。

但是当 Cython 中动态变量和静态变量混合时,那么内存管理会有微妙的影响。我们举个栗子:

# char * 表示 C 的字符串
# 它对应 Python 的 bytes 对象
# 但下面这行代码是编译不过去的
cdef char *name = "古明地觉".encode("utf-8")

编译的时候会失败,咦,不是说后面可以跟一个 bytes 对象吗?但问题是这个 bytes 对象是一个临时对象,什么是临时对象呢?就是创建完了但是没有变量指向它,准确的说是没有 Python 类型的变量指向它。

因为这里的 name 使用的是 C 的类型,所以它不会增加这个 bytes 对象的引用计数。因此这个 bytes 对象创建出来之后就会被销毁。编译时会抛出:Storing unsafe C derivative of temporary Python reference,告诉我们创建出来的Python对象是临时的。

那么如何解决这一点呢?答案是使用变量保存起来就可以了。

# 这种做法是完全合法的
# 因为这个 bytes 对象是被 Python 类型的变量指向了
cdef bytes name_py = "古明地觉".encode("utf-8")
# 或者 name_py = "古明地觉".encode("utf-8")
cdef char *name = name_py

所以 char * 比较特殊,它底层是使用一个指针来表示字符串。和整型和浮点型不同,cdef long a = 123,这个 123 直接就是 C 中的 long,可以直接使用。

但将 Python 的 bytes 对象赋值给 char *,在 C 的级别 char * 所引用的数据还是由 CPython 进行管理的,因为 bytes 对象内部有一个缓冲区,负责存储具体的数据,而 char * 会直接指向这个缓冲区。但它无法告诉解释器还有一个变量(非 Python 类型的变量)引用它,这就导致了 bytes 对象的引用计数不会加1,而是创建完之后就会被销毁。而 bytes 对象都销毁了,char * 类型的变量也就拿不到内部的数据了。

所以我们需要提前使用 Python 类型的变量(不管是静态声明还是动态声明)将其保存起来,让其引用计数加 1,这样就不会删除了。

那么下面的代码有没有问题呢?如果有问题该怎么改呢?

word1 = "hello".encode("utf-8")
word2 = "satori".encode("utf-8")

cdef char *word = word1 + word2

会不会出问题呢?显然会有大问题,尽管 word1 和 word2 指向了相应的 bytes 对象,但是 word1 + word2 则是会创建一个新的 bytes 对象,这个新的 bytes 对象可没有变量指向。所以这个新创建的 bytes 对象注定是昙花一现,创建完之后会被立刻销毁,因此无法赋值给 char * 变量。

另外创建 char * 还有一种方式:

cdef char *name = "satori"

此时的 "satori" 会被当成是 C 的字符串,所以这种做法也是可以的,不过很明显,它只能是 ascii 字符串。

但下面这种做法不行:

name_py = "satori"
cdef char *name = name_py

char * 需要接收 C 的字符串,但我们赋值给了一个变量,那么它就是 Python 类型了,而 Python 的 str 和 C 的 char * 无法直接转化,两者没有对应关系。于是报错:TypeError: expected bytes, str found。

而 char * 和 Python 的 bytes 是对应的,每个元素都是 0 到 255 的整数。

cdef bytes var_py = "abc"
# 等价于 C 的 char *var = {'a', 'b', 'c', '\0'};
# 或者 char *var = {97, 98, 99, '\0'};
cdef char *var = var 

同理 char * 在赋值给 Python 类型的变量时,也会自动转成 bytes 对象,因为这两者是对应的。

# char *name = {'s', 'a', 't', 'o', 'r', 'i', '\0'}
cdef char *name = "satori"
# 赋值给 Python 类型的变量
name_py = name
print(name)  # b'satori'

以上就是 char * 相关的内容,它表示 C 的字符串类型,对应 Python 的 bytes。显然它在操作的时候,速度要比 bytes 对象快很多,如果你希望程序运行的更快一些,那么不妨将 bytes 类型替换成 char * 类型。

因此关于 char * 来总结一下:

当然啦,char * 是 C 的字符串类型,Python 也有自己的字符串类型,也就是 str。

cdef str name_py = "satori"
cdef char *name_c = "satori"

print(name_py)  # satori
print(name_c)  # b'satori'

比较简单,没什么可说的。然后 Cython 还提供了一个 Py_UCS4,它表示只有一个字符的字符串。

def foo(Py_UCS4 single_char):
    print(single_char)

# 合法
foo("你")
# 不合法,长度不为 1
foo("你好")
"""
ValueError: only single character unicode strings 
            can be converted to Py_UCS4, got length 2
"""

以上代码会编译失败,另外 Py_UCS4 表示一个 UNICODE,所以这个 Py_UCS4 还可以换成 Py_UNICODE,效果是一样的,都表示长度为 1 的 Python 字符串。

以上就是字符串相关的内容,str 表示 Python 的字符串类型,char * 表示 C 的字符串类型,对应 Python 的 bytes 类型。使用 char * 的速度会更快,asyncpg 这个数据库驱动在解析数据时就将 bytes 换成了 char *,速度从而得到了很大的提升。

但我们说,C 的类型虽然速度快,可是不够灵活,它使用起来肯定没有 bytes 对象方便。如果你的程序没有到达性能瓶颈,可以考虑不使用 char *,直接使用 bytes 和 str 就行。通过 cdef bytes 和 cdef str 静态声明,速度依旧可以提升,至于要不要使用 char * 就看你自己的习惯了。


小结


以上就是静态整形和静态字符串类型相关的内容,在使用的时候会有一些意想不到的小陷阱,所以需要注意。

下一篇文章我们来说一说 Cython 中的函数。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多