Ref: https://www.tangramvision.com/blog/c-rust-generics-and-specialization

泛型入门:输入的类型

C++和 Rust 中的泛型都是一种将其他类型作为其定义的一部分的类型。泛型是通过在类型定义中指定占位符的一种方式,然后可以 使用更具体的类型来替换,例如在 C++中可以这定义一个泛型类型:

template<typename T>
struct MyArray {
    T* raw_array;
    std::size_t size;
};

对于这个泛型结构而言,MyArray<int>MyArray<std::string>是不同的类型。我们可以通过指定具体的T类型,来复用MyArray这个泛型结构体。这里的MyArray<T>就像一个“模板”一样。 泛型不仅仅局限于结构体,我们同样也能写出泛型函数:

template<typename T>
T timestwo(T number) {
    return number + number;
}

上面我们定义了一个非常简单的函数,用来将数值加倍。同理,用具体类型实例化的timestwo<int>timestwo<double>也不是同一个函数。

而要在 Rust 中实现上面的函数,可能稍微复杂一点:

use std::ops::Add;

fn timestwo<T>(number: T) -> <T as Add>::Output
where
    T: Add + Copy,
{
    number + number
}

很显然,上面 Rust 版本和 C++版本的实现相比看上去多了很多额外的语法。其中主要的区别是我们调用了特征边界检查,也就是T: Add + Copy, 或者用更通俗的话来说,T类型必须实现AddCopy 特征(trait)。

特征(traits)

Rust 中使用trait作为我们在程序中与类型交互的方式。trait是与实现trait的类型相关联的一组属性、函数或者类型。例如 Add 是一个允许执行添加操作的接口。它表示一个类型具有“添加”到其他类型的特征。其定义大致如下:

pub trait Add<Rhs = Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

上面这个 trait 有两个属性:

  1. 关联类型Output,用于定义add函数的返回类型;
  2. add函数,将自身添加到rhs.

Rust 在编写泛型的时候用下面的方式来定义 traits 的实现,就像一开始我们提供的示例那样:

use std::ops::Add;

fn timestwo<T>(number: T) -> <T as Add>::Output
where
    T: Add + Copy,
{
    number + number;
}

where 语句用于限定,泛型参数T必须是实现了AddCopy traits 的类型。

类型替换(Type Substitution)

我们至此依然没有解释为什么 Rust 的示例比 C++的要冗长的多。现在对 traits 有了一定的了解,我们开始来了解类型替换,主要包含:

  1. 什么是类型替换
  2. 什么时候触发类型替换
  3. 什么情况下的类型替换失败会被视为错误

替换就是将泛型中的T占位符填充成实际类型的过程。当我们在 C++中表示timestwo<int>的时候,我们将模板类型参数T替换成实际类型int。 而 C++和 Rust 在泛型中的主要区别体现在上述 2 和 3 方面:什么时候触发类型替换和什么情况下的类型替换失败会被视为错误。

替换顺序和失败

在 C++中,替换发生在 function/struct等最终类型 check 之前。所以在我们前面的例子中,如果我们不引入任何替换,C++基本上不会关心 模板是什么或者我们如何使用模板。例如:

#include <iostream>

template<typename T>
T timestwo(T number) {
    return number + number;
}

int main(int argc, char* argv[]) {
    std::cout << "Hello world\n";
}

除非模板本身定义有语法问题,否则 C++不会关心timestwo是否对所有类型都有意义。直到发生了类型替换,才会做类型检查。所以将一些不相关的类型 插入到timestwo函数中可能也不会出现任何问题。

有趣的是,C++有时候也可以替换一些预期之外的类型,例如std::stringstd::filesystem::path都实现了operator+操作符,因此这些类型都允许 做加法操作(从技术上来说,这里的+是 append 的意思,而不是数字的求和)。这就意味着,timestwo对这些类型也有效,即使我们仅仅期望T为数字类型。

有时这会导致一些混乱,因为模板适用于不太合适的类型。正如上面的例子中看到的,Rust 中可以通过添加一些特征绑定类避免这种情况。这样我们就只能传递 数字类型。而在 C++中,如果不使用高级特性的话,很难实现这一点。

C++的示例只会在当我们使用一个错不支持operator+操作符的类型实例化timestwo模板的时候失败。即使其他所有类型对这个模板而言都是错误的,它只需要对于 正在使用这个模板的类型是正确的就行。

// Okay, int 支持 '+' 操作符
int a = 2;
int b = timestwo(a);

// Foo 没有实现 '+' 操作符
struct Foo {
    int a;
    float b;
};

Foo c = Foo{1, 2.0};

// 错误不会出现在这里,而是出现在`timestwo`的定义中
// 因为类型检查出现在泛型替换之后
Foo d = timestwo(c);

所以只要我们不在模板中使用不支持我们期望的特征的类型,C++编译器就不会有任何错误或异常提示。

与之相对,Rust 采取了截然不同的处理方式。类型检查发生在模板替换之前。这也就是说,我们的泛型必须对任何可以被替换的类型有效,然后才允许我们做模板类型替换。 这也就是为什么 Rust 的示例代码不能像 C++那样写:

// 会发生编译错误,因为这个模板不适用于所有的类型
fn timestwo<T>(number: T) -> T {
    number + number;
}

fn main() {
    println!("Hello world!");
}

如果我们按照上面的方式编写 Rust 代码,那么我们无法保证每一种可能的T类型都能够被添加到自身,因我我们无法知道number + number是否对所有类型都是合法的。 例如:timestwo<bool>就不是合法的,因为bool类型在 Rust 中不能做+操作。

这也就是 Rust 中使用 traits 的原因——通过在模板类型参数T上指定特征边界,我们限定了泛型需要具有的特征范围。所以尽管我们没有在 Rust 中使用timestwo<string>,但是如果timestwo的定义没有添加限定条件的话,它一样是非法的。

利与弊

前面我们详细描述了 Rust 和 C++泛型之间的主要区别,即 Rust 对模板的正确性有更加严格的保证,必须在模板定义的时候指定模板所适用类型的所有特征。 而 C++在定义模板的时候并不要求能够适用于所有类型,只是在模板实例化的时候才会做相应的检查。

这是一个很微妙的区别,但是它却能产生很大的影响。C++中的泛型不能保证适用于所有类型,也没有真正明确的方法来实现一个模板,一旦它被成功编译, 就能适用于任何类型,我们总是能用一些新的类型破坏模板。在 C++中,越是复杂的泛型,使用起来越是要小心。

相比之下,Rust 能够保证泛型的可持续构建,并且在构建的过程中,对可接受的类型都能良好工作。但是这也为我们编码带来了额外的负担——我们需要 保证我们使用到的所有的属性都在特征范围之内,否则编译器就会报错。如果我们需要大量的特征边界,那么这些特征边界将会变得很长,而且很难处理。 有时候,将看起来很容易理解的属性编码为 traits 的时候,实践起来却很不容易理解。

此外,C++的模板编译错误是发生在模板实例化的时候,而 Rust 在定义模板的时候就导入了所有的相关特征,因此编译器在生成特定的实例之前已经拥有了 类型检查定义所需要的所有信息。在 Rust 中,如果你尝试在泛型函数中使用特征边界未指定的功能,那么在泛型函数的主体中会抛出相应的错误。如果你 尝试将类型不符合泛型特征边界所允许的类型使用到泛型函数,那么会在错误信息中明确指明传入的类型缺少哪种特征。C++在模板实例化的时候生成错误 ,也就是说如果缺少输入类型的属性,将在函数模板的主体中显示错误。对于使用中的每种不正确的输入类型,都会出现错误。此外 C++模板在不同编译 单元中重复出现,因此在编译器输出中出现多次同样的模板替换错误也并不罕见。

当然,如果你对 C++非常熟悉的话,可能会觉得这点差异也无足轻重。

特化(Specialization)

C++和 Rust 之间的另一个很大的区别是泛型的特化。泛型特化就是我们定义模板针对某些特殊类型执行特殊逻辑的过程。在这种定义中,模板针对具体的 类型的定义与泛型共存。C++中一个典型的例子是std::vectorstd::vector<T>的内部实现行为与std::vector<bool>不同,甚至针对这一特化有专门的文档:https://en.cppreference.com/w/cpp/container/vector_bool

C++和 Rust 的差异让我们不得不去思考,在有特化的地方如何进行代码转换。在 C++中,是支持泛型特化的,但是这使得代码中某些类型的属性检查变得 更加复杂。相反,Rust 不支持特化。下面让我们通过一些例子来看看特化是如何实现的。

C++ 特化

在 C++中,我们通常会像下面这样定义一个通用的Image类型:

template<typename Pixel>
struct Image {
    std::vector<Pixel> pixels;
    std::size_t width;
    std::size_t height;
};

这个类适用于多种像素类型,特别是像单像素、RGB 像素、BGRA 像素等。但是如果我们想使用交织像素(例如 YUV422),其中多个像素值被分组在一起, 使得矢量像素的一个元素不一定表示一个像素,我们将很难直接使用上面的定义。相反,假如我们有一些像 YUV422 的表示形式 UYVY,我们可以在上面的 模板定义后附加下面的特化:

struct UYVY {};

template<>
struct Image<UYVY> {
    // U, V, and Y sub-pixels are just single bytes.
    //
    // So we store the whole interleaved buffer without transforming it
    // or changing from YUV422 to YUV444, or RGB8, or something else.
    //
    // Then, when we index into this vector (with a member function or
    // otherwise), we just need to remember the interleaved pattern but
    // _ONLY_ for this specialization.
    std::vector<unsigned char> pixels;
    std::size_t width;
    std::size_t height;
};

正如我们所看到的,在 C++中添加模板特化是很简单的。C++为泛型特化制定了一系列规则,在模板实例化时,尽可能选择最具体的定义。在上面的例子 中template<>template<T>更具体,因为它拥有更少的泛型类型。

而缺点是,我们需要为每个特例版本实现一套独立的逻辑,当然这个也很显然,特例就是通用的例外情况。但是如果特例定义很多的话,这将是一项艰巨的 工作。

Rust 特化

Rust 无法像 C++那样在编写代码时为泛型定义特化的实现。 回到上面的Image的例子,我们显然不能像 C++中的std::vector<bool>或者Image<UYVY>那样为特定的类型做 特定的实现。在 Rust 中,traits 允许我们根据某些接口对类型进行分组,并且允许我们具有不同特征边界的相同泛型。 因此,我们可以将像素分为两组:

  • 非交织的像素类型:RGB, BGRA 等
  • 交织的像素类型:UYVY, YUYV 等

我们可以像下面这样来改造我们的代码:

pub struct Rgb {
    r: u8,
    g: u8,
    b: u8,
}

pub struct Bgra {
    r: u8,
    g: u8,
    b: u8,
    a: u8,
}

pub struct Uyvy {}

pub trait NotInterleaved {}
impl NotInterleaved for Rgb;
impl NotInterleaved for Bgra;

pub trait Interleaved {}
impl Interleaved for Uyvy;

pub struct Image<Pixel>
where
    Pixel: NotInterleaved,
{
    pixels: Vec<Pixel>,
    width: usize,
    height: usize,
}

pub struct Image<Pixel>
where
    Pixel: Interleaved,
{
    pixels: Vec<u8>,
    width: usize,
    height: usize,
}

但是这样写代码并不是太理想,因为这些 traits 对我们来说没有太大的意义。我们每次给Image<P>添加某种功能,都要 指定是针对Interleaved还是NotInterleaved。这在某种程度上也丧失了使用泛型的优点。

那么就没有更加优雅的方式来改进吗?当然有!假如我们只是为了适配 RGB、BGRA 和 UYVY 像素类型,我们可以尝试像下面 这样使用 traits 和泛型来抽象我们的代码:

pub struct ContiguousPixelImage<Pixel> {
    pixels: Vec<Pixel>,
    width: usize,
    height: usize,
}

pub struct UyvyImage {
    pixels: Vec<u8>,
    width: usize,
    height: usize,
}

// Instead of trying to make a template that does everything, we make serveral
// separate types from a template and group them via a trait instead.
pub trait Image {
    // All image operations / types /functions in here
}

impl<P> Image for ContiguousPixelImage<P> {
    // ...
}

impl Image for UyvyImage {
    // ...
}

这里并没有将泛型指定为一个单一的类型,而是用不同的名字定义了不同的类型。我们依然需要为不同的像素类型提供 不同的定义,但是可以将通用的部分抽象到一个 trait 中来统一处理。