作者|腾讯TEG后台开发工程师孟杰不久前,Stackoverflow网站对8万多名开发者进行了调查。》选项中,Rust排名第一一、赋值的移动语义1、C++vsRustC++的赋值操作是复制语义,不考虑优化,从语义上看,赋值后,内存中的一个对象变成了两个副本.修改新对象对旧对象没有副作用.Rust对赋值操作有更细粒度的控制,以下两种:对于所有实现了Copytrait的类型,赋值采用复制语义.对于其他情况,move使用语义,在Rust中,直接使用编译器来保证move语义,保证变量的值在被移出后不能被重用,如下例:fnmain(){letmutx=5;letrx0=&mutx;letrx1=rx0;println!("test{}",rx0);}会产生编译错误:error[E0382]:borrowofmovedvalue:`rx0`-->src/main.rs:5:25|3|letrx0=&mutx;|---发生移动是因为`rx0`的类型是`&muti32`,它没有实现`复制`trait4|让rx1=rx0;|---值移至此处5|println!("测试{}",rx0);|^^^valueborrowedhereaftermove很清楚的解释了原因:这个变量在move之后又被使用,用在什么地方,为什么要采用move语义。在C++中,可以通过禁用类的拷贝构造函数来达到禁止变量拷贝的目的。例如,无法编译以下代码:#includeusingnamespacestd;intmain(intargc,constchar*argv[]){autoint_p0=unique_ptr(newint);自动int_p1=int_p0;*int_p0=5;return0;}将在clang++中生成以下错误:main.cc:8:10:error:calltoimplicitly-deletedcopyconstructorof'std::__1::unique_ptr>'自动int_p1=int_p0;^~~~~~~/opt/llvm/clang-10.0.1/bin/../include/c++/v1/memory:2513:3:注意:复制构造函数被隐式删除,因为'unique_ptr>'hasauser-declaredmoveconstructorunique_ptr(unique_ptr&&__u)_NOEXCEPT^但是你只需要将错误行改成如下代码即可编译通过:autoint_p1=std::move(int_p0);但是后果就是程序在给*int_p0赋值的时候会产生一个coredump。这也是Rust所谓的内存安全,即只要不使用unsafe,编译器就可以发现错误的内存访问,拒绝编译。2.引用&T和变量引用&mutT还是上面的例子,如果把变量引用改成非变量引用(引用的默认形式),代码如下:fnmain(){letx=5;让rx0=&x;让rx1=rx0;println!("test{}",rx0);}可以编译。Rust文档声明如下:以下特性为所有&T实现,无论其引用类型如何:CopyClone(请注意,如果它存在,这将不会推迟T的Clone实现!)DerefBorrowPointer*&mutT引用获取所有除了Copy和Clone(以防止同时创建多个可变借用)外,加上以下内容,无论其引用类型如何:DerefMutBorrowMut&mutT实现Copy和Clone小于&T。因此,对于变量引用&mutT,赋值采用的是move语义,而对于普通引用&T,采用的是copy语义,因此上述程序改成普通引用即可编译通过。这就是为什么变量引用也被称为独占引用的原因,因为每次对一个变量引用赋值都意味着旧变量失效,这保证了全局只存在一个变量引用。Rust在这里体现了语言设计的优雅:将赋值操作的语义委托给类型系统,同时通过定义基本机制来约束自定义类型和内置类型的行为,并完成检查在编译时,而不是要求开发人员记住每个特殊情况。当你不了解这门语言时,这会形成一个学习曲线,但一旦你理解了它的例程(ThinkinginRust),它可以显着减轻编码过程中的精神负担。2、option和空指针1.enum和match在C++中,对于可能存在也可能不存在的变量,通常的做法之一是传入指针(包括现代C++中的智能指针shared_ptr和unique_ptr),在处理时,通过检查指针是否为空以确定变量是否存在。这是一种非常方便的做法,但同时这种方案不能在编译时做更多的检查,最终检查的责任交给了开发者。Rust针对这个问题主要使用了两种机制:枚举(enum)和模式匹配(match)。与C++enum相比,Rust的enum更像是C++union。它是Rust中ADT(代数数据类型)中求和类型(标记联合)的实现。在Rust中,enum可能包括一组类型中的一个,如:enumMessage{Quit,Move{x:i32,y:i32},Write(String),}上面的代码表示一个消息(Message)可能有三种类型:退出、移动和写入。当type为Move或Write时,也可以带上自己的具体数据。在处理Message时,会使用模式匹配机制获取具体类型进行处理:matchmessage{Message::Quit=>todo!(),Message::Move{x,y}=>todo!(),Message::Write(info)=>todo!(),}为了避免修改enum的定义后忘记在match中添加相应的处理,match在编译时会要求分支覆盖所有可能的情况。比如给Message添加一个新的item:enumMessage{Quit,Move{x:i32,y:i32},Write(String),Send(String),//Newadded}重新编译时会出现如下错误,提示开发添加Sendtomatch的处理。-->源/main.rs:9:11|1|/枚举消息{2||退出,3||移动{x:i32,y:i32},4||写(字符串),5||发送(字符串),||----未涵盖6||}||_-此处定义的“消息”...9|匹配消息{|^^^^^^^模式`Send(_)`未被覆盖|=help:确保所有可能的情况都被处理,可能通过添加通配符或者更多的matcharms=note:匹配的值是`Message`类型可以看出在C++中,和它最相似的类型其实是C++17的std::variant,匹配机制类似于std::visit。但是Rust在这方面做得比较好,体现在:同一个子类型可以因为不同的Tag出现多次,比如上面的Write和Send,两个子类型都是String。这是std::variant不能直接做的事情,除非它封装了一个结构。match会要求分支覆盖enum的所有变体,而std::visit也会在编译时检查完整的类型覆盖,但类型会考虑C++的隐式类型转换,所以使用时需要小心。2.Option有了上面的初步知识,我们现在就可以明白如何处理Rust中的悬空指针问题了。我们先看一下Option的定义:pubenumOption{///NovalueNone,///Somevalue`T`Some(T),}在Rust中,对于可选的情况,会定义作为Option类型的变量。假设一个函数提供从磁盘读取一个令牌,令牌可能存在也可能不存在,那么函数的定义将是:structToken{/*...*/};fnload_token()->Option;使用时会用到如下代码:lettoken=load_token();//此时token的类型为Optionmatchtoken{Some(token)=>{//注意这里的token是由Some(token)创建的,匹配的pattern已经覆盖了最外层令牌。此时token的类型为Token,//已经确保存在。todo!()},None=>todo!()}可以看出,在返回Option的情况下,Option不能直接当成T,只能通过模式匹配机制(match,iflet,whilelet,等)可以使用,提取T进行处理。这种强制性机制可确保检查可空变量并避免意外访问悬空指针。与用指针表达可选情况相比,Option具有更强的表达能力,因为没有T到T*的强制转换,保留了移动优化的可能性;同时,使用特殊类型来表达可选的Choose,语义上更加精确。了解Haskell的同学可以发现Option和Maybe完全一样。事实上,Rust的类型系统很大程度上受到了Haskell的影响,所以在很多地方都能看到Haskell的影子也就不足为奇了。学习Haskell也将有助于理解Rust。最后说明一下,C++17中加入的std::optional实现了类似的功能。从接口上看,它仍然像一个智能指针,需要在使用前进行判断,否则解引用std::nullopt仍然会导致运行时失败。3.迭代器Iterator1。Iterator在Rust中的地位Iterator是Rust比较独特的一个特性。对于Rust,通过以下方式遍历数组是低效的:letdata=vec![1,2,3,4,5];foriin0..data.len(){println!("{}",data[i]);}因为对安全的妥协,每次data[i]操作都会进行一次边界检查,这显然是不必要的在性能敏感的场景中是不可接受的。因此,Rust中推荐的做法是:forvindata{println!("{}",v);}使用迭代器的形式,避免了在获取最终值时再次进行边界检查,也更加简洁。可以看出,在地道的Rust风格中,遍历数组应该使用迭代器来完成,而不是通过遍历下标来进行索引。对于现代C++(C++11),容器遍历也提供了类似的语法方法:for(auto&&v:data){//dosomethingforv}对象,以std::vec::Vec为例,通常提供三种获取迭代器的方法,如下:iter():获取元素的引用,即&T,不可消耗。iter_mut():获取元素的变量引用,即&mutT,不可消耗。into_iter():获取元素的所有权,即T,consumable。这里的可耗性指的是在迭代完成后,原有的容器是否可以继续使用。对于into_iter(),在迭代过程中已经获得了容器中所有元素的所有权,所以最终的容器不再持有任何对象,同时被丢弃。因此,它被称为消耗品。3.IntoIterator对于一般的迭代形式:forxindata{}Rust期望data是一个实现了Iterator的对象。否则,它将尝试使用IntoIterator将数据转换为`Iterator`对象。所以对于data:Vec,实际展开变成如下代码:forxinIntoIterator::into_iter(data){}这里for...in语句使用IntoIterator::into_iter获取目标对象的迭代器。因此,任何实现IntoIterator的类型都可以使用for...in语句进行迭代。以std::vec::Vec为例,分别为Vec、&Vec、&mutVec实现IntoIterator,分别委托给into_iter()、iter()、iter_mut()来处理上述三种不同的迭代以上形式。以下示例(为清楚起见添加了类型注释):letmutdata:Vec=Vec::from([1,2,3,4]);//getreferenceforv:&i32in&data{}//Obtainvariablereferenceforv:&muti32in&mutdata{}//Obtainownershipforv:i32indata{}4.链式调用Rust的设计中,通过IteratorHandlecollections可以灵活高效的使用Adapter。Adapter是指Rust中的一类函数,它接收一个Iterator并返回一个Iterator。这样的接口规范可以用来将多个Adapter通过链式调用组合起来,完成复杂的功能。常见的Adapter包括:map、filter、filter_map等。除了Adapter之外,Rust还提供了一些其他函数用于迭代器的最终处理。例如:count:用来计算元素的个数。collect:用于将迭代器中的元素收集到一个实现了FromIterator的类型,如Vec、VecDeque、String等。reduce:使用一个函数来归约一个集合。同样,fold也可以用于初值缩减。可以看出,对于迭代器,Rust提供了丰富的函数来处理它们。有关详细信息,请参阅文档。这种编码风格与C++的旧风格截然不同。转用Rust后,当集合需要循环时,可以有意识地思考逻辑是否可以写成迭代器的形式,通常可以获得更简洁的代码,同时,如前所述,也可以获得更高性能的代码。最后,C++社区也在积极采用这种编码风格。在C++20中,范围已添加到标准中。提供的Range适配器与Rust适配器的概念基本相同。如C++的示例代码:autoconstints={0,1,2,3,4,5};autoeven=[](inti){return0==i%2;};autosquare=[](inti){returni*i;};//组成视图的“管道”语法:for(inti:ints|std::views::filter(even)|std::views::transform(square)){std::cout<Self{Self{n0:0,n1:1}}}implIteratorforFib{typeItem=u64;fnnext(&mutself)->Option{让n=self.n0+self.n1;自我.n0=自我.n1;self.n1=n;Some(self.n0)}}fnmain(){letfib=Fib::default();设平方=|i:u64|我*我;forninfib.map(square).take(10){println!("{}",n);}}5.总结这篇文章主要是记录我在从C++转向Rust时遇到的一些问题,尤其是两种语言在编程设计中对基本问题的不同处理方式。本文主要介绍三个主题:move语义、Option和Iterator。