查看原文
其他

这一天,我用 Rust 重写了已有 19 年历史的 C++ 库!

Henri Sivonen CSDN 2018-12-16

从版本 56 开始,Firefox 浏览器支持一种新的字符编码转换库,叫做 encoding_rs。它是用 Rust 编写的,代替了从 1999 年就开始使用的 C++ 编写的字符编码库 uconv。最初,所有调用该字符编码转换库的代码都是 C++,所以尽管新的库是用 Rust 编写的,它也必须能被 C++ 代码调用。实际上,在 C++ 调用者看来,这个库跟现代的 C++ 库没什么区别。下面是我实现这一点采用的开发方式。

相关阅读:

  • 关于 encoding_rs 本身:https://hsivonen.fi/encoding_rs/

  • 演讲视频:https://media.ccc.de/v/rustfest18-5-a_rust_crate_that_also_quacks_like_a_modern_c_library

  • 幻灯片:https://hsivonen.fi/rustfest2018/


怎样写现代 C++?


所谓“现代”C++的意思就是从 C++ 调用者来看,函数库遵循 C++ 的核心指南(https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines),并具备以下新特性:

  • 通过返回 std::unique_ptr / mozilla::UniquePtr 中在堆上分配的对象的指针进行堆内存分配管理。

  • 调用者分配的缓冲区用 gsl::span / mozilla::Span 来表示,而不是用普通的指针和长度表示。

  • 多个返回值使用 std::tuple / mozilla::Tuple 传递,而不是使用输出参数。

  • 非空的普通指针用 gsl::not_null / mozilla::NotNull 表示。

上面的 gsl:: 表示 Guidelines Support Library(https://github.com/microsoft/GSL),这个库能提供核心指南要求、但尚未存在于 C++ 标准库中的东西。


用 Rust 写 C++ 库?


“用 Rust”写 C++ 库的意思是指库中的大部分是用 Rust 写的,但提供给 C++ 调用者的接口至少在 C++ 调用者来看就像个真正的 C++ 库一样。


C++ 和 Rust 都与 C 有互操作性


C++ 的 ABI 非常复杂,而 Rust ABI 尚未完全确定。但是,C++ 和 Rust 都支持一些使用 C ABI 的函数。因此,要想让 C++ 和 Rust 拥有互操作性,就需要通过某种方法,让 C++ 把 Rust 代码看成 C 代码,Rust 把 C++ 代码看成 C 代码。


简化的情形


这篇文章并不是 Rust 与 C++ 互联的完整指南。encoding_rs 的接口非常简单,缺乏两种语言之间的互操作性上的常见问题。但是,encoding_rs 简化 C++ 接口的例子可以作为一个指南,给那些希望在设计函数库时了解跨语言互操作性的人们提供一些帮助。具体来说:

  • encoding_rs 从来不会调用 C++:跨语言调用是单向的。

  • encoding_rs 在调用返回后,不持有指向 C++ 对象的引用:因此 Rust 代码不需要管理 C++ 内存。

  • encoding_rs 不需要用 Rust 或 C++ 语言提供继承层次结构:因此两种语言中都没有 vtables。

  • encoding_rs 操作的数据类型非常简单:只有基本类型的连续缓冲区(u8 / uint8_t  和  u16 / char16_t  的缓冲区)。

  • 仅支持 panic=abort 配置(即 Rust 的崩溃会终止整个程序,无需回滚栈),而且这里给出的代码只有在该配置下才是正确的。这里给出的代码没有去防止 Rust 崩溃跨越 FFI 边界回滚,因此跨 FFI 边界的崩溃是未定义的行为。


API 快速概览


为了理解我们讨论的 Rust API(https://docs.rs/encoding_rs/0.8.13/encoding_rs/),先来从高层次看看。整个函数库有三个公开的结构体(struct):Encoding,Decoder 和 Encoder。从函数库的使用者角度来看,这些结构能为各种具体的编码提供统一的接口,所以可以像 traits、父类或接口一样使用, 但严格来说它们实际上是结构体。Encoding 的实例是静态分配的。Decoder 和 Encoder 封装了流转换的状态,是在运行时动态分配的。

Encoding 实例的引用(即&'static Encoding)可以通过标签获得(从协议文本中提取的文本识别信息),或通过命名静态变量(named static)获得。然后 Encoding 可以作为 Decoder 的参数使用,后者是在栈上分配的。

let encoding: &'static Encoding =
    Encoding::for_label( // by label
        byte_slice_from_protocol
    ).unwrap_or(
        WINDOWS_1252     // by named static
    );

let decoder: Decoder =
    encoding.new_decoder();


在处理流时,Decoder 中有个方法可以将流从调用者分配的一个切片解码到调用者分配的一个切片。解码器不进行堆分配操作。

pub enum DecoderResult {
    InputEmpty,
    OutputFull,
    Malformed(u8, u8),
}

impl Decoder {
    pub fn decode_to_utf16_without_replacement(
        &mut self,
        src: &[u8],
        dst: &mut [u16],
        last: bool
    ) -> (DecoderResult, usize, usize)
}

在处理流之外的情况时,调用者完全不需要处理 Decoder 和 Encoder 的任何东西。Encoding 会提供方法在一个缓冲区中处理整个逻辑输入流。

impl Encoding {
    pub fn decode_without_bom_handling_and_without_replacement<'a>(
        &'
static self,
        bytes: &'a [u8],
    ) -> Option<Cow<'
a, str>>
}



处理过程


0. 对 FFI 友好的设计

有些设计来自于问题域本身的简化因素。而有些只是选择。

字符编码库可以合理地将编码、解码器和编码器的概念表示成 traits(类似于 C++ 中没有字段的抽象父类),但是,encoding_rs 对这些概念采用了结构体(struct),以便在分发的时候能 match 成一个 enum,而不必依赖于 vtable(https://en.wikipedia.org/wiki/Virtual_method_table)。

pub struct Decoder { // no vtable
   variant: VariantDecoder,
   // ...
}

enum VariantDecoder { // no extensibility
    SingleByte(SingleByteDecoder),
    Utf8(Utf8Decoder),
    Gb18030(Gb18030Decoder),
    // ...
}

这样做的主要动机并不是消除 vtable 本身,而是故意让层次结构不能扩展。其背后反映的哲学是,添加字符编码不应该是程序员应当关心的事情。相反,程序应当使用 UTF-8 作为数据交换,而且程序不应当支持古老的编码,除非需要兼容已有的内容。这种不可扩展的层次结构能带来强类型安全。如果你从 encoding_rs 得到一个 Encoding 实例,那么你可以信任它绝不会给出任何编码标准中没有给出的特性。也就是说,你可以相信它绝不会表现出 UTF-7 或 EBCDIC 的行为。

此外,通过分发 enum,一个编码的解码器可以在内部根据 BOM 嗅探的结果变成另一个编码的解码器。

有人可能会说,Rust 提供编码转换器的方式是将它变成迭代适配器,接受字节迭代器的输入,然后输出 Unicode 的标量值,或者相反。然而迭代器不仅在跨越 FFI 边界时更复杂,还使得加速 ASCII 处理等技巧更难以实现。而直接接受一个切片进行读取和写入操作,不仅使得提供 C API 更容易(用 C 的术语来说,Rust 切片解构成对齐的非空指针和一个长度值),而且可以通过观察多个代码单元能放入单个寄存器(ALU 寄存器或 SIMD 寄存器)的情况,实现一次处理多个代码单元,从而实现 ASCII 处理加速。

如果 Rust 的原生 API 只处理基本类型、切片和(非 trait 对象的)结构体,那么与支持高级 Rust 特性的 API 相比,这个 API 更容易映射到 C API。(在 Rust 中,发生类型擦除时会产生一个 trait 对象。也就是说,你得到的是一个 trait 类型的引用,它并没有给出该引用指向的那个结构体的类型信息。)

1. 建立 C API

当涉及到的类型足够简单时,C 和 Rust之间的主要鸿沟,一是 C 语言缺乏方法、缺乏多返回值功能,二是不能以值形式传送 C 结构体之外的类型。

  • 方法用函数包裹起来,该函数的第一个参数是指向该方法所属结构体的指针。

  • 切片参数转换为两个参数:指向切片开头的指针,以及切片的长度。

  • 函数的返回值中,第一个基本类型的返回值作为返回值返回,其他返回值作为输出参数。当输出参数与同类型的输入参数相关时,使用 in/out 参数是合理的。

  • 如果 Rust 方法以值的形式返回一个结构体,那么封装函数将打包该结构体,并返回指向它的指针,因此 Rust 不必考虑该结构体。此外还要添加一个函数,用于释放该指针指向的结构体。这样,Rust 方法只需将指针打包,或者拆包。从 C 指针的角度来看,结构体是不透明的。

  • 作为特殊情况,获取编码名称的方法在 Rust 中返回 &'static str,它被包裹在一个函数中,接收一个指向可写入的缓冲区的指针,缓冲区的长度至少应当为最长的编码名称的长度。

  • enum 用来表示输入缓冲区的枯竭、输出缓冲区占满,或错误以及详细情况,这些 enum 在 C API 中转变成 uint32_t,并加上相应的常量来表示“输入空”或“输出满”以及一系列解释其他错误的规则。这种方式不是最理想的,但在这种情况下很好用。

  • 越界检查时的长度计算改成饱和运算(saturating)。也就是说,调用者需要将 SIZE_MAX 当作越界的信号。

2.在 C++ 中根据 C API 重建 API

即使是惯用的 C API(https://github.com/hsivonen/encoding_c/blob/master/include/encoding_rs.h)也不能当做现代 C++ API 使用。幸运的是,类似于多重返回值、切片等 Rust 概念可以在 C++ 中表示,只需将 C API 返回的指针解释成指向 C++ 对象的指针,就能展示出 C++ 的优雅。

大部分例子来自一个使用了 C++17 标准库类型的 API(https://github.com/hsivonen/encoding_c/blob/master/include/encoding_rs_cpp.h)。在 Gecko 中,我们一般会避免使用 C++ 标准库,而使用一个 encoding_rs 的特别版本的 C++ API,该版本使用了 Gecko 特有的类型(https://searchfox.org/mozilla-central/source/intl/Encoding.h)。这里我假设标准库类型的例子更容易被更多读者接受。

方法的优雅

对于每个 C 语言中不透明的构造体指针,C++ 中都会定义一个类,C 的头文件也会修改,使得从 C++ 编译器的角度来看,指针类型变成指向 C++ 类实例的指针。这些放在一起就相当于一个 reinterpret_cast 过的指针,而不需要实际写出 reinterpret_cast。

由于指针并不真正指向它们看似指向的类的实例,而是指向 Rust 结构体的实例,因此应该事先做好预防措施。这些类中没有定义任何字段。默认的无参数构造函数和复制构造方法被删除,默认的 operator= 也被删除。此外,这些类还不能包含虚方法。(最后一点是个重要的限制条件,稍后会讨论。)

class Encoding final {
// ...
private:
    Encoding() = delete;
    Encoding(const Encoding&) = delete;
    Encoding& operator=(const Encoding&) = delete;
    ~Encoding() = delete;
};

对于 Encoding 来说,所有实例都是静态的,因此析构函数也被删掉了。如果是动态分配的 Decoder 和 Encoder,还要添加一个空的析构函数和一个 static void operator delete。(后面会给一个例子。)这样能让这个伪 C++ 类的析构过程导向 C API 中相应类型的释放函数。

这些基础工作将指针变得看上去像是 C++ 类实例的指针。有了这些,就能在这些指针上实现方法调用了。(介绍完下一个概念后也会给出实例。)

返回动态分配的对象

前面说过,Rust API 以值方式返回 Encoder 或 Decoder,这样调用者可以将返回值放在栈上。这种情况被 FFI 的包裹代替,因此 C API 只需通过指针暴露堆上分配的对象。而且,这些指针也被重新解释为可 delete 的 C++ 对象指针。

不过还需要确保这些 delete 会在正确的时机被调用。在现代 C++ 中,如果对象在同一时刻只能有一个合法的所有者,那么对象指针会被包裹在 std::unique_ptr 或 mozilla::UniquePtr 中。老的 uconv 转换器支持引用计数,但在 Gecko 代码中所有实际的应用中,每个转换器都只有一个所有者。由于编码器和解码器的使用方式使得同一时刻只有一个合法的所有者,因此 encoding_rs 的两个 C++ 包裹就使用了 std::unique_ptr 和 mozilla::UniquePtr。

我们来看看 Encoding 中那个返回 Decoder 的工厂方法。在 Rust 中,这个方法接收 self 的引用,通过值返回 Decoder。

impl Encoding {
    pub fn new_decoder(&'static self) -> Decoder {
        // ...
    }
}

在 FFI 层,第一个参数是显式的指针类型,对应于 Rust 的 &self 和 C++ 的 this(具体来说,是 const 版本的 this)。我们在堆上分配内存(Box::new())然后将 Decoder 放进分配好的内存中。然后忘记内存分配(Box::into_row),这样可以将指针返回给 C,而不会在作用域结束时释放。为了能够释放内存,我们引入了一个新的函数,将 Box 放回,然后将它赋给一个变量,以便立即离开作用域,从而释放堆上分配的内存。

#[no_mangle]
pub unsafe extern "C" fn encoding_new_decoder(
    encoding: *const Encoding) -> *mut Decoder
{
    Box::into_raw(Box::new((*encoding).new_decoder()))
}

#[no_mangle]
pub unsafe extern "C" fn decoder_free(decoder: *mut Decoder) {
    let _ = Box::from_raw(decoder);
}

在 C 文件头中看起来像这样:

ENCODING_RS_DECODER*
encoding_new_decoder(ENCODING_RS_ENCODING const* encoding)
;

void
decoder_free(ENCODING_RS_DECODER* decoder)
;

ENCODING_RS_DECODER 是一个宏,用于在 C 头文件在 C++ 环境中使用(而不是作为纯 C API 使用)时将其替换成正确的 C++ 类型。

在 C++ 一侧,我们使用 std::unique_ptr,相当于 Rust 的 Box。实际上它们也非常相似:

let ptr: Box<Foo>
std::unique_ptr<Foo> ptr
Box::new(Foo::new(a, b, c))
make_unique<Foo>(a, b, c)
Box::into_raw(ptr)
ptr.release()
let ptr = Box::from_raw(raw_ptr);
std::unique_ptr<Foo> ptr(raw_ptr);

我们把从 C API 获得的指针包裹在 std::unique_ptr 中:

class Encoding final {
public:
    inline std::unique_ptr<Decoder> new_decoder() const
    {
        return std::unique_ptr<Decoder>(
            encoding_new_decoder(this));
    }
};

当 std::unique_ptr<Decoder> 离开作用域时,删除操作会通过 FFI 导向回 Rust,这是因为定义是下面这样的:

class Decoder final {
public:
    ~Decoder() {}
    static inline void operator delete(void* decoder)
    
{
        decoder_free(reinterpret_cast<Decoder*>(decoder));
    }
private:
    Decoder() = delete;
    Decoder(const Decoder&) = delete;
    Decoder& operator=(const Decoder&) = delete;
};

如何工作?

在 Rust 中,非 trait 的方法只不过是语法糖:

impl Foo {
    pub fn get_val(&self) -> usize {
        self.val
    }
}

fn test(bar: Foo) {
    assert_eq!(bar.get_val(), Foo::get_val(&bar));
}

对非 trait 类型的引用方法调用只不过是普通的函数调用,但第一个参数是指向 self 的引用。在 C++ 一侧,非虚方法的调用原理相同:非虚 C++ 方法调用只不过是函数调用,但第一个函数是 this 指针。

在 FFI/C 层,我们可以将同样的指针显式地作为第一个参数传递。

在调用 ptr->Foo() 时,其中的 ptr 是 T* 类型,而如果方法定义为 void Foo()(它在 Rust 中映射到 &mut self),那么 this 是 T* 类型,如果方法定义为 void Foo() const(在 Rust 中映射到 &self),则 this 是 const T* 类型,所以这样也能正确处理 const。

fn foo(&self, bar: usize) -> usize
size_t foo(size_t bar) const
fn foo(&mut self, bar: usize) -> usize
size_t foo(size_t bar)

这里“非 trait 类型”和“非虚”是非常重要的。要想让上面的代码正确工作,那么无论那一侧都不能有 vtable。这就是说,Rust 不能有 trait,C++ 也不能有继承。在 Rust 中,trait 对象(指向任何实现了 trait 的结构体的 trait 类型的引用)实现为两个指针:一个指向结构体实例,另一个指向对应于数据的具体类型的 vtable。我们需要能够把 self 的引用作为单一指针跨越 FFI 传递,所以在跨越 FFI 时无法携带 vtable 指针。为了让 C++ 对象指针兼容 C 的普通指针,C++ 将 vtable 指针放在了对象自身上。由于我们的指针指向的并不是真正带有 vtable 的 C++ 对象,而是 Rust 对象,所以必须保证 C++ 代码不会在指针目标上寻找 vtable 指针。

其结果是,Rust 中的结构对应的 C++ 中的类不能从 C++ 框架中的通用基类继承。在 Gecko 的情况中,C++ 类不能继承 nsISupports。例如,在 Qt 的语境下,对应的 C++ 类不能从 QObject 继承。

非空指针

Rust API 中有的方法会返回 &'static Encoding。Rust 的引用永远不会为 null,因此最好是将这个信息传递给 C++ API。C++ 中对应于此的是 gsl::not_null和mozilla::NotNull。

由于 gsl::not_null 和 mozilla::NotNull 只不过是类型系统层面的写法,它并不会改变底层指针的机器表示形式,因此对于有 Rust 保证的指针,跨越 FFI 之后可以认为它们绝不会为 null,所以我们想做的是,利用与之前将 FFI 返回的指针重新解释为指向无字段、无虚方法的 C++ 对象的指针同样的技巧来骗过 C++ 编译器,从而在头文件中声明那些 FFI 返回的绝不会为 null 的指针为类型 mozilla::NotNull<const Encoding*>。不幸的是,实际上这一点无法实现,因为在 C++ 中,涉及模板的类型不能在 extern "C" 函数的定义中使用,所以 C++ 代码最后只能在从 C API 接收到指针、包裹在 gsl::not_null 或 mozilla::NotNull 时进行一系列的 null 检查。

但是,也有一些定义是指向编码对象常量的静态指针(指向的目标是在 Rust 中定义的),而且恰巧 C++ 允许将这些定义为 gsl::not_null<const Encoding*>,所以我们这样实现了。(感谢 Masatoshi Kimura 指出这一点的可行性。)

Rust 中静态分配的 Encoding 实例的定义如下:

pub static UTF_8_INIT: Encoding = Encoding {
    name: "UTF-8",
    variant: VariantEncoding::Utf8,
};

pub static UTF_8: &'static Encoding = &UTF_8_INIT;

在 Rust 中,通用的规则(https://twitter.com/tshepang_dev/status/1051558270425591808)是 static 用来声明不会改变的内存地址,const 用来声明不会改变的值。因此,UTF_8_INIT 应当为 static,而 UTF_8 应当为 const:指向 static 实例的引用的值不会改变,但为这个引用静态分配的内存地址则不一定。不幸的是, Rust 有一条规则说,const 的右侧不能包含任何 static 的东西,因此这一条阻止了对 static 的引用,以确保 const 定义的右侧可以被静态检查,确定它是否适合任何假想的 const 定义——甚至是那些在编译时就试图解引用(dereference)的定义。

但对于 FFI,我们需要为 UTF_8_INIT 分配一块不会改变的内存,因为这种内存能在 C 的连接器中使用,可以让我们为 C 提供命名的指针类型的东西。上面说的 UTF_8 的表示形式已经是我们需要的了,但为了让 Rust 更优雅,我们希望 UTF_8 能参与到 Rust 的命名空间中。这意味着从 C 的角度来看,它的名字需要被改变(mangle)。我们浪费了一些空间来重新静态分配指针来避免改变名称,以供 C 使用:

pub struct ConstEncoding(*const Encoding);

unsafe impl Sync for ConstEncoding {}

#[no_mangle]
pub static UTF_8_ENCODING: ConstEncoding =
    ConstEncoding(&UTF_8_INIT);

这里使用了指针类型,以明确 C 语言会将其当做指针(即使 Rust 引用类型拥有同样的表现形式)。但是,Rust 编译器拒绝编译带有全局可视性指针的程序。由于全局变量可以被任何线程访问,多线程同时访问指针指向的目标可能会引发问题。这种情况下,指针目标不会被修改,因此全局可视性是没问题的。为了告诉编译器这一点,我们需要为指针实现 Sync 这个 marker trait。但是,trait 不能在指针类型上实现。作为迂回方案,我们为*const Encoding创建了一个新的类型。新的类型拥有与它包裹的类型同样的表现形式,但我们可以在新类型上实现 trait。实现 Sync 是 unsafe 的,因为我们告诉了编译器某些东西可以接受,这并不是编译器自己发现的。

在 C++ 中我们可以这样写(宏扩展之后的内容):

extern "C" {
    extern gsl::not_null<const encoding_rs::Encoding*> const UTF_8_ENCODING;
}

指向编码器和解码器的指针也绝不会为 null,因为内存分配失败会直接终止程序。但是 std::unique_ptr / mozilla::UniquePtr 和 gsl::nul / mozilla::NotNull 不能结合使用。

可选值

Rust 中常见的做法是用 Option<T> 表示返回值可能有值也可能没有值。现在的 C++ 提供了同样的东西:std::optional<T>。在 Gecko 中,我们使用的是 mozilla::Maybe<T>。

Rust 的 Option<T> 和 C++ 的 std::optional<T> 实际上是一样的:

return None;
   return std::nullopt;
return Some(foo);
   return foo;
is_some()
   operator bool()
   has_value()
unwrap()
   value()
unwrap_or(bar)
   value_or(bar)

但不幸的是,C++ 保留了安全性。从 std::optional<T> 中提取出包裹值时最优雅的方法就是使用 operator*(),但这个也是没有检查的,因此也是不安全的。

多返回值

尽管 C++ 在语言层面缺少对于多返回值的支持,但多返回值可以从库的层次实现。比如标准库,相应的部分是 std::tuple,std::make_tuple 和 std::tie。在 Gecko 中,相应的库是 mozilla::Tuple,mozilla::MakeTuple 和 mozilla::Tie。

fn foo() -> (T, U, V)
   std::tuple<T, U, V> foo()
return (a, b, c)
;
   return {a, b, c};
let (a, b, c) = foo();
   const auto [a, b, c] = foo();
let mut (a, b, c) = foo();
   auto [a, b, c] = foo();

切片

Rust 切片包裹了一个自己不拥有的指针,和指针指向内容的长度,表示数组中的一段连续内容。相应的 C 代码为:

src: &[u8]
   const uint8_t* src, size_t src_len
dst: &mut [u8]
   uint8_t* dst, size_t dst_len

C++ 的标准库中并没有对应的东西(除了 std::string_view 可以用来表示只读字符串切片之外),但 C++ 核心指南中已经有一部分叫做 span 的东西(https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#i13-do-not-pass-an-array-as-a-single-pointer):

src: &[u8]
   gsl::span<const uint8_tsrc
dst: &mut [u8]
   gsl::span<uint8_tdst
&mut vec[..]
   gsl::make_span(vec)
std::slice::from_raw_parts(ptrlen)
   gsl::make_span(ptrlen)
for item in slice {}
   for (auto&& item : span) {}
slice[i]
   span[i]
slice.len()
   span.size()
slice.as_ptr()
   span.data()

GSL 依赖于 C++14,但在 encoding_rs 发布时,Gecko 由于 Android 的阻碍,不得不停留在 C++11 上(https://bugzilla.mozilla.org/show_bug.cgi?id=1325632#c25)。因此,GSL 不能原样在 Gecko 中使用,我将 gsl::span 移植到了 C++11 上,变成 mozilla::Span(https://searchfox.org/mozilla-central/source/mfbt/Span.h#375)。移植的过程主要是去掉 constexpr 关键字,并用 mozilla:: 的类型和类型 trait 代替标准库中的类型。在 Gecko 改成 C++14 后,部分 constexpr 关键字被恢复了。

不论如何, 我们有了自己的 mozilla::Span,现在可以添加 gsl::span 中缺少的、像 Rust 一样的子 span 了。如果你需要子 span 下标 i 开始直到 j,但不包括 j,那么 gsl::span 的实现方法是:

&slice[i..]
   span.subspan(i)
&slice[..i]
   span.subspan(0, i)
&slice[i..j]
   span.subspan(ij - i)

而 mozilla::Span 的实现方法是:

&slice[i..]
    span.From(i)
&slice[..i]
    span.To(i)
&slice[i..j]
    span.FromTo(ij)

gsl::span 和 Rust 的切片有一个重要的区别:它们解构成指针和长度的方式不同。对于零长度的 gsl::span,指针可能会解构为 nullptr。而 Rust 切片中,指针必须不能为 null 且必须对齐,甚至零长度切片也是如此。乍一看起来似乎有点违反直觉:当长度为零时,指针永远不会解引用,那么它是否为 null 有什么关系吗?实际上,在优化 Option 之类的枚举之中的 enum 差异时这一点非常重要。None 表示为全零比特,所以如果包裹在 Some() 中,那么指针为 null、长度为零的切片就可能偶然被当做 None。通过要求指针不为 null 指针,Option 中的零长度切片就可以与 None 区分开来。通过要求指针必须对齐,当切片元素类型的对齐大于一时,就有可能进一步使用指针的低位比特。

在意识到我们不能将从 C++ 的 gsl::span::data() 中获得的指针直接传递给 Rust 的 std::slice::from_raw_parts() 后,我们必须决定要在哪里将 nullptr 替换成 reinterpret_cast<T*>(alignof(T))。如果使用 gsl::span 则有两个候选的位置:提供 FFI 的 Rust 代码中,或者在调用 FFI 的 C++ 代码中。而如果使用 mozilla::Span,我们可以改变 span 的实现代码,因此还有另外两个候选的位置:mozilla::Span 的构造函数,和指针的 getter 函数。

在这些候选位置中,mozilla::Span 的构造函数似乎是编译器最有可能优化掉某些检查的地方。这就是为什么我决定将检查放在这里的原因。这意味着如果使用 gsl::span,那么检查的代码必须移动到FFI的调用中。所有从 gsl::span 中获得的指针必须进行如下清洗:

template <class T>
static inline T* null_to_bogus(T* ptr)
{

    return ptr ? ptr : reinterpret_cast<T*>(alignof(T));
}

此外,由于这段检查并不存在于提供 FFI 的 diamante 中,C API 变得有点不寻常,因为它要求 C 的调用者即使在长度为零时也不要传递 NULL。但是,C API 在未定义行为方面已经有很多问题了,所以再加一个未定义行为似乎也不是什么大事儿。

合并到一起

我们来看看上面这些特性结合后的例子。首先,Rust 中的这个方法接收一个切片,并返回一个可选的 tuple:

impl Encoding {
    pub fn for_bom(buffer: &[u8]) ->
        Option<(&'static Encoding, usize)>
    {
        if buffer.starts_with(b"\xEF\xBB\xBF") {
            Some((UTF_8, 3))
        } else if buffer.starts_with(b"\xFF\xFE") {
            Some((UTF_16LE, 2))
        } else if buffer.starts_with(b"\xFE\xFF") {
            Some((UTF_16BE, 2))
        } else {
            None
        }
    }
}

由于它是个静态方法,因此不存在指向 self 的引用,在 FFI 函数中也没有相应的指针。该切片解构成一个指针和一个长度。长度变成 in/out 参数,用来返回切片刀长度,以及 BOM 的长度。编码变成返回值,编码指针为 null 表示 Rust 中的 tuple 为 None。

#[no_mangle]
pub unsafe extern "C" fn encoding_for_bom(buffer: *const u8,
                                          buffer_len: *mut usize)
                                          -> *const Encoding
{
    let buffer_slice =
        ::std::slice::from_raw_parts(buffer, *buffer_len);
    let (encoding, bom_length) =
        match Encoding::for_bom(buffer_slice) {
        Some((encoding, bom_length)) =>
            (encoding as *const Encoding, bom_length),
        None => (::std::ptr::null(), 0),
    };
    *buffer_len = bom_length;
    encoding
}

C 头文件中的签名如下:

ENCODING_RS_ENCODING const*
encoding_for_bom(uint8_t const* buffer, size_t* buffer_len)
;

C++ 层在 C API 上重建对应于 Rust API 的部分:

class Encoding final {
public:
    static inline std::optional<
        std::tuple<gsl::not_null<const Encoding*>, size_t>>
    for_bom(gsl::span<const uint8_t> buffer)
    {
        size_t len = buffer.size();
        const Encoding* encoding =
            encoding_for_bom(null_to_bogus(buffer.data()), &len);
        if (encoding) {
            return std::make_tuple(
                gsl::not_null<const Encoding*>(encoding), len);
        }
        return std::nullopt;
    }
};

这里我们必须显式使用 std::make_tuple,因为隐式构造函数在 std::tuple 嵌入到 std::optional 中时不能正确工作。

代数类型

之前,我们看到了 Rust 侧的流 API 可以返回这个 enum:

pub enum DecoderResult {
    InputEmpty,
    OutputFull,
    Malformed(u8, u8),
}

现在 C++ 也有了类似 Rust 的 enum 的东西:std::variant<Types...>。但在实践中,std::variant 很难用,因此,从优雅的角度来看,Rust 的 enum 本应是个轻量级的东西,所以没有道理使用 std::variant 代替。

首先,std::variant 中的变量是没有命名的。它们通过位置或类型来识别。命名变量曾经作为 lvariant(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0095r1.html)提议过,但并没有被接受。其次,即使允许重复的类型,使用它们也是不现实的。第三,并没有语言层面上相当于 Rust 的 match(https://doc.rust-lang.org/book/second-edition/ch06-02-match.html)的东西。曾经提议过的inspect(http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0095r1.html)相当于 match 的机制,但并未被接受。

在 FFI/C 层,上面 enum 的信息被打包到一个 u32 中。我们没有试图将它在 C++ 侧扩展成更漂亮的东西,而是简单地使用了与 C API 同样的 uint32_t。如果调用者需要在异常情况下从中提取出两个小的整数,那么调用者可以自己用位操作从 uint32_t 中提取。

FFI 代码如下:

pub const INPUT_EMPTY: u32 = 0;

pub const OUTPUT_FULL: u32 = 0xFFFFFFFF;

fn decoder_result_to_u32(result: DecoderResult) -> u32 {
    match result {
        DecoderResult::InputEmpty => INPUT_EMPTY,
        DecoderResult::OutputFull => OUTPUT_FULL,
        DecoderResult::Malformed(bad, good) =>
            (good as u32) << 8) | (bad as u32),
    }
}

使用零作为 INPUT_EMPTY 的魔术值是个微优化。在某些架构上,与零比较的代价要比与其他常量比较更低,而表示解码时的异常情况和无法映射的情况的值不会与零重叠。

通知整数溢出

Decoder 和 Encoder 拥有一些方法用于查询最坏情况下的缓冲区大小需求。 调用者提供输入的代码单元的数量,方法返回要保证相应的转换方法不会返回OutputFull 所需的最小缓冲区大小(以代码单元为单位)。

例如,将 UTF-16 编码成 UTF-8,最坏情况下等于乘以三。至少在原理上,这种计算可以导致整数溢出。在 Rust 中,整数溢出被认为是安全的,因为即使由于整数溢出而分配了太少的缓冲区,实际上访问缓冲区也会进行边界检查,所以整体的结果是安全的。但是,缓冲区访问在 C 或 C++ 中通常是没有边界检查的,所以 Rust 中的整数溢出可能会导致 C 或 C++ 中的内存不安全,如果溢出的计算结果被用来确定缓冲区分配和访问时的大小的话。对于 encoding_rs 而言,即使是 C 或 C++ 负责分配缓冲区,写入操作也是由 Rust 进行的, 所以也许是没问题的。但为了确信起见,encoding_rs 提供的最坏情况的计算也进行了溢出检查。

在 Rust 中,经过溢出检查的结果会返回 Option<usize>。为保持 C API 中类型的简单性,C API 会返回 size_t,并用 SIZE_MAX 通知溢出。因此,C API 实际上使用的是饱和算术(saturating arithmetic)。

在使用标准库类型的 C++ API 中,返回类型是 std::optional<size_t>。在 Gecko 中,我们使用了一个整数类型的包裹,提供溢出检查和有效性标志。在 Gecko 版本的 C++ API 中,返回值是 mozilla::CheckedInt<size_t>,这样处理溢出信号的方式与 Gecko 的其他部分一致。(边注:我发现 C++ 标准库依然没有提供类似 mozilla::CheckedInt 的包裹以进行整数运算中的溢出检查时感到非常震惊——这应该是标准就支持的避免未定义行为的方式。)


重建非流式 API


我们再来看看 Encoding 的非流式 API 中的方法:

impl Encoding {
    pub fn decode_without_bom_handling_and_without_replacement<'a>(
        &'
static self,
        bytes: &'a [u8],
    ) -> Option<Cow<'
a, str>>
}

返回类型 Option 中的类型是 Cow<'a, str>,这个类型的值或者是自己拥有的 String,或者是从别的地方借来的字符串切片(&'a str)。借来的字符串切片的生存时间'a 就是输入切片(bytes: &'a [u8])的生存时间,因为在借的情况下,输出实际上是从输入借来的。

将这种返回值映射到 C 中面临着问题。首先,C 不提供任何方式表示可能拥有也可能借的情况。其次,C 语言没有标准类型来保存堆上分配的字符串,从而知道字符串的长度和容量,从而能在字符串被修改时重新分配其缓冲区。也许可以建立一种新的 C 类型,其缓冲区由 Rust 的 String 负责管理,但这种类型就没办法兼容 C++ 的字符串了。第三,借来的 C 字符串切片在 C 语言中将会表现成原始的指针和一个长度,一些文档说这个指针仅在输入指针有效的时候才有效。因此并没有语言层面的机制来防止指针在释放之后被使用。

解决方案并不是完全不在 C 层面提供非流式 API。下 Rust 侧,非流式 API 只是个构建在流式 API 和一些验证函数(ASCII 验证、UTF-8 验证、ISO-2022-JP ASCII 状态验证)上的便利 API。

尽管 C++ 的类型系统能够表示与 Rust 的 Cow<'a, str> 相同的结构体,如std::variant<std::string_view, std::string>,但这种C++的Cow是不安全的,因为它的生存期限'a无法被 C++ 强制。尽管 std::string_view(或gsl::span)可以(在大多素情况下)在 C++ 中作为参数使用,但作为返回值类型,它会导致在释放后发生访问。与 C 一样,最好的情况就是有某个文档能说明只要输入的 gsl::span 有效,输出的 std::string_view 就有效。

为了避免发生释放后访问,我们在依然使用 C++17 的 C++ API 的版本中,简单地令 C++ 的 decode_without_bom_handling_and_without_replacement() 函数永远复制并返回一个 std::optional<std::string>。

但在 Gecko 的情况中,我们能够在保证安全的情况下做得更好。Gecko 使用了 XPCOM 字符串(https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Guide/Internal_strings),它提供了多种存储选项,特别是:从其他人那里(不安全地)借来的 dependent string,自动用将短字符串行内嵌入至行内缓冲区的 auto string,以及指向堆上分配的引用计数器缓冲区的 shared string。

如果要解码的缓冲区是个指向堆上分配的引用计数器缓冲区的 XPCOM 字符串,而且我们需要解码至 UTF-8(而不是 UTF-16),而在这种情况下本应该从 Rust 那里借(除非是删除 BOM 的情况),现在我们可以另输出字符串指向与输入相同的堆上分配的引用计数器缓冲区(并增加引用计数)。这正是 mozilla::Encoding 的非流式 API 做法。

与 Rust 相比,除了输入字符串必须使用引用计数存储以便复制能正确工作之外,还有另外一个限制:如果 BOM 被移除,那么输入不能有 UTF-8 BOM。虽然 Rust 可以从输入中借出不带 BOM 的那一段切片,但对于 XPCOM 字符串,增加引用计数的方式只有在输入和输输出的字节内容完全一致的情况下才能正确工作。如果省略掉开头的三个字节,它们就不是完全一致了。

虽然使用 C++17 标准库类型的 C++ API 中的非流式 API 是在 C++ 流式 API 的基础上构建的,但为了更多的安全性,mozilla::Encoding 的非流式 API 并不是基于流式 C++ API 构建的,而是在 Rust 语言的流式 Rust API 的基础上构建的(https://searchfox.org/mozilla-central/source/intl/encoding_glue/src/lib.rs)。在 Gecko 中,我们有 XPCOM 字符串的 Rust 绑定(https://searchfox.org/mozilla-central/source/servo/support/gecko/nsstring/src/lib.rs),所以可以从 Rust 中操作 XPCOM 字符串。


结语:我们真的需要用指针来引用 Decoder 和 Encoder 吗?


由于 C++ 没有安全的借用机制而导致必须在非流式 API 中进行复制之外,还有一点点令人失望的是,从 C++ 中实例化 Decoder 和 Encoder 需要进行堆分配操作,而 Rust 调用者是在栈上分配这些类型。我们能让 C++ 的使用者也避免堆分配操作吗?

答案是可以,但正确地实现这一点需要让 C++ 的构建系统查询 rustc 以构建常量,使得系统变得异常复杂。

我们不能跨越 FFI 直接用值的形式返回非 C 的结构体,但如果一个恰当地对齐的指针有足够多的内存,我们可以将非 C 的结构体写到由 FFI 的另一侧提供的内存中。实际上,API 支持这个功能,作为之前在堆上实例化新的 Decoder 的操作的一种优化措施:

#[no_mangle]
pub unsafe extern "C" fn encoding_new_decoder_into(
    encoding: *const Encoding,
    decoder: *mut Decoder)
{
    *decoder = (*encoding).new_decoder();
}

即使文档说 encoding_new_decoder_into() 应当旨在之前从 API 获得了 Decoder 的指针的情况下使用,对于 Decoder 的情况来说,使用 = 进行赋值操作应当不是问题,就算指针指向的内存地址没有初始化,因为 Decoder 并没有实现 Drop。用 C++ 的术语来说,Rust 中的 Decoder 没有析构函数,所以只要该指针之前指向合法的 Decoder,那么使用 = 进行赋值不会进行任何清理工作。

如果编写一个 Rust 结构体并实现 Drop 使之析构成未初始化的内存,那就应该使用 std::ptr::write() 代替 =。std::ptr::write() 能“用给定的值覆盖内存地址,而不会读取或放弃旧的值”。也许,上面的情况也能作为使用 std::ptr::write() 的很好的例子,尽管严格来说并不那么必要。

从 Rust 的 Box 中获得的指针能保证正确地对齐,并且指向足够大小的一片内存。如果 C++ 要分配栈内存供 Rust 代码写入,就要让 C++ 代码使用正确的大小和对齐。而从 Rust 向 C++ 传递这两个值的过程,就是整个代码变得不稳定的开始。

C++ 代码需要自己从结构体发现正确的大小和对齐。这两个值不能通过调用 FFI 函数获得,因为 C++ 必须在编译时就确定这两个值。大小和对齐并不是常量,因此不能手动写到头文件中。首先,每当 Rust 结构体改变时这两个值都会改变,因此直接写下来有可能过会导致它们不能适应 Rust 结构体改变后的真实需求。其次,这两个值在 32 位体系和 64 位体系上不一样。第三,也是最糟糕的一点,一个 32 位体系上的对齐值可能与另一个 32 位体系的对齐值不一样。具体来说,绝大多数目标体系上的 f64 的对齐值是 8,如 ARM、MIPS 和 PowerPC,而 x86 上的 f64 的对齐值是 4。如果 Rust 有 m68k 的移植(https://lists.llvm.org/pipermail/llvm-dev/2018-August/125325.html),那么有可能会使 32 位平台上的对齐值产生更多不确定性(https://bugzilla.mozilla.org/show_bug.cgi?id=1325771#c49)。

似乎唯一的正确方法就是,作为构建过程的一部分,从 rustc 中提取出正确的大小和对齐信息,然后再编译 C++ 代码,这样就可以将两个数字写入生成的 C++ 头文件中,供 C++ 代码参考。更简单的方法是让构建系统运行一小段Rust程序,利用 std::mem::size_of和std::mem:align_of 获取这两个数值并输出到 C++ 头文件中。这个方案假定构建和实际运行发生在同一个目标体系上,所以不能在交叉编译中使用。这一点可不太好。

我们需要从 rustc 中提取给定的结构体在特定体系下的大小和对齐值,但不能通过执行程序的方式。我们发现(https://blog.mozilla.org/nnethercote/2018/11/09/how-to-get-the-size-of-rust-types-with-zprint-type-sizes/)rustc有个命令行选项,-Zprint-type-sizes,能够输出类型的大小和对齐值。不幸的是,这个选项仅存在于每日构建版本上……不过不管怎样,最正确的方法还是让一个构架脚本首先用该选项调用 rustc,解析我们关心的大小和对齐,然后将它们作为常量写入 C++ 头文件总。

或者,由于“过对齐”(overalign)是允许的,我们可以信任结构体不会包含 SIMD 成员(对于128位向量来说对齐值为 16),因此对齐值永远为 8。我们还可以检查在 64 位平台上的对齐值,然后永远使用该值,希望其结果是正确的(特别是希望在 Rust 中结构体增长时,有人能记得更新给 C++ 看的大小)。但寄希望于有人记得什么事情,使用 Rust 就失去了意义。

不管怎样,假设常量 DECODER_SIZE和DECODER_ALIGNMENT 可以在 C++ 中使用,那么可以这样做:

class alignas(DECODER_ALIGNMENT) Decoder final
{
  friend class Encoding;
public:
  ~Decoder() {}
  Decoder(Decoder&&) = default;
private:
  unsigned char storage[DECODER_SIZE];
  Decoder() = default;
  Decoder(const Decoder&) = delete;
  Decoder& operator=(const Decoder&) = delete;
  // ...
};

其中:

  • 构造器 Decoder() 没有被标记为 delete,而是标记为 default,但仍然是 private。

  • Encoding 被定义为 friend,使上面的构造函数能够访问。

  • 添加了 public 的默认移动构造函数。

  • 添加了一个 private 字段,类型为 unsigned char[DECODER_SIZE]。

  • Decoder 本身定义为 alignas(DECODER_ALIGNMENT)。

  • operator delete 不再重载。

然后,Encoding 上的 new_decoder() 可以这样写(改名为 make_decoder 以避免在 C++ 中不寻常地使用“new”这个词):

class Encoding final
{

public:
  inline Decoder make_decoder() const
  
{
    Decoder decoder;
    encoding_new_decoder_into(this, &decoder);
    return decoder;
  }
  // ...
};

使用方法:

Decoder decoder = input_encoding->make_decoder();

注意在 Encoder 的实现之外试图定义 Decoder decoder;而不立即初始化会导致编译错误,因为 Decoder() 构造函数是私有的。

我们来分析发生了什么:

  • unsigned char 数组提供了 Rust Decoder 的存储。

  • C++ Decoder 没有基类、虚方法等,所以实现没有提供任何隐藏的成员,Decoder 的地址与它的 storage 成员的地址相同,因此可以简单地把 Decoder 自身的地址传递给 Rust。

  • unsigned char 的对齐为 1(即不限制),因此 Decoder 上的 alignas 需要确定对齐。

  • 默认的移动构造函数会 memmove Decoder 中的字节,而 Rust 的 Decoder 是可以移动的。

  • 私有的默认、无参数的构造函数,使得任何在 Encoder 之外对 C++ Decoder 只作定义而不立即初始化的行为导致编译错误。

  • 但是,Encoder 能实例化一个未初始化的 Decoder 并传递其指针给 Rust,这样 Rust 代码就能将 Rust 的 Decoder 示例写入 C++ 通过指针提供的内存中。

原文:https://hsivonen.fi/modern-cpp-in-rust/

作者:Henri Sivonen,Mozilla 的软件开发者,致力于网络层和底层,如HTML解析器、字符编码转换器等。

译者:弯月,责编:屠敏

推荐阅读:

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存