缘起
实践出真知
快速获取
澄清概念
Ownership
Move
Reference
Mutablereference
解释错误
数据竞态条件
构建树状结构
渲染树状结构
总结
TL;DR
Rust是一门系统编程语言,也是许多区块链底层编程语言,不论是旧欢Parity,还是新贵Libra;不论是微软还是Linux的核心开发者都对它青眼有加。
Rust有一些很迷人的地方,比如:AOT,内存安全性,空指针安全性,还是丰富的类型系统,异或是庞大的社区生态。这些别的语言也有,但是都没有这么彻底。
Rust的特点也十分突出,Ownership和borrow、references还有shadow,令人眼花缭乱,最最有趣的就是你写一段程序,编译器会blame你很多,你就感觉是在和编译器搏斗。
学习Rust不只是一时兴起(早在2015年我就听闻过),也是一种拥抱变化的态度,重要的是可以让你在看众多区块链代码时不会那么心慌。
语言的趋势反映了未来的主流开发群体的预测,这点Rust确实算是后起之秀。
本次话题,我会讲解我学习Rust的过程,希望指导大家在学习新编程语言时应该怎么做才更高效,同时会用一段小程序tree来给大家演示它的与众不同之处。
缘起
做区块链的基本几乎没有人不知道Rust这门编程语言,它非常受区块链底层开发人员的青睐。说来也奇怪,Rust起源于Mazilla,唯一大规模应用就是Firefox,作为小众语言却在区块链圈子里火了。这其中应该和以太坊的发起人GovinWood创建的Parity项目有关,Parity是一款用Rust编写的以太坊客户端。
最初接触Rust的时间大概是2015年,当年有同事发了一封“是否对Rust编程语言感兴趣的”的邮件。当时年少不懂事热血,觉得这门语言因为它小众很酷,所以特别适合拿来练功,所以就激情地回应了邮件,结果之后就没有了下文,想必那位同事也因为响应的人数太少而兴致缺缺。
2019年,区块链圈中的一次大事件是Facebook要发非主权货币Libra,随之而来是基于Rust之上的Move编程语言。这个Move说白了就是Move的一种DSL,用比较学术的话说是指称(denotational)语义,用简单的编译器把Move的语法翻译成Rust的语法然后借助Rust的编译器生成二进制码。这个过程没有什么惊喜,不过Move语言显然是借鉴了Rust中移交(Move)主权(Ownership)的概念,它表征了这样一种事实——数字资产只能有一个主人,一旦移动,就会发生主权转移,以前的主人就丧失了该主权。这种想法和Rust中主权管理非常契合,所以不难理解为什么Libra的开发团队把名字也照搬过来了。当然,Libra的底层区块链也用的是Rust。这个大事件加上以太坊Parity的珠玉在前,对于程序员这群天生喜欢新鲜事物的人类而言,学习Rust的热情必然水涨船高。
大概就是在这种契机下,我开始学习Rust的。依照老规矩,我还是会从tree这个命令行程序入手,在试错中逐步学习Rust这门语言。包含它的基本数据类型,组合数据类型,控制流,模块(函数)以及文件和集合操作,还有最关键的Ownership的应用。
实践出真知
学习Rust最深刻的体验莫过于和编译器较劲,这也是我听到过最多的抱怨。我想许多新手看到这么多警告或者错误,嘴上不说,心里应该很不是滋味。但是这也是Rust引以为豪的设计哲学。
每一门新进的语言都有自己的本质原因(Rationale)或者设计哲学,比如Lisp家族的Clojure就有Eleganceandfamiliarityareorthogonal的玄言妙语;往远古追溯,Java的WriteOnce,RunAnywhere豪言壮语;而Rust的基本设计哲学是Ifitcompiles,thenitworks,这个条件有多苛刻我们稍微想一想就能知道——动态弱类型语言向静态强类型语言的逐步趋同态势,基本已经宣告了类型系统的胜利。
但即便如此,现代软件工程也还是处处强调程序员要手写各种测试确保代码运行时的正确性——从单元测试到集成测试,从冒烟测试到回归测试,从Profiling到性能测试。这些测试方法和工具已经深入到软件工程的方方面面,然而各类软件还是漏洞百出。Rust发出这种高调宣言,不免有夜郎自大之嫌疑。不过程序届是个能造概念也能落地概念的神奇圈子,高调的牛吹着吹着也就实现了。况且,Rust充分诠释了现代编程语言的核心思想——约束程序员,不是劝劝你的意思,是憋死你的意思。
我在《我是如何学习新的编程语言》中说过学习的最好方式是有目的地试错,我时常拿来练手的程序叫tree-listcontentsofdirectoriesinatree-likeformat.这段程序需要用到的Rust基本构件有:
基础概念1.变量-可变性-mut4.可变引用-mut复合数据类型1.String-String::from("")//非基本类型2.Slice-""orvec[..]2.struct-struct{}集合及其操作1.Vec_-Vec::new()//考虑到集合需要自动扩展2.iter()3..map()4..enumerate()5..flatten()6..collect()7..ext()//集合拼接控制语句1.ifExpressions-if{}else{}2.recursions模块1.fn-fnx(s:String)-VecString功能组件1.快速获取
这里举个例子,为了解如何拼接两个集合时,需要事先搞明白几个问题:
集合的构造?
集合的拼接?
结果的断言?
在没有repl的条件下,唯一快速上手的工具就是文档,在,可以搜到Structstd::vec::Vec的详细解释。
通过例子程序,可以很快知道集合的构造方式如下:
letmutv=vec![1,2,3];();assert_eq!(v,[3,2,1]);
vec!宏可以快速构造出一个集合来,顺便试验下它的reverse方法。那么集合如何拼接呢?为了解答这个问题,我一般会用搜索引擎,或者深入文档,查找如concat,app等关键字,每每总有收获。
在不考虑非功能需求的前提下,我们先用最直接的方式实现,例如:文档中给出的样例ext方法
letv=vec![1,2,3];([1,2,3].iter().cloned());//编译错误
注意,这里编译失败。Rust编译器会直截了当地给出错误信息。
error[E0596]:cannotborrow`v`asmutable,asitisnotdeclaredasmutable--src/:13:5|12|letv=vec![1,2,3];|-help:considerchangingthistobemutable:`mutv`13|([1,2,3].iter().cloned());|^cannotborrowasmutable
错误信息中透露出我们的程序在尝试借用(borrow)一个不可变的变量。borrow和mutable都是新的概念。对于新的概念,我们会习惯地用熟知的知识去类比。如果套用函数式编程中不可变的特性,大体可以猜到Rust中的变量默认是不可变的。但是cannotborrowasmutable中borrow确实是有点超出认知范围。那么此时弄清定义是非常有必要的。
澄清概念
学习语言的过程中最需要注意的事项就是澄清概念。当遇到崭新的概念时,我们得停下先去补充这部分的知识,然后再回过头来理解和解决实际遇到的问题。因为每一门编程语言都有本门派的哲学原理,它本身就萃取了多种理论和实践的成果,所以必须学习这些概念。学习的过程其实就是逐步澄清概念的过程。
在学习(尝试定义)borrow的过程中,我又先后接触到了ownership,move,reference,mutablereference等概念。所以我定义了这些概念:
Ownership
变量拥有它指称的值的所有权。
在Rust当中,变量拥有它指称的值,即变量(variable)是它指称值(value)的主人(owner),值一次只能有一个主人,一旦主人离开作用域它的值就会被销毁。
Move
把一个变量的值重新赋值给另一个变量的行为。
根据Ownership的定义,值一次只能有一个主人,所以此时该值的所有权会被转移给另一个变量,原来的变量就丧失了对这个值的所有权,导致的直接影响就是这个变量此后不再可用。
Reference
一个变量指向(referto)值而非拥有该值的所有权的状态。
在很多赋值的场景,包括变量赋值或者函数参数赋值,我们并不希望之后原来的变量不再可用,此时可以通过(ampersands创建一个指向值的引用,将引用进行赋值时不会发生Move,所以原来的变量依旧可用。这种赋值行为被称为borrow(借用)。结合实际,我们拥有的物品可以出借给别人,别人享有该物品的使用权(Possession),而非所有权(Ownership)。
Mutablereference
标识该引用的值是可变的。
很多场景下,我们希望引用传递的值是可以改变的。此时我们就必须通过mut标识该引用,否则不允许修改操作发生。值得注意的是,mut标识要求原来的变量也必须是mut的,这很好理解,可变的变量的引用也得可变。而且为了防止数据竞态条件的发生,在同一个作用域下,mut的引用只能有一个,因为一旦出现多个可变引用,就可能遭遇不可重复读风险(注意,Rust保证这里没有并行修改的风险)。而且同一个值的mut和的引用不能共存,因为我们不希望一个只读的值同时还能被写mut,这样会导致歧义。
解释错误
澄清了必要概念以后,我们再来回顾上面的代码。先去看一下这个ext函数的定义:
fnextI(mutself,iter:I)whereI:IntoIteratorItem=T,Extsacollectionwiththecontentsofaniterator
原来只是一个语法糖,真正的方法调用会把self作为第一个参数传递到ext(mutself,iter:I)当中。可变引用作为函数参数赋值,那么自然原来的变量也必须声明成可变的。
所以我们照着它的指示修正如下:
letmutv=vec![1,2,3];//加上一个mut修饰符([1,2,3].iter().cloned());
这回编译器消停了,利用assert_eq!,我们来验证ext操作的正确性。
assert_eq!(v,[1,2,3,1,2,3]);
另外,值得注意的是,Rust和我们熟悉的函数式编程有些不同,集合的拼接不会产生一个新的集合,而是对原有的集合进行修改。一般情况下,我们都会警惕可能会出现数据的竞态条件——多个线程对该集合进行写入操作怎么办?带着这个问题,我们反思一下什么是数据的竞态条件。
数据竞态条件
数据竞态条件发生的必要条件有:
多个引用同时指向相同的数据;
至少有一个引用在写数据;
对于数据的访问没有同步机制。
考察1和2:
假如此处有两个引用指向同一个集合,如下:
letmutv=vec![1,2,3];letr1=mutv;letr2=mutv;assert_eq!(r1,r2);
编译器会立即给出编译错误
error[E0499]:cannotborrow`v`asmutablemorethanonceatatime--src/:13:10|12|letr1=mutv;|------firstmutableborrowoccurshere13|letr2=mutv;|^^^^^^secondmutableborrowoccurshere14|assert_eq!(r1,r2);|-------------------firstborrowlaterusedhere
也就是说,在指定的作用域下只能有一个可变引用。为什么要如此设计呢?在单线程下,这好像并不会出现数据竞争的问题[1]()。不过考虑到下面这种场景的语义,我们思考一下。
letmutv=vec![1,2,3];letr1=mutv;letr2=mutv;assert_eq!(r2[1],2);*r1=vec![0]assert_eq!(r2[1],2);//失效
一旦允许r1改变数据,那对于r2而言,它先前持有的数据就已经发生改变甚至失效,再拿来使用就有问题了,在上面这个例子当中,*r1解除引用后被重新赋值,导致v的值随之改变,但是r2并不知情,依旧使用r2[1]导致此处越界。这个问题和数据库中事务的不可重复读(提交读)的隔离级别类似,但是在单线程下这并不能算作充分的理由,只是说在语义层面有细微的不自然,留待后续研究。
蹊跷的是,如果我将两个可变引用放到不同的函数中,同样的逻辑却可以绕过编译器错误。
fnmain(){letmutv=vec![1,2,3];mut1(mutv);mut2(mutv);}fnmut1(v:mutVeci32){*v=vec![0];}fnmut2(v:mutVeci32){println!("{}",v[1]);//panickedat'indexoutofbounds'运行时错误}可见,上述的论述并没有解释清楚在单线程下同一个作用域下限制多个可变引用的根本原因。
对于mut和其实也可以做同样的解释。所以mut和在Rust同一个作用域中无法共存。
考察3:
至于在多线程的环境下,是否会出现数据竞态条件,我们得看Rust在线程使用方面的限制。在Rust的上下文里,使用Thread::spawn的线程时必须Move所有权[2](),因为在Rust看来,Thread的LifeTime(生命周期)会比调用它的函数的生命周期的长,如果不Move所有权,那么线程中数据就会在调用函数结束后释放掉变量的内存,导致线程中的数据无效。所以,这样的限制是很有必要的,但反过来想,一旦数据的所有权发生转移,那么多个线程并行修改同样数据的可能性也就不复存在。
构建树状结构
structEntry{name:String,children:VecEntry}fntree(path:Path)-Entry{Entry{name:_name().and_then(|name|_str()).map_or(String::from("."),|str|String::from(str)),children:_dir(){children(path)}else{Vec::new()}}}既然是树状结构,定义的结构体就是递归的。这里的structEntry{}就是一种递归的结构。我想实现的树状结构大致如下:
entry::{name,[child]}child::entryRust中没有显式的return,最后一个表达式的结果会被当成返回值,所以此处整个Entry结构体会被返回。
_name().and_then(|name|_str()).map_or(String::from("."),|str|String::from(str)),这段代码看上去很复杂,但实现的功能其实很简单,目的是为了获取当前文件的文件名。那么逻辑为何如此绕呢?这是由于Rust中的多种字符串表示导致的问题,暂按不表。先去看看各个函数的定义。
_name的定义
pubfnfile_name(self)-OptionOsStr
and_then是我们常见的flat_map操作在Rust中的命名,其目的是为了在两个Option之间实现转换。
_str的定义
pubfnto_str(self)-Optionstr
上面的_name().and_then(|name|_str())最终转变成了Optionstr,在其上调用_or方法并提供默认值:字符串"."。为什么要提供默认值呢?这和OsStr到Str的转换密切相关,当我们传入参数"."时,_name返回的其实是一个None。
构建了父级的树状结构,我们需要把子级的树状结构也一并完成,最终通过递归,构建出一棵内存中的目录树。
fnchildren(dir:Path)-VecEntry{fs::read_dir(dir).expect("unabletoreaddir").into_iter().map(|e|("unabletogetentry")).filter(|e|is_not_hidden(e)).map(|e|()).map(|e|tree(e)).collect()}fnis_not_hidden(entry:DirEntry)-bool{_name().to_str().map(|s|!_with(".")).unwrap_or(false)}这里也存在挺多的转换操作,我们一一解释。
fs::read_dir(dir).expect("unabletoreaddir")使用expect是因为fs::read_dir返回的是一个ResultReadDir,在其上调用expect会尝试解开其中的值,如果有错则会抛出错误。解开的结果类型是ReadDir,它是io::ResultDirEntry的迭代器,也就是一个目录下的所有类目,可以在上面调用into_iter()创建出可以被消费的迭代器。
.map(|e|("unabletogetentry")).filter(|e|is_not_hidden(e)).map(|e|()).map(|e|tree(e))接着,解开ResultDirEntry之后,我们把隐藏文件过滤掉,因为filter接收的一个闭包,这个闭包的类型声明是P:FnMut(Self::Item)-bool,所以filter接收的所有元素都是引用类型,故调用时无需需声明成is_not_hidden(e)。
然后利用()获取每个文件的全路径,并依次交给tree去递归构建。经过tree和children两个函数的交替递归,内存中的一棵目录树就被构建出来了。
有了内存中的树状结构,我们接下来就可以渲染这个结构了。具体的做法如下:
对于第一层目录名,如果它是最后一个目录,则前缀修饰为L_branch="└──";反之,装饰成T_branch="├──"。
对于有子目录,如果是其父目录是父级最后一个目录,则前缀装饰为SPACER="";反之,前缀装饰成I_branch="│"。
逻辑如下:
fndecorate(is_last:bool,children:VecString)-VecString{constI_BRANCH:str="│";constT_BRANCH:str="├──";constL_BRANCH:str="└──";constSPACER:str="";letprefix_first=ifis_last{L_BRANCH}else{T_BRANCH};letprefix_rest=ifis_last{SPACER}else{I_BRANCH};letmutfirst=vec![format!("{}{}",prefix_first,children[0])];(children[1..].iter().map(|child|format!("{}{}",prefix_rest,child)).collect::Vec_());first}这里比较好用的字符串拼接操作是format!("{}{}",str,str)。
渲染树状结构
fnrer_tree(tree:Entry)-VecString{letmutnames=vec![];//errorletchildren=;letchildren:Vec_=().enumerate().map(|(i,child)|decorate(()-1==i,rer_tree(child))).flatten().collect();(children);names}这里会有编译错误,错误信息如下:
error[e0507]:cannotmoveoutof``whichisbehindasharedreference--src/:48:26|48|letmutnames=vec![];|^^^^^^^^^moveoccursbecause``hastype`std::string::string`,whichdoesnotimplementthe`copy`trait
由于不是标量类型(ScalarType),它没有实现copytrait(见提示),又因为tree本身是复合类型(CompoundType),如果发生Move的话,包含它的tree就有问题了。为了避免发生这种情况,我们不得不去引用。但是一旦加上引用,又会出现类型不匹配的编译错误。
59|names|^^^^^expectedstruct`std::string::String`,foundreference|=note:expectedtype`std::vec::Vecstd::string::String`foundtype`std::vec::Vecstd::string::String`
我们期待的是VecString而不是VecString,所以需要重新构建出一个String出来。可以使用String::from(String)方法
letmutnames=vec![String::from()];
这样修改下来,才能保证编译完全通过。但事实上,Rust给我们提供了一个更加便捷的写法
letmutnames=vec![_owned()]
使用to_owned()表示重新拷贝了一份数据,和重新构建一个String出来别无二致。
组合调用
usestd::env;usestd::path::Path;usestd::fs::{self,DirEntry};fnmain(){letargs:VecString=env::args().collect();println!("{}",rer_tree(tree(Path::new(args[1]))).join("\n"));}rer_tree返回的是VecString,所以为了打印出来,我们将所有元素用"\n"join到一起。
.├──├──└──src└──
总结
学习下来的一些主观感觉是Rust中的概念繁杂,有些地方的设计确实让人有些迷惑。再加上类型众多(如:OsStr,String),代码很难通过直觉判断写出,需要大量查阅文档才能让编译器消停。所以学习曲线相对陡峭。
不过,语言约束的越多,某种程度上讲,对于程序员而言却是福音。Ifitcompiles,thenitworks.的哲学理念在前,学习道阻且长,努力加餐饭。
提示
一般标量类型都实现了copytrait.
所有的整型,如:u32
布尔类型,如:true或false
字符类型,如:char
浮点数类型,如:f64
当且仅当所有元素都是Copy的元组,如:(i32,i32)是Copy,但是(i32,String)就不是Copy的。