前言
最近在学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不同的主要有:
- 第5行返回值类型的表示
- 第8行直接等于一个if控制块
- 倒数第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
是一个枚举类型,但是下面对于c1
和c2
的声明却比较有意思,声明成了某个特定枚举值之后,还包含了若干其它值。
这样一来,和cpp的枚举差别可谓天壤之别。我认为这么做应该是对于rust中没有继承的一种补偿,因为上面这段代码我们可以看作PockerCard
是一个接口,而Clubs
, Spades
, Diamonds
, Hearts
则是实现了这个接口的4个类。
此外,和rust枚举密切相关的一个类型是Option<T>
——一个十分重要以至于被包含进了rust的prelude中的类型,其定义如下:
enum Option<T> {
Some(T),
None,
}
对其设计的背后是对null值的处理(与讨论),与cpp中提供NULL
,nullptr
不同,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>
的区别:
这里的虚函数指针、虚函数表的概念和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,这是一本非常不错的书,本文大部分内容都是摘自这部书或者由这部书延申。