python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Cython处理C字符串

Cython处理C字符串的示例详解

作者:古明地觉

如果你在使用 Cython 加速 Python 时遇到了瓶颈,但还希望更进一步,那么可以考虑将数据的类型替换成 C 的类型,所以本文为大家介绍了Cython处理C字符串的方法,希望对大家有所帮助

楔子

在介绍数据类型的时候我们说过,Python 的数据类型相比 C 来说要更加的通用,但速度却远不及 C。如果你在使用 Cython 加速 Python 时遇到了瓶颈,但还希望更进一步,那么可以考虑将数据的类型替换成 C 的类型,特别是那些频繁出现的数据,比如整数、浮点数、字符串。

由于整数和浮点数默认使用的就是 C 的类型,于是我们可以从字符串入手。

创建 C 字符串

先来回顾一下如何在 Cython 中创建 C 字符串。

cdef char *s1 = b"abc"
print(s1)  # b'abc'

C 的数据和 Python 数据如果想互相转化,那么两者应该存在一个对应关系,像整数和浮点数就不必说了。但 C 的字符串本质上是一个字符数组,所以它和 Python 的 bytes 对象是对应的,我们可以将 b"abc" 直接赋值给 s1。并且在打印的时候,也会转成 Python 的 bytes 对象之后再打印。

或者还可以这么做:

cdef char s1[4]
s1[0], s1[1], s1[2] = 97, 98, 99

cdef bytes s2 = bytes([97, 98, 99])

print(s1)  # b'abc'
print(s2)  # b'abc'

直接声明一个字符数组,然后再给数组的每个元素赋值即可。

Python 的 bytes 对象也是一个字符数组,和 C 一样,数组的每个元素不能超过 255,所以两者存在对应关系。在赋值的时候,会相互转化,其它类型也是同理,举个例子:

# Python 整数和 C 整数是存在对应关系的
# 因为都是整数,所以可以相互赋值
py_num = 123
# 会根据 Python 的整数创建出 C 的整数,然后赋值给 c_num
cdef unsigned int c_num = py_num
# print 是 Python 的函数,它接收的一定是 PyObject *
# 所以在打印 C 的整数时,会转成 Python 的整数再进行打印
print(c_num, py_num)
"""
123 123
"""
# 但如果写成 cdef unsigned int c_num = "你好" 就不行了
# 因为 Python 的字符串和 C 的整数不存在对应关系
# 两者无法相互转化,自然也就无法赋值

# 浮点数也是同理,Python 和 C 的浮点数可以相互转化
cdef double c_pi = 3.14
# 赋值给 Python 变量时,也会转成 Python 的浮点数再赋值
py_pi = 3.14
print(c_pi, py_pi)
"""
3.14 3.14
"""

# Python 的 bytes 对象和 C 的字符串可以相互转化
cdef bytes py_name = bytes("古明地觉", encoding="utf-8")
cdef char *c_name = py_name
print(py_name == c_name)
"""
True
"""

# 注意:如果 Python 字符串所有字符的 ASCII 🐴均不超过 255
# 那么也可以赋值给 C 字符串
cdef char *name1 = "satori"
cdef char *name2 = b"satori"
print(name1, name2)
"""
b'satori' b'satori'
"""
# "satori" 会直接当成 C 字符串来处理,因为它里面的字符均为 ASCII
# 就像写 C 代码一样,所以 name1 和 name2 是等价的
# 而在转成 Python 对象的时候,一律自动转成 bytes 对象
# 但是注意:cdef char *c_name = "古明地觉" 这行代码不合法
# 因为里面出现了非 ASCII 字符,所以建议在给 C 字符串赋值的时候,一律使用 bytes 对象


# C 的结构体和 Python 的字典存在对应关系
ctypedef struct Girl:
    char *name
    int age

cdef Girl g
g.name, g.age = b"satori", 17
‍# 在打印的时候,会转成字典进行打印
# 当然前提是结构体的所有成员,都能用 Python 表示
print(g)
"""
{'name': b'satori', 'age': 17}
"""

所以 Python 数据和 C 数据是可以互相转化的,哪怕是结构体,也是可以的,只要两者存在对应关系,可以互相表示。但像指针就不行了,Python 没有任何一种原生类型能和 C 的指针相对应,所以 print 一个指针的时候就会出现编译错误。

以上这些都是之前介绍过的内容,但很多人可能都忘了,这里专门再回顾一下。

引用计数陷阱

这里需要再补充一个关键点,由于 bytes 对象实现了缓冲区协议,所以它内部有一个缓冲区,这个缓冲区内部存储了所有的字符。而在基于 bytes 对象创建 C 字符串的时候,不会拷贝缓冲区里的内容(整数、浮点数都是直接拷贝一份),而是直接创建一个指针指向这个缓冲区。

# 合法的代码
py_name = "古明地觉".encode("utf-8")
cdef char *c_name1 = py_name

# 不合法的代码,会出现如下编译错误
# Storing unsafe C derivative of temporary Python reference
cdef char *c_name2 = "古明地觉".encode("utf-8")

为啥在创建 c_name2 的时候就会报错呢?很简单,因为这个过程中进行了函数调用,所以产生了临时对象。换句话创建的 bytes 对象是临时的,这行代码执行结束后就会因为引用计数为 0 而被销毁。

问题来了,c_name2 不是已经指向它了吗?引用计数应该为 1 才对啊。相信你能猜到原因,这个 c_name2 的类型是 char *,它是一个 C 的变量,不会增加对象的引用计数。这个过程就是创建了一个 C 级指针,指向了临时的 bytes 对象内部的缓冲区,而解释器是不知道的。

所以临时对象最终会因为引用计数为 0 被销毁,但是这个 C 指针却仍指向它的缓冲区,于是就报错了。我们需要先创建一个 Python 变量指向它,让其不被销毁,然后才能赋值给 C 级指针。为了更好地说明这个现象,我们使用 bytearray 举例说明。

cdef bytearray buf = bytearray("hello", encoding="utf-8")
cdef char *c_str = buf

print(buf)  # bytearray(b'hello')
# 基于 c_str 修改数据
c_str[0] = ord("H")
# 再次打印 buf
print(buf)  # bytearray(b'Hello')
# 我们看到 buf 被修改了

bytearray 对象可以看作是可变的 bytes 对象,它们内部都实现了缓冲区,但 bytearray 对象的缓冲区是可以修改的,而 bytes 对象的缓冲区不能修改。所以这个例子就证明了上面的结论,C 字符串会直接共享 Python 对象的缓冲区。

因此在赋值的时候,我们应该像下面这么做。

print(
    "你好".encode("utf-8")
)  # b'\xe4\xbd\xa0\xe5\xa5\xbd'

# 如果出现了函数或类的调用,那么会产生临时对象
# 而临时对象不能直接赋值给 C 指针,必须先用 Python 变量保存起来
cdef bytes greet = "你好".encode("utf-8")
cdef char *c_greet1 = greet

# 如果非要直接赋值,那么赋的值一定是字面量的形式
# 这种方式也是可以的,但显然程序开发中我们不会这么做
# 除非它是纯 ASCII 字符
# 比如 cdef char *c_greet2 = b"hello"
cdef char *c_greet2 = b"\xe4\xbd\xa0\xe5\xa5\xbd"

print(c_greet1.decode("utf-8"))  # 你好
print(c_greet2.decode("utf-8"))  # 你好

以上就是 C 字符串本身相关的一些内容。

那么重点来了,假设我们将 Python 的字符串编码成 bytes 对象之后,赋值给了 C 字符串,那么 C 语言都提供了哪些 API 让我们去操作呢?

strlen

strlen 函数会返回字符串的长度,不包括末尾的空字符。C 字符串的结尾会有一个 \0,用于标识字符串的结束,而 strlen 不会统计 \0。

# C 的库函数,一律通过 libc 进行导入
from libc.string cimport strlen

cdef char *s = b"satori"
print(strlen(s))  # 6

注意:strlen 和 sizeof 是两个不同的概念,strlen 计算的是字符串的长度,只能接收字符串。而 sizeof 计算的是数据所占用的内存大小,可以接收所有 C 类型的数据。

from libc.string cimport strlen

cdef char s[50]
# strlen 是从头遍历,只要字符不是 \0,那么数量加 1
# 遇到 \0 停止遍历,所以 strlen 计算的结果是 0
print(strlen(s))  # 0
# 而 sizeof 计算的是内存大小,当前数组 s 的长度为 50
print(sizeof(s))  # 50

s[0] = 97
print(strlen(s))  # 1
s[1] = 98
print(strlen(s))  # 2
print(sizeof(s))  # 50

当然啦,你也可以手动模拟 strlen 函数。

from libc.string cimport strlen

cdef ssize_t my_strlen(const char *string):
    """
    计算 C 字符串 string 的长度
    """
    cdef ssize_t count = 0
    while string[count] != b"\0":
        count += 1
    return count

cdef char *name = b"Hello Cruel World"
print(strlen(name))  # 17
print(my_strlen(name))  # 17

还是很简单的,当然啦,我们也可以调用内置函数 len 进行计算,结果也是一样的。只不过调用 len 的时候,会先基于 C 字符串创建 bytes 对象,这会多一层转换,从而影响效率。

strcpy

然后是拷贝字符串,这里面有一些需要注意的地方。

from libc.string cimport strcpy

cdef char name[10]
strcpy(name, b"satori")
print(name)  # b'satori'

strcpy(name, b"koishi")
print(name)  # b'koishi'

# 以上就完成了字符串的拷贝,但要注意 name 是数组的名字
# 我们不能给数组名赋值,比如 name = b"satori"
# 这是不合法的,因为它是一个常量
# 我们需要通过 name[索引] 或者 strcpy 的方式进行修改


# 或者还可以这么做,创建一个 bytearray 对象,长度 10
# 注意:这里不能用 bytes 对象,因为 bytes 对象的缓冲区不允许修改
cdef buf = bytearray(10)
cdef char *name2 = buf
strcpy(name2, b"marisa")
print(buf)  # bytearray(b'marisa\x00\x00\x00\x00')
print(name2)  # b'marisa'

# 不过还是不建议使用 bytearray 作为缓冲区
# 直接通过 cdef char name2[10] 声明即可

char name[10] 这种形式创建的数组是申请在栈区的,如果想跨函数调用,那么应该使用 malloc 申请在堆区。

然后 strcpy 这个函数存在一些隐患,就是它不会检测目标字符串是否有足够的空间去容纳源字符串,因此可能导致溢出。

from libc.string cimport strcpy

cdef char name[6]
# 会发生段错误,解释器异常退出
# 因为源字符串有 6 个字符,再加上一个 \0
# 那么 name 的长度至少为 7 才可以
strcpy(name, b"satori")
print(name)

因此如果你无法保证一定不会发生溢出,那么可以考虑使用 strncpy 函数。它和 strcpy 的用法完全一样,只是多了第三个参数,用于指定复制的最大字符数,从而防止目标字符串发生溢出。

第三个参数 size 定义了复制的最大字符数,如果达到最大字符数以后,源字符串仍然没有复制完,就会停止复制。如果源字符串的字符数小于目标字符串的容量,则 strncpy 的行为与 strcpy 完全一致。

from libc.string cimport strncpy

cdef char name[6]
# 最多拷贝 5 个字符,因为要留一个给 \0
strncpy(name, b"satori", 5)
print(name)  # b'sator'


# 当然,即使目标字符串容量很大,我们也可以只拷贝一部分
cdef char words[100]
strncpy(words, b"hello world", 5)
print(words)  # b'hello'

以上就是字符串的拷贝,并且对于目标字符串来说,每一次拷贝都相当于一次覆盖,什么意思呢?举个例子。

from libc.string cimport strcpy

cdef char words[10]
strcpy(words, b"abcdef")
# 此时的 words 就是 {a, b, c, d, e, f, \0, \0, \0, \0}
# 然后我们继续拷贝,会从头开始覆盖
strcpy(words, b"xyz")
# 此时的 words 就是 {x, y, z, \0, e, f, \0, \0, \0, \0}
# 因为字符串自带 \0,所以 z 的结尾会有一个 \0
# 而 C 字符串在遇到 \0 的时候会自动停止
print(words)  # b'xyz'
# 将 words[3] 改成 d
words[3] = ord("d")
print(words)  # b'xyzdef'

所以要注意 \0,它是 C 编译器判断字符串是否结束的标识。

strcat

strcat 函数用于连接字符串,它接收两个字符串作为参数,把第二个字符串的副本添加到第一个字符串的末尾。这个函数会改变第一个字符串,但是第二个字符串不变。

from libc.string cimport strcpy, strcat

cdef char words1[20]
strcpy(words1, b"Hello")
print(words1)  # b'Hello'
strcpy(words1, b"World")
print(words1)  # b'World'

cdef char words2[20]
strcat(words2, b"Hello")
print(words2)  # b'Hello'
strcat(words2, b"World")
print(words2)  # b'HelloWorld'

注意,strcat 会从目标字符串的第一个 \0 处开始,追加源字符串,所以目标字符串的剩余容量,必须足以容纳源字符串。否则拼接后的字符串会溢出第一个字符串的边界,写入相邻的内存单元,这是很危险的,建议使用下面的 strncat 代替。

strncat 和 strcat 的用法一致,但是多了第三个参数,用于指定追加的最大字符数。

from libc.string cimport strncat, strlen


cdef char target[10]
cdef char *source = b"Hello World"
# 追加的最大字符数等于:容量 - 当前的长度 - 1
strncat(target, source,
        sizeof(target) - strlen(target) - 1)
print(target)  # b'Hello Wor'

为了安全,建议使用 strncat。

strcmp

strcmp 用于字符串的比较,它会按照字符串的字典序比较两个字符串的内容。

from libc.string cimport strcmp

# s1 == s2,返回 0
print(
    strcmp(b"abc", b"abc")
)  # 0

# s1 > s2,返回 1
print(
    strcmp(b"abd", b"abc")
)  # 1

# s1 < s2,返回 0
print(
    strcmp(b"abc", b"abd")
)  # -1

由于 strcmp 比较的是整个字符串,于是 C 语言又提供了 strncmp 函数。strncmp 增加了第三个参数,表示比较的字符个数。

from libc.string cimport strcmp, strncmp

print(
    strcmp(b"abcdef", b"abcDEF")
)  # 1

# 只比较 3 个字符
print(
    strncmp(b"abcdef", b"abcDEF", 3)
)  # 0

比较简单,并且比较规则和 strcmp 一样。

sprintf

sprintf 函数 printf 类似,但是用于将数据写入字符串,而不是输出到显示器。

from libc.stdio cimport sprintf

cdef char s1[25]
sprintf(s1, b"name: %s, age: %d", b"satori", 17)
print(s1)
"""
b'name: satori, age: 17'
"""

# 也可以指向 bytearray 的缓冲区
cdef buf = bytearray(25)
cdef char *s2 = buf
sprintf(s2, b"name: %s, age: %d", b"satori", 17)
print(s2)
print(buf)
"""
b'name: satori, age: 17'
bytearray(b'name: satori, age: 17\x00\x00\x00\x00')
"""

# 或者申请在堆区
from libc.stdlib cimport malloc
cdef char *s3 = <char *>malloc(25)
sprintf(s3, b"name: %s, age: %d", b"satori", 17)
print(s3)
"""
b'name: satori, age: 17'
"""

同样的,sprintf 也有严重的安全风险,如果写入的字符串过长,超过了目标字符串的长度,sprintf 依然会将其写入,导致发生溢出。为了控制写入的字符串的长度,C 语言又提供了另一个函数 snprintf。

snprintf 多了一个参数,用于控制写入字符的最大数量。

from libc.stdio cimport snprintf

cdef char s1[10]
# 写入的字符数量不能超过: 最大容量 - 1
snprintf(s1, sizeof(s1) - 1, 
         b"name: %s, age: %d", b"satori", 17)
print(s1)
"""
b'name: sa'
"""

建议使用 snprintf,要更加的安全,如果是 sprintf,那么当溢出时会发生段错误,这是一个非常严重的错误。

动态申请字符串内存

我们还可以调用 malloc, calloc, realloc 函数为字符串动态申请内存,举个例子:

from libc.stdlib cimport (
    malloc, calloc
)
from libc.string cimport strcpy

# 这几个函数所做的事情都是在堆上申请一块内存
# 并且返回指向这块内存的 void * 指针
cdef void *p1 = malloc(4)
# 我们想用它来存储字符串,那么就将 void * 转成 char *
strcpy(<char *>p1, b"abc")
# 或者也可以这么做
cdef char *p2 = <char *>malloc(4)
strcpy(p2, b"def")

print(<char *>p1)  # b'abc'
print(p2)  # b'def'

# 当然,申请的内存不光可以存储字符串,其它数据也是可以的
cdef int *p3 = <int *> malloc(8)
p3[0], p3[1] = 11, 22
print(p3[0] + p3[1])  # 33


# 以上是 malloc 的用法,然后是 calloc
# 它接收两个参数,分别是申请的元素个数、每个元素占用的大小
cdef int *p4 = <int *>calloc(10, sizeof(int))
# 它和下面是等价的
cdef int *p5 = <int *>calloc(10 * 4)

如果是在 C 里面,那么 malloc 申请的内存里面的数据是不确定的,而 calloc 申请的内存里面的数据会被自动初始化为 0。但在 Cython 里面,它们都会被初始化为 0。

并且还要注意两点:

最后一个函数是 realloc,它用于修改已经分配的内存块的大小,可以放大也可以缩小,返回一个指向新内存块的指针。

from libc.stdlib cimport (
    malloc, realloc
)
from libc.string cimport strcpy

cdef char *p1 = <char *>malloc(4)
strcpy(p1, b"abc")

# p1 指向的内存最多能容纳 3 个有效字符串
# 如果希望它能容纳更多,那么就要重新分配内存
p1 = <char *>realloc(p1, 8)

# 如果新内存块小于原来的大小,则丢弃超出的部分;
# 大于原来的大小,则返回一个全新的地址,数据也会自动复制过去
# 如果第二个参数是 0,那么会释放掉内存块

# 如果 realloc 的第一个参数是 NULL,那么等价于 malloc
cdef char *p2 = <char *>realloc(NULL, 40)
# 等价于 cdef char *p2 = <char *>malloc(40)


# 由于有分配失败的可能,所以调用 realloc 之后
# 最好检查一下它的返回值是否为 NULL
# 并且分配失败时,原有内存块中的数据不会发生改变。

在 C 里面,malloc 和 realloc 申请的内存不会自动初始化,一般申请完之后还要手动初始化为 0。但在 Cython 里面,一律会自动初始化为 0,这一点就很方便了。

memset

memset 是一个初始化函数,它的作用是将某一块内存的所有字节都设置为指定的值。

from libc.stdlib cimport malloc
from libc.string cimport memset

# 函数原型
# void *memset  (void *block, int c, size_t size)
cdef char *s1 = <char *>malloc(10)
memset(<void *> s1, ord('a'), 10 - 1)
# 全部被设置成了 a
print(s1)  # b'aaaaaaaaa'

cdef char *s2 = <char *>malloc(10)
# 只设置前三个字节
memset(<void *> s2, ord('a'), 3)
print(s2)  # b'aaa'

在使用 memset 的时候,一般都是将内存里的值都初始化为 0。

memcpy

memcpy 用于将一块内存拷贝到另一块内存,用法和 strncpy 类似,但前者不光可以拷贝字符串,任意内存都可以拷贝,所以它接收的指针是 void *。

from libc.string cimport memcpy

cdef char target[10]
cdef char *source = "Hello World"

# 接收的指针类型是 void *,它与数据类型无关
# 就是以字节为单位,将数据逐个拷贝过去
# 并且还有第三个参数,表示拷贝的最大字节数
memcpy(<void *> target, <void *> source, 9)
print(target)  # b'Hello Wor'


# 同样的,整数数组也可以
cdef int target2[5]
cdef int source2[3]
source2[0], source2[1], source2[2] = 11, 22, 33
memcpy(<void *> target2, <void *> source2, 5 * sizeof(int))
print(target2[0], target2[1], target2[2])  # 11, 22, 33


# 当然你也可以自己实现一个 memcpy
cdef void my_memcpy(void *src, void *dst, ssize_t count):
    # 不管 src 和 dst 指向什么类型,统一当成 1 字节的 char
    # 逐个遍历,然后拷贝过去即可
    cdef char *s = <char *>src
    cdef char *d = <char *>dst
    # 在 Cython 里面解引用不可以通过 *p 的方式,而是要使用 p[0]
    # 因为 *p 这种形式在 Python 里面有另外的含义
    while count != 0:
        s[0] = d[0]
        s += 1
        d += 1

# 测试一下
cdef float target3[5]
cdef float source3[3]
source3[0], source3[1], source3[2] = 3.14, 2.71, 1.732
memcpy(<void *> target3, <void *> source3, 5 * sizeof(float))
print(target3[0], target3[1], target3[2])  # 3.14, 2.71, 1.732

所以在拷贝字符串的时候,memcpy 和 strcpy 都可以使用,但是推荐 memcpy,速度更快也更安全。

memmove

memmove 函数用于将一段内存数据复制到另一段内存,它跟 memcpy 的作用相似,用法也一模一样。但区别是 memmove 允许目标区域与源区域有重叠。如果发生重叠,源区域的内容会被更改;如果没有重叠,那么它与 memcpy 行为相同。

from libc.string cimport memcpy, memmove

cdef char target1[20]
cdef char target2[20]
cdef char *source = "Hello World"
# target1、target2 和 source 均不重叠
# 所以 memcpy 和 memmove 是等价的
memcpy(<void *>target1, <void *>source, 20 - 1)
memmove(<void *>target2, <void *>source, 20 - 1)
print(target1)  # b'Hello World'
print(target2)  # b'Hello World'

# 但 &target1[0] 和 &target[1] 是有重叠的
# 将 target1[1:] 拷贝到 target1[0:],相当于每个字符往前移动一个位置
memmove(<void *>&target1[0], <void *>&target1[1], 19 - 1)
print(target1)  # b'ello World'
# 显然此时内容发生了覆盖,这时候应该使用 memmove

应该很好理解。

memcmp

memcmp 用于比较两个内存区域是否相同,前两个参数是 void * 指针,第三个参数比较的字节数,所以它的用法和 strncmp 是一致的。

from libc.string cimport memcmp, strncmp

cdef char *s1 = b"Hello1"
cdef char *s2 = b"Hello2"
# s1 == s2 返回 0;s1 >= s2 返回 1;s1 <= s2 返回 -1
print(memcmp(<void *> s1, <void *> s2, 6))  # -1
print(memcmp(<void *> s1, <void *> s2, 5))  # 0
print(strncmp(s1, s2, 6))  # -1
print(strncmp(s1, s2, 5))  # 0

# 所以 memcmp 和 strncmp 的用法是一样的
# 但 memcmp 在比较的时候会忽略 \0
cdef char s3[5]
cdef char s4[5]
# '\0' 的 ASCII 码就是 0
# 所以 s3 就相当于 {'a', 'b', 'c', '\0', 'e'}
s3[0], s3[1], s3[2], s3[3], s3[4] = 97, 98, 99, 0, 100
# s4 就相当于 {'a', 'b', 'c', '\0', 'f'}
s4[0], s4[1], s4[2], s4[3], s4[4] = 97, 98, 99, 0, 101
# strncmp 在比较的时候,如果遇到 \0,那么字符串就结束了
print(strncmp(s3, s4, 5))  # 0
# memcmp 支持所有数据类型的比较,不单单针对字符串
# 所以它在比较的时候不会关注 \0,就是逐一比较每个字节,直到达到指定的字节数
# 因为 e 的 ASCII 码小于 f,所以结果是 -1
print(memcmp(<void *> s3, <void *> s4, 5))  # -1

以上就是 memcmp 的用法,我们总结一下出现的函数。

小结

以上就是在 Cython 中处理 C 字符串的一些操作,说实话大部分都是和 C 相关的内容,如果你熟悉 C 的话,那么这篇文章其实可以不用看。

因为 Cython 同理理解 C 和 Python,在加速的时候不妨把字符串换成 char * 试试吧。比如有一个操作 pgsql 的异步驱动 asyncpg 就是这么做的,因此速度非常快。

到此这篇关于Cython处理C字符串的示例详解的文章就介绍到这了,更多相关Cython处理C字符串内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文