解密 Python 如何调用 Rust 编译生成的动态链接库(一)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 解密 Python 如何调用 Rust 编译生成的动态链接库(一)


楔子



Rust 让 Python 更加伟大,随着 Rust 的流行,反而让 Python 的生产力提高了不少。因为有越来越多的 Python 工具,都选择了 Rust 进行开发,并且性能也优于同类型的其它工具。比如

  • ruff:速度极快的代码分析工具,以及代码格式化工具;
  • orjson:一个高性能的 JSON 解析库;
  • watchfiles:可以对指定目录进行实时监控;
  • polars:和 pandas 类似的数据分析工具;
  • pydantic:数据验证工具;
  • ......

总之现在 Rust + Python 已经成为了一个趋势,并且 Rust 也提供了一系列成熟好用的工具,比如 PyO3、Maturin,专门为 Python 编写扩展。不过关于 PyO3 我们以后再聊,本篇文章先来介绍如何Rust 代码编译成动态库,然后交给 Python 的 ctypes 模块调用。

因为通过 ctypes 调用动态库是最简单的一种方式,它只对操作系统有要求,只要操作系统一致,那么任何提供了 ctypes 模块的 Python 解释器都可以调用。

当然这也侧面要求,Rust 提供的接口不能太复杂,因为 ctypes 提供的交互能力还是比较有限的,最明显的问题就是不同语言的数据类型不同,一些复杂的交互方式还是比较难做到的,还有多线程的控制问题等等。

之前说过使用 ctypes 调用 C 的动态库,里面详细介绍了 ctypes 的用法,因此本文关于 ctypes 就不做详细介绍了。


举个例子



下面我们举个例子感受一下 Python 和 Rust 的交互过程,首先通过如下命令创建一个 Rust 项目:

cargo new py_lib --lib

创建完之后修改 Cargo.toml,在里面加入如下内容:

[lib]
# 编译之后的动态库的名称
name = "py_lib"
# 表示编译成一个和 C 语言二进制接口(ABI)兼容的动态链接库
crate-type = ["cdylib"]

cdylib 表示生成动态库,如果想生成静态库,那么就指定为 staticlib。

下面开始编写源代码,在生成项目之后,src 目录下会有一个 lib.rs,它是整个库的入口点。我们的代码比较简单,直接写在 lib.rs 里面即可。

#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}
#[no_mangle]
pub extern "C" fn get_square_root(v: i32) -> f64 {
    (v as f64).sqrt()
}

在定义函数时需要使用 pub extern "C" 进行声明,它表示创建一个外部可见、遵循 C 语言调用约定的函数,因为 Python 使用的是 C ABI。

此外还要给函数添加一个 #[no_mangle] 属性,让编译器在将 Rust 函数导出为 C 函数时,不要改变函数的名称。确保在编译成动态库后,函数名保持不变,否则在调用动态库时就找不到指定的函数了。

Rust 有个名称修饰(Name Mangling)的机制,在跨语言操作时,会修改函数名,增加一些额外信息。这种修改对 Rust 内部使用没有影响,但会干扰其它语言的调用,因此需要通过 #[no_mangle] 将该机制禁用掉。

代码编写完成,我们通过 cargo build 进行编译,然后在 target/debug 目录下就会生成相应的动态库。由于库的名称我们指定为 py_lib,那么生成的库文件名就叫 libpy_lib.dylib。

当功能全部实现并且测试通过时,最好重新编译一次,并加上 --release 参数。这样可以对代码进行优化,当然编译时间也会稍微长一些,并且生成的库文件会在 target/release 目录中。

编译器生成动态库后,会自动加上一个 lib 前缀(Windows 系统除外),至于后缀则与操作系统有关。

  • Windows 系统,后缀名为 .dll;
  • macOS 系统,后缀名为 .dylib;
  • Linux 系统,后缀名为 .so;


然后我们通过 Python 进行调用。

import ctypes
# 使用 ctypes 很简单,直接 import 进来
# 然后使用 ctypes.CDLL 这个类来加载动态链接库
# 或者使用 ctypes.cdll.LoadLibrary 也是可以的
py_lib = ctypes.CDLL("../py_lib/target/debug/libpy_lib.dylib")
# 加载之后就得到了动态链接库对象,我们起名为 py_lib
# 然后通过属性访问的方式去调用里面的函数
print(py_lib.add(11, 22))
"""
33
"""
# 如果不确定函数是否存在,那么建议使用反射
# 因为函数不存在,通过 . 的方式获取是会抛异常的
get_square_root = getattr(py_lib, "get_square_root", None)
if get_square_root:
    print(get_square_root)
    """
    <_FuncPtr object at 0x7fae30a2b040>
    """
# 不存在 sub 函数,所以得到的结果为 None
sub = getattr(py_lib, "sub", None)
print(sub)
"""
None
"""

所以使用 ctypes 去调用动态链接库非常方便,过程很简单:

  • 1)通过 ctypes.CDLL 去加载动态库;
  • 2)加载动态链接库之后会返回一个对象,我们上面起名为 py_lib;
  • 3)然后直接通过 py_lib 调用里面的函数,但为了程序的健壮性,建议使用反射,确定调用的函数存在后才会调用;

我们以上就演示了如何通过 ctypes 模块来调用 Rust 编译生成的动态库,但显然目前还是远远不够的,比如说:

from ctypes import CDLL
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
square_root = py_lib.get_square_root(100)
print(square_root)  # 0

100 的平方根是 10,但却返回了 0。这是因为 ctypes 在解析返回值的时候默认是按照整型来解析的,但当前的函数返回的是浮点型,因此函数在调用之前需要显式地指定其返回值类型。

不过在这之前,我们需要先来看看 Python 类型和 Rust 类型之间的转换关系。


数值类型



使用 ctypes 调用动态链接库,主要是调用库里面使用 Rust 编写好的函数,但这些函数是需要参数的,还有返回值。而不同语言的变量类型不同,Python 不能直接往 Rust 编写的函数中传参,因此 ctypes 提供了大量的类,帮我们将 Python 的类型转成 Rust 的类型。

与其说转成 Rust 的类型,倒不如说转成 C 的类型,因为 Rust 导出的函数要遵循 C 的调用约定。

d45418e84ae9906ede1d6d338ee124cf.png

下面来测试一下,首先编写 Rust 代码:

#[no_mangle]
pub extern "C" fn add_u32(a: u32) -> u32 {
    a + 1
}
#[no_mangle]
pub extern "C" fn add_isize(a: isize) -> isize {
    a + 1
}
#[no_mangle]
pub extern "C" fn add_f32(a: f32) -> f32 {
    a + 1.
}
#[no_mangle]
pub extern "C" fn add_f64(a: f64) -> f64 {
    a + 1.
}
#[no_mangle]
pub extern "C" fn reverse_bool(a: bool) -> bool {
    !a
}

编译之后 Python 进行调用。

from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
print(py_lib.add_u32(123))
"""
124
"""
print(py_lib.add_isize(666))
"""
667
"""
try:
    print(py_lib.add_f32(3.14))
except Exception as e:
    print(e)
"""
<class 'TypeError'>: Don't know how to convert parameter 1
"""
# 我们看到报错了,告诉我们不知道如何转化第 1 个参数
# 因为 Python 的数据和 C 的数据不一样,所以不能直接传递
# 但整数是个例外,除了整数,其它数据都需要使用 ctypes 包装一下
# 另外整数最好也包装一下,因为不同整数之间,精度也有区别
print(py_lib.add_f32(c_float(3.14)))
"""
1
"""
# 虽然没报错,但是结果不对,结果应该是 3.14 + 1 = 4.14,而不是 1
# 因为 ctypes 调用函数时默认使用整型来解析,但该函数返回的不是整型
# 需要告诉 ctypes,add_f32 函数返回的是 c_float,请按照 c_float 来解析
py_lib.add_f32.restype = c_float
print(py_lib.add_f32(c_float(3.14)))
"""
4.140000343322754
"""
# f32 和 f64 是不同的类型,占用的字节数也不一样
# 所以 c_float 和 c_double 之间不可混用,虽然都是浮点数
py_lib.add_f64.restype = c_double
print(py_lib.add_f64(c_double(3.14)))
"""
4.140000000000001
"""
py_lib.reverse_bool.restype = c_bool
print(py_lib.reverse_bool(c_bool(True)))
print(py_lib.reverse_bool(c_bool(False)))
"""
False
True
"""

不复杂,以上我们就实现了数值类型的传递。


字符类型



字符类型有两种,一种是 ASCII 字符,本质上是个 u8;一种是 Unicode 字符,本质上是个 u32。

5de118349733a74ab0484c7582bd595e.png

编写 Rust 代码:

#[no_mangle]
pub extern "C" fn get_char(a: u8) -> u8  {
    a + 1
}
#[no_mangle]
pub extern "C" fn get_unicode(a: u32) -> u32  {
    let chr = char::from_u32(a).unwrap();
    if chr == '憨' {
        '批' as u32
    } else {
        a
    }
}

我们知道 Rust 专门提供了 4 个字节 char 类型来表示 unicode 字符,但对于外部导出函数来说,使用 char 是不安全的,所以直接使用 u8 和 u32 就行。

编译之后,Python 调用:

from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
# u8 除了可以使用 c_byte 包装之外,还可以使用 c_char
# 并且 c_byte 里面只能接收整数,而 c_char 除了整数,还可以接收长度为 1 的字节串
print(c_byte(97))
print(c_char(97))
print(c_char(b"a"))
"""
c_byte(97)
c_char(b'a')
c_char(b'a')
"""
# 以上三者是等价的,因为 char 说白了就是个 u8
# 指定返回值为 c_byte,会返回一个整数
py_lib.get_char.restype = c_byte
# c_byte(97)、c_char(97)、c_char(b"a") 都是等价的
# 因为它们本质上都是 u8,至于 97 也可以解析为 u8
print(py_lib.get_char(97))  # 98
# 指定返回值为 c_char,会返回一个字符(长度为 1 的 bytes 对象)
py_lib.get_char.restype = c_char
print(py_lib.get_char(97))  # b'b'
py_lib.get_unicode.restype = c_wchar
print(py_lib.get_unicode(c_wchar("嘿")))  # 嘿
# 直接传一个 u32 整数也可以,因为 unicode 字符底层就是个 u32
print(py_lib.get_unicode(ord("憨")))  # 批

以上就是字符类型的操作,比较简单。


字符串类型



再来看看字符串,我们用 Rust 实现一个函数,它接收一个字符串,然后返回大写形式。

use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn to_uppercase(s: *const c_char) -> *mut c_char {
    // 将 *const c_char 转成 &CStr
    let s = unsafe {
        CStr::from_ptr(s)
    };
    // 将 &CStr 转成 &str
    // 然后调用 to_uppercase 转成大写,得到 String
    let s = s.to_str().unwrap().to_uppercase();
    // 将 String 转成 *mut char 返回
    CString::new(s).unwrap().into_raw()
}

解释一下里面的 CStr 和 CString,在 Rust 中,CString 用于创建 C 风格的字符串( \0 结尾),拥有自己的内存。关键的是,CString 拥有值的所有权,当实例离开作用域时,它的析构函数会被调用,相关内存会被自动释放。

而 CStr,它和 CString 之间的关系就像 str 和 String 的关系,所以 CStr 一般以引用的形式出现。并且 CStr 没有 new 方法,不能直接创建,它需要通过 from_ptr 方法从原始指针转化得到。

然后指针类型是 *const*mut,分别表示指向 C 风格字符串的首字符的不可变指针和可变指针,它们的区别主要在于指向的数据是否可以被修改。如果不需要修改,那么使用 *const 会更安全一些。

我们编写 Python 代码测试一下。

from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
s = "hello 古明地觉".encode("utf-8")
# 默认是按照整型解析的,所以不指定返回值类型的话,会得到脏数据
print(py_lib.to_uppercase(c_char_p(s)))
"""
31916096
"""
# 指定返回值为 c_char_p,表示按照 char * 来解析
py_lib.to_uppercase.restype = c_char_p
print(
    py_lib.to_uppercase(c_char_p(s)).decode("utf-8")
)
"""
HELLO 古明地觉
"""

从表面上看似乎挺顺利的,但背后隐藏着内存泄露的风险,因为 Rust 里面创建的 CString 还驻留在堆区,必须要将它释放掉。所以我们还要写一个函数,用于释放字符串。

use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn to_uppercase(s: *const c_char) -> *mut c_char {
    let s = unsafe {
        CStr::from_ptr(s)
    };
    let s = s.to_str().unwrap().to_uppercase();
    CString::new(s).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn free_cstring(s: *mut c_char) {
    unsafe {
        if s.is_null() { return }
        // 基于原始指针创建 CString,拿到堆区字符串的所有权
        // 然后离开作用域,自动释放
        CString::from_raw(s)
    };
}

然后来看看 Python 如何调用:

from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
s = "hello 古明地觉".encode("utf-8")
# Rust 返回的是原始指针,这里必须要拿到它保存的地址
# 所以指定返回值为 c_void_p,如果指定为 c_char_p,
# 那么会直接转成 bytes 对象,这样地址就拿不到了
py_lib.to_uppercase.restype = c_void_p
# 拿到地址,此时的 ptr 是一个普通的整数,但它和指针保存的地址是一样的
ptr = py_lib.to_uppercase(c_char_p(s))
# 将 ptr 转成 c_char_p,获取 value 属性,即可得到具体的 bytes 对象
print(cast(ptr, c_char_p).value.decode("utf-8"))
"""
HELLO 古明地觉
"""
# 内容我们拿到了,但堆区的字符串还没有释放,所以调用 free_cstring
py_lib.free_cstring(c_void_p(ptr))

通过 CString 的 into_raw,可以基于 CString 创建原始指针 *mut,然后 Python 将指针指向的堆区数据拷贝一份,得到 bytes 对象。

但这个 CString 依旧驻留在堆区,所以 Python 不能将返回值指定为 c_char_p,因为它会直接创建 bytes 对象,这样就拿不到指针了。因此将返回值指定为 c_void_p,调用函数会得到一串整数,这个整数就是指针保存的地址。

我们使用 cast 函数可以将地址转成 c_char_p,获取它的 value 属性拿到具体的字节串。再通过 c_void_p 创建原始指针交给 Rust,调用 CString 的 from_raw,可以基于 *mut 创建 CString,从而将所有权夺回来,然后离开作用域时释放堆内存。



接下篇:https://developer.aliyun.com/article/1617447

相关文章
|
1天前
|
Rust 安全 Python
解密 Python 如何调用 Rust 编译生成的动态链接库(二)
解密 Python 如何调用 Rust 编译生成的动态链接库(二)
11 1
|
1天前
|
存储 自然语言处理 编译器
Python 源文件编译之后会得到什么,它的结构是怎样的?和字节码又有什么联系?
Python 源文件编译之后会得到什么,它的结构是怎样的?和字节码又有什么联系?
15 0
|
算法框架/工具 Caffe Python
python3编译caffe错误:cannot find -lboost_python3
python3编译caffe错误:cannot find -lboost_python3
191 0
|
3天前
|
安全 Python
Python 高级编程:高效读取 txt 文件的技巧与实践
在 Python 中,读取 txt 文件是常见操作。本文介绍了使用 `with` 语句自动管理文件资源、逐行读取文件、读取特定字节范围内容、处理编码问题以及使用缓冲读取提高性能等高级方法,确保代码高效且安全。通过这些技巧,你可以更灵活地处理文件内容,并避免资源泄漏等问题。原文链接:https://www.wodianping.com/app/2024-10/44183.html
33 18
|
4天前
|
机器学习/深度学习 Linux Python
Python编程教学
Python教学
27 13
|
3天前
|
缓存 Python
Python编程中的装饰器深度探索
本文深入探讨了Python中装饰器的高级用法,从基本定义到实际应用,展示了如何利用装饰器提升代码的灵活性和可维护性。通过具体示例,解析了装饰器在函数增强、日志记录、权限验证等方面的应用,旨在帮助读者彻底理解和掌握这一强大的编程工具。
|
1天前
|
数据处理 开发者 Python
Python 高级编程:深入解析 CSV 文件读取
在Python中,读取CSV文件是数据处理的重要环节。本文介绍了两种高效方法:一是利用pandas库的`read_csv`函数,将CSV文件快速转换为DataFrame对象,便于数据操作;二是通过csv模块的`csv.reader`按行读取CSV内容。此外,还涉及了如何选取特定列、解析日期格式、跳过指定行以及分块读取大文件等高级技巧,帮助开发者更灵活地处理各种CSV文件。参考链接:&lt;https://www.wodianping.com/app/2024-10/48782.html&gt;。
16 6
|
2天前
|
存储 UED Python
Python编程入门:打造你的第一个程序
【9月更文挑战第36天】在数字时代的浪潮中,编程已成为一项基础技能。本文以Python语言为例,通过构建一个简单的计算器程序,引领初学者步入编程的世界。从基础语法到实现功能,我们将一步步解锁编程的乐趣。无论你是编程新手还是想扩展知识边界的爱好者,这篇文章都将为你打开一扇通往编程世界的大门。让我们开始这段旅程,探索代码的魅力吧!
|
1天前
|
索引 Python
Python 高级编程:深入探索字符串切片
在Python中,字符串切片功能强大,可灵活提取特定部分。本文详细介绍切片技巧:基本切片、省略起始或结束索引、使用负数索引、设定步长及反转字符串等。此外,还介绍了如何结合其他操作进行切片处理,如先转换大小写再提取子串。 来源:https://www.wodianping.com/yeyou/2024-10/48238.html
11 4
|
2天前
|
Python
Python 脚本高级编程:从基础到实践
本文介绍了Python脚本的高级概念与示例,涵盖函数的灵活应用、异常处理技巧、装饰器的使用方法、上下文管理器的实现以及并发与并行编程技术,展示了Python在自动化任务和数据操作中的强大功能。包括复杂函数参数处理、自定义装饰器、上下文管理器及多线程执行示例。
25 5