C++工程师的Rust迁移之路(4)- 继承与组合 - 中

在上一篇文章 zhuanlan.zhihu.com/p/75 中,我利用了一个介绍继承的经典例子罗列出来了C++和Rust达成同样的功能的方法。

在本文中,我将会更加系统的介绍C++和Rust中的语言特性,以及它们之间的优缺点。

多态

在编程语言和类型论中,多态(英语:polymorphism)指为不同数据类型的实体提供统一的接口。 多态类型(英语:polymorphic type)可以将自身所支持的操作套用到其它类型的值上。[1]

简单的说,就是我们可以对不同的类型,用统一的接口进行操作。

而根据派发(dispatch)发生的时间的不同,又分为了静态(编译时)多态,和动态(运行时)多态。

我们举个非常典型的例子,在几乎所有的语言中,我们都会存在流这样一种IO对象,用来操作流式数据。而在这些类型中,我们通常会提供read和write两个方法用以读写数据。

当我们需要写一个算法,希望适用于所有的IO流对象的话,我们有两个方式去做:

C++中的运行时多态 - 继承

class IStream {
public:
    virtual ~IStream() {}
public:
    virtual size_t read(std::uint8_t* buffer, size_t capacity) = 0;
    virtual size_t write(const std::uint8_t* buffer, size_t size) = 0;
};

class Console : public IStream { ... };
class FileStream : public IStream { ... };

void some_algorithm(IStream& stream) {
    std::uint8_t buffer[BUFFER_SIZE];
    size_t read = stream.read(buffer, sizeof(buffer));
    // do something
}

这就是典型的动态(运行时)多态的示例。它实现的方式是,在每个Console和FileStream的对象的开头加入了一个域(vptr),它指向了一张虚函数表。当使用对应的read和write方法的时候,会先找到这个虚函数表指针,再找到对应的函数指针,再进行对应的函数调用。

这里就引入了虚函数的开销,所以对于标榜零成本抽象的C++来说,这个代价很大,所以引入了另外一个方案:

C++中的编译时多态 - 模版与重载

class Console { ... };
class FileStream { ... };

template < class TStream >
void some_algorithm(TStream& stream) {
    std::uint8_t buffer[BUFFER_SIZE];
    size_t read = stream.read(buffer, sizeof(buffer));
    // do something
}

这里可以看到,由于在编译阶段模版展开的时候,直接通过重载连接到了对应的函数,派发(dispatch)是在编译阶段完成的,所以称为静态派发,这样就消除了虚函数的开销。

C++中的多态面临的问题

  1. 在使用静态派发时,由于完全依赖重载,当编写对应的代码时,很难保证你的类完整实现了调用代码的要求,再加上了深度模版的使用,导致出错信息非常难以阅读;为了解决这个问题C++标准委员会在C++ 20标准中加入了concepts的概念,它可以显式的提出约束,使用的例子可以参见上一篇文章 zhuanlan.zhihu.com/p/75,而更多的信息,大家可以参见cppreference[2]
  2. 在使用动态派发时,由于vptr存在,它会破坏对象本身的内存结构,当你的对象还需要与其他库(特别是C语言编写的库)进行交互的时候,内存结构就会称为一个显著的问题;
  3. 由于C++是一个非常成熟的语言,而concept又是在下一个标准中才会加入进来的概念,所以对于静态派发和动态派发的约束是完全不一样的语法,而且对于同样的约束,如果我们需要同时使用静态和动态派发的话,必须写两遍(一遍虚基类,一遍concepts)。

Rust的解决方案

对于上述提到的3个问题,在Rust中有一个统一的解决方案,那就是trait系统。

trait Stream {
    fn read(&mut self, buffer: &mut [u8]) -> usize;
    fn write(&mut self, buffer: &[u8]) -> usize;
}

struct Console;
struct FileStream;

impl Console { ... }
impl FileStream { ... }

impl Stream for Console {
   fn read(&mut self, buffer: &mut [u8]) -> usize { ... }
   ...
}

impl Stream for FileStream { ... }

fn some_algorithm_dynamic(stream: &mut dyn Stream) {
    let mut buffer = [0u8; BUFFER_SIZE];
    stream.read(&mut buffer);
    // do something
}

fn some_alogrithm_static<T : Stream>(stream: &mut T) {
    let mut buffer = [0u8; BUFFER_SIZE];
    stream.read(&mut buffer);
    // do something
}
完整的代码略长,大家可以直接去我准备好的Playground玩耍

对比C++的代码,我们可以看到:

  1. 对于静态派发,我可以直接使用T: Stream的形式提供约束,要求这个泛型函数的类型参数T实现了Stream这个trait;
  2. 在动态派发的时候,Rust选择了一个不一样的方式来实现。我们关注一下函数some_algorithm_dynamic函数的参数类型。它是&mut dyn Stream,表示它是一个Stream类型的可变trait object。与C++不同的是,在Rust中,虚函数表指针并没有置入对象的内存结构,而是将它作为trait object这个胖指针对象的字段传入(可以认为这个trait object是一个有2个字段的结构体,一个指向了对象,另一个指向了vtable);
  3. 在Rust中trait的语义统一了静态与动态派发两种需求;只需要一次申明,一次实现,即可根据你的需要实现静态与动态派发。

这是这个小系列的第二篇,在下一篇文章中,我会进一步阐释使用Rust中的trait实现静态派发的时候,与C++的concept有什么不同,以及各有什么优劣。

延伸阅读

上一篇

黄珏珅:C++工程师的Rust迁移之路(3)- 继承与组合 - 上zhuanlan.zhihu.com图标

下一篇

黄珏珅:C++工程师的Rust迁移之路(5)- 继承与组合 - 下zhuanlan.zhihu.com图标

参考

  1. ^多态 (计算机科学) https://zh.wikipedia.org/wiki/%E5%A4%9A%E5%9E%8B_(%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%A7%91%E5%AD%A6)
  2. ^制约与概念 https://zh.cppreference.com/w/cpp/language/constraints
编辑于 2019-08-15