第 23 章 外部函数

赛博空间。难以想象的复杂性。光线交织在思维的非空间、数据的集群和星座中。就像城市的灯光,渐行渐远……

——《神经漫游者》,William Gibson

很遗憾,世界上并不是每个程序都是用 Rust 编写的。我们希望能够在 Rust 程序中使用以其他语言实现的关键库和接口。Rust 的 外部函数接口(foreign function interface,FFI)允许 Rust 代码调用以 C 语言编写的(在某些情况下是用 C++ 编写的)函数。由于大多数操作系统提供了 C 接口,因此 Rust 的外部函数接口能帮你立即访问各种底层设施。

在本章中,我们将编写一个与 libgit2 链接的程序, libgit2 是用于 Git 版本控制系统的 C 库。我们将先展示如何借助第 22 章演示的不安全特性在 Rust 中直接使用 C 函数,然后再展示如何构建 libgit2 的安全接口,这些灵感都来自开源的 git2-rs crate。

假设你已经熟悉 C 以及编译和链接 C 程序的机制。如果你擅长使用 C++,那么情况也一样。假设你对 Git 版本控制系统也比较熟悉。

确实存在用于和许多其他语言(包括 Python、JavaScript、Lua 和 Java)通信的 Rust crate。由于篇幅所限,本书就不详细介绍了,但归根结底,所有这些接口都是用 C 的外部函数接口构建的,因此无论要使用哪种语言,本章都会为你提供一个良好的开端。

23.1 寻找共同的数据表示

Rust 和 C 的共同点在于它们都是面向机器的语言,因此为了预测 C 代码的 Rust 值是什么样的(反之亦然),就需要考虑它们的机器层面表示。本书重点展示了值在内存中的实际表示方式,因此你可能已经注意到 C 和 Rust 的数据世界有很多共同点。例如,Rust 的 usize 和 C 的 size_t 是一样的,而结构体在两种语言中基本上是相同的概念。为了建立 Rust 和 C 类型之间的对应关系,我们将从原始类型开始,然后逐步处理更复杂的类型。

鉴于 C 语言主要用于系统编程,它对类型的表示形式总是出奇地宽松。例如, int 通常为 32 位长,但可以更长,也可以短至 16 位;C char 可以是有符号的也可以是无符号的。为了应对这种可变性,Rust 的 std::os::raw 模块定义了一组保证与某些 C 类型具有相同表示形式的 Rust 类型(参见表 23-1)。这些涵盖了原始整数和字符类型。

表 23-1:Rust 中的 std::os::raw 类型

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_chari8u8
  • Rust 的 bool 等效于 C 或 C++ 的 bool
  • Rust 的 32 位 char 类型并不对应于 wchar_twchar_t 的宽度和编码会因不同的具体实现而异。Rust 的 32 位 char 类型更接近于 C 的 char32_t 类型,但该类型的编码仍然不能保证是 Unicode。
  • Rust 的原始 usize 类型和 isize 类型与 C 的 size_tptrdiff_t 具有相同的表示。
  • C 和 C++ 的指针与 C++ 的引用对应于 Rust 的裸指针类型 *mut T*const T
  • 从技术上讲,C 标准允许其实现在 Rust 中没有对应类型的表示,比如 36 位整数、有符号值的原码(sign-and-magnitude)表示法1等。在实践中,对于每个移植了 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::;

#[repr(C)]
pub struct git_error {
 pub message: *const c_char,
 pub klass: c_int
}

#[repr(C)] 属性只会影响结构体本身的布局,而不会影响其各个字段的表示法,因此要匹配 C 结构体,每个字段也必须使用类 C 类型,比如把 char * 表示为 *const c_char,把 int 表示为 c_int

在这种特殊情况下, #[repr(C)] 属性可能不会更改 git_error 的布局。确实没有几种有趣的方式来排布指针和整数。但是 C 和 C++ 会保证结构体的成员按照声明的顺序出现在内存中,且每个成员都在不同的地址中,而 Rust 会重新排序字段以将结构体的整体大小最小化,并让零大小的类型不占用空间。 #[repr(C)] 属性告诉 Rust 要遵循 C 对给定类型的排布规则。

还可以使用 #[repr(C)] 来控制 C 风格枚举的表示法:

#[repr(C)]
#[allow(non_camel_case_types)]
enum git_error_code {
 GIT_OK = 0,
 GIT_ERROR = -1,
 GIT_ENOTFOUND = -3,
 GIT_EEXISTS = -4,
 ...
}

通常,Rust 在选择枚举的表示方式时会使用各种小技巧。例如,我们曾提到 Rust 用于将 Option<&T> 存储在单个机器字(如果 T 是固定大小的话)中的技巧。如果没有 #[repr(C)],那么 Rust 就会使用单字节来表示 git_error_code 枚举。但如果加了 #[repr(C)],则 Rust 会向 C 看齐,使用 C 中 int 大小的值。

还可以要求 Rust 为枚举提供与某些整数类型相同的表示形式。如果前面的定义以 #[repr(i16)] 开始,那么它将为你提供一个 16 位类型,其表示形式与以下 C++ 枚举相同:

#include <stdint.h>

enum git_error_code: int16_t {
 GIT_OK = 0,
 GIT_ERROR = -1,
 GIT_ENOTFOUND = -3,
 GIT_EEXISTS = -4,
 ...
};

如前所述, #[repr(C)] 也适用于联合体。 #[repr(C)] 联合体的字段总是从联合体的内存的第一位开始(索引为 0)。

假设你有一个 C 结构体,它会使用联合体来保存一些数据和一个标记值,以指示应该使用联合体的哪个字段,就像 Rust 枚举一样。

enum tag {
 FLOAT = 0,
 INT = 1,
};

union number {
 float f;
 short i;
};

struct tagged_number {
 tag t;
 number n;
};

通过将 #[repr(C)] 应用于枚举、结构体和联合体类型,并使用 match 语句根据标记在更大的结构体中选择一个联合体字段,Rust 代码可以与这个结构体进行互操作:

#[repr(C)]
enum Tag {
 Float = 0,
 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 字符串的指针,那它可能会将嵌入的空字符误认为是字符串的末尾,或者跑到字符串末尾寻找不存在的终止符 null。但反过来,你可以借用一个 C 字符串作为 Rust 的 &str,只要其内容是格式良好的 UTF-8。

这种情况迫使 Rust 将 C 字符串视为与 String&str 完全不同的类型。在 std::ffi 模块中, CString 类型和 CStr 类型表示拥有和借用的以空字符结尾的字节数组。与 Stringstr 相比, CStringCStr 上的方法非常有限,仅限于构造和转换为其他类型。23.2 节会展示这些类型的作用。

23.2 声明外部函数与变量

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 的:安全的 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 函数会构建一个以 null 结尾的 C 字符串。它会首先检查其参数是否有嵌入的空字符,因为这些嵌入的空字符不能用 C 字符串表示,如果发现任何字符就返回一个错误(因此需要 unwrap 其结果)。否则,它会在末尾添加一个空字节并返回一个拥有结果字符的 CString

CString::new 的开销取决于你传入的类型。它可以接受任何实现了 Into<Vec<u8>> 的值。传递 &str 需要一次内存分配和一次复制,因为转换为 Vec<u8> 会让向量拥有一个分配在堆上的字符串副本。但是按值传递 String 只会消耗此字符串并接管其缓冲区,因此除非为了添加额外的空字符要强制调整缓冲区大小,否则本次转换根本不需要复制文本或分配内存。

CString 会解引用成 CStr,后者的 as_ptr 方法会返回指向字符串开头的 *const c_char。这是 strlen 期望的类型。在这个例子中,对字符串调用 strlen 时,会找到 CString::new 放置在那里的空字符,并返回其按字节计算的长度。

还可以在 extern 块中声明全局变量。POSIX 系统有一个名为 environ 的全局变量,该变量会持有进程的环境变量的值。在 C 中,可以将其声明为如下形式:

extern char **environ;

在 Rust 中,则要这样写:

use std::ffi::CStr;
use 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 来构建出借用它的 CStrto_string_lossy 方法会返回一个 Cow<str>:如果 C 字符串包含格式良好的 UTF-8,则 Cow 会将其内容借用为 &str,不包括空字节终止符。否则, to_string_lossy 就会在堆中复制文本,用 Unicode 的官方替代字符 替换格式错误的 UTF-8 序列,并从中构建一个拥有型 Cow。无论是哪种方式,其结果都会实现 Display,因此可以使用 {} 格式参数打印它。

23.3 使用库中的函数

要使用特定库提供的函数,可以在 extern 块的顶部放置一个 #[link] 属性,该属性中的 name 是 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")] 属性会在 crate 中留下一个便签,大意是当 Rust 创建最终的可执行文件或共享库时,应该链接到 git2 库。Rust 会使用系统的链接器来构建可执行文件。在 Unix 上,它会在链接器命令行上传入参数 -lgit2;在 Windows 上,它会传入 git2.LIB

#[link] 属性也适用于库 crate。在构建依赖于其他 crate 的程序时,Cargo 会收集整个依赖图中的 link 注解,并将它们全部包含在最终链接中。

在这个示例中,如果你想在自己的机器上进行操作,则需要构建 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 标志。)这会在 C:\Users\JimB\libgit2-0.25.1\build\Debug 目录中生成一个导入库 git2.LIB 和一个动态链接库 git2.DLL。(其余说明只针对 Unix,只有在 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 status: 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: could not compile `git-toy` due to previous error

可以通过编写 构建脚本 来告诉 Rust 在何处搜索库,这是 Cargo 在构建时会编译和运行的 Rust 代码。构建脚本可以做各种各样的事情,比如动态生成代码、编译 C 代码并将其包含在 crate 中,等等。在这个例子中,只需将库的搜索路径添加到可执行文件的链接命令中即可。Cargo 在运行构建脚本时会解析构建脚本的输出以获取此类信息,因此构建脚本只需将正确的关键信息打印到其标准输出即可。

要创建构建脚本,请将名为 build.rs 的文件添加到 Cargo.toml 文件的所属目录中,其中包含如下内容:

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 也提供了很好的模拟示例,它的构建脚本处理了一些复杂的情况。

23.4 libgit2 的裸接口

要正确使用 libgit2,需要弄清楚以下两个问题。

  • 在 Rust 中使用 libgit2 函数需要做什么?
  • 如何围绕 libgit2 函数构建安全的 Rust 接口?

我们将分别回答这两个问题。在本节中,我们会编写一个程序,该程序本质上是一个巨大的 unsafe 块,其中充满了非惯用的 Rust 代码,这反映了混合语言中固有的类型系统和使用惯例的冲突。我们将这个程序称为 接口。虽然代码会很乱,但把 Rust 代码使用 libgit2 时必须发生的所有步骤都说清楚了。

然后,在 23.5 节中,我们会构建一个 libgit2 的安全接口,用 Rust 的类型系统来强制执行 libgit2 强加给其用户的规则。幸运的是, libgit2 是一个设计得非常好的 C 库,所以那些由于 Rust 的安全性要求而让我们不得不问的问题都有很好的答案,从而可以构建出没有 unsafe 函数的 Rust 惯用接口。

我们要编写的程序非常简单,它会以一个路径作为命令行参数,打开那里的 Git 存储库,然后打印出其头部提交。虽然此程序很简单,但足以说明构建出安全的 Rust 惯用接口的关键策略。

对于这个裸接口,程序最终用到的 libgit2 中的函数和类型集比我们之前用过的更大,因此比较合理的做法是将 extern 块移动到它自己的模块中。我们将在 git-toy/src 中创建一个名为 raw.rs 的文件,其内容如下:

#![allow(non_camel_case_types)]

use std::os::raw::;

#[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) -> *const 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 公共头文件将 git_repository 类型定义成了一个不完整结构体类型的 typedef

typedef struct git_repository git_repository;

由于这个类型的细节对库来说是私有的,因此公共头文件不会定义 struct git_repository,以确保库的用户永远不能自己构建这种类型的实例。Rust 中用来模拟不完整结构体类型的写法如下所示:

#[repr(C)] pub struct git_repository { _private: [u8; 0] }

这是一个包含无元素数组的结构体类型。由于 _private 字段不是 pub 的,因此无法在这个模块外构造此类型的值,这完美地映射了一种只能由 libgit2 构造出来的 C 类型,并且此类型只能通过裸指针进行操作。

手动编写大型 extern 块可能是一件苦差事。如果你正在为复杂的 C 库创建 Rust 接口,可以试试 bindgen crate,它可以使用从构建脚本中调用的函数来解析 C 头文件,并自动生成相应的 Rust 声明。由于篇幅所限,这里就不展示 bindgen 的实际操作了,但是 bindgen 在 crates.io 的页面上有指向其文档的链接。

接下来我们将完全重写 main.rs。首先需要声明 raw 模块:

mod raw;

根据 libgit2 的约定,可能出错的函数会返回一个整数代码,正数或 0 表示成功,负数表示失败。如果发生错误, giterr_last 函数会返回一个指向 git_error 结构体的指针,以提供关于出错原因的详细信息。 libgit2 拥有这个结构体,所以我们不需要自己释放,但它可能会被下一次库调用覆盖。一个合适的 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 可以打印的内容。

接下来需要用一个函数来打印出某次提交:

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_authorgit_commit_message 来检索需要的信息。这两个函数遵循 libgit2 文档中解释过的如下契约:

如果函数返回一个对象作为返回值,那么这个函数就是一个 getter,并且该对象的生命周期与其父对象相关联。

在 Rust 术语中, authormessage 是从 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());
 }
}

该函数从处理路径参数和初始化库的代码开始,所有这些我们之前都见过。下面是第一段新代码:

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 这样的函数会将新值的所有权传给调用者。

接下来,再来看看负责查找存储库“当前头部提交”的“对象哈希”的代码:

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 类型会存储一个对象标识符——一个 160 位哈希码,Git 在内部及其用户界面中会使用它来标识提交、文件的各个版本等。对 git_reference_name_to_id 的调用会为当前 "HEAD" 提交查找对象标识符。

在 C 语言中,通过将指向变量的指针传给用来填充其值的函数来初始化变量是完全正常的。 git_reference_name_to_id 对它的第一个参数也会这样处理。但是 Rust 不允许借用对未初始化变量的引用。固然可以用 0 来初始化 oid,但这是一种浪费,因为存储在那里的任何值都会被简单地覆盖。

可以让 Rust 为我们提供一份未初始化内存,但是因为在任意时刻读取未初始化内存都是瞬时的未定义行为,所以 Rust 提供了一个抽象 MaybeUninit 来简化它的使用。 MaybeUninit<T> 告诉编译器为类型 T 留出足够的内存,但如果你认为这样做不安全,就不要碰它。虽然这个内存由 MaybeUninit 拥有,但编译器也会避免某些可能导致未定义行为的优化,即使代码中没有任何地方显式访问这块未初始化内存也会如此。

MaybeUninit 提供了一个 as_mut_ptr() 方法,该方法会生成一个 *mut T 指针以指向它包装的可能未初始化内存。通过将该指针传给可初始化这块内存的外部函数,然后调用 assume_initMaybeUninit 就会生成完全初始化过的 T,这样就可以避免未定义行为,而无须支付先初始化然后立即丢弃值带来的额外开销。 assume_init 是不安全的,因为在无法确定内存是否真正被初始化过的情况下在 MaybeUninit 上调用它会立即导致未定义行为。

在这个例子中, assume_init 是安全的,因为 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));

这会获取提交的对象标识符并查找实际的提交,当成功时会把 git_commit 指针存储在 commit 中。

main 函数的其余部分不言自明。它调用之前定义的 show_commit 函数,释放提交对象和存储库对象,最后关闭库。

现在我们可以在手头任何现有 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 <jimb@red-bean.com>

Animate goop a bit.

23.5 libgit2 的安全接口

libgit2 的裸接口是不安全特性的一个完美示例:当然可以正确使用它(但我们要知道这些规则——就像这里一样),但 Rust 无法强制执行那些你必须遵循的规则。要为像这样的库设计一个安全的 API,就要识别所有这些规则,然后找出一种方法把任何违反这些规则的行为都转化为类型检查错误或借用检查错误。

下面是 libgit2 为程序正确使用其特性而制定的一些规则。

  • 在使用任何其他库函数之前,必须调用 git_libgit2_init。调用 git_libgit2_shutdown 后不得再使用任何库函数。
  • 除了输出参数,传给 libgit2 函数的所有值都必须完全初始化。
  • 当调用失败时,用于保存调用结果的输出参数将处于未初始化状态,不得使用它们的值。
  • git_commit 对象引用的是派生出它的 git_repository 对象,所以前者的生命周期不能超出后者。( libgit2 文档中并未对此做明确说明,我们从接口中存在某些函数做出推断,然后通过阅读源代码进行验证。)
  • 同样, git_signature 总是借用自给定的 git_commit,前者的生命周期不能超出后者。(文档中确实涵盖了这种情况。)
  • 与提交关联的消息以及作者的姓名和电子邮件地址都是从提交中借用的,并且在提交被释放后不得使用。
  • libgit2 对象一旦被释放,就不能再使用了。

事实证明,可以通过 Rust 的类型系统或借助内部管理细节来构建强制执行所有这些规则的 libgit2 Rust 接口。

在开始之前,先稍微重构一下本项目。我们想要一个导出安全接口的 git 模块,其中来自先前程序的裸接口是一个私有子模块。

整个源代码树结构如下所示:

git-toy/
├── Cargo.toml
├── build.rs
└── src/
 ├── main.rs
 └── git/
 ├── mod.rs
 └── raw.rs

按照 8.2.2 节中解释过的规则, 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 <you@example.com>"]
edition = "2021"

[dependencies]
libc = "0.2"

现在我们已经重构了自己的模块,接下来要考虑错误处理。即使是 libgit2 的初始化函数也会返回一个错误代码,所以在开始之前需要先解决这个问题。一个符合惯用法的 Rust 接口需要自己的 Error 类型,以捕获 libgit2 故障代码以及来自 giterr_last 的错误消息和类。一个适当的错误类型必须实现通常的 Error 特型、 Debug 特型和 Display 特型。然后,它要有自己的使用此 Error 类型的 Result 类型。下面是 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> {
 // 显示`Error`其实就是显示来自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 函数与裸接口版本的 check 函数之间的主要区别在于,它构造了一个 Error 值,而不是打印错误消息并立即退出。

现在可以处理 libgit2 的初始化了。安全接口会提供一个 Repository 类型,表示打开的 Git 存储库,具有解析引用、查找提交等方法。下面是 git/mod.rs 中的 Repository 的定义:

/// Git存储库
pub struct Repository {
 // 这必须始终是一个指向有效`git_repository`结构体的指针
 // 不能有任何其他`Repository`型指针指向它
 raw: *mut raw::git_repository
}

Repositoryraw 字段不是公共的。由于只有这个模块中的代码可以访问 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 = ptr::null_mut();
 unsafe {
 check(raw::git_repository_open(&mut repo, path.as_ptr()))?;
 }

 Ok(Repository { raw: repo })
 }
}

由于使用安全接口执行任何操作的唯一方法就是先获取 Repository 值,并且 Repository::open 会以调用 ensure_initialized 开始,因此我们可以确信 ensure_initialized 会在任何 libgit2 函数之前被调用。 ensure_initialized 的定义如下所示:

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 符合要求并会确保 libgit2 正确关闭。

7.1.1 节曾提到让 panic 跨越语言边界是未定义行为。从 atexitshutdown 的调用就是这样的一个边界,所以 shutdown 不能引发 panic 是非常必要的。这就是为什么 shutdown 不能简单地使用 .expect 来处理 raw::git_libgit2_shutdown 报告的错误。相反,它必须报告错误并终止进程本身。POSIX 禁止在 atexit 处理程序中调用 exit,因此 shutdown 会调用 std::process::abort 来突然终止程序。

要尽快安排调用 git_libgit2_shutdown,比如当最后一个 Repository 值被丢弃时。但无论我们如何安排,调用 git_libgit2_shutdown 都是此安全 API 的职责。在调用它的那一刻,任何现存的 libgit2 对象都会变得无法安全使用,因此安全 API 不能直接对外暴露这个函数。

Repository 的裸指针必须始终指向有效的 git_repository 对象。这意味着关闭此存储库的唯一方法就是丢弃拥有它的 Repository 值:

impl Drop for Repository {
 fn drop(&mut self) {
 unsafe {
 raw::git_repository_free(self.raw);
 }
 }
}

通过只在指向 raw::git_repository 的唯一指针即将消失时调用 git_repository_freeRepository 类型还确保了此指针在释放后永远不再使用。

Repository::open 方法会使用一个名为 path_to_cstring 的私有函数,该函数有两个定义,一个用于类 Unix 系统,一个用于 Windows:

use std::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 都会接受以 null 结尾的 C 字符串作为路径。在 Windows 上, libgit2 会假定这些 C 字符串包含格式良好的 UTF-8,并会在内部将它们转换为 Windows 实际需要的 16 位路径。这通常是有效的,但并不理想。Windows 允许格式不正确的 Unicode 文件名,因此不能用 UTF-8 表示。如果你有这样的文件,则不可能将其名称传给 libgit2

在 Rust 中,文件系统路径的正确表示是 std::path::Path,它是经过精心设计的,可以处理可能出现在 Windows 或 POSIX 上的任何路径。这意味着 Windows 上存在无法传给 libgit2Path 值,因为它们不是格式良好的 UTF-8。因此,尽管 path_to_cstring 的行为不尽如人意,但实际上已经是我们在给定 libgit2 接口的情况下所能做的最佳选择了。

刚刚展示的两个 path_to_cstring 定义依赖于对 Error 类型的转换: ? 运算符会尝试进行此类转换,Windows 版本会显式地调用 .into()。这些转换并不明显:

impl From<String> for Error {
 fn from(message: String) -> Error {
 Error { code: -1, message, class: 0 }
 }
}

// NulError是当`CString::new`在字符串中嵌入了空字符时返回的内容
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 字节的哈希值,因此在安全 API 中公开它是完全没问题的:

/// 存储在Git对象数据库中的某种对象的标识符:提交、
/// 树、blob、标签等。这是对象内容的宽哈希码
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 类型,如下所示:

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> 视为持有对某个 Repository 的生命周期 'repo 的引用。

查找某个提交的方法如下所示:

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 的生命周期联系起来的呢?根据 5.3.7 节介绍过的规则, find_commit 的签名省略了所涉及引用的生命周期。如果把生命周期写出来,那么完整的签名是这样的:

fn find_commit<'repo, 'id>(&'repo self, oid: &'id Oid)
 -> Result<Commit<'repo>>

这正是我们想要的:Rust 将返回的 Commit 视为从 selfRepository)借入的值。

Commit 被丢弃时,必须释放它的 raw::git_commit

impl<'repo> Drop for Commit<'repo> {
 fn drop(&mut self) {
 unsafe {
 raw::git_commit_free(self.raw);
 }
 }
}

可以从 Commit 中借入 Signature(姓名和电子邮件地址)和提交消息的文本:

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 借用了它们的文本。所以我们的安全 Signature 类型包括 PhantomData<&'text str>,以告诉 Rust 此类型要表现得就像含有一个生命周期为 'text&str。和以前一样,无须编写任何代码, Commit::author 就能正确地将它返回的 Signature'text 生命周期关联到 Commit'text 生命周期。 Commit::message 方法会对持有提交消息的 Option<&str> 执行相同的操作。

Signature 包括检索作者姓名和电子邮件地址的方法:

impl<'text> Signature<'text> {
 /// 将作者姓名以`&str`的形式返回。如果作者姓名不是格式良好的UTF-8,则返回`None`
 pub fn name(&self) -> Option<&str> {
 unsafe {
 char_ptr_to_str(self, (*self.raw).name)
 }
 }

 /// 将作者的电子邮件地址以`&str`的形式返回。如果作者的
 /// 电子邮件地址不是格式良好的UTF-8,则返回`None`
 pub fn email(&self) -> Option<&str> {
 unsafe {
 char_ptr_to_str(self, (*self.raw).email)
 }
 }
}

前面的方法都依赖于私有实用函数 char_ptr_to_str

/// 尝试从`ptr`中借用`&str`,这里假设`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 的文档非常好,但无法从 libgit2 文档中看出 git_signatureemail 指针和 author 指针是否可以为空。我们(本书作者)在源代码中“挖掘”了一段时间,但始终无法以这种或那种方式说服自己,最终还是决定,为了以防万一,最好让 char_ptr_to_str 能够接受空指针。而如果是 Rust 代码,这种问题立即就可以通过类型回答出来:如果它是 &str,那你可以期望一定有字符串;如果它是 Option<&str>,那就是可选的。

最后,我们为所需的全部功能都提供了安全接口。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)"));
}

在本章中,我们已经把一个未提供多少安全保证的简单接口,进化成了能将任何违反接口契约的行为转变为某种 Rust 类型错误的安全接口,把本质上不安全的 API 包装成了安全的 API。作为回报,现在 Rust 可以确保你使用的接口的正确性了。在大多数情况下,我们让 Rust 执行的规则就是 C 和 C++ 程序员最终不得不强加于自身的那些规则。你之所以会觉得 Rust 比 C 和 C++ 严格得多,不是因为这些规则太陌生,而是因为对这些规则的执行不仅是机械化的,更是综合而全面的。

23.6 结论

Rust 不是一门简单的语言。它的目标是横跨两个截然不同的世界。它是一门现代编程语言,天生安全,具有闭包和迭代器等便捷特性。但 Rust 旨在让你以最低的运行期开销控制运行计算机的原始能力。

该语言的轮廓就是由这些目标勾勒出来的。Rust 设法用安全代码弥合了大部分差距。它的借用检查器和零成本抽象让你尽可能接近裸机,却不必冒着未定义行为的风险。如果这还不够,或者你想利用现有的 C 代码,不安全代码和外部函数接口会随时待命。但同样,该语言不会止步于只提供这些不安全特性并“尊重”你的命运。它的目标始终是使用不安全特性来构建安全 API。这就是我们刚刚在 libgit2 中所做的,也是 Rust 团队在 BoxVec、其他集合、通道等方面所做的。标准库充满了安全的抽象,但在幕后实现中使用了一些不安全代码。

像 Rust 这样一门雄心勃勃的语言,也许注定不会成为最简单的工具。Rust 安全、快速、并发且高效。请用它来构建大型、快速、安全、强大的系统,以充分利用它们所运行的硬件的全部能力。请用它让我们的软件变得更好!