Cyberspace. Unthinkable complexity. Lines of lightranged in the non-space of the mind, clusters andconstellations of data. Like city lights, receding . . .
——William Gibson, Neuromancer
不幸的是,世界上不是每个程序都是用Rust编写的。我们可能想在我们的Rust程序中使用很多用其他语言实现的优秀的库和接口。Rust的 外部语言接口(foreign function interface(FFI)) 让Rust代码能调用C编写的函数和一部分C++编写的函数。因为大多数操作系统提供C接口,Rust的外部函数接口允许直接访问任何类型的底层设施。
在本章中,我们将编写一个链接到libgit2
的程序,它是一个用于Git版本控制系统的C库。首先,我们将展示怎么直接在Rust中使用C函数,就使用我们上一章中介绍的unsafe特性。然后,我们将展示如何构建libgit2
的safe接口,借助开源的git2-rs
crate的灵感,这个crate正好实现了这一点。
我们将假设你熟悉C语言和编译链接C程序的机制。处理C++也相差不多。我们还假设你熟悉Git版本控制系统。
确实有Rust crate用于和很多其他语言例如Python、JavaScript、Lua和Java交互。我们没有足够的篇幅来介绍它们,但所有这些接口最终都是使用C外部函数接口实现的,因此无论你想和什么语言交互,这一章都能给你一个开头。
Rust和C的公共基础是机器语言,因此为了预测Rust的值在C代码中看起来是什么样的,或者反过来,你需要考虑它们的机器级表示。在整本书中,我们一直着重展示一个值在内存中的实际表示,因此你可能已经注意到了C和Rust的数据世界有很多共通之处:例如Rust的usize
和C的size_t
是相同的,两门语言中的结构体也基本相同。为了建立起Rust和C中相应类型的关系,我们将从基本类型开始,并逐渐扩展到更复杂的类型。
鉴于C主要用作系统编程,C中类型的表示总是令人惊讶的宽松:例如一个int
通常是32位,但可能会更长,或者短到16位;一个C的char
可能是有符号的也可能是无符号的。为了应对这种可变性,Rust的std::os::raw
模块定义了一些Rust的类型,这些类型保证和相应的C类型有完全相同的表示(”表23-1”)。其中包括基本的整数和字符类型。
C类型 | 相应的std::os::raw 类型 |
---|---|
short |
c_short |
int |
c_int |
long |
c_long |
long long |
c_longlong |
unsigned short |
c_ushort |
unsigned, unsigned int |
c_uint |
unsigned long |
c_ulong |
unsigned long long |
c_ulonglong |
char |
c_char |
signed char |
c_schar |
unsigned char |
c_uchar |
float |
c_float |
double |
c_double |
void *, const void * |
*mut c_void, *const c_void |
有关”表23-1”的注意事项:
- 除了
c_void
之外,这里所有的Rust类型都是某些基本Rust类型的别名:例如c_char
是i8
或者u8
。 - Rust的
bool
等价于C或C++的bool
。 - Rust的32位
char
类型并不等同于wchar_t
,后者的宽度和编码取决于具体实现。C的char32_t
倒是更接近一点,但它的编码仍然并不保证是Unicode。 - Rust的基础
usize
和isize
类型和C的size_t
和ptrdiff_t
有相同的表示。 - C/C++指针和C++的引用对应Rust的原始指针类型
*mut T
和*const T
。 - 从技术上讲,C标准允许实现使用一些Rust中没有相应类型的表示:36位整数、用符号+数字来表示有符号值等等。在实践中,在Rust被移植到的每个平台上,每个基本的C整数类型在Rust中都有对应的类型。
为了定义兼容C结构体的Rust结构体类型,你可以使用#[repr(C)]
属性。在结构体的定义上面放上#[repr(C)]
可以要求Rust按照C编译器的方式放置结构体中的字段。例如,libgit2
的 git2/errors.h 头文件定义了下面的C结构体来提供一个错误的详情:
typedef struct {
char *message;
int klass;
} git_error;
你可以按照下面这样定义一个内存表示完全相同的Rust类型:
use std::os::raw::{c_char, c_int};
#[repr(C)]
pub struct git_error {
pub message: *const c_char,
pub klass: c_int
}
#[repr(C)]
属性只影响struct自身的布局,不会影响单个字段的表示,因此为了和C struct匹配,每一个字段也都要使用C风格的类型:例如用*const c_char
替换char *
,用c_int
替换int
。
在这个特定的例子中,#[repr(C)]
属性可能并不会改变git_error
的布局。因为实际上没有那么多放置一个指针和一个整数的方法。但C和C++都保证一个结构体的成员按照声明的顺序依次在内存中排布,而Rust会按照总结构体大小最小的方式来组织字段,并且0大小的类型不占用空间。#[repr(C)]
属性告诉Rust按照C的规则来布局。
你也可以使用#[repr(C)]
来控制C风格的enum的表示:
#[repr(C)]
#[allow(non_camel_case_types)]
enum git_error_code {
GIT_OK = 0,
GIT_ERROR = -1,
GIT_ENOTFOUND = -3,
GIT_EEXISTS = -4,
...
}
通常情况下,Rust在选择如何表示enum时会使用各种技巧。例如,我们提到过Rust在一个单字中存储Option<&T>
(如果T
是sized)。如果没有#[repr(C)]
,Rust会使用单个字节来表示git_error_code
enum;有了#[repr(C)]
之后,Rust会和C一样用一个C int
一样大的值来存储。
你可以要求Rust使用和某些整数相同的表示来存储enum。上面的定义如果以#[repr(i16)]
开头,将会得到一个和下面C++ enum相同的16位的表示:
#include <stdint.h>
enum git_error_code: int16_t {
GIT_OK = 0,
GIT_ERROR = -1,
GIT_ENOTFOUND = -3,
GIT_EEXISTS = -4,
...
};
正如之前提到的,#[repr(C)]
还可以用于union。#[repr(C)]
union的字段总是从union的内存的第一个位(0偏移处)开始。
假设你有一个C struct使用一个union来存储一些数据,再用一个tag值来指示应该使用union的哪个值,类似于Rust的enum一样:
enum tag {
FLOAT = 0,
INT = 1,
};
union number {
float f;
short i;
};
struct tagged_number {
tag t;
number n;
};
Rust可以通过对enum、struct和union类型都应用#[repr(C)]
来实现一个这样的结构体,然后使用match
语句基于tag来选择一个struct中的union的字段:
#[repr(C)]
enum Tag {
Float = 0,m
Int = 1
}
#[repr(C)]
union FloatOrInt {
f: f32,
i: i32,
}
#[repr(C)]
struct Value {
tag: Tag,
union: FloatOrInt
}
fn is_zero(v: Value) -> bool {
use self::Tag::*;
unsafe {
match v {
Value { tag: Int, union: FloatOrInt { i: 0 } } => true,
Value { tag: Float, union: FloatOrInt { f: num } } => (num == 0.0),
_ => false
}
}
}
使用这种技术,即使是复杂的结构体也可以很容易地跨FFI边界使用。
在Rust和C之间传递字符串要稍微更难一点。C使用一个空结尾的字符数组的指针来表示字符串。另一边Rust显式地存储字符串的长度,要么是String
的一个字段,要么是一个胖引用&str
的第二个字。Rust的字符串不是空字符结尾的;事实上,它们的内容里可能包含空字符,这些空字符和其他字符一样,没有区别。
这意味着你不能借用一个Rust字符串来当做C字符串:如果你传给C代码一个Rust字符串的指针,它可能错误地把内容中一个空字符当做字符串的结尾,或者越界去查找一个不存在的结尾空字符。从另一个方向考虑,你也许可以借用一个C字符串作为一个Rust的&str
,只要它的内容是有效的UTF-8。
这种情况强迫Rust必须把C的字符串看做和String
、&str
完全不同的类型。在std::ffi
模块中,CString
和CStr
类型表示拥有所有权的和借用的空字符结尾的字节数组。和String
、str
比起来,CString
和CStr
的方法非常有限,基本只有构建自身和转换成其他类型的方法。我们将在下一节中用实例展示这些类型。
用extern
块来声明其他库中定义的函数和变量,最终的Rust编译出的可执行文件会链接到这些库。例如,在大多数平台上,每个Rust程序都会链接到标准的C库,因此我们可以像这样告诉Rust C库里的strlen
函数:
use std::os::raw::c_char;
extern {
fn strlen(s: *const c_char) -> usize;
}
这样就告诉了Rust这个函数的名字和类型,定义将会在之后进行链接。
Rust假设在extern
块中声明的函数使用C语言的惯例来传递参数和接受返回值。它们被定义为unsafe
函数。对strlen
来说,这是正确的选择:它实际上是一个C函数,它的C规范要求你传入一个有效的以空字符结尾的字符串指针,而这是一个Rust无法强制的合约。(几乎任何接受原始指针作为参数的函数都必须是unsafe
的:safe Rust可以从任何整数构造原始指针,但解引用这样的指针可能会导致未定义行为。)
有了这个extern
块,我们可以像任何其他Rust函数一样调用strlen
,尽管它的类型显示了它是别的语言中定义的函数:
use std::ffi::CString;
let rust_str = "I'll be back";
let null_terminated = CString::new(rust_str).unwrap();
unsafe {
assert_eq!(strlen(null_terminated.as_ptr()), 12);
}
CString::new
函数构建出一个空字符结尾的C字符串。它首先检查它的内容里是否有空字符,因为如果有空字符就不能用C字符串来表示它,如果找到了空字符就返回一个error(因此需要调用unwrap
)。否则,它向结尾添加一个空字符并返回一个CString
,这个CString
持有最后的字符的所有权。
CString::new
的开销取决于参数的类型。它接受任何实现了Into<Vec<u8>>
的类型,传递一个&str
需要一次内存分配和拷贝,因为它到Vec<u8>
的转换需要在堆上构建一份字符串的拷贝,这样vector才能拥有字符串的所有权。但以值传递一个String
简单地消耗掉这个字符串并获取它的缓冲区的所有权,因此除非向缓冲区中添加一个空字符需要扩大缓冲区,否则这个转换完全不需要拷贝文本或内存分配。
CString
解引用到CStr
,后者的as_ptr
方法返回一个*const c_char
指向字符串的头部。这正是strlen
期望的类型。在这个例子中,strlen
遍历字符串,寻找CString::new
添加的空字符,然后返回字符串的长度,即字节数。
你也可以在extern
块中声明全局变量,POSIX系统有一个叫做environ
的全局变量存储进程的环境变量。在C中,它被声明为:
extern char **environ;
在Rust中,你可以写:
use std::ffi::CStr;
ues std::os::raw::c_char;
extern {
static environ: *mut *mut c_char;
}
为了打印出环境变量的第一个元素,你可以写:
unsafe {
if !environ.is_null() && !(*environ).is_null() {
let var = CStr::from_ptr(*environ);
println!("first environment variable: {}",
var.to_string_lossy())
}
}
在确保了environ
有第一个元素之后,代码调用了CStr::from_ptr
来构建一个借用它的CStr
。to_string_lossy
方法返回一个Cow<str>
:如果C字符串包含有效的UTF-8,Cow
会以&str
的形式借用它的内容,不包括结尾的空字符。否则,to_string_lossy
在堆上构造一份文本的拷贝,把其中非UTF-8的字符序列替换为官方的Unicode替换字符�,并构造出一个拥有它所有权的Cow
。无论是哪种情况,结果都实现了Display
,因此你可以使用{}
格式化参数打印出它。
为了使用一个特定的库提供的函数,你可以在extern
块上方加上#[link]
属性来指定Rust应该链接的库的名称。例如,这里有一个程序调用libgit2
的初始化和结束函数,但不做任何其他事:
use std::os::raw::c_int;
#[link(name = "git2")]
extern {
pub fn git_libgit2_init() -> c_int;
pub fn git_libgit2_shutdown() -> c_int;
}
fn main() {
unsafe {
git_libgit2_init();
git_libgit2_shutdown();
}
}
extern
和之前一样声明了外部函数。#[link(name = "git2")]
属性会要求当Rust创建最终的可执行文件或者共享库时,它应该链接到git2
库。Rust使用系统的链接器来构建可执行文件:在Unix上,它会向链接器的命令行传递-lgit2
参数;在Windows上,它会传递git2.LIB
参数。
#[link]
属性在库crate中也可以工作。当你构建一个依赖其他crate的程序时,Cargo会从整个依赖图中抓取所有链接项并在最后的链接中全部加进去。
如果你想要在你自己的机器上继续这个例子,你需要自己构建libgit2
。我们使用libgit2 0.25.1版本。为了编译libgit2
,你需要安装CMake构建工具和Python;我们使用了CMake 3.8.0版本和Python 2.7.13版本。
构建libgit2
的完整文档可以在网站上找到,但因为非常简单所以我们将在这里展示基本的一些步骤。在Linux上,假设你已经把库的源码解压到了目录 /home/jimb/libgit2-0.25.1 :
$ cd /home/jimb/libgit2-0.25.1
$ mkdir build
$ cd build
$ cmake ..
$ cmake --build .
在Linux上,这会生成一个共享库 /home/jimb/libgit2-0.25.1/build/libgit2.so.0.25.1 ,还有一些指向它的符号链接,包括有一个叫 libgit2.so 的。在macOS上,结果与此类似,但库的名字叫 libgit2.dylib 。
在Windows上也非常简单。假设你把源码解压到了目录 C:\Users\JimB\libgit2-0.25.1 。在一个Visual Studio的命令提示符中:
> cd C:\Users\JimB\libgit2-0.25.1
> mkdir build
> cd build
> cmake -A x64 ..
> cmake --build .
这些命令和在Linux上用的命令几乎一样,除了在第一次运行CMake的时候必须指定64位的构建来匹配你的Rust编译器。(如果你安装了32位的Rust工具链,那么你应该省略第一条cmake
命令的-A x64
标记。)这会产生一个导入库 git2.LIB 和一个动态链接库 git2.DLL ,都在目录 C:\Users\JimB\libgit2-0.25.1\build\Debug 。(之后的命令都是以Unix为例,如果Windows上的命令大不相同的话会再加上Windows上的命令。)
在一个单独的目录里创建Rust程序:
$ cd /home/jimb
$ cargo new --bin git-toy
Created binary (application) `git-toy` package
复制我们之前展示的代码并粘贴到 src/main.rs 里。当然,如果你尝试构建,Rust会不知道在哪查找你构建的libgit2
库:
$ cd git-toy
$ cargo run
Compiling git-toy v0.1.0 (/home/jimb/git-toy)
error: linking with `cc` failed: exit code: 1
|
= note: /usr/bin/ld: error: cannot find -lgit2
src/main.rs:11: error: undefined reference to 'git_libgit2_init'
src/main.rs:12: error: undefined reference to 'git_libgit2_shutdown'
collect2: error: ld returned 1 exit status
error: aborting due to previous error
error: could not complie `git-toy`
To learn more, run the command again with --verbose.
你可以通过编写一个 构建脚本 来告诉Rust在哪搜索这个库,构建脚本是Cargo会在编译期编译并运行的Rust代码。构建脚本可以做很多事:动态生成代码,编译要被包含在crate中的C代码等等。在这个例子中,你需要做的只是向可执行文件的链接命令中添加一个库的搜索路径。当Cargo运行构建脚本时,它会解析构建脚本的输出来获取这类信息,因此构建脚本只需要把正确的信息打印到标准输出就可以了。
为了创建你自己的构建脚本,在 Cargo.toml 所在的目录下添加一个叫 build.rs 的文件,内容如下:
fn main() {
println!(r"cargo:rustc-link-search=native=/home/jimb/libgit2-0.25.1/build");
}
这是Linux上的正确路径;在Windows上,你应该把native=
之后的路径替换为 C:\Users\JimB\libgit2-0.25.1\build\Debug 。(我们忽略了一些细节来保证这个例子的简洁;在一个真实的应用中,你应该避免在构建脚本中使用绝对路径。我们在本节结尾给出了如何正确实现这一点的文档。)
现在你基本上可以运行这个程序了。在macOS上它可能可以立即工作;但在Linux系统上你可能会看到类似这样的输出:
$ cargo run
Compiling git-toy v0.1.0 (/tmp/rustbook-transcript-tests/git-toy)
Finished dev [unoptimized + debuginfo] target(s)
Running `target/debug/git-toy`
target/debug/git-toy: error while loading shared libraries: libgit2.so.25: cannot open shared object file:
no such file or directory
意思是说,尽管Cargo成功地把可执行文件链接到了库,但它不知道怎么在运行时找到共享库。在Windows上通过弹出一个对话框来报告这个错误。在Linux上,你必须设置LD_LIBRARY_PATH
环境变量:
$ export LD_LIBRARY_PATH=/home/jimb/libgit2-0.25.1/build:$LD_LIBRARY_PATH
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/git-toy`
在macOS上,你可能需要设置DYLD_LIBRARY_PATH
。
在Windows上,你必须设置PATH
环境变量:
> set PATH=C:\Users\JimB\libgit2-0.25.1\build\Debug;%PATH%
> cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/git-toy`
>
当然,在一个要部署的应用中你可能想避免只为了寻找你的库而修改环境变量。一种替代方案是把C库静态链接到你的crate里。这会把库的目标文件拷贝到crate的 .rlib 文件里,和这个crate中的Rust代码生成的目标文件和元数据放在一起。然后这整个集合会参与最终的链接。
Cargo的一个惯例是提供C库访问的crate应该命名为LIB-sys
,其中LIB
是C库的名称。一个-sys
crate应该只包含静态链接的库和包含extern
块和类型定义的Rust模块。更高层的接口应该应该在依赖-sys
crate的crate中实现。这允许多个上游的crate依赖同一个-sys
crate,假设有一个版本的-sys
crate可以满足每个上游的需要的话。
有关Cargo的构建脚本和与系统库的链接的完整文档见在线Cargo文档。它展示了如何在构建脚本中避免绝对路径、控制编译选项、使用例如pkg-config
的工具,等等。git2-rs
crate也提供了模仿的好例子,它的构建脚本处理了一些很复杂的情况。
如何正确地使用libgit2
可以分解为两个问题:
- 如何在Rust中使用
libgit2
的函数? - 如何通过它们构建一个safe Rust的接口?
我们将逐一解决这两个问题。在本节中,我们将编写一个程序,它基本上是一个巨大的unsafe
块,充满了不规范的Rust代码,它反映了类型系统和惯例的冲突,这也是混合语言编程所固有的特点。我们称其为 原始(raw) 接口。它的代码可能有些凌乱,但它能展示出Rust代码使用libgit2
时必须的所有步骤。
然后,在下一节中,我们将构建一个libgit2
的safe接口,它使用Rust的类型来强迫 libgit2 对使用者隐含的要求。幸运的是,libgit2
是一个设计得非常好的C库,因此Rust的安全性要求的问题都有很好的答案,并且我们可以构建出没有unsafe
函数的规范的Rust接口。
我们要写的程序非常简单:它以命令行参数的形式接受一个路径,打开那里的Git仓库,然后打印出head commit的信息。但这足以用来展示构建safe和规范的Rust接口的关键策略。
对于原始接口,程序需要很多libgit2
中的函数和类型,因此把extern
块移动到它自己的模块是有意义的。我们将在 git-toy/src 中创建一个叫 raw.rs 的文件,内容如下:
#![allow(non_camel_case_types)]
use std::os::raw::{c_int, c_char, c_uchar};
#[link(name = "git2")]
extern {
pub fn git_libgit2_init() -> c_int;
pub fn git_libgit2_shutdown() -> c_int;
pub fn giterr_last() -> *const git_error;
pub fn git_repository_open(out: *mut *mut git_repository,
path: *const c_char) -> c_int;
pub fn git_repository_free(repo: *mut git_repository);
pub fn git_reference_name_to_id(out: *mut git_oid,
repo: *mut git_repository,
reference: *const c_char) -> c_int;
pub fn git_commit_lookup(out: *mut *mut git_commit,
repo: *mut git_repository,
id: *const git_oid) -> c_int;
pub fn git_commit_author(commit: *const git_commit) -> *const git_signature;
pub fn git_commit_message(commit: *const git_commit) -> *cosnt c_char;
pub fn git_commit_free(commit: *mut git_commit);
}
#[repr(C)] pub struct git_repository { _private: [u8; 0] }
#[repr(C)] pub struct git_commit { _private: [u8; 0] }
#[repr(C)]
pub struct git_error {
pub message: *const c_char,
pub klass: c_int
}
pub const GIT_OID_RAWSZ: usize = 20;
#[repr(C)]
pub struct git_oid {
pub id: [c_uchar; GIT_OID_RAWSZ]
}
pub type git_time_t = i64;
#[repr(C)]
pub struct git_time {
pub time: git_time_t,
pub offset: c_int
}
#[repr(C)]
pub struct git_signature {
pub name: *const c_char,
pub email: *const c_char,
pub when: git_time
}
其中每一项都是根据libgit2
的头文件来声明的。例如 libgit2-0.25.1/include/git2/repository.h 中包含这个声明:
extern int git_repository_open(git_repository **out, const char *path);
这个函数尝试打开path
处的Git仓库。如果一切顺利,它会创建一个git_repository
对象并把该对象的指针存储在out
指向的位置。等价的Rust声明如下:
pub fn git_repository_open(out: *mut *mut git_repository,
path: *const c_char) -> c_int;
libgit2
public的头文件使用typedef定义了一个不完全的git_repository
类型:
typedef struct git_repository git_repository;
因为这个类型的详情是private的,因此public的头文件中并没有定义struct git_repository
,以确保库的使用者永远不能自己构造这个类型的实例。在Rust中一种可行的定义不完全的struct类型的方法是:
#[repr(C)] pub struct git_repository { _private: [u8; 0] }
这个struct类型包含一个没有元素的数组。因为_private
字段不是pub
的,所以不能在这个模块之外构造这个类型的值,正好对应了libgit2
中不应该手动构造的类型。这种类型只能通过原始指针来进行操作。
手动编写一个巨大的extern
块可能会很繁琐。如果你正在编写一个复杂的C库的Rust接口,你可能会想尝试bindgen
crate,它包含一些可以在构建脚本中使用的函数,这些函数可以解析C头文件并自动生成相应的Rust声明。这里我们没有篇幅去介绍bindgen
,但crates.io上的bindgen
的页面包含了它的文档的链接。
接下来我们将完全重写 main.rs 。首先,我们需要声明raw
模块:
mod raw;
根据libgit2
的惯例,可能失败的函数会返回一个整数码,成功时整数码为正数或者0,失败时整数码为负数。如果有错误发生,giterr_last
函数将会返回一个git_error
结构体的指针,这个结构体会提供更多有关错误的细节。libgit2
持有这个结构体,所以我们不需要自己free它,但它可能被后续的调用覆盖。一个恰当的Rust接口应该使用Result
,但是在原始的版本中,我们将按照libgit2
的方式使用它的函数,因此我们将编写我们自己的函数来处理错误:
use std::ffi::CStr;
use std::os::raw::c_int;
fn check(activity: &'static str, status: c_int) -> c_int {
if status < 0 {
unsafe {
let error = &*raw::giterr_last();
println!("error while {}: {} ({})", activity,
CStr::from_ptr(error.message).to_string_lossy(),
error.klass);
std::process::exit(1);
}
}
status
}
我们将使用这个函数检查libgit2
调用的结果:
check("initializing library", raw::git_libgit2_init());
这里使用了之前用过的CStr
的方法:使用from_ptr
来从C字符串构造一个CStr
,使用to_string_lossy
来把它转换成Rust可以打印的类型。
接下来,我们需要一个函数来打印出一次commit:
unsafe fn show_commit(commit: *const raw::git_commit) {
let author = raw::git_commit_author(commit);
let name = CStr::from_ptr((*author).name).to_string_lossy();
let email = CStr::from_ptr((*author).email).to_string_lossy();
println!("{} <{}>\n", name, email);
let message = raw::git_commit_message(commit);
println!("{}", CStr::from_ptr(message).to_string_lossy());
}
给定一个git_commit
的指针,show_commit
会调用git_commit_author
和git_commit_message
来获取它需要的信息。这两个函数遵循了libgit2
在文档中解释的一个惯例:
如果一个函数返回一个对象作为返回值,那个这个函数是一个getter,并且对象的生命周期被绑定到父对象上。
用Rust的术语来说就是,author
和message
是从commit
的借用,所以show_commit
不需要自己释放它们,但它绝对不能在commit
被释放之后仍然持有它们。因为这个API使用了原始指针,所以Rust不会为我们检查它们的生命周期:如果我们意外地创建了悬垂指针,可能直到程序崩溃时我们才会发现。
上面的代码假设这些字段都持有UTF-8文本,但这个假设并不总是正确的。Git也允许其他的编码。正确地解析这些字符串可能需要使用encoding
crate。为了保持简洁,我们在这里忽略这些问题。
我们程序的main
函数如下:
use std::ffi::CString;
use std::mem;
use std::ptr;
use std::os::raw::c_char;
fn main() {
let path = std::env::args().skip(1).next()
.expect("usage: git-toy PATH");
let path = CString::new(path)
.expect("path contains null characters");
unsafe {
check("initializing library", raw::git_libgit2_init());
let mut repo = ptr::null_mut();
check("opening repository",
raw::git_repository_open(&mut repo, path.as_ptr()));
let c_name = b"HEAD\0".as_ptr() as *const c_char;
let oid = {
let mut oid = mem::MaybeUninit::uninit();
check("looking up HEAD",
raw::git_reference_name_to_id(oid.as_mut_ptr(), repo, c_name));
oid.assume_init()
};
let mut commit = ptr::null_mut();
check("looking up commit",
raw::git_commit_lookup(&mut commit, repo, &oid));
show_commit(commit);
raw::git_commit_free(commit);
raw::git_repository_free(repo);
check("shutting down library", raw::git_libgit2_shutdown);
}
}
这段代码首先处理path参数并初始化库,都是我们之前见过的内容。第一行有趣的代码是:
let mut repo = ptr::null_mut();
check("opening repository",
raw::git_repository_open(&mut repo, path.as_ptr()));
对git_repository_open
的调用会尝试在打开指定路径的Git仓库。如果它成功了,它会分配一个新的git_repository
对象并设置repo
指向它。Rust隐式把引用转换成原始指针,因此这里传递的&mut repo
提供了调用所需的*mut *mut git_repository
参数。
这里展示了另一个libgit2
在使用中的惯例(摘自libgit2
的文档):
以指针的指针形式的第一个参数返回的对象由调用者持有,调用者负责释放它们。
用Rust的术语来说就是,像git_repository_open
这样的函数会把新值传递给调用者。
接下来,考虑查找仓库当前head commit的对象哈希值的代码:
let oid = {
let mut oid = mem::MaybeUninit::uninit();
check("looking up HEAD",
raw::git_reference_name_to_id(oid.as_mut_ptr(), repo, c_name));
oid.assume_init()
};
git_oid
类型存储了一个对象的标识符——Git内部使用的一个160位的哈希值,Git使用它来识别commit、文件的不同版本,等等。对git_reference_name_to_id
的调用会查找当前"HEAD"
commit的对象标识符。
在C语言中向函数传递一个指针,然后在函数里填充指针指向对象的值是一种非常常用的初始化变量的方法,git_reference_name_to_id
的第一个参数也是这样的。但是Rust不允许我们借用一个未初始化变量的引用。我们可以用0值初始化oid
,但这是一种浪费:因为之后存储在这里的值都会被覆盖。
要求Rust给我们未初始化的内存是可行的,但因为在任何时刻读取未初始化的内存都是未定义行为,因此Rust提供了一个抽象MaybeUninit
来方便使用。MaybeUninit<T>
告诉编译器为你的类型T
分配足够的内存空间,但并不能访问这块空间,直到你声明现在已经可以安全地访问了。当这块内存的所有权属于MaybeUninit
时,编译器也可以避免一些可能会导致未定义行为的特殊优化,否则即使你没有在代码中显式地访问未初始化的内存,这些优化也可能导致未定义行为。
MaybeUninit
提供了一个方法as_mut_ptr()
,它产生一个指向它持有的未初始化内存的*mut T
指针。把这个指针传给用于初始化内存的外部函数,然后调用MaybeUninit
的unsafe的assume_init
方法来产生一个完全初始化过的T
。这样既不会有先初始化后立刻重新初始化的开销,也可以避免未定义行为。assume_init
是unsafe的,因为如果在并没有正确初始化的内存上调用它会立刻导致未定义行为。
在这个例子中是安全的,因为git_reference_name_to_id
会初始化MaybeUninit
拥有的内存。我们也可以将MaybeUninit
用于repo
和commit
变量,但因为它们只是单个字,所以我们只是直接将它们初始化为空:
let mut commit = ptr::null_mut();
check("looking up commit",
raw::git_commit_lookup(&mut commit, repo, &oid));
这里接受commit的对象标识符然后查找commit,成功时把一个git_commit
对象的指针存储在commit
中。
main
函数的其他部分应该是不言自明的。它调用了之前定义的show_commit
函数,然后释放了commit和repository对象,最后关闭了库。
现在我们可以在任何Git仓库上尝试我们的程序:
$ cargo run /home/jimb/rbattle
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/git-toy /home/jimb/rbattle`
Jim Blandy <[email protected]>
Animate goop a bit.
libgit2
的原始接口是一个unsafe特性的完美示例:它完全可以被正确地使用(正如我们在这里所做的一样),但Rust不能强迫这些你必须遵守的规则。为一个类似这样的库设置一个safe的API的主要问题是识别所有这些规则,然后将所有违反规则的行为转换为违反借用后检查的错误。
这里是程序使用到的libgit2
的特性的规则:
- 在调用库中任何其他函数之前,你必须先调用
git_libgit2_init
。在调用git_libgit2_shutdown
之后你不能再调用库中任何一个函数。 - 除了输出参数之外,传递给
libgit2
函数的所有值必须是必须是完全初始化的。 - 当一次调用失败时,用来存储调用结果的输出参数仍然保持未初始化,你绝对不能使用它的值。
- 一个
git_commit
对象引用了派生它的git_repository
对象,因此前者的生命周期绝对不能比后者长。(这一点并没有在libgit2
的文档中说明;我们根据接口中的某些函数的存在推断出这一点,并通过阅读源码证实了它。) - 类似的,一个
git_signature
也总是借用自一个给定的git_commit
,因此前者的生命周期绝对不能比后者长。(文档并没有覆盖这一点。) - 一个commit关联的message和author的name和email总是借用自commit,因此绝对不能在commit被free之后再使用它们。
- 一旦一个
libgit2
对象被释放之后,绝不能再次使用它。
事实证明,你可以构建出一个强迫所有这些规则的libgit2
的Rust接口,可能是通过Rust的类型系统,也可能是通过内部管理细节。
在我们开始之前,让我们重新组织一下这个项目。我们希望有一个git
模块用来导出safe的接口,之前的程序中的原始接口作为一个private的子模块。
整个源码树看起来像这样:
git-toy/
|-- Cargo.toml
|-- build.rs
|-- src/
|-- main.rs
|-- git/
|-- mod.rs
|-- raw.rs
按照我们在“单独文件中的模块”中介绍过的规则,git
模块的源码应该在 git/mod.rs ,git::raw
子模块的源码应该在 git/raw.rs 。
我们将完全重写 main.rs 。它应该以git
模块的声明开始:
mod git;
然后,我们将需要创建 git 子目录并把 raw.rs 移动进去:
$ cd /home/jimb/git-toy
$ mkdir src/git
$ mv src/raw.rs src/git/raw.rs
git
模块需要声明它的raw
子模块。文件 src/git/mod.rs 必须声明:
mod raw;
因为它不是pub
的,所以这个子模块对主程序不可见。
另外我们将需要使用一些libc
crate里的函数,因此我们必须在 Cargo.toml 中添加依赖。现在这个文件完整的内容是:
[package]
name = "git-toy"
version = "0.1.0"
authors = ["You <[email protected]>"]
edition = "2018"
[dependencies]
libc = "0.2"
现在我们已经重新组织了我们的模块,让我们来考虑下错误处理。就连libgit2
的初始化函数都可能返回一个错误码,因此我们必须在准备开始之前准备好这一点。一个规范的Rust接口需要自己的Error
类型,它需要捕获libgit2
的错误码和来自giterr_last
的错误类型和消息。一个正确的错误类型必须实现通常的Error
、Debug
和Display
trait。然后,它需要自己的Result
类型来使用这个Error
类型。这里是 src/git/mod.rs 中必须的定义:
use std::error;
use std::fmt;
use std::result;
#[derive(Debug)]
pub struct Error {
code: i32,
message: String,
class: i32
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> result::Result<(), fmt::Error> {
// Display 一个`Error`只需要display `libgit2`的错误信息
self.message.fmt(f)
}
}
impl error::Error for Error { }
pub type Result<T> = result::Result<T, Error>;
为了检查原始库调用的结果,这个模块需要一个函数用来把一个libgit2
返回的错误码转换成一个Result
:
use std::os::raw::c_int;
use std::ffi::CStr;
fn check(code: c_int) -> Result<c_int> {
if code >= 0 {
return Ok(code);
}
unsafe {
let error = raw::giterr_last();
// libgit2保证(*error).message总是非空并且以空字符结尾
// 所以这里的调用是安全的。
let message = CStr::from_ptr((*error).message)
.to_string_lossy()
.into_owned();
Err(Error {
code: code as i32,
message,
class: (*error).klass as i32
})
}
}
这个函数和之前原始版本里的check
函数的主要区别就是它构建一个error
而不是打印出错误信息然后立即退出。
现在我们已经准备好处理libgit2
的初始化了。safe的接口将提供一个Repository
类型来表示一个打开的Git仓库,它有一些方法用来解析引用、查找commit等等。继续在 git/mod.rs 中实现Repository
的定义:
/// 一个Git仓库
pub struct Repository {
// 它必须总是指向一个还在生存的`git_repository`结构体,
// 不能有别的`Repository`也指向同一个结构体。
raw: *mut raw::git_repository
}
Repository
的raw
字段并不是public的。因为只有这个模块中的代码可以访问raw::git_repository
指针,所以保证这个模块是正确的就可以保证指针总是被正确地使用。
如果创建一个Repository
的唯一方法就是成功打开一个新的Git仓库,那么将能确保每个Repository
都指向一个不同的git_repository
对象:
use std::path::Path;
use std::ptr;
impl Repository {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Repository> {
ensure_initialized();
let path = path_to_cstring(path.as_ref())?;
let mut repo j= ptr::null_mut();
unsafe {
check(raw::git_repository_open(&mut repo, path.as_ptr()))?;
}
Ok(Repository { raw: repo })
}
}
因为safe接口里想做任何事都必须先创建一个Repository
值,而且Repository::open
里首先是一个ensure_initialized
的调用,所以我们可以确信ensure_initialized
会在任何libgit2
函数之前被调用。它的定义如下:
fn ensure_initialized() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
unsafe {
check(raw::git_libgit2_init())
.expect("initializing libgit2 failed");
assert_eq!(libc::atexit(shutdown), 0);
}
});
}
extern fn shutdown() {
unsafe {
if let Err(e) = check(raw::git_libgit2_shutdown()) {
eprintln!("shutting down libgit2 failed: {}", e);
std::process::abort();
}
}
}
std::sync::Once
类型帮助我们以一种线程安全的方式运行初始化代码。只有第一个调用ONCE.call_once
的线程会运行给定的闭包。任何之后的调用,不管是这个线程还是别的线程,都会阻塞住直到第一次调用结束,然后它们会立刻返回,不会再次运行给定的闭包。一旦闭包结束之后,调用ONCE.call_once
的开销就非常小了,只需要原子地读取一个存储在ONCE
里的标记。
在上面的代码中,初始化闭包调用了git_libgit2_init
然后检查结果。它稍微简化了一下,直接用expect
来保证初始化成功,而没有尝试把错误传播回调用者。
为了确保程序会调用git_libgit2_shutdown
,初始化闭包使用了C库的atexit
函数,它接受一个在退出进程之前要调用的函数的指针。Rust的闭包不能用作C函数的指针:一个闭包实际上是一个匿名类型的值,它还携带着它捕获或者引用到的值;一个C函数指针只是一个指针。然而,Rust的fn
类型可以用作函数指针,只要你用extern
来声明它们以让Rust知道要使用C的惯例。本地函数shutdown
里调用git_libgit2_shutdown
并保证libgit2
正确地退出。
在“栈展开”中,我们提到过跨语言边界的panic是未定义行为。从atexit
到shutdown
的调用就是这样一个边界,因此shutdown
不能panic。这就是为什么shutdown
没有简单地使用.expect
来处理raw::git_libgit2_shutdown
返回的错误,而是手动汇报错误并中断进程。POSIX禁止在atexit
里调用exit
,因此shutdown
调用了std::process::abort
来突然终止程序。
更早地调用git_libgit2_shutdown
也是可行的,即在最后一个Repository
值被drop的时候调用。但无论如何,调用git_libgit2_shutdown
必须是safe API的责任。在调用它时,任何现存的libgit2
对象都会变为不能再使用的状态,因此一个safe API绝对不能直接暴露这个函数。
一个Repository
的原始指针必须总是指向一个还在生存的git_repository
对象。这隐含了关闭一个仓库的唯一方法是drop掉拥有它的Repository
:
impl Drop for Repository {
fn drop(&mut self) {
unsafe {
raw::git_repository_free(self.raw);
}
}
}
通过在指向raw::git_repository
的指针即将销毁时调用git_repository_free
,Repository
类型还可以保证这个指针永远不会在free掉之后再被使用。
Repository::open
方法使用了一个叫做path_to_cstring
的private函数,它有两个定义——一个用于类Unix系统,一个用于Windows:
use sdt::ffi::CString;
#[cfg(unix)]
fn path_to_cstring(path: &Path) -> Result<CString> {
// `as_bytes`方法只在类Unix系统中存在。
use std::os::unix::ffi::OsStrExt;
Ok(CString::new(path.as_os_str().as_bytes())?)
}
#[cfg(windows)]
fn path_to_cstring(path: &Path) -> Result<CString> {
// 尝试转换为UTF-8。如果失败了,libgit2就不能处理这个路径了。
match path.to_str() {
Some(s) => Ok(CString::new(s)?),
None => {
let message = format!("Couldn't convert path '{}' to UTF-8",
path.display());
Err(message.into())
}
}
}
libgit2
的接口让这段代码变得更棘手一点。在所有的平台上,libgit2
都以空字符结尾的C字符串接受路径。在Windows上,libgit2
假设这些C字符串持有有效的UTF-8,并在内部把它们转换成Windows实际要求的16位路径。这通常是可以工作的,但并不是最理想的方案。Windows允许文件名不是有效的Unicode,因此也不能用UTF-8来表示。如果你有一个这样的文件,那么不可能把它的名字传递给libgit2
。
在Rust中,文件系统路径的正确表示是std::path::Path
,它被精心设计用来处理任何可能出现在Windows或POSIX上的路径。这意味着有一些Windows上的Path
值不能传递给libgit2
,因为它们不是有效的UTF-8。因此尽管path_to_cstring
的行为不是很理想,但它确实是最好的方案了。
两个path_to_cstring
的定义都依赖到我们的Error
类型的转换:?
运算符会尝试这样的转换,并且Windows版本的显式调用了.into()
。这些转换并不起眼:
impl From<String> for Error {
fn from(message: String) -> Error {
Error { code: -1, message, class: 0 }
}
}
// NulError是如果字符里有0字节时`CString::new`会返回的Error类型
impl From<std::ffi::NulError> for Error {
fn from(e: std::ffi::NulError) -> Error {
Error { code: -1, message: e.to_string(), class: 0 }
}
}
接下来,让我们看看如何把一个Git引用解析到一个对象标识符。因为一个对象标识符只是一个20字节的哈希值,因此直接在safe API中暴露它也是完全没问题的:
/// 一些存储在Git对象数据库中的对象(commit、tree、blob、tag等)的标识符。
/// 它是对象内容的哈希值。
pub struct Oid {
pub raw: raw::git_oid
}
我们将给Repository
添加一个方法来执行查找功能:
use std::mem;
use std::os::raw::c_char;
impl Repository {
pub fn reference_name_to_id(&self, name: &str) -> Result<Oid> {
let name = CString::new(name)?;
unsafe {
let oid = {
let mut oid = mem::MaybeUninit::uninit();
check(raw::git_reference_name_to_id(
oid.as_mut_ptr(), self.raw,
name.as_ptr() as *const c_char
))?;
oid.assume_init()
};
Ok(Oid { raw: oid })
}
}
}
尽管在查找失败时oid
仍然保持未初始化的状态,但这个函数通过遵循Rust的Result
的惯例保证它的调用者永远不可能看到这个未初始化的值:调用者要么得到一个携带着正确初始化的Oid
值的Ok
,要么得到一个Err
。
接下来,这个模块需要一种方法从仓库里提取commit,我们的Commit
类型定义如下:
use std::marker::PhantomData;
pub struct Commit<'repo> {
// 这个指针总是指向一个可用的`git_commit`结构体。
raw: *mut raw::git_commit,
_marker: PhantomData<&'repo Repository>
}
正如我们之前提到的一样,一个git_commit
对象的生命周期必须不长于它引用的git_repository
对象。Rust的生命周期可以让代码精准地捕获这个规则。
本章之前的RefWithFlag
例子使用了一个PhantomData
字段来告诉Rust在对待一个类型时,把它看成持有一个给定生命周期的引用,即使这个类型显然并不持有这样的引用。Commit
类型需要类似的操作。在这个例子中,_marker
字段的类型是PhantomData<&'repo Repository>
,它告诉Rust应该把Commit<'repo>
看成好像持有一个生命周期是'repo
的Repository
的引用。
查找一个commit的方法如下:
impl Repository {
pub fn find_commit(&self, oid: &Oid) -> Result<Commit> {
let mut commit = ptr::null_mut();
unsafe {
check(raw::git_commit_lookup(&mut commit, self.raw, &oid.raw))?;
}
Ok(Commit { raw: commit, _marker: PhantomData })
}
}
这是怎么把Commit
的生命周期关联到Repository
的生命周期的?根据“省略生命周期参数”中列出的规则,find_commit
的签名省略了引用的生命周期。如果我们写出生命周期的话,完整的签名应该是:
fn find_commit<'repo, 'id>(&'repo self, oid: &'id Oid)
-> Result<Commit<'repo>>
这正是我们想要的:Rust对待返回的Commit
时就好像它借用了self
(即Repository
)一样。
当一个Commit
被drop时,它必须释放它的raw::git_commit
:
impl<'repo> Drop for Commit<'repo> {
fn drop(&mut self) {
unsafe {
raw::git_commit_free(self.raw);
}
}
}
你可以从一个Commit
借用一个Signature
(一个name和email地址)和commit message的文本:
impl <'repo> Commit<'repo> {
pub fn author(&self) -> Signature {
unsafe {
Signature {
raw: raw::git_commit_author(self.raw),
_marker: PhantomData
}
}
}
pub fn message(&self) -> Option<&str> {
unsafe {
let message = raw::git_commit_message(self.raw);
char_ptr_to_str(self, message)
}
}
}
这是Signature
类型:
pub struct Signature<'text> {
raw: *const raw::git_signature,
_marker: PhantomData<&'text str>
}
一个git_signature
对象总是从其他地方借用内容;具体来说,git_commit_author
返回的签名从git_commit
借用文本。因此我们的safe Signature
类型包含一个PhantomData<&'text str>
来告诉Rust把它看做好像持有一个生命周期是'text
的&str
。和之前一样,不需要我们写任何内容,Commit::author
也可以正确地把它返回的Signature
的'text
生命周期关联到那个Commit
的生命周期。Commit::message
方法对持有commit message的Option<&str>
也实现了相同的效果。
一个Signature
包含一些获取author的name和email地址的方法:
impl<'text> Signature<'text> {
/// 以`&str`返回author的name,
/// 如果不是有效的UTF-8则返回`None`
pub fn name(&self) -> Option<&str> {
unsafe {
char_ptr_to_str(self, (*self.raw).name)
}
}
/// 以`&str`返回author的email,
/// 如果不是有效的UTF-8则返回`None`
pub fn email(&self) -> Option<&str> {
unsafe {
char_ptr_to_str(self, (*self.raw).email)
}
}
}
上面的方法依赖于一个private的工具函数char_ptr_to_str
:
/// 尝试从`ptr`借用一个`&ste`,给定的`ptr`可能为空或者指向无效的UTF-8.
/// 给返回结果赋予一个好像从`_owner`借用的生命周期。
///
/// 安全性:如果`ptr`非空,它必须指向一个空字符结尾的C字符串,
/// 这个字符串的生命周期必须至少和`_owner`一样长。
unsafe fn char_ptr_to_str<T>(_owner: &T, ptr: *const c_char)
-> Option<&str> {
if ptr.is_null() {
return None;
} else {
CStr::from_ptr(ptr).to_str().ok()
}
}
_owner
参数的值永远不会被使用,但它的生命周期会被用到。如果显式地标注这个函数的生命周期将是:
fn char_ptr_to_str<'o, T: 'o>(_owner: &'o T, ptr: *const c_char)
-> Option<&'o str>
CStr::from_ptr
函数会返回一个生命周期不受限的&CStr
,因为它是从原始指针借用的。不受限的生命周期几乎总是不好的,因此应该尽可能地约束它们。包含_owner
参数会导致Rust把它的生命周期赋给返回值,因此调用者可以得到一个被更加精确地约束的引用。
尽管libgit2
的文档相当不错,但它并没有说清楚git_signature
的email
和author
是否可以为空。我们在源代码套索掘了一段时间,但无法说服自己它一定不为空,最后决定让char_ptr_to_str
最好为空指针做准备,以防万一。在Rust中,这种问题可以通过类型快速回答:如果是&str
,那你可以确信一定有一个字符串;如果是Option<&str>
,那就可能为空。
最后,我们为所需的所有功能提供了safe的接口。 src/main.rs 中的新main
函数就简单很多,并且看起来更像真正的Rust代码了:
fn main() {
let path = std::env::args_os().skip(1).next()
.expect("usage: git-toy PATH");
let repo = git::Repository::open(&path)
.expect("opening repository");
let commit_oid = repo.reference_name_to_id("HEAD")
.expect("looking up 'HEAD' reference");
let commit = repo.find_commit(&commit_oid)
.expect("looking up commit");
let author = commit.author();
println!("{} <{}>\n",
author.name().unwrap_or("(none)"),
author.email().unwrap_or("none"));
println!("{}", commit.message().unwrap_or("(none)"));
}
在本章中,我们从最简单的不提供很多安全性保证的接口开始,到通过以内部的unsafe API为基础,把任何对合约的违反都转换成Rust的类型错误来构建出safe的API。最后得到的结果是一个Rust可以保证被正确使用的接口。在大多数情况下,我们让Rust强迫的规则都是C和C++程序自己隐式遵守的那些规则。Rust之所以感觉起来比C和C++严格得多,并不是因为这些规则很陌生,而是因为这种规则被机械地、全面地强制。
Rust并不是一门简单的语言。它的目标横跨了两个不同的世界。它是一门现代化的编程语言,以安全为设计原则,同时还有像闭包和迭代器这样的便利设施,但它的目标是让你能以最小的运行时开销来控制机器的原始能力。
这门语言的轮廓由这些目标决定。Rust设法用safe代码来实现大部分的功能。它的借用检查器和0开销抽象让你尽可能地接近裸机,同时还没有未定义行为的风险。当这些还不够或者当你想使用现有的C代码时,unsafe代码和外部函数接口已准备就绪。但再重复一次,它并不是只提供给你unsafe特性然后祝你好运,它的目标总是使用unsafe特性来构建safe的API。这正是我们基于libgit2
所做的工作。这也是Rust的团队对Box
、Vec
、其他集合、channel等等设施所做的工作:标注库充满了用unsafe代码实现的safe的抽象。
Rust的雄心也许并不是成为最简单的工具。但Rust是安全、快速、并发、高效的。使用它来构建大型、快速、安全、强大的系统,充分利用它们所运行的硬件的力量。使用它来让软件变得更好。