C++工程师的Rust迁移之路

rust 专栏收录该内容
35 篇文章 1 订阅

C++工程师的Rust迁移之路(1)- 起步

Rust应该是最近最热门的几个语言之一。

它既有C++的零成本抽象能力;又跟C语言一样,贴近底层,内存布局一览无遗;但同时又没有这些语言的历史负担,具有现代语言非常优秀的表达和抽象能力;最重要的是,它从语言层面上实现了内存与线程安全。

本系列文章,是专门针对对Rust感兴趣的C++工程师的,主要介绍了完成相同的任务在C++和Rust中的异同。关于Rust设计上的优秀和特点,就不在本系列文章中集中解释了,大家可以在每一个对比细节中慢慢感受,看它是不是直击你的痛点。

Hello, World

C++

#include <iostream>
int main(int argc, char* argc[]) {
    std::cout<<"Hello, world"<<std::endl;
    return 0;
}

Rust

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

从第一个例子可以看到Rust的语法习惯对于从类C语言转过来的开发者还是很友好的。代码块仍然是大括号包围的,语句仍然是分号结尾的,当然主函数还是叫做main。

变量

C++

int number = 0;
const int const_number = -100;
number = const_number;

Rust

let mut number = 0;
let const_number = -100;
number = const_number;

这里可以看到,在Rust中,变量默认是不可变的,除非加了mut关键字。

基本数据类型

C++

bool boolean = true;
std::uint8_t u8 = 0;
std::int16_t i16 = 0;
std::size_t size = 0;
float real = 0;
double precise_real = 0;
char character = 'A';
const char* c_string = "Hello, world";
std::string string = "Hello, world";

Rust

let boolean: bool = true;
let uint8: u8 = 0;
let int16: i16 = 0;
let size: usize = 0;
let real: f32 = 0;
let precise_real: f64 = 0;
let character: char = ' ';
let str_ref: &str = "Hello, world  ";
let string: String = "Hello, world  ".to_owned();

这里可以看到Rust的数据类型系统跟C++还是有很大的不同:

  • 数值类型都是定长的类型,这样开发者在开发的时候可以明确的知道它在内存中占用的大小,以及它们的取值范围,避免犯错;
  • 字符类型是4字节的Unicode字面量(Scalar Value),可以完整的涵盖Unicode的所有字符集,所以可以看到我可以把emoji 赋值给一个字符变量;
  • 和C++的const char*类似的是&str类型,str类型实际上是无法在程序中定义的(因为它的长度是动态的),你只能使用它的引用类型(&str);它指向一个以UTF8编码存储的字符串(这样比较节约内存,又能完整覆盖unicode编码),但操作的时候,又是以char类型进行操作的,避免用户手工处理UTF8的过程中出现错误。(说句题外话,Rust的字符串库是所有语言中,对Unicode规范支持的最完善的,没有之一)。
  • Rust中的String类型实际上是std::string::String类型,它与C++的std::string类似,内部存储了字符串的拷贝,因此提供了操作字符串的内容,比如修改字符串等等。同样的,它内部的存储模式是UTF8,但操作的时候是以character为单位来操作的。

C++工程师的Rust迁移之路(2)- 类与结构体

在上文 https://zhuanlan.zhihu.com/p/75385189 中,我从一个Hello World的示例开始,简要介绍了一下C++和Rust在变量和基础数据类型之前的异同。

在本文中,我将在C++类和Rust的结构体之间做一个对比,顺便介绍Rust中的核心概念Trait。

类vs结构体

C++

class Rectangle {
public:
    Rectangle(float width, float height)
        : width_(width), height_(height)
    {}

public:
    float area() const {
        return width_ * height_;
    }

    void resize(width, height) {
        width_ = width;
        height_ = height;
    }

private:
    float width_, height_;
};

Rust

struct Rectangle {
    width: f32,
    height: f32
}

impl Rectangle {
    pub fn new(width: f32, height: f32) -> Rectangle {
        Rectangle {
            width: width,
            height: height
        }
    }
    
    pub fn area(&self) -> f32 {
        self.width * self.height
    }
    
    pub fn resize(&mut self, width: f32, height: f32) {
        self.width = width;
        self.height = height;
    }
}

对于C++开发者来说,有一件事情是众所周知的:struct和class本质上是一样的,唯一的区别就是struct的成员默认是public的,而class的成员默认是private的。

而对于Rust来说,它没有class的概念,只有struct的概念。而Rust中的struct的成员默认都是private的,除非加上pub关键词做修饰。

从语法上来说,可以看到Rust跟C++有一个很大的不同。在C++里面,往往我们的方法声明(对于模版类来说,甚至定义也是如此)是包含在class body这个大的语句块内的,而且对于顺序没有明确的要求。而在Rust里面,结构体的声明仅包含了它内部的数据结构,相关的实现是放在额外的impl语句块中的。这个变化看似稀松平常,但内含着巨大的好处,特别是类的逻辑比较复杂的时候。作为C++程序员,我们一定碰到过那种在一个类的头上和一个类的尾端,甚至是某些函数之间都声明了成员变量的情况。这就引入了一个风险,就是当你修改代码的时候,有可能会忘记给其中的某些变量赋值,这非常危险。而在Rust中,由于结构体的成员变量是与函数分开,并且集中在一起的,就大大降低了出现这种情况的概率。

在C++中,我们写一个类时,第一个要做的事情,就是定义它的构造函数;而反观Rust,它是没有构造函数这个概念的。上述代码中的new函数,如果对比C++的概念的话,相当于Rectangle类的一个静态方法,它的名字叫做new,接受2个f32类型的参数,返回值是一个Rectangle对象。这里有三点需要注意的:

  1. 构造Rectangle的语法。可以看到,构造Rectangle的时候,直接通过指定它的私有变量的值来实现了构造。而Rust的静态检查器是非常严格的,如果你忘记了构造其中的某个成员变量,它会直接在编译阶段报错,并阻止编译。所以,当你修改了结构体的结构以后,永远不用担心会在某处代码出现未初始化成员变量的bug,因为这样的情况通不过编译。
  2. 可以看到new函数中,并没有return语句。这是一个非常关键的点。在C++中代码块是一个语句(statement),它是不能作为右值的;而在Rust中,代码块是一个表达式(expression)。所以,在C++中常用的三目运算符([condition] ? [true_exp] : [false_exp])在Rust中并不存在,取而代之的是if表达式(if [condition] { [true_exp] } else { [false_exp] })。
  3. 另外,我们可以看到,在Rectangle {}的后面是没有分号的,这表示了它是一个表达式,而不是语句。当它不加分号时,这个代码块的类型是Rectangle,而加上了分号以后,它的类型就变成了(),也就是unit type,与函数声明的返回值Rectangle不符,编译出错。关于unit type的更多细节,在以后的文章中,我可以再做进一步的阐述。

我们再接着看area和resize两个方法,可以看到它们的第一个参数分别是&self, 和&mut self。这里其实也有两点需要注意的:

  1. 与C++一样,&表示引用,这里的&self其实是一个语法糖,相当于self: &Rectangle,而&mut self相当于self: &mut Rectangle
  2. 就像我在前文中所说,在Rust中,默认的行为是不可变的,除非加上mut关键词。那么在这里的&self,就相当于C++中的const方法,而&mut self相当于非const方法。

Playground

Rust语言的官网提供了一个Playground工具,可以供大家在无需安装Rust环境的前提下,试用Rust。

本文中的例子我也发布到了Rust Playground上,链接如下:

Rust Playground​play.rust-lang.org

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

  1. 修正了C++ 20中的concept语法

在上一篇文章 https://zhuanlan.zhihu.com/p/75734426 中,介绍了Rust中的结构体和C++中的类之间的异同。

在本文中,将会介绍一个Rust中的核心概念Trait,以及它和C++中的继承有何不同,各有什么优劣。

原本希望在一篇文章中说清楚这些概念,不过随着本文的撰写,发现内容比较多,所以将会分成2~3篇文章,本文是其中的第一篇。

在本文中,将会包含以下内容:

  • 从C++中讲述继承和多态的经典例子Bird继承自Animal入手
  • 再通过鸵鸟的例子发现这种继承关系的局限性
  • 再引入蝙蝠的例子发现上述改进方案的局限性
  • 再通过C++ 20的concepts特性来解决这些问题
  • 最后再对比Rust中,实现相同功能的例子

后续更深入的例子和分析,将会在后续的文章中进一步阐述。

继承

在每本C++的教材中,都会用下面这个经典的例子

class Animal {
protected:
    Animal(const std::string& name)
        : name_(name)
    {} 
public:
    virtual ~Animal() {}

public:
    virtual void eat() {
        std::cout<<name_<<" eats sth."<<std::endl;
    }

protected:
    std::string name_;
};

class Bird : public Animal {
protected:
    Bird(const std::string& name)
        : Animal(name)
    {}

public:
    virtual void fly() {
        std::cout<<name_<<" flys"<<std::endl;
    }
    virtual void tweet() {
        std::cout<<name_<<" tweets"<<std::endl;
    }
};

void eat_and_fly(Bird& bird) {
    bird.eat();
    bird.fly();
}

这看起来非常美好,可以看到Bird类复用了Animal类中的eat方法,同时又定义了fly和tweet方法作为Bird类的方法以便在子类中复用。

直到,我们引入了一个新的类:

鸵鸟(图片来自pxhere.com,CC0授权)

class Ostrich : public Bird {
public:
    Ostrich(const std::string& name)
        : Bird(name)
    {}

public:
    virtual void fly() {
        throw std::string("Urrr, Ostrich actually cannot fly");
    }
};

Ostrich ostrich;
eat_and_fly(ostrich); // program crash here

Oops, 我是一只不会飞的鸟,怎么破。

于是,我们可以加一层继承关系,区分开来会飞和不会飞的鸟:

class FlyableBird : public Bird {
public:
    virtual void fly() ...
};

void eat_and_fly(FlyableBird& bird) {
    bird.eat();
    bird.fly();
}

然而,显示的世界是复杂的。还有这么一种动物:

蝙蝠(图片来自于en.wikipedia.org,匿名,公共领域)

它不是鸟,可是它也会飞,也会吃东西,那么eat_and_fly理论上来说也应该能作用在蝙蝠身上。

于是,我们得写一个新的函数重载:

void eat_and_fly(FlyableMammal& animal) {
    animal.eat();
    animal.fly();
}
当你发现你在重复你自己的的时候,你应该引起警惕,是不是可以通过抽象,避免这样的重复。 - 我忘记从哪儿开来的了 orz

作为一个程序员,我们怎么能忍受重复我们自己呢?于是我们用模板改写了这样的代码:

template <typename T>
void eat_and_fly(T& animal) { ... }

但是总归会出现一些熊孩子,他们会试图把它用在鸵鸟身上,看看让鸵鸟飞是怎样一副图景,于是

eat_and_fly(ostrich); // cannot compile

编译失败。对于今天的例子来说,编译器给出的出错信息还比较简单,可以直接看到Ostrich类并不存在eat方法,而对于重度使用模板的代码来说,很容易看到的就是一堆长达数十行且难于阅读的模板出错信息[1]了。所以,为了解决这个问题(当然,不光光为了解决这个问题),在C++ 20中引入了一个新的概念,叫做concept[2]

template <typename T>
concept CanFly = requires(T a) {
    { a.fly() };
};

template <typename T>
concept CanEat = requires(T a) {
    { a.eat() };
};

template <typename T>
    requires CanFly<T> && CanEat<T>
void eat_and_fly(T& animal) { ... }
注:以上代码只有在GCC6之上的版本,并且加上了-fconcepts编译选项才能编译通过

代码中定义了2个Concept,分别是CanFly和CanEat,它们的要求是,这个类型的对象有一个对应的方法,返回值是void(没有返回值)。

然后在模版函数中,要求类型T既满足CanFly的concept,又满足CanEat的concept。

Trait

在进一步阐释C++中基于继承和模版(以及重载)实现的动态分发和静态分发(dynamic and static dispatch)有什么问题之前,我们可以看一下在Rust怎么实现上面的需求:

trait CanFly {
    fn fly(&mut self);
}

trait CanEat {
    fn eat(&mut self);
}

struct FlyableBird {
    name: String
}

impl FlyableBird {
    pub fn new(name: String) -> FlyableBird {
        FlyableBird { name: name }
    }
}

impl CanFly for FlyableBird {
    fn fly(&mut self) {
        println!("{} flys", &self.name);
    }
}

impl CanEat for FlyableBird {
    fn eat(&mut self) {
        println!("{} eats", &self.name);
    }
}

fn eat_and_fly<T : CanFly + CanEat>(sth: &mut T) {
    sth.eat();
    sth.fly();
}
该代码我也放到了Rust Playground中,位于 此处,大家通过浏览器直接测试和体验。
实际上上述代码中的mut关键字是可以都去掉的,不过为了保持跟C++代码行为的一致性,我都加上了mut关键字。

这里我们首先定义了2个trait,分别是CanFly和CanEat,它们的作用与上文的C++中的concepts类似(实际上有所不同,在下文中再做解释)。然后定义了一个结构体,名唤FlyableBird,它除了实现了自己的一个new函数外,还额外有2个impl块,分别为它实现了CanFly和CanEat trait。

而eat_and_fly函数是一个范型(generic,与C++中的template也有所不同[3])函数,它的范型参数是T,而T要满足同时实现了CanFly和CanEat两个trait的要求。

可以看到这段代码与C++中使用Concept有一些相似之处,比如它们都通过concept/trait定义了类型的行为,它们都可以对concept/trait进行组合。不过这两个概念并非等价的概念,各有所长,同时也各有局限。

具体的差异和优缺点,且听下回分解。

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

在上一篇文章 https://zhuanlan.zhihu.com/p/75755125 中,我利用了一个介绍继承的经典例子罗列出来了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的概念,它可以显式的提出约束,使用的例子可以参见上一篇文章 https://zhuanlan.zhihu.com/p/75755125,而更多的信息,大家可以参见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迁移之路(5)- 继承与组合 - 下

  1. 修正了C++ 20中的concept语法

在上一篇文章 https://zhuanlan.zhihu.com/p/76740667 中,我介绍多态、静态分发和动态分发的概念,以及他们各自在C++和Rust中的实现方式。

在本文中,我会重点讲Rust中的Trait实现的静态分发与C++ 20(准确的说,现在还叫做C++ 2a)中的concepts的区别。

在具体介绍这个区别之前,我想跟大家介绍一个概念,叫做duck typing(鸭子类型)。

鸭子类型

呃……你没有看错,这个鸭子就是你平常理解的那个鸭子,我也没有翻译错……

鸭子类型[1]是鸭子测试的一个应用:

如果它走起来像鸭子,也跟鸭子一样发出嘎嘎的叫声,那么它就是鸭子

听起来似乎非常无厘头,但这个模式实际上被广泛的应用于多种语言。

在C++中的应用

template <typename T>
concept Stream = requires(T a, std::uint8_t* mut_buffer, size_t size, const std::uint8_t* buffer)
{
    { a.read(mut_buffer, size) } -> std::convertible_to<size_t>;
    { a.write(buffer, size) } -> std::convertible_to<size_t>;
};

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

在Golang中的应用

type Stream interface {
    Read(uint32) []byte
    Write([]byte) uint32
}

type Console struct { ... }
type FileStream struct { ... }

func (c Console) Read(size uint32) []byte {
   ...
}

func (c Console) Write(data []byte) uint32 {
   ...
}

在上面的两个例子中,我们可以注意到,Console和FileStream这两个类型都没有显示的声明自己兼容Stream concept(interface),但在编译阶段,编译器可以根据他们实现的方法来判断他们支持Stream要求的操作,从而实现多态。

这个功能看似非常诱人,省去了显式声明的麻烦,但也带来了问题。

鸭子类型的局限性

程序员的造词能力通常是非常匮乏的(大家每次要给变量命名时的抓耳挠腮可以证明这一点),所以非常容易在方法名上重复,但在两个语境中又可能具有完全不同的语义。

举个例子:

template <typename T>
concept Thread = requires(T a, int signal) {
  { a.kill(signal) };
};

class DuckFlock {
public:
    void kill(int amount);
};

void nofity_thread(Thread& t) {
    t.kill(SIGUSR1);
}

原本我以为给鸭群发了一个信号,让它们打印一下状态,结果一不小心就杀掉了10只鸭子[2],真的只能召唤华农兄弟了。

Rust的设计

在Rust中,是不允许这种情况出现的,必须显式的生命类型实现的是哪个trait:

trait Thread {
  fn kill(&mut self, signal:i32);
}

trait Flock {
  fn kill(&mut self, amount:i32);
}

struct DuckFlock {
  ducks: i32
}

impl DuckFlock {
  pub fn new(amount: i32) -> DuckFlock {
    DuckFlock{ ducks: amount }
  }
}

impl Thread for DuckFlock {
  fn kill(&mut self, signal: i32) {
    if signal == 10 {
        println!("We have {} ducks", self.ducks);
    } else {
        println!("Unknown signal {}", signal);
    }
  }
}

impl Flock for DuckFlock {
  fn kill(&mut self, amount: i32) {
    self.ducks -= amount;
    println!("{} ducks killed", amount);
  }
}

fn main() {
  let mut flock = DuckFlock::new(100);
  
  {
      let thread:&mut Thread = &mut flock;
      thread.kill(10);
  }
  
  {
      let flock:&mut Flock = &mut flock;
      flock.kill(10);
  }
  
  {
      let thread:&mut Thread = &mut flock;
      thread.kill(10);
  }
}

同样的,这个例子我也放到Rust Playground,欢迎大家前去玩耍。

Markers

在Rust中,由于实现Trait必须要显式声明,这就衍生出了一种特殊类型的trait,它不包含任何的函数要求:

trait TonyFavorite {}
trait Food {
    fn name(&self) -> String;
}

struct PeikingDuck;

impl Food for PeikingDuck {
    fn name(&self) -> String {
        "Peiking Duck".to_owned()
    }
}

impl TonyFavorite for PeikingDuck {}

struct Liver;

impl Food for Liver {
    fn name(&self) -> String {
        "Liver".to_owned()
    }
}

fn eat<T: Food + TonyFavorite>(food: T) {
    println!("Tony only eat his favorite food like {}", food.name());
}

fn main() {
    eat(PeikingDuck);
    // eat(Liver); // compile error
}

这里例子的Playground在此

事实上,在Rust中,类似的Marker还有非常多,比如Copy、Sync、Send等等。在后续的文章中,再跟大家逐一解释这些trait的含义与妙用。

在下一节的文章中,我会介绍Rust类型系统和C++类型系统最大的不同之一:Rust结构体不能继承,以及为什么。敬请期待。

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

好久不见,最近工作比较忙,一直没空上来更新文章,给大家道个歉。

在上一篇文章 https://zhuanlan.zhihu.com/p/78333162 中,我重点介绍了Rust中的Trait机制和C++ 20中的concepts的区别。(说个题外话,现在除了GCC之外,在VC++的下一个release 16.3中,VC也加入了concepts的支持,真的是可喜可贺呀!)。

在本文中,我想重点探讨一下,为何Rust中没有继承,以及这个设计有什么优点和缺陷。

继承的两个功能

熟悉面向对象编程语言(比如C++,Java或者C#)的朋友们,一定对继承非常熟悉。在我的经验中,继承主要承担2个功能:

  1. 动态派发。(这一点通常被认为是面向对象的精髓)我们可以通过调用父类对象的方法,而执行由该对象实际类型的子类方法进行。从而实现运行时多态的目的。典型的应用场景就是定义一个Stream的基类,然后定义ConsoleStream,FileStream,TcpStream等等的子类继承自Stream,并override掉对应的虚函数。
  2. 代码复用。由于相似类型的对象具备一些相同的行为,通过为他们抽象出相同的父类以复用代码。这个需求的典型应用场景在GUI系统中。比如:
class Layoutable {
public:
   float x() const;
   void setX(float);
   float y() const;
   void setY(float);
   float width() const;
   void setWidth(float);
   float height() const;
   float setHeight(float);
private:
   float x_, y_, width_, height_;
};

class Widget : public Layoutable {
public:
    std::string name() const;
    void setName(const std::string&);
private:
    std::string name_;
};

class Label : public Widget {
public:
    std::string text() const;
    void setText(const std::string&);
private:
    std::string text_;
};

在之前的文章

黄珏珅:C++工程师的Rust迁移之路(4)- 继承与组合 - 中​zhuanlan.zhihu.com

中,介绍了Rust中依托Trait机制实现的动态分发功能,所以上文中的继承的第一个功能已经实现了。

而对于第二个功能,我们在下面的文章中做进一步的阐述。

耦合

大家在学习面向对象的时候,都学过一个原则,就是“高内聚、低耦合”。

对于高耦合的问题,相信大家都已经非常清楚,在这里我举一个很常见的例子来说明它的问题:

class GraphicsContext {
public:
    void setPenStyle(PenStyle penStyle);
    void setPenColor(Color color);

    void drawLine(Line line);
    void drawString(const std::string& text);
};

class Label {
public:
    void paint(GraphicsContext& gc) {
        gc.setPenStyle(PenStyle::Solid);
        gc.setPenColor(Color::Black);
        gc.drawLine(Line(0, 0, 100, 0));
        gc.drawString(text_);
   }
};

class Button {
public:
    void paint(GraphicsContext& gc) {
        gc.setPenStyle(PenStyle::Dash);
        gc.setPenColor(Color::Blue);
        gc.drawLine(Line(0, 0, 0, 20));
        gc.drawString(text_);
   }
};

这段代码是非常典型的GUI系统的代码。在这里Label和Button对象都与GraphicsContext深度的耦合到了一起。

假如我们的GUI系统增强了功能,GraphicsContext加入了一个新的功能:setLineWidth。而在某个控件中,调用了这个函数,设置了线宽。那么在它之后绘制的所有控件的外观都会发生变化。这就使得Label和Button这两个控件是否能够正确工作依赖于外部的GraphicsContext对象的状态,从而增加了Bug出现的风险。

所以,高耦合的风险可以用一句大白话说明白,就是“牵一发而动全身”,使得写出没有Bug的程序的难度大大增加。

注:个人认为,设计GUI绘图系统的最佳实践是WPF的设计,有兴趣的朋友可以去看一下。

继承是最深度的耦合

上面说了耦合的危害,而继承是最深度的耦合。因为在继承的关系中,父类将自己的实现暴露给了子类,而子类将不再面向接口,而是面向父类的实现编程。

在面向对象的领域,有一个本经典著作,当然就是“四人帮”的《设计模式》。在本书的开头就旗帜鲜明地提出“组合优于继承”,说的也是这个道理。

我们还是以GUI系统作为一个例子:

class Widget;  // 控件基类
class Input : public Widget { // 文本框类
    virtual onKeyPress(ScanCode key); // 实现基本的文本输入逻辑
    virtual onPaint(GraphicsContext& gc); // 实现文本框绘制逻辑
    std::string text_;
};
class DateInput : public Input { // 输入日期的文本框类
    virtual onKeyPress(ScanCode key); // 增加日期输入的检查
    virtual onPaint(GraphicsContext& gc); // 采用特殊的日期格式渲染文本
    Date date_;
};

在上述的继承关系中,一切都很自然。

然而,需求总是多变的。这个时候产品经理突然跟你说,要求给文本框增加一个功能,当文本框没有内容的时候,要在文本框中显示一个hint文本显示的功能的时候,就会导致它的行为对DateInput的行为发生影响,进而造成Bug。最可悲的是,这个Bug不是由DateInput的代码引入的,但是却需要通过修改DateInput的代码来修复,完全违背了面向对象中的封装性的特性。

其他语言的解决方案

在其他的语言中也发现了类似的问题,自然的也衍生出了一些解决方案。

比如Ruby中的mixin功能。

module Layoutable
    x = 0
    y = 0
    width = 0
    height = 0
    def Layoutable.move(x, y)
        Layoutable.x = x
        Layoutable.y = y
    end
    def Layoutable.resize(w, h)
        Layoutable.width = w
        Layoutable.height = h
    end
end

module WithText
...
end

class Input
    include Layoutable
    include WithText
    ..
end

class PictureBox
    include Layoutable
    ...
end

(Ruby现在已经没落到知乎的编辑器不支持ruby的代码显亮的程度了吗?)

这是非常典型的一个例子。从这里,我们看到类之前的继承关系消失了,取而代之是一种组合关系。通过module关键词来定义可复用的代码逻辑,再通过include关键字来复用预定义的逻辑,这就是非常典型的组合的设计。在这种设计中,如果我想影响全局的layout行为,那么直接修改Layoutable的代码即可;如果我想针对特定的控件做处理,只需要修改对应的class,而不用担心会对其他的控件产生影响。

Rust的方案

熟悉我前面的文章的朋友应该能看出来,Ruby作为动态语言,实际上是一种Duck-typing的设计。而Rust并没有采用这样的设计。

针对这个问题,Rust有几个不同层次的方案来满足代码复用的需求:

默认实现

trait Layoutable {
    fn position(&self) -> (f32,f32);
    fn size(&self) -> (f32,f32);
    fn set_position(&mut self, x: f32, y: f32);
    fn set_size(&mut self, width: f32, height: f32);
}

trait Dockable : Layoutable {
    fn dock_left(&mut self, parent: &dyn Layoutable) {
        let (width, _) = self.size();
        let (_, height) = parent.size();
        self.set_position(0f32, 0f32);
        self.set_size(width, height);
    }
}

#[derive(Copy, Clone, Debug)]
struct Widget {
   pos: (f32, f32),
   size: (f32, f32)
}

impl Widget {
    pub fn new(x: f32, y: f32, width: f32, height: f32) -> Widget {
        Widget {
            pos: (x, y),
            size: (width, height)
        }
    }
}

impl Layoutable for Widget {
    fn position(&self) -> (f32,f32) { self.pos }
    fn size(&self) -> (f32,f32) { self.size }
    fn set_position(&mut self, x: f32, y: f32) { self.pos = (x, y); }
    fn set_size(&mut self, width: f32, height: f32) { self.size = (width, height); }
}

impl Dockable for Widget {}

fn main() {
    let screen = Widget::new(0.0, 0.0, 1920.0, 1080.0);
    let mut window = Widget::new(100.0, 200.0, 50.0, 90.0);

    println!("Screen: {:?}", screen);
    println!("Window: {:?}", window);
    window.dock_left(&screen);
    println!("Docked Window: {:?}", window);
}

按照惯例,我也将这个代码放到了Rust playground中:https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=b3f0761a083cb322effbc41b8388f35a

这里的重点是在Dockable这个trait的定义上。这里有2点需要特别注意的:

  1. 这个定义方式与C++中继承的语法非常相似,但是它的语义是完全不同的。这里的意思是,如果一个结构体实现了Dockable这个trait,那么它必须同时实现Layoutable这个trait。其实是一种依赖关系,而非继承的关系。
  2. 这个trait中的dock_left函数是包含函数体的。这个称为trait中的函数的默认实现。所以我们看39行,为Widget结构体实现Dockable trait时,是无需定义dock_left这个函数的实现的。

在实际使用的过程中,如果有的结构体需要定制自己的实现,也是可以覆盖默认实现的:

// ...
#[derive(Copy, Clone, Debug)]
struct MarginWidget {
    pos: (f32, f32),
    size: (f32, f32),
    margin: f32
}

impl MarginWidget { /* ... */ }

impl Layoutable for MarginWidget { /* ... */ }

impl Dockable for MarginWidget {
    fn dock_left(&mut self, parent: &dyn Layoutable) {
        let (width, _) = self.size();
        let (_, height) = parent.size();
        self.set_position(self.margin, self.margin);
        self.set_size(width, height - 2f32 * self.margin);
    }
}

fn main() {
    let screen = Widget::new(0.0, 0.0, 1920.0, 1080.0);
    let mut window = MarginWidget::new(100.0, 200.0, 50.0, 90.0, 8.0);
    // ...
}

对应的playground位于:https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=b926bdee1f63a72939d88c6fc9c2172b

从代码中,我们看到,Widget和MarginWidget的Layoutable trait的实现我们写了2遍,这是一个copy代码的过程,对于专业的软件开发工作来说,是非常危险而且不优雅的。针对这个问题,Rust有一个解决方案:

宏(Macro)

熟悉C/C++的朋友看到宏这个词的时候一定会“虎躯一震”。几乎所有的编码规范都要求我们尽量避免使用宏。

其根本原因在于宏展开后,它会污染展开处的语法作用域(同时也会受到对应语法作用域的影响),很难保证展开后的宏还能保持正确性。比如:

#define TIMES5(v) (v * 5)
 
int main(int argc, char* argv[]) {
    std::cout<<TIMES5(2)<<std::endl; // good
    std::cout<<TIMES5(1 + 2)<<std::endl; // wrong
    return 0;
}

但是Rust中的宏略有不同,它的宏是在独立的语法作用域的,举个例子:

macro_rules! times5 {
    ($e: expr) => {
        $e * 5
    };
}

fn main() {
    println!("{}", times5!(2));
    println!("{}", times5!(1 + 2));
}

这个结果是正确的,Playground在此:https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=cbb82366fb333294508fe2a85e5dcbd3

所以对应的,我们可以将我们的代码改成这样:

// ...
macro_rules! impl_layoutable {
    ($e: ty) => {
        impl Layoutable for $e {
            fn position(&self) -> (f32,f32) { self.pos }
            fn size(&self) -> (f32,f32) { self.size }
            fn set_position(&mut self, x: f32, y: f32) { self.pos = (x, y); }
            fn set_size(&mut self, width: f32, height: f32) { self.size = (width, height); }
        }
    };
}

// ...
impl_layoutable!(Widget);

// ...
impl_layoutable!(MarginWidget);

// ...

这里定义了一个宏impl_layoutable,然后使用这个宏为Widget和MarginWidget实现了Layoutable这个trait的功能。

这个Playground在此: https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=941b3d3d428d2cbe38994456fbeb66f0

Custom Derive

然而这么写终归是不太优雅的。所以Rust提供了Custom Derive这个机制。

如果你认真的阅读了上面的代码的话,会看到在结构体申明的上面,有一行额外的内容:

#[derive(Copy, Clone, Debug)]
struct Widget { /* ... */ }

这一行的意思是,让编译器为这个结构体自动生成Copy, Clone和Debug这3个trait的实现。而Custom Derive的意思就是,可以自己定义一种Derive的类型。

Custom Derive相对来说比较复杂,不容易三言两语讲清楚,所以在后续的文章中,我会另行行文介绍。

对于上述例子使用Custom Derive的实现,大家可以看我放在GitHub的例子:

https://github.com/cnwzhjs/cpp_

C++工程师的Rust迁移之路(7)- 生命周期 - 上

在之前的四篇文章中,主要深入了Rust中一些抽象机制,以及跟C++的区别。也讨论了一下Rust的模式跟C++的模式之前的异同。

熟悉Rust的朋友(以及听说过Rust学习曲线陡峭的朋友)可能都着急了,为啥都6篇文章了,还没有讲到生命周期,这是Rust最著名(可能也是最“臭名昭著”)的特性了。甚至于有了这样的段子:

小李是一名资深的 Rust 工程师,今天他上班只花了一小时就完成了需求的开发。
然后连续加班了三个晚上才使得编译通过。

当然,通常结果是好的。通常编译通过的Rust代码,Bug是非常少的。

其实选择“晚一点”聊这个话题,我是有意为之的。主要是考虑到两点:

其一,这个概念是大家学习过的其他的语言中并不显式存在的,所以突然聊这个话题不容易理解;

其二,当然是想把大家先“骗”到这个专题里面,别让这个相对“难”一些的话题把各位看官都吓跑了。(LoL)

C++的引用

前面我说过Rust语言和它的团队是非常实用主义的。这个概念的出现,一定是为了解决系统级编程中容易犯的错误的。

所以,我们先从C++的引用开始聊起,看看Rust的这个语言特性要解决的问题,然后再来聊Rust的语法和机制。

而关于C++的引用,我们又得从C语言说起:

struct Option {
  int some_int;
  char some_string[100];
};

struct Service {
  Option* option;
};

void init(Service* svc, Option* option) {
  svc->option = option;
}

这是很典型的一段C语言的代码。当然,作为一个经验丰富的软件工程师,你一定能看到在这里其实有2个很大的风险:

  1. 这里svc指针有可能是一个空指针;
  2. 这里的option指针也可能是一个空指针;

这样,就很容易导致C语言写的软件中常见的Segment Fault的错误了(当然,在windows下叫“非法操作”)。

所以C++为了解决这个问题,就引入了一个引用的概念,C++的版本就是这样的:

struct Option {
  int some_int;
  std::string some_string;
};

class Service {
public:
  Service(const Option& opt) : option_(opt) {}
  ~Service();
private:
  const Option& option_;
};

这样就避免了出现引用空指针的问题。

这个问题其实在Java、C#等语言中也是一个很常见的错误。相信大家都碰到过NullPointerException的错误吧。所以C#在新的版本中加入了nullable reference types,Java则是在Java 14中给NullPointerException加入了更容易阅读的错误信息。

然而,这里还是有一个风险:

std::shared_ptr<Service> service;

static void create_service() {
  Option option;
  service = std::make_shared<Service>(option);
}

int main(int argc, char* argv[]) {
  create_service();
  service->do_something();
}

这个代码是很常见的一个C++代码中的Bug,这里我们看到option变量是create_service的一个本地变量,是分配在栈上的。但create_service返回到main函数里面以后option对象已经销毁了。这就会导致了service持有一个已经销毁的Option对象的引用,导致了内存错误。

而这里本质的问题在于,option对象的生存时间要短于service对象的生存时间,从而导致这个对象可能在销毁后被使用。这也就造成了大量的内存访问的bug。

Rust的解决方案

Rust的作者发现了这个问题,痛定思痛,想出了一个解决方案,就是从编译器层面增加生命周期的检查,保证无法写出这样的Bug(当然,在Rust中,使用unsafe关键词还是可以为所欲为的):

struct Option {
  pub some_int : i32,
  pub some_string : String
}

impl Option {
  pub fn new<T: Into<String>>(some_int : i32, some_string : T) -> Option {
    Option { some_int: some_int, some_string: some_string.into() }
  }
}

struct Service {
  option: &Option  // 编译错误 “expected lifetime parameter”
}

上面的代码,我是照着C++的版本“直译”的。可以看到这里编译器直接就报错了,因为编译器无法判断这里的option的生命周期长于Service的生命周期。

所以,为了解决这个问题,我们需要给它写上生命周期函数:

struct Service<'a> {
  option: &'a Option
}

impl<'a> Service<'a> {
  pub fn new(option: &'a Option) -> Service {
    Service { option: option }
  }
  
  pub fn name(&self) -> String {
    self.option.some_string.clone()
  }
}

这里可以看到,定义Service结构体的时候,增加了一个生命周期参数<'a>。对应的,在它的字段option的声明的时候,把这个生命周期参数给了&Option这个引用类型。它的意思是这里引用的Option的生命周期必须要长于Service对象的生命周期。

于是我们接着往下写:

fn create_service() -> Service { // 编译错误:missing lifetime specifier
  let opt = Option::new(1, "hello");
  Service::new(&opt);
}

这里直接就编译错误了,原因是Service结构体声明的时候需要一个生命周期参数,而这里的代码没有给出生命周期参数。所以,只有保证了生命周期的正确,编译才能通过,进而保证了不出现类似的Bug。

修改的代码如下:

fn main() {
  //let service = create_service();
  let opt = Option::new(1, "hello");
  let svc = Service::new(&opt);
  println!("Hello, {}", svc.name());
}

完整的代码,按照惯例我准备了Rust Playground,大家可以前去玩耍:

Rust Playground​play.rust-lang.org

C++工程师的Rust迁移之路(8)- 和类型与积类型

这段时间因为工作比较忙,后来又因为疫情的原因,当然也要加上懒癌发作,导致接近半年的时间没有来更新文章了,这里向各位看官老爷们鞠躬道歉。

对不起!对不起!对不起!


好。现在进入正题。在上周日(2020年3月29日)的晚上 

@张汉东

 大大收上海科技大学的邀请,做了一期Zoom直播讲座(录像跳转)。在直播的过程中,大家对于和类型和积类型的概念非常感兴趣。

我觉得这个话题挺有意思的,非常有的一聊,所以也就治好了懒癌,来聊聊和类型和积类型的话题。

编程语言中数据类型的数学本质

先说结论:数据类型本质上就是数学中的集合

我们先来回顾一下高等数学(或者数学分析)里面的集合的概念。(已经忘光的同学,可以回去看B站上复旦大学陈纪修老师的数分课程)(作为交大毕业生推荐五角场某高校的网课是不是XX不正确……)。

在高等数学中,有个集合,我们是非常常用的:

 

集合的运算

我们再回顾一下数学中的集合的两个运算:

集合的并运算

Rust中的和类型与积类型

在大多数常见的计算机语言中,复合数据类型都是积类型。

比如结构体:

enum Color {
  Red,
  Green,
  Blue
}

struct Option {
  pub some_i8 : i8,
  pub some_color : Color
}

let some_option = Option {
  some_i8 = 0i8,
  some_color = Color::Red
};

那么,它的值的可能性就是:i8的所有可能性(-128~127共256种)乘以Color的所有可能性(共3种),一共768种。


那么这个和类型有什么作用呢,我能不能等价于C++中的枚举+union呢?比如:

enum class Color {
  Red,
  Green,
  Blue
};

enum class OptionType {
  SomeI8,
  SomeColor
};

struct Option {
  OptionType type;
  union {
    std::int8_t i8;
    Color color;
  } value;
};

简单的说,不能。我们举个典型的例子:

#include <string>
#include <vector>

struct Option {
  OptionType type;
  union {
    std::string str;
    std::vector<int> vec;
  }
};

Option some_var;

在上面的代码里,当some_var离开生命周期,被析构的时候,编译器应该调用std::string的析构函数呢,还是应该调用std::vector<int>的析构函数?

所以这样的写法在C++中式不安全的,属于UB。

而在Rust的Enum中,编译器会自动生成根据该Enum变量的取值来调用对应的析构函数。这样的设计和结构体的对比可以带来至少三个好处:

  1. 更小的内存消耗(对象的内存占用以最大的那个枚举项的类型为界,而不是将所有对象需要的内存加总起来);
  2. 更加安全(不会出现非法的枚举类型的值,也不会出现对应对象是非法的可能性(比如type填了color,但是color字段却没有赋值))
  3. 更小的运行时开销(只会调用一个成员的析构函数)

所以,在Rust中,这种用法是非常普遍的:

enum Result<R, E> {
  Ok(R),
  Err(E)
}

enum Option<T> {
  Some(T),
  None
}

Result这个泛型类型通常用在函数的返回值上,当发生错误时,返回Err(E) (E是错误的数据类型),当正常时,返回Ok(R) (R是结果的类型)。

而Option这个泛型类型通常用来表达nullable的语义。比如返回一个空对象时,则用Option::None, 而对象存在,则用Option::Some(T)。

C++工程师的Rust迁移之路(9)- const generics(上)

前两天我发了一条朋友圈,内容是:“第一次看到一个warning那么开心 ”,配图是这个:

在2020年的最后一天,Rust发布了1.49版本,按照正常的节奏,const generics的一部分feature将会在12周,也就是大约3个月内进入stable了。

那么什么是const generics,它有什么作用呢?

array

我们先来看一段代码:

fn main() {
    let v1 = [1u32; 32];
    let v2 = [2u32; 33];

    println!("{:?}", v1); // a
    println!("{:?}", v2); // b
//                   ^^ `[u32; 33]` cannot be formatted using `{:?}` because it doesn't implement `std::fmt::Debug`
}

如果我们使用的编译器是1.37及以前的版本,这段代码a处是可以正常编译通过的,但是b处是无法编译通过的。

这里的秘密藏在libcore中的array模块的源代码里面:

// https://github.com/rust-lang/rust/blob/1.37.0/src/libcore/array.rs : 116
macro_rules! array_impls {
    ($($N:expr)+) => {
        $(
            // ...
            #[stable(feature = "rust1", since = "1.0.0")]
            impl<T: fmt::Debug> fmt::Debug for [T; $N] {
                fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                    fmt::Debug::fmt(&&self[..], f)
                }
            }
            // ...
        )+
    }
}
array_impls! {
     0  1  2  3  4  5  6  7  8  9
    10 11 12 13 14 15 16 17 18 19
    20 21 22 23 24 25 26 27 28 29
    30 31 32
}

我们可以看到,为了给array实现Debug,实际上是通过声明宏生成了32份impl Debug for [T; N]。

而考虑到控制生成的rlib和二进制的大小,Rust默认只实现了1~32个元素的Rust的这些trait。

使用const generic实现

但是,从1.47开始,就没有这个问题了,那么这是怎么做到的呢,我们看1.47的同样的实现:

// https://github.com/rust-lang/rust/blob/1.47.0/library/core/src/array/mod.rs : 170
#[stable(feature = "rust1", since = "1.0.0")]
impl<T: fmt::Debug, const N: usize> fmt::Debug for [T; N] {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Debug::fmt(&&self[..], f)
    }
}

可以注意到,这里的第二个泛型参数的类型是usize(注意不是usize作为泛型参数,而是泛型参数本身的类型就是usize)。这样就彻底解决了这个问题。

那么1.38~1.46之间是怎么样的呢?代码如下

// https://github.com/rust-lang/rust/blob/master/library/core/src/array/mod.rs : 186
#[stable(feature = "rust1", since = "1.0.0")]
impl<T: fmt::Debug, const N: usize> fmt::Debug for [T; N]
where
    [T; N]: LengthAtMost32,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Debug::fmt(&&self[..], f)
    }
}

可以看到,虽然系统库本身已经修改了,但是还是加了一个LengthAtMost32,估计是因为当时rlib的格式和rustc的实现还不够完善,所以没有全部实现。

不过这是题外话了,按下不表。

Matrix

那么是不是只有系统库会用到呢?

其实实际工作中会经常使用到这个功能,比如为数组实现自己的Trait。

特别是写线性代数的库的时候,将会非常有用:

 

use std::{
    fmt::Debug,
    ops::{Add, Mul},
};

#[derive(Copy, Clone, Debug)]
struct Matrix<T: Copy + Debug, const N: usize, const M: usize>([[T; M]; N]);

impl<T: Copy + Debug, const N: usize, const M: usize> Matrix<T, N, M> {
    pub fn new(v: [[T; M]; N]) -> Self {
        Self(v)
    }

    pub fn with_all(v: T) -> Self {
        Self([[v; M]; N])
    }
}

impl<T: Copy + Default + Debug, const N: usize, const M: usize> Default for Matrix<T, N, M> {
    fn default() -> Self {
        Self::with_all(Default::default())
    }
}

impl<T, const N: usize, const M: usize, const L: usize> Mul<Matrix<T, M, L>> for Matrix<T, N, M>
where
    T: Copy + Default + Add<T, Output = T> + Mul<T, Output = T> + Debug,
{
    type Output = Matrix<T, N, L>;

    fn mul(self, rhs: Matrix<T, M, L>) -> Self::Output {
        let mut out: Self::Output = Default::default();

        for r in 0..N {
            for c in 0..M {
                for l in 0..L {
                    out.0[r][l] = out.0[r][l] + self.0[r][c] * rhs.0[c][l];
                }
            }
        }

        out
    }
}

type Vector<T, const N: usize> = Matrix<T, N, 1usize>;

fn main() {
    let m = Matrix::new([[1f64, 0f64, 0f64], [1f64, 2f64, 0f64], [1f64, 2f64, 3f64]]);
    let v = Vector::new([[10f64], [20f64], [40f64]]);

    println!("{:?} * {:?} = {:?}", m, v, m * v);
}

C++工程师的Rust迁移之路(10)- 引用与指针(上) 

今天在群聊里面有一位朋友问了一个很有趣的问题:

let arr = [1i32, 2i32, 3i32];
let i = arr.iter();
assert_eq!(i.nth(1), Some(&2)); // 1

上述代码块中为何是Some(&2),而不是Some(2)。

直接的回答

对于这个问题,其实比较直接的回答是这样的:

arr的类型

这里arr的类型是一个数组,它的类型是[i32; 3];

i的类型

这里i的类型来自于数组的iter()的返回值,它的定义在core库中slice模块的mod.rs中

#[stable(feature = "rust1", since = "1.0.0")]
#[inline]
pub fn iter(&self) -> Iter<'_, T> {
    Iter::new(self)
}

所以它的类型其实是 core::slice::iter::Iter<'_, T>。

而Iter的实现关键在于core::slice::iter::macros::iterator!这个宏:

这个宏的定义在这里

macro_rules! iterator {
    (
        struct $name:ident -> $ptr:ty,
        $elem:ty,
        $raw_mut:tt,
        {$( $mut_:tt )?},
        {$($extra:tt)*}
    ) => {

        // ...

        #[stable(feature = "rust1", since = "1.0.0")]
        impl<'a, T> Iterator for $name<'a, T> {
            type Item = $elem;

            #[inline]
            fn next(&mut self) -> Option<$elem> {
                // could be implemented with slices, but this avoids bounds checks

                // SAFETY: `assume` calls are safe since a slice's start pointer
                // must be non-null, and slices over non-ZSTs must also have a
                // non-null end pointer. The call to `next_unchecked!` is safe
                // since we check if the iterator is empty first.
                unsafe {
                    assume(!self.ptr.as_ptr().is_null());
                    if mem::size_of::<T>() != 0 {
                        assume(!self.end.is_null());
                    }
                    if is_empty!(self) {
                        None
                    } else {
                        Some(next_unchecked!(self))
                    }
                }
            }

            //...
        }
}

而调用这个宏的地方位于slice的iter模块中:

iterator! {struct Iter -> *const T, &'a T, const, {/* no mut */}, {
    fn is_sorted_by<F>(self, mut compare: F) -> bool
    where
        Self: Sized,
        F: FnMut(&Self::Item, &Self::Item) -> Option<Ordering>,
    {
        self.as_slice().windows(2).all(|w| {
            compare(&&w[0], &&w[1]).map(|o| o != Ordering::Greater).unwrap_or(false)
        })
    }
}}

i.nth(usize)的类型

所以,上述i的类型其实是std::slice::iter::Iter<i32>,而它实现了std::iter::Iterator<Item=&i32>[1]这个trait。我们再来看Iterator中nth的定义

#[inline]
#[stable(feature = "rust1", since = "1.0.0")]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
    self.advance_by(n).ok()?;
    self.next()
}

所以对应的,i.nth(usize)的返回值的类型是Option<&i32>。

小结

那么自然的,要判断它是否相等,右边的值自然应该是Some(&2) (类型是Option<&i32>),而非Some(2) (类型是 Option<i32>)。

好死不死

但是呢,在群里回答这个问题的时候,我好死不死,多说了一句:

“引用不是指针,比较的是值不是地址”。

这个说法就引发了群里的讨论,群友特别指出在汉东大大的《Rust编程之道》中说了引用(reference)是一个简单指针,而切片(slice)是一个胖指针。为何会有我上面说的这个语义的差异呢?

这里先引用一下编程之道中的原文(位于第5章第4节):

“引用(Reference)是Rust体统的一种指针语义。引用是基于指针的实现,它与指针的区别是,指针保存的是其指向内存的地址,而引用可以看作某块内存的别名(Alias),使用它需要满足编译器的各种安全检查规则。”

引用和指针之间到底有什么区别和联系?

一句话概括就是:两者的底层都是基于指针实现的,区别在于两者表达的语义前者是别名,后者是指针。

听起来这是一句废话,并不能加深大家的理解。我们知道Rust中很多机制的设计是来源于C++的。而C++也恰好也有引用和指针,那么我们先看一下C++中引用和指针的区别:

C++中的指针和引用

struct MyInteger {
  int v;
};

// 1
static bool opeartor==(const MyInteger& a, const MyInteger& b) {
  return a.v == b.v;
}

// 2
auto a = MyInteger { 10 };
auto b = MyInteger { 10 };

// 3
MyInteger &ra = a;
MyInteger &rb = b;

MyInteger *pa = &a;
MyInteger *pb = &b;

//4
assert(ra == rb);
assert(pa != pb);

在1处,我们定义了一个==运算符的重载,它通过比较两个MyInteger对象的内容来确定两者是否相等;

在2处,我们定义了2个MyInteger对象;

在3处,我们定义了2个MyInteger的引用和指针,分别指向a和b;

在4处,我们分别比较了两个引用,和两个指针,我们可以看到它们的行为是有差异的。

所以,我们再来看上面所说的:

两者的底层都是基于指针实现的,区别在于两者表达的语义前者是别名,后者是指针。

也就是说:

  1. 对于引用的操作,在语义上相当于对原变量的操作,也就是说“引用是变量的别名”
  2. 对于指针的操作,在语义上是对指针变量本身的操作,除非使用了解引用运算符将它转换成引用;

在上述的讨论中,主要得到了几个结论:

  1. (&[T]).iter() 的返回值是 impl Iterator<&T>
  2. nth(usize)的返回值是 Option<&T>
  3. 引用是“变量的别名”
  4. 对指针的操作,在语义上是对指针变量本身的操作

在下一篇文章中,我会再深入的分析一下C++和Rust中的指针和引用有什么不同。Rust的设计反应了设计者怎样的设计哲学。敬请期待。

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

A Guide to Porting C C++ to Rust.epub Build a Node.js Project from Scratch.epub Build your applications with Webpack.epub Build Your Own Lisp 中文版.epub C 语言进阶.epub Ceph 运维手册.epub Chromium中文文档.epub Databricks Spark 知识库.epub devops-collective-inc PowerShell.7z Docker Swarm 源码分析.epub Docker简明教程.epub FreeBSD 101 Hacks.epub Golang 101 hacks.epub Hadoop The Definitive Guide 4e 中文版.epub InfluxDB简明手册.epub Internet Of Things 101 Intel® Edison.epub introduction-to-touchdesigner.epub Istio官方文档中文版.epub Java NIO简明教程.epub Java面试问题.epub Kivy中文编程指南.epub kungeekUI.epub laravel 源码解析.epub Learning Rust.epub Little ASP.NET Core Book.epub MongoDB入门指南.epub OpenStack最终用户文档(简体中文版).epub PHP 5.5 Web零基础教程:开发一个在线阅读网站.epub PHP 扩展开发入门.epub Python 3 Basics Tutorial.epub Python 3 Module Examples.epub Python Testing Tutorial.epub Rancher 实战红宝书.epub RubyFu.epub Rxjs 5 ultimate.epub Sanic-For-Pythoneer.epub sdn网络指南.epub Serenity开发者指南中文版.epub Spark GraphX源码分析.epub spark机器学习算法研究和源码分析.epub Spring Data 中文版本.epub Spring Framework 5 中文文档.epub Tornado Tcp Program.epub Understanding Model-View-Controller.epub ZoePython星际旅.epub 从Python到Django入门教程.epub 使用Ionic2制作移动应用.pdf 和我一步步部署 kubernetes 集群.epub 在迭代1前.epub 文件系统层次结构标准中文版.epub 深入理解OpenStack自动化部署.epub 给iOS开发者Sketch入门教程.epub 编写高质量代码改善 Python 程序 91 个建议.epub
相关推荐
©️2020 CSDN 皮肤主题: Age of Ai 设计师:meimeiellie 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值