前言

一直以来笔者都在从事以C++为主要开发语言的工作,但事实上在实际的工作中,并不可能一门语言就cover住所有的需求。一来,对于像前端、CGI、脚本、数据库相关等工作任务,本来就着其专用的工具和语言,作为后端开发程序员都是必须涉猎的,也就是所谓「全栈」;二来,C++虽然全能,但它也逐渐老了,在一些更为专用的领域会显得臃肿,因此越来越多新兴语言也开始百花齐放。

作为软件、互联网行业的一员,我们也必须时刻跟上时代的脚步,在加上一些大佬的安利,笔者决定开始研究Rust,并且着重研究这门语言的世界观、倾向性以及它与C++的异同。

本系列文章作为笔者的学习笔记和研究产出,主要面向下列读者群体:

  1. 已经有足够的C++知识积累或开发经验
  2. 希望转型到Rust,或是希望以Rust作为第二技能的,又或是单纯兴趣性地学习和研究Rust的
  3. 并不满足于表面涉猎,希望能深入理解和领悟的

作为一门程序语言,能够找到的入门级教程可以说数不胜数,但笔者认为,针对Rust来说,从C++转入Rust的教程比单纯的Rust入门教程更加有意义,理由如下:

  1. 很少有人会把Rust作为第一入门语言的,或者说,假如这个人没有接触过编程,那么他大概率是不会把Rust作为第一门学习编程的语言的(反倒是C语言、js或者Swift作为第一门语言的概率要大得多)。
  2. 从Rust的很多设计理念上来说,他针对于C++容易出现的各种问题做了非常多的优化,所以对于那些「饱受C++摧残」的程序员来说,更加适合来学习一下Rust,从中可以获得很多不一样的思路。
  3. Rust相对来说会更加偏底层一些,比如说嵌入式、各种系统内核、各种中间件、各种通用工具等,这些领域正好完美命中了C和C++之间的尴尬地带,或者说用C语言不太够但用C++又门槛太高且风险较大,Rust提供了一个比较好的解决方案,非常适合那些不满足于纯C,但又忍受不了C++的这部分人或团队。

因此,针对于本身是C++程序员,又希望涉足Rust的这部分人群来说,更加需要的是一个专有的教程,而非通用的Rust入门教程。

综上所述,笔者以《面向C++程序员的Rust教程》进行开题,作为本身就是C++程序员兼Rust学习者的身份,相信能给读者带来切身体会和不一样的理解感悟。

从Hello World说起

按照世界级惯例,程序语言教程都由一个Hello World开始。一来我们可以以最少的篇幅来编写一段可运行的程序,让我们建立对这门语言的主观印象;二来也可以从这个最简单的程序里看到很多端倪,这有助于我们接下来的学习和研究。

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

算上大括号也就只有短短的3行,但其实能看出不少东西的,我们一一来说。

首先,整体上来说,Rust也采用了大括号和分号的代码风格,这一点跟C++一样,而不同于Python和Go。函数体是用大括号包裹的,并且每行语句都以分号结尾,那么这种风格通常来说都是对缩进和空格、空行不敏感的,因此这里的缩进、空行等都是一种让代码可读性更高的编码规范,而不是语法本身要求。

其次,这里出现了我们熟悉的main函数,作为程序的入口。函数是可以直接单独存在的,不需要强行包裹在类中,这一点与Java不同,说明Rust至少是「可以」做面向过程编程的。

最后,我们可以看到main函数是空参数空返回值的,这一点与C/C++不同,也就是说当Rust程序作为应用程序时,其与OS或父进程之间的交流应当有其他专有的方式,而不是通过传参。这种限制其实有他自己的独特优势的,这点我们在后面章节再详细介绍。

其他呈现的细节还有比如说双引号表示字符串,而单引号表示字符,这与C++表现一致,不同于js、Python、shell等。这些都等后面章节我们再一一诉说。

这里希望读者可以有一个主观印象,Rust程序大致就是长这个样子哒~。

类型说明符

在C语言以及其衍生语言(如C++、Java、Objective-C等)中,有一个非常大的特点(甚至说是缺陷),就是「行为」被隐含在了「类型」说明符之中。

举个例子来说:

int a;

这里的int首先是表达了「创建变量」的含义,其次才是表示「变量类型是整型」。「创建变量」这种表示动作的语义是隐藏在里面的,根据类型说明符的位置、上下文、组合等不同来区分。比如上面单独一个类型符号表示创建变量,而如果加上小括号就表示「函数声明」的动作:

int f();

这里的int()共同表示「函数声明」,而int又承担了「函数返回值类型」的类型说明的含义。

而在一些「仅需要表示动作,不需要类型」的场合,C++的这种语法就显得很奇怪和诡异,比如说:

template <typename T1, typename T2>
auto f(T1 a, T2 b) -> decltype(a + b) {}

这里的auto并不表示任何类型,因为返回值类型在箭头后面的表达式,因此这里只是需要一个占位符,跟小括号一起表达「函数定义」这样的动作语义,所以强行塞了一个auto

而对于Rust来说,这个问题就得到了非常好的解决,那就是把「动作」和「类型」这两件事分来,比如说:

let a: i32;

我们看到这里有两个关键字,其中let表示「定义变量」这个动作,而i32表示类型,也就是32位有符号整型。

那么,当遇到只需要动作描述,不需要类型描述的场景,这个语法就很自然,只要去掉类型描述符就好了,比如说:

let a = 5;

这时a的类型就会由其初始化的值类型来推导,整体语法上会比C++的auto占位符自然得多。

再比如说定义函数,也是用fn关键字,与类型的符号分开表示。

另一点就是,在C/C++当中,我们推荐使用intlong这种视架构而定的类型,比如说long在32位环境下是4字节,在64位环境下是8字节。原因是在CPU的寄存器原理上。匹配寄存器长度的数据读写是效率最高的。但这种缺点也很明显,因为你不能确定它的具体长度,自然也就不能确定它的值域范围。所以我们看到其实更多情况下int32_tint64_t使用得更广泛。这其实就是一个编程思维问题,如果你是底层思维,那你考虑更多的是硬件的位宽,但如果你是上层思维,你更多考虑的是类型的值域。

Rust当中虽然也有匹配架构的isizeusize类型,但是显然更被推荐的是i32u16等这种指定长度的类型,其实这也说明了Rust设计之初,是希望你更加聚焦到程序逻辑上,而不是底层框架上的。

引用/指针

在C++当中,引用和指针是独立的两套语法和语义,但底层实现上又非常相似,大多数场景下用指针或是引用都不会有太大差别,但这也就造成了另一个问题,就是我们可能难以区分「值传递」和「引用传递」。

举个例子来说:

int a = 5;
f(a); // 但从这里是看不出来a是值传递还是引用传递的

C++引入的这种「引用传递」的语义,原本的目的也是为了屏蔽「函数调用栈之间的地址传递」这样的信息,让你感觉就是真的把变量本身传进去了一样。但与之带来的就是它无法与真正的值传递进行区分,对于上例来说void f(int);void f(int &)的函数类型就会有不同的表现。

有写团队为了区分这种情况,会有规定类似于「出参必须使用指针」这样的方法,希望能在调用时对出入参进行区分,比如说:

void f(int in, int *out);
void Demo() {
  int a = 5;
  int b = 0;
  f(a, &b); // 加了&的是出参
}

但这样做局限性也很大,首先我们还是区分不了入参是值传递和引用传递,比如说:

void f1(Test t);
void f2(const Test &t);

void Demo() {
  Test t;
  f1(t); // 入参,但会触发拷贝构造
  f2(t); // 入参,但是单纯引用传递
}

另一方面就是说,对于「指针类型的入参」,可能还是会添加取地址符,但本质上却作为了入参,比如说:

// 这个函数是为了打印指针的值,因此p并不是出参
void PrintPtr(const void *p) {
  printf("%p", p);
}

void Demo() {
  int a = 5;
  PrintPtr(&a); // 其实并不是把a当做出参,而是把「&a」当做了入参
}

当然,像C++这种同时保留指针和引用语义的语言也是屈指可数的,大多数语言都只会保留其一,比如说Go当中,仅保留指针语义而没有引用语义。

// 只有指针语法
func f(a *int) {
  *a = 5 // 解指针后操作出参
}

func main() {
  var a = 3
  f(&a) // 显式取地址
  fmt.Println(a)
}

另一种就是像Java这种的,完全取消了引用和指针,只是会根据实际传参时的数据类型来决定使用值传递还是引用传递:

class StringWrapper {
  public String s;
}

public class Main {
  static void f(int a, StringWrapper s) {
    a = 5;
    s.s = "123";
  }
  public static void main(String[] args) {
	int a = 2;
	StringWrapper s = new StringWrapper();
	s.s = "abc";
	f(a, s); // 基本类型用值传递,自定义类型用引用传递
	System.out.printf("%d, %s", a, s.s); // 所以a仍然是2,但s.s变成了"123"
  }
}

而对于Rust来说,非常特殊,Rust的「引用」语法可以认为是「指针」和「引用」的一个融合体,在行为上介于两者之间。

首先,取地址运算和解指针运算的逻辑与C/C++完全相同:

let a = 5;
let p = &a;
println!("p={}|*p={}", p, *p); // 这里p和*p都是5

虽然这里用&得到「指针」,用*来「解指针」的逻辑看似更符合指针的行为,但实际上,这种类型本身却更符合引用,比如我们直接打印p的值,出来的并不是地址,而是p的引用值,也就是a的值。

我们在所有Rust教程中都不会找到「指针」这个描述,官方也把他称作「引用」,但我们应当明白的是,Rust引用并不完全匹配C++引用的语法语义,而是引用与指针的结合。

比如说,C++当中的引用一旦初始化,后续不能改变其绑定的实体,但Rust引用可以,这里其实更符合指针的行为:

let a = 5;
let b = 8;
let mut p = &a;
p = &b;
println!("a={}|b={}|p={}", a, b, p); // a=5|b=8|p=8

虽然它看上去更像指针,但其实Rust是屏蔽了「指针就是内存地址」这一层含义的,就像上面我们打印p并不会出地址,同样地,它也不支持任何指针偏移或轧差运算:

let a= 5;
let mut p = &a;
p += 1; // err: cannot use `+=` on type `&{integer}`

并且,除了在print时会「自动解引用」外,在自定义类型访问成员的时候也会做这种自动的解引用:

struct Test {
  a: i32,
  b: i32
}

fn main() {
  let t = Test{a: 1, b: 2};
  let p = &t;
  println!("{}|{}", p.a, (*p).a); // 1|1
}

也就是说,Rust把这里的(*p).a语法糖为p.a,而并非C/C++中的p->a,从这点上来说,似乎又更符合引用的行为。

总之,Rust引用并不是C++中理解的「引用」或是「指针」,大家一定要避免先入为主的观念,要区分清楚它在不同位置的行为。

【未完,更新中……】

03-26 05:13