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) []byteWrite([]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 Layoutablex = 0y = 0width = 0height = 0def Layoutable.move(x, y)Layoutable.x = xLayoutable.y = yenddef Layoutable.resize(w, h)Layoutable.width = wLayoutable.height = hend
endmodule WithText
...
endclass Inputinclude Layoutableinclude WithText..
endclass PictureBoxinclude 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; // goodstd::cout<<TIMES5(1 + 2)<<std::endl; // wrongreturn 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 specifierlet 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); // aprintln!("{:?}", 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  910 11 12 13 14 15 16 17 18 1920 21 22 23 24 25 26 27 28 2930 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>
whereT: 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) -> boolwhereSelf: 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的设计反应了设计者怎样的设计哲学。敬请期待。

C++工程师的Rust迁移之路相关推荐

  1. 10玩rust_C++工程师的Rust迁移之路(5)- 继承与组合 - 下

    2020-11-25 更新: 修正了C++ 20中的concept语法 在上一篇文章 https://zhuanlan.zhihu.com/p/76740667 中,我介绍多态.静态分发和动态分发的概 ...

  2. 运维工程师打怪升级进阶之路 V2.0

    很多读者伙伴们反应总结的很系统.很全面,无论是0基础初学者,还是有基础的入门者,或者是有经验的职场运维工程师们,都反馈此系列文章非常不错! 命名:<运维工程师打怪升级之路> 版本:V1.0 ...

  3. 考拉海购全面云原生迁移之路

    今年 8 月底,入驻"阿里动物园"一周年的考拉海购首次宣布战略升级,在现有的跨境业务基础上,将重点从以"货"为中心变成以"人"为中心,全面发 ...

  4. 关于硬件工程师的真相:敢问路在何方?

    关于硬件工程师的真相:敢问路在何方? 硬件工程师,曾经有多少人希望从事的职业?在别人眼里好像能够从事硬件设计需要你了解很多东西,可以从事这个职业之后才逐渐发现,硬件工程师处在一种非常难受的困境当中!想 ...

  5. Facebook MySQL 8.0 迁移之路

    MySQL,一款由 Oracle 公司开发的开源数据库,Facebook 一些最关键的工作负载均有赖于它来提供动力.为了支持不断发展的业务需求,我们积极地开发了一些 MySQL 的新特性.这些功能改变 ...

  6. 一位软件工程师的财富自由之路

    大家好,我是 ConardLi,今天我们来聊聊搞钱,我猜大多数小伙伴应该还是一名打工人,大家都有一个财富自由的梦想.但是,做为一个打工人,真的能实现财富自由吗?我身边有这样的例子,但是少之又少,因为每 ...

  7. [转] Web前端研发工程师编程能力飞升之路

    [转] Web前端研发工程师编程能力飞升之路 分类: Javascript | 转载请注明: 出自 海玉的博客 今天看到这篇文章.写的非常有意思.发现自己还有很长的一段路要走. [背景] 如果你是刚进 ...

  8. 致敬科技工作者:资深Java工程师的技术成长之路

    在全国科技者工作日这个特殊的日子里,我们向所有为科技进步付出辛勤努力的工作者们致敬.本文将分享一位资深Java工程师的技术成长之路(本人自己),以及他在技术领域取得的成就. 1. 热爱编程的起点 在计 ...

  9. All in Linux:一个算法工程师的IDE断奶之路

    一只小狐狸带你解锁 炼丹术&NLP 秘籍 在合格的炼丹师面前,python可能被各种嫌弃 前不久卖萌屋的lulu写了一篇vim的分享<算法工程师的效率神器--vim篇>,突然想起来 ...

  10. 高级运维工程师的打怪升级之路

    运维工程师在前期是一个很苦逼的工作,在这期间可能干着修电脑.掐网线.搬机器的活,显得没地位!时间也很碎片化,各种零碎的琐事围绕着你,很难体现个人价值,渐渐的对行业很迷茫,觉得没什么发展前途. 这些枯燥 ...

最新文章

  1. 区块链将带来怎样的应用?
  2. HtmlWebpackPlugin实现资源的自定义插入
  3. 实战Nginx与PHP(FastCGI)的安装、配置与优化
  4. android-学习1 配置环境
  5. 2、Flume1.7.0入门:安装、部署、及flume的案例
  6. c++17(26)-数组、二维数组的指针、指向数组的指针、指向数组的指针的指针
  7. C++ 执行cmd命令 并获取输出
  8. 磁力mysql搜索_多功能搜索 搜索系统安装 小说 电影 磁力
  9. mysql 所有外键_mysql中的外键
  10. Oracle 角色权限表
  11. XenApp/XenDesktop快速部署工具- QDT for 7.6 LTSR
  12. 华为路由器ws5200虚拟服务器,华为路由器端口映射怎么弄?华为WS5200路由添加端口映射规则设置...
  13. macOS High Sierra 10.13.6 英伟达显卡Nvidia显卡 失效处理方案
  14. yolo训练自己的数据所用到的标记图片的工具
  15. 易桌面打印室一般多久能到,易桌面打印室怎么用
  16. r语言做绘制精美pcoa图_如何绘制精美的PCoA图形
  17. 人工智能领域专业术语合集
  18. java线程报时代码_什么?一个核同时执行两个线程?
  19. gitlab配置126邮箱发送邮件
  20. 行列式的子式、主子式、顺序主子式、余子式、代数余子式

热门文章

  1. android 2048 游戏 源码
  2. 黑马程序员全套Java教程_Java基础入门视频教程零基础自学Java必备教程视频讲义(4)
  3. 前端神器-网站图片抓取精灵V1.0正式发布
  4. Vue 2.0 + Axios + Vue Router 实现CNode社区
  5. SwitchHosts工具介绍及下载
  6. Keli Proteus 8 的使用教程
  7. SoapUI5.1.2安装和破解教程
  8. 【51单片机】STC-ISP软件保姆级烧录教程(以普中A2开发板为例)
  9. javascript函数传参方式
  10. Android sdk下载安装配置教程