如何看待 Rust 这门语言?

2020 了, Rust 好像如五年前预期的一样,在稳步发展... Rust 会成为一门经典编程语言么?它将来的应用场景会是在哪?
关注者
1,405
被浏览
3,246,450
登录后你可以
不限量看优质回答私信答主深度交流精彩内容一键收藏

C++程序员视角下的Rust语言

自上世纪80年代初问世以来,C++就是一门非常重要的系统级编程语言。到目前为止,仍然在很多注重性能、实时性、偏硬件等领域发挥着重要的作用。

C++和C一样,可以通过指针直接操作内存,这给了程序员的编程提供了相当大的自由度。但指针就是一把双刃剑,给了程序员灵活、自由地表达设计意图的同时,也给程序埋下了非常大的隐患。

C/C++程序员一定被各种程序崩溃、线程死锁、程序行为不正确等问题折磨过,要知道这些都是程序中最严重的Bug了。 这些问题有时还非常灵异,有时很快就可以复现问题,有时很久都难以出现一次。但只要程序哪怕出现过一次这样的问题,也就说明了自己的程序是存在漏洞和隐患的。

程序员肯定是非常害怕这样的程序被发布并部署到实际生产环境中的,这无疑给自己埋了一个雷啊。如果这是非常重要的软件(如火箭飞行控制程序、银行交易系统等),那么一旦问题发生,后果是难以预料的。

为了避免此类问题的发生,C/C++程序员通常需要付出更多时间、精力、耐心去学习更底层的计算机系统的工作原理和技术细节;在软件设计和编程时,还需要非常地细致和小心,反复琢磨自己的程序是否存在低级错误和逻辑漏洞等。

但有时候,即使程序员非常小心,错误还是难以避免的。因为C和C++根本没有提供一种对这类问题的检查和保证机制,而完全相信程序员自己去解决这类问题。 实践证明,这根本是靠不住的。这对编写程序的C/C++程序员有着非常高的要求,即使经验非常丰富的C/C++程序员,也不一定敢保证自己的程序完全不会存在这类问题吧。

既然人不一定靠的住,那么就要在机器层面提供强力的检查和保证机制去规范程序员遵守一定的规则以避免这类问题的发生。

针对内存安全问题,目前大部分的高级编程语言基本都是内置垃圾回收机制。这的确避免了不安全的内存操作,减少了程序员的心智负担,但这是通过性能的代价换来的,也就注定了这类编程语言的使用范围是受限的,对硬件的性能是有一定要求的。

C++很早就已经意识到这样的问题,也提出了自己的方案,即基于RAII机制的智能指针,没有走内置垃圾回收机制的路子。

RAII机制是C++问世以来就一直存在的,本质上是C++对象的确定性析构机制,即对象在其生命周期结束时,析构函数保证会被自动调用。

传统的裸指针无法承载资源所有权机制和生命周期管理的实现,但增加一层抽象的智能指针则可以,核心思想是用栈上对象管理堆内存/内核资源:

  • std::unique_ptr<T>表达了独占所有权机制,无论什么时候都只有唯一的对象持有资源的所有权,但所有权可以通过移动语义转让的
  • std::shared_ptr<T>表达了共享所有权机制,通过引用计数机制,保证可以有多个对象同时持有某个资源的所有权,只有在引用计数为0时,资源才会被释放
  • std::weak_ptr<T>则完全是配合std::shared_ptr<T>而存在的,不影响所有权,但却有方法可以知道自己手上的资源是否还存活/有效,这是裸指针做不到的

如果说C++在内存安全上做出了自己的努力,那么在线程并发安全上则努力程度还不够,这部分基本还是需要靠程序员自己去保证的。

而Rust则是从一开始就在内存安全和线程安全上下足了功夫,同时没有抛弃性能。Rust自始至终给自己的定位就是一门系统级编程语言。

Rust和C++一样,没有走内置垃圾回收机制的路子,而是从语言的内在机制上去解决C和C++内存安全和线程安全的痛点。

Rust通过更强大和完善的类型系统和所有权机制,引入了如下核心语言内在机制:

  • 值的唯一所有权
  • 默认内置基本类型的值拷贝语义
  • 默认对象的移动语义(所有权转移)
  • 默认不可变(只读)内存访问
  • 所有权不可变引用
  • 唯一所有权可变引用
  • 跨函数引用的生命周期标注
  • 不支持空指针

结合Rust核心库和标准库提供的Send和Sync trait、智能指针、Option<T>等,保证程序的内存安全和线程安全。需要注意的是,Rust对于线程安全,只能做到避免数据竞争,无法做到避免条件竞争;另外,在Rust中,把引用更习惯称为借用(borrow),以强调借用所有权之意。

Rust标准库提供了几种与C++类似的智能指针:

  • Box<T>,相当于C++中的std::unique_ptr<T>
  • Rc<T>,相当于C++中的std::shared_ptr<T>
  • Weak<T>,相当于C++中的std::weak_ptr<T>
  • Arc<T>,线程安全的Rc<T>
  • ......

Rust的类型系统在很大程度上借鉴了Haskell语言的类型系统,而在内存安全的所有权机制上则是充分吸收C++的RAII机制思想。

Rust和C++一样,也是支持面向对象、泛型编程、函数式编程等多种编程风格/范式的编程语言。

从面向对象编程的角度来看,Rust和C++在对象概念的语言表达形式上存在明显的不同。

对C++程序员来说,类的概念是深入人心,构造函数和析构函数不可或缺。但Rust是没有类的概念的,对等的概念则是强化到了结构体(struct)上了,可能是认为C++中class和struct是差不多的,只是默认访问权限上不同。

在Rust中,struct的定义则纯粹表达了C++中类的数据成员部分,而完全不会看到任何函数的影子,当然数据成员的访问权限默认也是私有的; C++类的成员函数,在Rust中是在impl块中单独进行描述的。

但和C++类最大的不同,感觉还在构造函数和析构函数上。在Rust中,是不支持构造函数的,而析构函数则是需要通过实现Drop这个trait来表达的。Rust的惯例是在struct的impl块中实现一个New函数来模拟C++的构造函数。当然,这个函数的调用是需要程序员自己去手动调的,Rust编译器不会有任何额外的支持;而实现Drop的struct,Rust编译器则会保证在其对象的生命周期结束后,drop函数一定会被自动调用。这样才能保证实现RAII机制。

Rust中的类成员函数和类静态成员函数的区别在于第一个参数是不是&self或&mut self,有则表示是类的成员函数,而没有则是类的静态成员函数。在Rust中,self相对于C++中的this,区别在于C++类成员函数的this是不需要程序员自己写出来的,由编译器生成,而在Rust中则需要程序员自己写出来。而使用&self的成员函数,则相当于C++中const成员函数;使用&mut self,则是C++中非const的成员函数。

Rust中trait是非常重要的概念,它承担了类似C++中通过纯虚类表达接口的意图。Rust中强调组合优先继承的思想,不支持struct级的继承,但支持trait的接口继承,这和Java等编程语言一样。

和C++中虚函数类似,Rust中trait中负责定义接口函数的原型,也可以为接口函数提供默认的实现。特别的是,Rust也支持不提供任何接口的trait,这样的trait则退化为标签的概念。在Rust中,作为标签使用的trait很常见,例如核心库中提供的Copy、Send、Sync等trait就是这样,主要用于给Rust编译器标识出某种语义,便于编译器进行相关的类型安全检查。

C++支持虚函数和继承表达的动态多态性和基于模板的静态多态性。Rust则做得更好,通过trait机制统一了动态多态性和静态多态性的表达形式,而且是一个实现可以同时支持这两种多态性。

Rust中,动态多态性的具体表达形式和C++是类似的,例如,通过将trait引用作为函数的形参,而给这个函数传实参时,必须要传实现了该trait的对象;而静态多态性也是通过泛型实现的,但在表达对泛指类型T的约束上要比C++完善,而C++20的concept才能做到类似的表达效果。

在支持函数式编程上,少不了lambda表达式的支持,当然,Rust的枚举(enum)也功不可没。

和C/C++中的枚举不同,Rust的枚举值可以关联不同数据类型的值或不关联值,结合match的模式匹配,表达能力大大增强。 这种表达能力完全替代了C/C++中switch & case。

当然,在模式匹配的支持上,Rust标准库提供的Option<T>、Result<T,E>、Some<T>、None、Ok<T>、Err<E>等出镜率也是很高的,为程序员表达自己的设计意图提供了强力的工具。

C缺乏有效的错误处理机制,而C++提供的异常机制并没有得到程序员广泛的认可,至少在用不用异常的问题上,大家是犹豫的,甚至有些公司明确禁止异常的使用,如Google的C++编程规范中就明确提出过。

禁用C++的异常,可能是考虑到异常本身带来的代码膨胀、性能等问题,也可能是某种历史性因素。没有异常的C++,在错误表达上就退化到和C一样的水平上了,基本就是基于返回错误码。

Rust似乎吸取了这方面的教训,并没有提供异常机制,而是通过上述提到的Result<T,E>、Option<T>、模式匹配(match、if let、while let)、panic!、assert!等来提供一套错误处理机制。

在形式上,偏向返回错误码的风格,但提供的内涵又比C的错误码要强很多;在性能上,也没有出现C++异常带来的问题。这充分体现了零成本抽象的设计思想。

C/C++中的宏是通过预处理器负责处理的,对编译器而言完全无感知。这就说明宏不属于类型系统的一部分,编译器无法对此进行安全检查。 正是这样,C/C++中的宏在使用时才要特别小心,否则,一不小心就会引入问题。在C++中通过增加一些额外的语言机制,让程序员去替代宏的那部分功能。 例如,提供内联函数机制替代宏函数、提供const去定义常量等。

而在Rust中,宏则被鼓励去使用,体现在Rust的标准库上就在广泛使用宏。 例如,println!、vec!等这些都是Rust标准库提供的宏。

Rust的宏,给了程序员一个可以自己去创造新语法的工具,这是有利于程序员写出更清晰明了的代码,提高代码的可读性。 而能写出面向人的代码则无疑是非常有价值的。 现代的高级编程语言,特别是动态编程语言,在这条路上越走越远。 越接近人的自然语言表达能力,程序员的生产效率就会越高。

现代的编程语言,对于程序的组织上,基本都抛弃了C/C++提供的头文件和源文件分离的机制。无疑,Rust也是这样。C++20提供的moudle机制也在走向这条道路。

在编程语言的互操作上,C的ABI无疑是一个事实上的标准。

Rust作为一个定位支持系统级编程的语言,肯定不会放弃和C的兼容性。这体现在Rust结构体的内存布局和基于trait实现的动态多态性上(在C++中,将虚函数表指针和结构体的数据成员放在一起,从而在对象内存布局上破坏了和C的兼容性)。

另外,为了充分利用现有C的代码,Rust提供了FFI机制和unsafe块。在unsafe块中可以绕过Rust严格的类型安全检查机制,而这部分的代码的安全性就自然需要程序员自己去保证了。

在一些基本的语言表达方式上,Rust和C/C++也存在一些不同,体现在:

  • 变量默认是不可变绑定(let),需要修改变量,则需明确使用可变绑定(let mut)
  • 没有实现Copy trait的对象,绑定、赋值、非引用传参时默认是移动语义
  • 支持函数内嵌定义
  • 支持函数表达式返回(最后不加分号)
  • 在同一个作用域内,变量可以重新绑定(let),在Rust中叫做遮蔽机制
  • 支持零尺寸的结构体、空枚举、空数组([T, 0])
  • 两种字符串类型变量:&str相当于C++中的const char*,用于指向字符串字面常量;而String相对于C++中的std::string,支持可变引用&Mut String和不可变引用&String
  • 基本的数据类型都实现了Copy trait,默认在栈上分配,支持复制语义;而String、Vec等默认只支持移动语义,要进行深拷贝,需要显式调用clone函数
  • 不支持switch & case,使用match模式匹配代替
  • 不支持三目运算符
  • 支持?运算符,用于调用的函数返回异常时,直接退出当前函数并返回对应的错误Err<T>
  • 指示编译器的属性,如让结构体支持整体打印,可在结构体定义处加上#[derive(Debug)],相当于让编译器自动给指定的结构体加上实现Debug trait的代码
  • 支持文档化注释:///和//!,使用cargo doc可以基于代码生成对应的html文档;当然同时也支持C++的那两种形式
  • ......

Rust在对编程开发套件上的支持也是非常有吸引力的。

虽然目前Rust还没有自己的IDE,但强大的cargo和统一的包管理库(crate.io)为编程带来极大的方便,不用为搭建开发环境而费神了。

另外,Rust编译器的错误提示真的非常好,想起C++异常报错的天书,完全是两样的感受。

C++从C++11开始逐步走向现代化之路,而Rust则完全是一个现代化的编程语言。

虽然Rust定位于一门系统级编程语言,但它并没走C++兼容C的老路,完全没有历史的包袱,可以轻装上阵,充分吸收各家编程语言之长,避其之短。

Rust的设计目标是非常明确的,提供内存安全、线程安全而又不失性能的现代化系统级编程语言。

Rust有完全不亚于C++的表达能力和性能,又解决了C++的最大痛点(内存安全、线程安全),这对C++程序员来讲无疑是非常有吸引力的。

目前,C++仍然是我的主力编程语言,但我对Rust是看好的。

虽然现在对Rust的了解并不深入,只写过一些简单的Demo,并没有用于实际的开发,但我觉得Rust仍是值得C++程序员认真去学习的一门编程语言。

它不仅实用,反过来也会促进对C++中关键概念和问题的理解。

-完-