前言

最近在学Rust,粗浅一看,Rust和C/Cpp还是有不少相似的地方的,而我自己对Cpp还算有一点了解,所以就想对比学习一下。Rust作为一个比较新的语言,各个方面都如同青春少女般焕发着活力,与之相比,Cpp像是一个背负着房贷车贷上有老下有小的中年人,虽然步履沉重,但也有独特的内涵和魅力。这篇文章则是在我自己学习Rust过程中,以Cpp为参考的一些粗浅感想。

表达式、语句和函数

Rust上来就震惊我的便是其表达式。在cpp中,也有表达式和语句的区分,但往往我们不会特别细分这二者,原因便是我们往往不会用到完全独立的表达式,而是将表达式套在语句中进行使用。

而Rust则不一样,比如下面这段代码:

fn main() {
    assert_eq!(ret_unit_type(), 6)
}

fn ret_unit_type() -> i32{
    let x = 1;
    let str;
    let y = if x % 2 == 1 {
        str = "odd";
    } else {
        str = "even";
    };

    let z = if x % 2 == 1 { "odd" } else { "even" };
    x + 5
}

入眼感觉与Cpp不同的主要有:

  1. 第5行返回值类型的表示
  2. 第8行直接等于一个if控制块
  3. 倒数第2行的x + 5

第一个好理解,cpp11之后其实是支持了模板函数里面的返回类型后置,差不多写法。

第二个则是rust里面的特性,if控制块实际上是一个表达式,那么自然可以(将结果)赋值给一个变量,但还有个特殊的地方在于,y的类型是(),而x的类型是&str()是rust中比较特殊的一个类型,可以认为代表一个占位符,当实际上没有什么返回的时候,那么返回的类型就是(),这也有时候被用来将map当set用(value不额外占空间)。而z呢,则是因为if表达式里面的"odd", “even”都是表达式,所以会把他们作为if表达式返回的结果。不过,这个if表达式给变量赋值的方法本身倒也好理解,上面对y赋值的代码也可以用cpp中的三元表达式写作auto y = x % 2 == 1 ? "odd" : "even";

最后第三个也不难理解了,x + 5没有带分号,这也是一个表达式,那么它的结果就会作为ref_unit_type这个函数的返回值,这一点和cpp大相径庭,在Cpp中,只能写x + 5;,这是一个语句(带分号),它的返回值会被丢弃,而不是作为所属函数的返回值。

对于直接将表达式结果作为函数返回值这一点有什么意义,现在还没有体会到,但是对于上面允许将if表达式结果赋值给变量这种做法,我觉得还是非常方便的。

除了()这种返回类型,rust还有一个特殊的函数返回类型是!,一般来讲是这个函数会导致程序崩溃时使用,比如下面的死循环函数。还有一种说法是对于“发散函数”,可以用!代表该函数不会返回任何值,但这个暂时还不是很明白具体应用场景。

#![allow(unused)]
fn main() {
fn forever() -> ! {
  loop {
    //...
  };
}
}

所有权系统和RAII

rust和cpp都是高性能的编程语言,因此不会引入GC,那么对于内存管理,二者走了截然不同的道路。cpp是使用以智能指针为代表的RAII来管理,而rust则是所有权和借用。

其实,对于堆上数据的处理,rust中的所有权形式上像极了cpp的unique_ptr<T>,比如下面这段代码中,对于s1的打印会报错:

#![allow(unused)]
fn main() {
let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);  // 所有权已经被转移给了s2,所以不能继续被使用
}

再比如下面这段代码,s这个字符串先是所有权被转移到了takes_ownership中(也可以认为是s进入了新的作用域中),然后打印,然后返回,所有权转移到了s2身上。

fn main() {
    let s = String::from("hello");  // s 进入作用域
    let s2 = takes_ownership(s);
}

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
    some_string  // 如果这里不返回,那么some_string指涉的字符串会被drop掉
}

从上面可以看出,所有权系统和unique_ptr<T>在形式上有着极高的相似度,当堆上的变量发生再一次赋值(不管是显式赋值还是类似函数传参的隐式赋值),很像是执行了unique_ptr<T>(std::move(another))这样的语句,将所有权进行了转移。

但是!rust的所有权系统和cpp的RAII最本质的区别在于,rust的所有权系统并不需要在运行期来确定所有权,而是在编译期就可以确定,这样减少了运行时的开销。

借用和引用

上面代码中通过函数传参和返回来反复转移所有权虽然巧妙,但有时候未必方便,rust中提供了一种比较方便的解决方案就是“借用”,这个又像极了cpp中的引用,我们不妨看下面一段代码:

fn main() {
    let mut s1 = String::from("hello");

	change(&mut s1);
    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

calculate_length这个函数的参数是一个String的借用,然后返回其长度;change这个函数的参数则是一个String可变借用,然后对其进行操作。这一套简直像极了cpp里面的引用,形式上不同之处可能就在于,rust是默认const引用,而cpp是默认非const引用吧。此外,如果把借用类比于weak_ptr<T>似乎也可以,它对资源有使用权,但是没有所有权。

但是事情没有这么简单,比如看下面这段微调后的代码:

fn main() {
    let mut s1 = String::from("hello");
	let r1 = &s1;
    let r2 = &mut s1;
	change(r2);
    let len = calculate_length(r1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

这段代码会报错,原因是同时存在了可变借用和不可变借用。在rust中,对于同一作用域范围可以同时定义多个不可变借用(只读借用),但不可以定义多个可变借用,也不可以同时出现可变借用和不可变借用。这一点可比cpp严格太多了,这个体系很容易让人联想到读写锁,rust的安全性在这里也是体现得淋漓尽致。

此外,rust还有一个挺重要的特性叫做Non-Lexical Lifetimes(NLL),意思就是当一个变量在代码中再也没有被使用过的话,变量的作用域就此结束,而不必等到花括号结束,比如下面的代码:

fn main() {
   let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    println!("{} and {}", r1, r2);
    // r1,r2作用域在这里结束

    let r3 = &mut s;
    println!("{}", r3);
} // r3作用域在这里结束

由于r1, r2的作用域提前结束,定义一个可变借用r3并不会导致报错。

最后,rust和cpp在这方面还有个显著的不同点就是,rust不会根据形参类型来自动将实参引用传值,反之亦然。下面这段rust代码会报错,原因是函数形参是char的借用类型,然而传入的却是char类型,要修复这个错误,则要传入&a

fn main(){
    let a : char = 'a';
    println!("{}", get_value(a));
}

fn get_value(value : &char) -> i32{
    16
}

字符串的差异

rust和cpp都有字符串类型,但是rust中有一个比较特殊的类型叫做“字符串字面量”,即&str,这个也可以表示字符串切片。str是rust语言本身就支持的类型,而String则是标准库中支持的类型。从这个角度看,可能&str有点像const char*,但是&str还可以表示字符串切片,比如&s[1..4],使用起来比cpp还是方便了不是一星半点(cpp17之后也可以用string_view,但总归是不如这种原生的切片好用)。

但是不同于Python等语言提供的切片,在rust中进行字符串切片是一种比较危险的操作,原因就在于rust中的字符串切片是按照字节来进行切片的,而对于一些复杂字符或者中文常使用UTF-8等编码,一个字会占大于一个字节,比如下面这段代码中,如果我们去掉了注释,那么程序就会报错,因为这三个汉字都是3个Byte的长度,我们取前两个字节,那么必然是没有意义的,所以程序会直接崩溃。

fn main() {
let s = String::from("我和你");

println!("{},{}", &s[..3], &s[6..]);
// println!("{},{}", &s[..2], &s[5..8]);
}

与之相应的,rust中想要通过索引取字符串中的字符是不被允许的,理由也和上面类似。这一点比cpp着实严格了很多,cpp处理中文字符也会出现上述问题,但是并不止于让程序崩溃,也不会禁止掉索引取字符。还有一个原因让rust这样设计,那就是因为对于索引操作,用户往往期待其时间复杂度为O(1),但在rust中是做不到O(1)的,因为有的时候需要遍历整个字符串才能确定当前取那几个Byte组成一个自然字。

如果在rust中要按照自然字来取字符串中某个字符,就需要借助一些库函数,比如:

use utf8_slice;
fn main() {
   let s = "The 🚀 goes to the 🌑!";

   let rocket = utf8_slice::slice(s, 4, 5);
   // Will equal "🚀"
}

最后,rust中字符默认是unicode字符,所以char类型的长度是4个字节。这个一开始也让我有点迷惑,因为如果char是固定长度的,那么为什么String类型要搞成这么复杂呢。背后原因我想是因为要节省空间吧。

结构体的差异

首先,rust中是没有class这个概念的,它不是一门完全面向对象的语言。rust的struct我觉得是跟cpp差距相当之小的了,都是有成员、方法的概念,rust支持结构体的解构,这一点也和cpp17之后的特性非常相似,其它一些差距大都是类似语法糖的东西,比如下面这段用一个结构体实例去初始化另一个结构体的简便写法:

let user2 = User {
    email: String::from("another@example.com"),
    ..user1
};

但是,rust结构体最重要的地方在于,如果结构体中使用了借用,那么必须要声明生命周期,这个还没有学到,后面补充。

枚举的差异

rust和cpp枚举用法非常之大……一开始看到颇为不能接受。最大的差别在于数据关联(或者我更愿意称之为数据包含),详见下面一段代码:

enum PokerCard {
    Clubs(u8),
    Spades(u8),
    Diamonds(u8, char),
    Hearts(u8),
}

fn main() {
   let c1 = PokerCard::Spades(5);
   let c2 = PokerCard::Diamonds(13, 'p');
}

显然,PockerCard是一个枚举类型,但是下面对于c1c2的声明却比较有意思,声明成了某个特定枚举值之后,还包含了若干其它值。

这样一来,和cpp的枚举差别可谓天壤之别。我认为这么做应该是对于rust中没有继承的一种补偿,因为上面这段代码我们可以看作PockerCard是一个接口,而Clubs, Spades, Diamonds, Hearts则是实现了这个接口的4个类。

此外,和rust枚举密切相关的一个类型是Option<T>——一个十分重要以至于被包含进了rust的prelude中的类型,其定义如下:

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

对其设计的背后是对null值的处理(与讨论),与cpp中提供NULLnullptr不同,rust中并没有null的身影,取而代之的是Option<T>,它充分利用了上面说的数据关联,如果该值确实存在,那么会是一个Some(T),否则是一个None。不可否认,这个跟cpp中的weak_ptr<T>是有一定相似度的,在cpp中,我们可以检查weak_ptr<T>指向的资源是否存在,但是我们不能直接提领资源,而是要再次构建一个新的shared_ptr<T>来提领,并且weak_ptr<T>一般只能管理堆上的资源,而rust则可以针对任何对象进行提领,这一点上来看,rust的设计无疑是十分精妙的。

最后,rust中的枚举和结构体一样,可以给它添加方法,并且还支持泛型,这一点对我的思想也颇有颠覆性。

模式匹配和Switch

Switch可以说是上世纪留存下来的老古董了,浑身都散发着腐朽的气息。rust中的模式匹配,呃,则是这个老古董的子孙,还比较年轻φ(* ̄0 ̄)。

对于我来讲,rust的模式匹配并不难接受,因为它的用法和C#中的switch expression十分相似,都是讲switch转为表达式的形式并且加了许多语法糖。与switch expression相似地,rust模式匹配中有一个非常方便的功能是模式绑定,意思就是在模式匹配的过程中,可以用某个变量绑定被匹配的变量中的某个值。说起来可能有点绕,不如看下面的代码:

enum Action {
    Say(String),
    MoveTo(i32, i32),
    ChangeColorRGB(u16, u16, u16),
}

fn main() {
    let actions = [
        Action::Say("Hello Rust".to_string()),
        Action::MoveTo(1,2),
        Action::ChangeColorRGB(255,255,0),
    ];
    for action in actions {
        match action {
            Action::Say(s) => {
                println!("{}", s);
            },
            Action::MoveTo(x, y) => {
                println!("point from (0, 0) move to ({}, {})", x, y);
            },
            Action::ChangeColorRGB(r, g, _) => {
                println!("change color into '(r:{}, g:{}, b:0)', 'b' has been ignored",
                    r, g,
                );
            }
        }
    }
}

这里s, x, y, r, g都是绑定出的变量,作用域仅限于模式匹配块中,但是很方便根据被匹配地变量进行操作,此外,rust中的模式匹配也常常和枚举一起使用(比如Option<T>)。

还有一个地方rust和C#非常相像,那就是match guards,如果你使用过C#中的case guards,你一定会惊呼这二者的相似性。但是在这个的基础上,rust提供了更进一步的用法,那就是@绑定,可以让我们在限制分支范围的时候,又用到变量的值,如下:

enum Message {
    Hello { id: i32 },
}

let msg = Message::Hello { id: 5 };

match msg {
    Message::Hello { id: id_variable @ 3..=7 } => {
        println!("Found an id in range: {}", id_variable)
    },
    Message::Hello { id: 10..=12 } => {
        println!("Found an id in another range")
    },
    Message::Hello { id } => {
        println!("Found some other id: {}", id)
    },
}

rust在这里还有些小语法糖(if-let, while-let, 使用..忽略剩余值):

if let Some(3) = v {
    println!("three");
}
while let Some(top) = stack.pop() {
    println!("{}", top);
}
let numbers = (2, 4, 8, 16, 32);
match numbers {
    (first, .., last) => {
        println!("Some numbers: {}, {}", first, last);
    },
}

最后,有一个小坑,使用模式 &mut V 去匹配一个可变引用时需要格外小心,因为匹配出来的 V 是一个值,而不是可变引用,比如下面的代码是无法通过编译的,因为value是一个String类型,并且即使改成&value => value.push_str(" world!") 也没用,只能改成value => value.push_str(" world!")

fn main() {
    let mut v = String::from("hello,");
    let r = &mut v;

    match r {
       &mut value => value.push_str(" world!") 
    }
}

再本质一些,其实rust中的变量绑定、函数参数绑定都是一种模式匹配,例如let (x, y, z) = (1, 2, 3);无疑是需要左右两边数量都对的上。当然,现在我只知道这样一个结论,我想如果以后有机会看一下rust编译器的实现,这里的疑云就会通通破散。

泛型与模板

rust支持对于结构体、方法、枚举都支持泛型,不过rust中的泛型仍然不是真正的泛型,而是和cpp的模板在本质上都是一样的,二者都没有在牺牲运行时间来完成这一特性,而是牺牲编译时间来完成这一特性。换句话讲,rust的泛型的本质仍然是编译器自动为我们生成许许多多的类和函数,这也叫做静态分发 (static dispatch),不过rust当中似乎不需要对泛型类做实例化。

rust中不仅支持类型泛型,还支持静态泛型,比如将一个int值作为泛型参数。此外rust还支持特化,这一点和cpp也是极其相似,作为一个cpp开发者,学习rust泛型这块确实比较轻松一些。

特征与接口

与rust泛型密切相关的便是特征 (Traits),由于还包含了很多其它相关联的概念,比如特征对象对象安全等,这里大概是我目前遇到的最复杂的地方,对于这一部分我想同时和cpp与C#来比较,因为它的特点和这两种语言有着诸多的相似性。

天下苦cpp久矣,cpp中以虚函数为根基实现多态、继承,但是对于接口 (Interface),却没有一个抽象,导致我们只能定义一个全是纯虚函数的类来近似它。rust的特征我觉得就和C#中的Interface非常相似,都是定义一个纯粹的方法集合,这样的方式既提供了一种好的抽象和集成,又避免了装箱拆箱的损耗。rust的特征支持提供默认实现。

既然有了特征(接口),那么就会涉及到约束的问题,这里rust和C#本质上是差不多的,只是表述方式不同,贴一个对比:

// way 1
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {}
// way 2
fn some_function<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{}
int SomeFunction<T, U>(T t, U u) where T: Display, Clone, where U: Clone, Debug
{}

rust的特征有两个地方让我觉得特别方便,一个是包外特征实现(自己起的名字🤣),另一个是derive自动派生

包外特征实现主要的意思是如果有一个类是别人的库定义的,我们在自己的库里面有一个接口,那么我们仍然可以impl a for b,不过这里有一个叫做孤儿规则的东西,意思是如果上面的类和接口全是别人的包里定义的,跟当前的作用域完全没毛线关系,那么就不能impl a for b。相比之下,C#想做这件事情唯一的途径就是对这个类再包裹一层。

derive自动派生意思就是把一个接口的默认实现分配给一个类,非常方便,代码如下:

#[derive(A)]
struct B {}

当然,rust的特征相比C#和cpp的继承体系还是有一定缺陷的,比如下面的几段代码,C#能通过编译,但是rust就不行,而cpp则是编译能通过,但不一定是我们预期的行为,因为在返回的时候实际上调用了拷贝构造函数,给我们的是一个新的A对象,跟B,C都没有关系了(当然,用指针是可以的,但这里不作进一步的讨论)。

A ReturnDifferentImpls(bool switch) {
    if(switch)
    {
        return new B();
    }
    else 
    {
        return new C();
    }
}
fn return_different_impls(switch: bool) -> impl A {
    if switch {
        B
    } else {
        C
    }
}
A return_different_impls(bool switch) {
    if(switch){
    	B b;
        return b;
    }
    else{
    	C c;
        return c;
    }
}

引起这一点的原因恐怕是rust既不像C#那样用引用类型来行事,也不拥有cpp那样的对象内存分布与对象行为(拷贝构造等),并且rust还在很多地方要求编译期就知道变量的尺寸,所以将特征实现用作返回类型的时候,不允许不确定的实际类型。

rust引入了特征对象的概念来解决这种情况(当然,不完全是为了这个问题),如果说静态分发是产生上述问题的一个重要原因的话,特征对象就是用动态分发的方式来解决这个问题的。而rust中的动态分发和cpp的虚函数体系实在太像了,下图解释了静态分发Box<T>和动态分发Box<dyn Trait>的区别:

rust-dynamic-dispatch

这里的虚函数指针、虚函数表的概念和cpp如出一辙,这里便不多解释。通过这种方式,rust就可以在编译器确定出对象的尺寸大小。

rust中还有一个相关的概念叫做对象安全,当且仅当下面两个条件成立时,一个特征是对象安全的:

  • 方法的返回类型不能是 Self
  • 方法没有任何泛型参数

只有一个特征是对象安全的,它才能拥有特征对象。至于为什么要有上面这两条的限制,这里就直接引用一段话:

对象安全对于特征对象是必须的,因为一旦有了特征对象,就不再需要知道实现该特征的具体类型是什么了。如果特征方法返回了具体的 Self 类型,但是特征对象忘记了其真正的类型,那这个 Self 就非常尴尬,因为没人知道它是谁了。如果特征里的方法有泛型参数,当使用特征时其会放入具体的类型参数:此具体类型变成了实现该特征的类型的一部分。而当使用特征对象时其具体类型被抹去了,故而无从得知放入泛型参数类型到底是什么。

从第二点限制上来看,rust特征和C#接口比略输一筹,因为C#不仅支持接口中定义泛型方法,还支持定义泛型接口。

从第一点限制上来看,rust在这里则是比cpp略输一小筹,因为cpp虽然没有Self这个概念,但是可以通过CRTP技术变相实现,如下方代码所示。不过这也算不得很好的实践,CRTP一般不是用来干这个的。总体来讲,rust的特征与特征对象我觉得还是比较好用的。

template<typename T>
class A {
public:
    T someFunc() {
        return static_cast<T*>(this)->createObj();
    }
};

class B : public A<B> {
public:
    B createObj() {
        B b;
        return b;
    }
};

最后,rust的特征还有一些特性比如默认泛型类型参数、方法遮蔽 (method shadowing)和限定语法等,这些都能在cpp和C#中找到对应特性。

类型转换之地狱

rust的类型转换看起来真的是……无尽的黑暗……🙄

相比起cpp的四种cast变换以及C#的强制类型转换与IConvertable,rust这一大摊子,又是as,又是into还有类似interpret_cast那种的转换,属实是地狱。

不过这一块还没有深入接触过,也没啥理解,以后再来写。

文档和注释的差距

这里用“差距”而不是“差别”,用心昭然若揭……rust的文档和注释功能实在太强大了,甩了cpp无数条街,也远比C#的xml注释强大,具体的不赘述,可见该文档

总结

总的来讲,rust非常强大,学了rust之后才知道,其实C#最近几个版本有些特性或许是学习了rust(和其它语言),rust作为一门年轻的语言,站在巨人(说的就是你,cpp)的肩膀上,又没有什么历史包袱,感觉像是一个精致又有活力的少女(此处省略一万字),勾起了Rinne极大的兴致……咳咳。

初学rust主要跟随了course.rs,这是一本非常不错的书,本文大部分内容都是摘自这部书或者由这部书延申。