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
类型必须实现Add
和Copy
特征(trait)。
特征(traits)
Rust 中使用trait
作为我们在程序中与类型交互的方式。trait
是与实现trait
的类型相关联的一组属性、函数或者类型。例如
Add
是一个允许执行添加操作的接口。它表示一个类型具有“添加”到其他类型的特征。其定义大致如下:
pub trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
上面这个 trait 有两个属性:
- 关联类型
Output
,用于定义add
函数的返回类型; 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
必须是实现了Add
和Copy
traits 的类型。
类型替换(Type Substitution)
我们至此依然没有解释为什么 Rust 的示例比 C++的要冗长的多。现在对 traits 有了一定的了解,我们开始来了解类型替换,主要包含:
- 什么是类型替换
- 什么时候触发类型替换
- 什么情况下的类型替换失败会被视为错误
替换就是将泛型中的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::string
和std::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::vector
,std::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 中来统一处理。
评论