写了两年的Rust代码,从一开始的一脸懵逼,到现在有一些感悟,希望能一点一点的记录下来,也算是自己的一份总结。
起因
第一次写东西,起因是最近有一些思考想表达出来。
而想表达出来的起因是看到一些Rust的普及文章,教你怎么用Rust来实现各种设计模式,看到这样的标题让我很痛苦,原因是Rust根本就不该按设计模式的那一套来写。
设计模式的出现本身就是在面向对象的基础上的,是给写面向对象编程的指导建议,所以用在C++,Java的架构设计中会感觉得心应手。而Rust语言虽然又一些面向对象的特征,但是个人觉得完全应该包含在面向对象的语言中,而且特别不应该用面向对象的方式去写Rust代码,更不应该编程中用面向对象的方式去思考,不是不能,而是你越写你会越痛苦。
导致这种结果的原因我想根源是编程范式的不同导致的。
用过程范式写Rust代码
编程范式大概分为面向对象,面向过程和面向函数三种类型。
大部分语言与这几种编程范式并不是绑定关系,就是一种语言你可以用三种编程范式中的任何一种来写,只是语言本身的特征导致更倾向于某种范式,大部分该语言写的代码都是该范式的逻辑来写的。
编程范式更像是一种写代码的思考方式,当你用特定语言的主范式来写代码的时候,你会感觉很舒服,反之则会越写越别扭。就像C语言,用面向对象的范式来写,会特别麻烦,用面向函数的范式来写会导致效率特别低下。
我想Rust应该是面向过程的语言,所以应该用面向过程的范式来写,更应该做的,是按照面向过程的范式来思考。
Rust更像是一个对C语言的升级,C语言是一般是归类为一门面向过程的语言,这种语言的核心是两个部分,数据和算法。我去读一些解释Linux内核的书,大部分时候内容都是先解释一个数据结构,然后解释围绕这个数据结构的各个函数(算法)。比如讲进程,都是先把表达进程的数据结构,解释该结构的各个字段代表的意义,然后是围绕该结构的各个函数。我去看Android NDK的API,也是围绕某个数据结构的一系列函数,这里的数据结构也完全隐藏了,是一个不透明结构体,你能做的只是调用相应的函数,结构体的内容你是不能访问的。
这看上去很面向对象,但是这里面有很大的不同。
-
面向过程的语言,像C语言这类,强调的是算法对数据的改变,也就是写函数是为了改变传递的结构体参数。
-
面向函数的语言,强调的是空间转换,一个函数的目的是把一个空间的数据转换到另一个空间,所以特别强调函数纯不纯。
-
面向对象的语言,强调的是对象之间的关系和交互,所以你会看到某些操作是向一个对象发送一个消息对象,工厂对象是生成对象的对象,Object-C甚至本身就是在向对象发送消息这一概念的基础上设计出来的。
Rust特别强调的是它的内存管理模式,而Rust的内存管理模式是建构在生命周期,借用,和借用检查这一整套逻辑之上的,而借用等这一整套的概念都是面向过程这一概念的延伸。
为什么这么说呢?拿可变引用和不可变引用来说,这是只有面向过程的范式才会有的。
-
面向过程的函数会改变参数数据,所以才会需要知道哪些是会改变的,哪些不会改变。
-
函数式编程的函数参数是不可变的,所以没有知道可变不可变的必要,都是不可变的。
-
面向对象,我想基本的概念应该是消息的接受对象是可变的,而消息对象是不可变的。
所以,学习Rust的过程,更应该去跟C语言学习,特别是思考方式。设计模式是面向对象的思考方式,不应该往Rust上面套,更不应该用这种方式去思考。
Rust是C语言+现代语法糖
C语言是一门古老的语言,但是现在依然很活跃,操作系统的内核基本都是用C语言写的,目前Rust也在逐步加入。
我想C语言写操作系统,除了够快,够底层意外,另一个最重要的原因是C语言足够精确,当你写下一段C语言代码,你能精确的知道你在干什么。
这好像是一句废话,我会逐步的解释我为什么要这么说,但是说来话长,暂时留坑不展开。
现在来说另一个问题,C语言太老了。
在C语言发明以后的这几十年,编程语言有了巨大的发展,但是C语言几乎没有改变,Rust语言所做的,就是把这几十年的这些新发展补到C语言中去。
第一种补充,是语法上的语法糖。
举个例子,比如我们用一个算法去修改一段数据,C语言的写法可能是这样:
struct SomeData;
void do_something_on_data(struct SomeData* data);
struct SomeData data;
do_something_on_data(&data) {// @1
data.xxx...
}
大部分的C语言API都是这么设计的,函数的第一个参数是要操作的数据。
而我们现在更希望用下面这种方式来写
data.do_someting();
后面一种更清晰简洁,所要表达的意思是一样的。我们每一天都写下打量的这种代码,已经习以为常。
但是C语言,大部分时候只能用上面一种方式写,如果想用下面一种方式写,则需要大量的代码,包含函数指针等等一堆高级概念才能勉强实现。
而现在的编程语言基本都是后面一种样子,而且类似的语法糖类的表达方式已经发展出很多很多,不要把这些语法糖像打补丁一样补给C语言。
我们来看看Rust是如何做的。
struct SomeData;
impl SomeData {
fn do_something(&self){
self.xxx...
}
}
let data = SomeData;
data.do_something();
这里我希望强调的是,Rust的代码是可以精确的与C语言的代码对应的。
第二种补充,是套路上的语法糖
我觉得这种补充最有代表性的是闭包,生成器和异步,而且从本质来讲,这三个概念在底层是一样的。
为什么是一样的?说来话长,留坑以后来填,简单的说,这三种语法糖都是编译器自动生成一个匿名结构体,然后自动生成一个方法,然后自动调用该方法,带来的好处是可以把一段代码和关联的数据打包到一起,然后可以存起来,挪到另外的地方去执行。
我想出现这种情况的原因是在编程语言发展过程中逐渐形成了一些新的套路,这些套路的底层逻辑很清晰,但是实现很样板话,当把无聊的样板部分做了之后,使用的时候看起来代码很短,很清晰,而且非常符合人的直觉。
比如异步,最开始我想是因为网络编程天生就是一种异步模式,最开始的实现只能把一段清晰的逻辑切段分割到毫无关联天南海北的各个地方,人们自然而言希望把这些代码写在一起,按照清晰的逻辑顺序放在一起,然后就出现了生成器模式,但是还是不够简单明了,然后就有了异步,可以把异步的代码像同步一样写,但是异步执行。
这种套路用C语言可以实现吗?完全可以,只是太麻烦了,而且实现的过程非常机械化,而且容易出错,如果让编译器帮我们做,可以省掉很多的麻烦。
Rust填补了这种类型的语法糖。
这里我想特别强调的是Rust的零成本抽象,就是Rust在C语言基础之上添加的大部分新的语言特征,如果你用C语言去实现,一般与Rust自动实现的相差无几,不会付出额外的代价。
所以一切只是语法糖。
学习Rust的过程就是把这些语法糖剥掉,等全部剥掉了,也就学会了。
什么是Rust融汇贯通
我曾经看过一个教编写操作系统的课程,里面讲汇编,讲C语言,那个讲师说什么是C语言融汇贯通,就是在他的眼中,没有C语言。
什么叫没有C语言呢?就是你写下的是C语言,但是脑子里想的是汇编和内存,就是明白你写下的每一行代码到底在干什么。
我很想说,当你融汇贯通Rust,你写下一段Rust代码,脑中也是汇编和内存,但是我也做不到,太多的高级语法糖了,映射到汇编和内存我觉得完全没必要。
但是,可以映射到C语言。
我最开始写Rust,每天都在跟编译器搏斗,总在想怎又不让我这么写,这逻辑完全没毛病啊。
后来我去研究Rust的各种概念,理解背后是怎么实现的,想明白如果用C语言怎么实现,逐步的把Rust的各种语法糖剥离掉,之后就很少碰到与编译器搏斗的情况了。
现在,我觉得,Rust编译器真的是我们写代码的超级助手,编译器会帮助我们找到我们思考中的漏洞。
但是,我也想说,伴随着Rust提供给我们的内存安全,无惧并发等等高级特征的同时,Rust也关上了很多可能性,也就是你不能用Rust随心所欲的表达任何你想表达的设计结构,这也是我文章开头所说的用设计模式的方式写Rust代码会越写越别扭的原因。
我们需要按照Rust的思考方式去设计架构,才能越写越顺,不会被Rust语言所限制,也就是需要按Rust的思路来思考问题,在实现的时候才会遇到更少的语言层面的阻碍。
在写Rust的过程中我阅读了很多的代码,感谢Crate.io,上面大量优质的库,同样的问题我发现大量的优雅解决方案,我希望逐步的总结出来,梳理自己的思路。
这里推荐两个我认为特别重要的学习资源。
-
Rust 标准库,一切的源头都在标准库中,包括思维方式,我后面想写的内容也会围绕标准库展开。
-
范长春的《深入浅出Rust》。我认为这是被严重低估的一本书,我无法想象在那么多年前,有人可以如此深刻的理解Rust,现在依然是我需要隔三差五查阅的必备参考书。