作者:京东零售周凯一。前言 Rust语言由Mozilla开发,最早发布于2014年9月,是一种高效、可靠的通用高级语言。其高效不仅限于开发效率,它的执行效率也是令人称赞的,是一种少有的兼顾开发效率和执行效率的语言。Rust语言具备如下特性: 高性能Rust速度惊人且内存利用率极高。由于没有运行时和垃圾回收,它能够胜任对性能要求特别高的服务,可以在嵌入式设备上运行,还能轻松和其他语言集成。 可靠性Rust丰富的类型系统和所有权模型保证了内存安全和线程安全,让您在编译期就能够消除各种各样的错误。 生产力Rust拥有出色的文档、友好的编译器和清晰的错误提示信息,还集成了一流的工具包管理器和构建工具,智能地自动补全和类型检验的多编辑器支持,以及自动格式化代码等等。 Rust最近几年发展非常迅速,广受一线程序员的欢迎,Rust有一个官方维护的模块库(crates。io:RustPackageRegistry),可以通过编译器自带的cargo管理工具方便的引入模块,目前crates。io上面的模块数量已经突破10万个,仍在快速增长,此情此景仿佛过去10年node。js的发展情景再现。 12月11日,LinusTorvalds发布了Linux6。1内核稳定版,并带来一个重磅的新闻,即Linux6。1将包含对Rust语言的原生支持。尽管这一功能仍在构建中,不过这也意味着,在可见的将来,Linux的历史将翻开崭新的一页除了C之外,开发人员将第一次能够使用另一种语言Rust进行内核开发。 在近几年的讨论中,是否在Linux内核中引入Rust多次成为议题。不过包括Torvalds在内的一众关键人物均对此表示了期待。早在2019年,AlexGaynor和GeoffreyThomas就曾于LinuxSecuritySummit安全峰会上进行了演讲。他们指出,在Android和Ubuntu中,约有三分之二的内核漏洞被分配到CVE中,这些漏洞都是来自于内存安全问题。原则上,Rust可以通过其typesystem和borrowchecker所提供的更安全的API来完全避免这类错误。简言之,Rust比C更安全。谷歌Android团队的WedsonAlmeidaFilho也曾公开表示:我们觉得Rust现在已经准备好加入C语言,作为实现内核的实用语言。它可以帮助我们减少特权代码中潜在错误和安全漏洞的数量,同时很好地与核心内核配合并保留其性能特征。 当前,谷歌在Android中广泛使用Rust。在那里,目标不是将现有的CC转换为Rust,而是随着时间的推移,将新代码的开发转移到内存安全语言。这一言论也逐渐在实践中得到论证。随着进入Android的新内存不安全代码的数量减少,内存安全漏洞的数量也在减少。从2019年到2022年,相关漏洞占比已从Android总漏洞的76下降到35。2022年,在Android漏洞排行中,内存安全漏洞第一次不再是主因。 本文将探寻相比于其他语言,Rust是怎样实现内存安全的。Rust针对创建于内存堆上的复杂数据类型,设计了一套独有的内存管理机制,该套机制包含变量的所有权机制、变量的作用域、变量的引用与借用,并专门针对字符串、数组、元组等复杂类型设计了slice类型,下面将具体讲述这些机制与规则。二。变量的所有权 Rust的核心功能(之一)是所有权(ownership)。虽然该功能很容易解释,但它对语言的其他部分有着深刻的影响。 所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。 因为所有权对很多程序员来说都是一个新概念,需要一些时间来适应。好消息是随着你对Rust和所有权系统的规则越来越有经验,你就越能自然地编写出安全和高效的代码。持之以恒! 当你理解了所有权,你将有一个坚实的基础来理解那些使Rust独特的功能。在本章中,我们将通过完成一些示例来介绍所有权,这些示例基于一个常用的数据结构:字符串。 栈(Stack)与堆(Heap)在很多语言中,你并不需要经常考虑到栈与堆。不过在像Rust这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。我们会在本文的稍后部分描述所有权与栈和堆相关的内容,所以这里只是一个用来预热的简要解释。栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作后进先出(lastin,firstout)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做进栈(pushingontothestack),而移出数据叫做出栈(poppingoffthestack)。栈中的所有数据都必须占用已知且固定的大小。在编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。内存分配器(memoryallocator)在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针(pointer)。这个过程称作在堆上分配内存(allocatingontheheap),有时简称为分配(allocating)。(将数据推入栈中并不被认为是分配)。因为指向放入堆中数据的指针是已知的并且大小是固定的,你可以将该指针存储在栈上,不过当需要实际数据时,必须访问指针。想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。入栈比在堆上分配内存要快,因为(入栈时)分配器无需为存储新数据去搜索内存空间;其位置总是在栈顶。相比之下,在堆上分配内存则需要更多的工作,这是因为分配器必须首先找到一块足够存放数据的内存空间,并接着做一些记录为下一次分配做准备。访问堆上的数据比访问栈上的数据慢,因为必须通过指针来访问。现代处理器在内存中跳转越少就越快(缓存)。继续类比,假设有一个服务员在餐厅里处理多个桌子的点菜。在一个桌子报完所有菜后再移动到下一个桌子是最有效率的。从桌子A听一个菜,接着桌子B听一个菜,然后再桌子A,然后再桌子B这样的流程会更加缓慢。出于同样原因,处理器在处理的数据彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的主要目的就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。2。1。所有权规则 首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则: Rust中的每一个值都有一个所有者(owner)。值在任一时刻有且只有一个所有者。当所有者(变量)离开作用域,这个值将被丢弃。2。2。变量作用域 既然我们已经掌握了基本语法,将不会在之后的例子中包含fnmain(){代码,所以如果你是一路跟过来的,必须手动将之后例子的代码放入一个main函数中。这样,例子将显得更加简明,使我们可以关注实际细节而不是样板代码。 在所有权的第一个例子中,我们看看一些变量的作用域(scope)。作用域是一个项(item)在程序中有效的范围。假设有这样一个变量: 变量s绑定到了一个字符串字面值,这个字符串值是硬编码进程序代码中的。这个变量从声明的点开始直到当前作用域结束时都是有效的。示例1中的注释标明了变量s在何处是有效的。{s在这里无效,它尚未声明从此处起,s是有效的使用s}此作用域已结束,s不再有效 示例1:一个变量和其有效的作用域 换句话说,这里有两个重要的时间点: 当s进入作用域时,它就是有效的。 这一直持续到它离开作用域为止。 目前为止,变量是否有效与作用域的关系跟其他编程语言是类似的。现在我们在此基础上介绍String类型。2。3。String类型 为了演示所有权的规则,我们需要一个比基本数据类型都要复杂的数据类型。前面介绍的类型都是已知大小的,可以存储在栈中,并且当离开作用域时被移出栈,如果代码的另一部分需要在不同的作用域中使用相同的值,可以快速简单地复制它们来创建一个新的独立实例。不过我们需要寻找一个存储在堆上的数据来探索Rust是如何知道该在何时清理数据的。 我们会专注于String与所有权相关的部分。这些方面也同样适用于标准库提供的或你自己创建的其他复杂数据类型。 我们已经见过字符串字面值,即被硬编码进程序里的字符串值。字符串字面值是很方便的,不过它们并不适合使用文本的每一种场景。原因之一就是它们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道:例如,要是想获取用户输入并存储该怎么办呢?为此,Rust有第二个字符串类型,String。这个类型管理被分配到堆上的数据,所以能够存储在编译时未知大小的文本。可以使用from函数基于字符串字面值来创建String,如下:letsString::from(hello); 这两个冒号::是运算符,允许将特定的from函数置于String类型的命名空间(namespace)下,而不需要使用类似stringfrom这样的名字。 可以修改此类字符串:letmutsString::from(hello);s。pushstr(,world!);pushstr()在字符串后追加字面值println!({},s);将打印hello,world! 那么这里有什么区别呢?为什么String可变而字面值却不行呢?区别在于两个类型对内存的处理上。2。4。内存与分配 就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。 对于String类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着: 必须在运行时向内存分配器(memoryallocator)请求内存。 需要一个当我们处理完String时将内存返回给分配器的方法。 第一部分由我们完成:当调用String::from时,它的实现(implementation)请求其所需的内存。这在编程语言中是非常通用的。 然而,第二部分实现起来就各有区别了。在有垃圾回收(garbagecollector,GC)的语言中,GC记录并清除不再使用的内存,而我们并不需要关心它。在大部分没有GC的语言中,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个bug。我们需要精确的为一个allocate配对一个free。 Rust采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是示例1中作用域例子的一个使用String而不是字符串字面值的版本:{letsString::from(hello);从此处起,s是有效的使用s}此作用域已结束,s不再有效 这是一个将String需要的内存返回给分配器的很自然的位置:当s离开作用域的时候。当变量离开作用域,Rust为我们调用一个特殊的函数。这个函数叫做drop,在这里String的作者可以放置释放内存的代码。Rust在结尾的}处自动调用drop。 注意:在C中,这种item在生命周期结束时释放资源的模式有时被称作资源获取即初始化(ResourceAcquisitionIsInitialization(RAII))。如果你使用过RAII模式的话应该对Rust的drop函数并不陌生。 这个模式对编写Rust代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。2。4。1。变量与数据交互的方式(一):移动 在Rust中,多个变量可以采取不同的方式与同一数据进行交互。让我们看看示例2中一个使用整型的例子。letx5; 示例2:将变量x的整数值赋给y 我们大致可以猜到这在干什么:将5绑定到x;接着生成一个值x的拷贝并绑定到y。现在有了两个变量,x和y,都等于5。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个5被放入了栈中。 现在看看这个String版本:lets1String::from(hello);lets2s1; 这看起来与上面的代码非常类似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个s1的拷贝并绑定到s2上。不过,事实上并不完全是这样。 看看图1以了解String的底层会发生什么。String由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。 图1:将值hello绑定给s1的String在内存中的表现形式 长度表示String的内容当前使用了多少字节的内存。容量是String从分配器总共获取了多少字节的内存。长度与容量的区别是很重要的,不过在当前上下文中并不重要,所以现在可以忽略容量。 当我们将s1赋值给s2,String的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如图2所示。 图2:变量s2的内存表现,它有一份s1指针、长度和容量的拷贝 这个表现形式看起来并不像图3中的那样,如果Rust也拷贝了堆上的数据,那么内存看起来就是这样的。如果Rust这么做了,那么操作s2s1在堆上数据比较大的时候会对运行时性能造成非常大的影响。 图3:另一个s2s1时可能的内存表现,如果Rust同时也拷贝了堆上的数据的话 之前我们提到过当变量离开作用域后,Rust自动调用drop函数并清理变量的堆内存。不过图2展示了两个数据指针指向了同一位置。这就有了一个问题:当s2和s1离开作用域,他们都会尝试释放相同的内存。这是一个叫做二次释放(doublefree)的错误,也是之前提到过的内存安全性bug之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。 为了确保内存安全,在lets2s1之后,Rust认为s1不再有效,因此Rust不需要在s1离开作用域后清理任何东西。看看在s2被创建之后尝试使用s1会发生什么;这段代码不能运行:lets1String::from(hello);lets2s1;println!({},world!,s1); 你会得到一个类似如下的错误,因为Rust禁止你使用无效的引用。cargorunCompilingownershipv0。1。0(file:projectsownership)error〔E0382〕:borrowofmovedvalue:s1srcmain。rs:5:282lets1String::from(hello);moveoccursbecauses1hastypeString,whichdoesnotimplementtheCopytrait3lets2s1;valuemovedhere45println!({},world!,s1);valueborrowedhereaftermoveFormoreinformationaboutthiserror,tryrustcexplainE0382。error:couldnotcompileownershipduetopreviouserror 如果你在其他语言中听说过术语浅拷贝(shallowcopy)和深拷贝(deepcopy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为Rust同时使第一个变量无效了,这个操作被称为移动(move),而不是浅拷贝。上面的例子可以解读为s1被移动到了s2中。那么具体发生了什么,如图4所示。 图4:s1无效之后的内存表现 这样就解决了我们的问题!因为只有s2是有效的,当其离开作用域,它就释放自己的内存,完毕。 另外,这里还隐含了一个设计选择:Rust永远也不会自动创建数据的深拷贝。因此,任何自动的复制可以被认为对运行时性能影响较小。2。4。2。变量与数据交互的方式(二):克隆 如果我们确实需要深度复制String中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做clone的通用函数。第五章会讨论方法语法,不过因为方法在很多语言中是一个常见功能,所以之前你可能已经见过了。 这是一个实际使用clone方法的例子:lets1String::from(hello);lets2s1。clone();println!(s1{},s2{},s1,s2); 这段代码能正常运行,并且明确产生图3中行为,这里堆上的数据确实被复制了。 当出现clone调用时,你知道一些特定的代码被执行而且这些代码可能相当消耗资源。你很容易察觉到一些不寻常的事情正在发生。2。4。3。只在栈上的数据:拷贝 这里还有一个没有提到的小窍门。这些代码使用了整型并且是有效的,他们是示例2中的一部分:letx5;println!(x{},y{},x,y); 但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用clone,不过x依然有效且没有被移动到y中。 原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量y后使x无效。换句话说,这里没有深浅拷贝的区别,所以这里调用clone并不会与通常的浅拷贝有什么不同,我们可以不用管它。 Rust有一个叫做Copytrait的特殊注解,可以用在类似整型这样的存储在栈上的类型上。如果一个类型实现了Copytrait,那么一个旧的变量在将其赋值给其他变量后仍然可用。 Rust不允许自身或其任何部分实现了Droptrait的类型使用Copytrait。如果我们对其值离开作用域时需要特殊处理的类型使用Copy注解,将会出现一个编译时错误。 那么哪些类型实现了Copytrait呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现Copy,任何不需要分配内存或某种形式资源的类型都可以实现Copy。如下是一些Copy的类型: 所有整数类型,比如u32。 布尔类型,bool,它的值是true和false。 所有浮点数类型,比如f64。 字符类型,char。 元组,当且仅当其包含的类型也都实现Copy的时候。比如,(i32,i32)实现了Copy,但(i32,String)就没有。2。5。所有权与函数 将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。示例3使用注释展示变量何时进入和离开作用域: 文件名:srcmain。rsfnmain(){letsString::from(hello);s进入作用域takesownership(s);s的值移动到函数里。。。。。。所以到这里不再有效letx5;x进入作用域makescopy(x);x应该移动函数里,但i32是Copy的,所以在后面可继续使用x}这里,x先移出了作用域,然后是s。但因为s的值已被移走,没有特殊之处fntakesownership(somestring:String){somestring进入作用域println!({},somestring);}这里,somestring移出作用域并调用drop方法。占用的内存被释放fnmakescopy(someinteger:i32){someinteger进入作用域println!({},someinteger);}这里,someinteger移出作用域。没有特殊之处 示例3:带有所有权和作用域注释的函数 当尝试在调用takesownership后使用s时,Rust会抛出一个编译时错误。这些静态检查使我们免于犯错。试试在main函数中添加使用s和x的代码来看看哪里能使用他们,以及所有权规则会在哪里阻止我们这么做。2。6。返回值与作用域 返回值也可以转移所有权。示例4展示了一个返回了某些值的示例,与示例3一样带有类似的注释。 文件名:srcmain。rsfnmain(){lets1givesownership();givesownership将返回值转移给s1lets2String::from(hello);s2进入作用域lets3takesandgivesback(s2);s2被移动到takesandgivesback中,它也将返回值移给s3}这里,s3移出作用域并被丢弃。s2也移出作用域,但已被移走,所以什么也不会发生。s1离开作用域并被丢弃fngivesownership()String{givesownership会将返回值移动给调用它的函数letsomestringString::from(yours);somestring进入作用域。somestring返回somestring并移出给调用的函数}takesandgivesback将传入字符串并返回该值fntakesandgivesback(astring:String)String{astring进入作用域astring返回astring并移出给调用的函数} 示例4:转移返回值的所有权 变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过drop被清理掉,除非数据被移动为另一个变量所有。 虽然这样是可以的,但是在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传进去再返回来就有点烦人了,除此之外,我们也可能想返回函数体中产生的一些数据。 我们可以使用元组来返回多个值,如示例5所示。 文件名:srcmain。rsfnmain(){lets1String::from(hello);let(s2,len)calculatelength(s1);println!(Thelengthof{}is{}。,s2,len);}fncalculatelength(s:String)(String,usize){letlengths。len();len()返回字符串的长度(s,length)} 示例5:返回参数的所有权 但是这未免有些形式主义,而且这种场景应该很常见。幸运的是,Rust对此提供了一个不用获取所有权就可以使用值的功能,叫做引用(references)。三。引用与借用 示例5中的元组代码有这样一个问题:我们必须将String返回给调用函数,以便在调用calculatelength后仍能使用String,因为String被移动到了calculatelength内。相反我们可以提供一个String值的引用(reference)。引用(reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。与指针不同,引用确保指向某个特定类型的有效值。 下面是如何定义并使用一个(新的)calculatelength函数,它以一个对象的引用作为参数而不是获取值的所有权: 文件名:srcmain。rsfnmain(){lets1String::from(hello);letlencalculatelength(s1);println!(Thelengthof{}is{}。,s1,len);}fncalculatelength(s:String)usize{s。len()} 首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递s1给calculatelength,同时在函数定义中,我们获取String而不是String。这些符号就是引用,它们允许你使用值但不获取其所有权。图5展示了一张示意图。 图5:Strings指向Strings1示意图 注意:与使用引用相反的操作是解引用(dereferencing),它使用解引用运算符,。我们将会在第八章遇到一些解引用运算符,并在第十五章详细讨论解引用。 仔细看看这个函数调用:lets1String::from(hello);letlencalculatelength(s1); s1语法让我们创建一个指向值s1的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。 同理,函数签名使用来表明参数s的类型是一个引用。让我们增加一些解释性的注释:fncalculatelength(s:String)usize{s是String的引用s。len()}这里,s离开了作用域。但因为它并不拥有引用值的所有权,所以什么也不会发生 变量s有效的作用域与函数参数的作用域一样,不过当s停止使用时并不丢弃引用指向的数据,因为s并没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。 我们将创建一个引用的行为称为借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。 如果我们尝试修改借用的变量呢?尝试示例6中的代码。剧透:这行不通! 文件名:srcmain。rsfnmain(){letsString::from(hello);change(s);}fnchange(somestring:String){somestring。pushstr(,world);} 示例6:尝试修改借用的值 这里是错误:cargorunCompilingownershipv0。1。0(file:projectsownership)error〔E0596〕:cannotborrowsomestringasmutable,asitisbehindareferencesrcmain。rs:8:57fnchange(somestring:String){help:considerchangingthistobeamutablereference:mutString8somestring。pushstr(,world);somestringisareference,sothedataitreferstocannotbeborrowedasmutableFormoreinformationaboutthiserror,tryrustcexplainE0596。error:couldnotcompileownershipduetopreviouserror 正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。3。1。可变引用 我们通过一个小调整就能修复示例6代码中的错误,允许我们修改一个借用的值,这就是可变引用(mutablereference): 文件名:srcmain。rsfnmain(){letmutsString::from(hello);change(muts);}fnchange(somestring:mutString){somestring。pushstr(,world);} 首先,我们必须将s改为mut。然后在调用change函数的地方创建一个可变引用muts,并更新函数签名以接受一个可变引用somestring:mutString。这就非常清楚地表明,change函数将改变它所借用的值。 可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个s的可变引用的代码会失败: 文件名:srcmain。rsletmutsString::from(hello);letr1letr2println!({},{},r1,r2); 错误如下:cargorunCompilingownershipv0。1。0(file:projectsownership)error〔E0499〕:cannotborrowsasmutablemorethanonceatatimesrcmain。rs:5:144letr1firstmutableborrowoccurshere5letr2secondmutableborrowoccurshere67println!({},{},r1,r2);firstborrowlaterusedhereFormoreinformationaboutthiserror,tryrustcexplainE0499。error:couldnotcompileownershipduetopreviouserror 这个报错说这段代码是无效的,因为我们不能在同一时间多次将s作为可变变量借用。第一个可变的借入在r1中,并且必须持续到在println!中使用它,但是在那个可变引用的创建和它的使用之间,我们又尝试在r2中创建另一个可变引用,该引用借用与r1相同的数据。 这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。新Rustacean们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是Rust可以在编译时就避免数据竞争。数据竞争(datarace)类似于竞态条件,它可由这三个行为造成: 两个或更多指针同时访问同一数据。 至少有一个指针被用来写入数据。 没有同步数据访问的机制。 数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码! 一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能同时拥有:letmutsString::from(hello);{letr1}r1在这里离开了作用域,所以我们完全可以创建一个新的引用letr2 Rust在同时使用可变与不可变引用时也采用的类似的规则。这些代码会导致一个错误:letmutsString::from(hello);letr1s;没问题letr2s;没问题letr3大问题println!({},{},and{},r1,r2,r3); 错误如下:cargorunCompilingownershipv0。1。0(file:projectsownership)error〔E0502〕:cannotborrowsasmutablebecauseitisalsoborrowedasimmutablesrcmain。rs:6:144letr1s;noproblemimmutableborrowoccurshere5letr2s;noproblem6letr3BIGPROBLEMmutableborrowoccurshere78println!({},{},and{},r1,r2,r3);immutableborrowlaterusedhereFormoreinformationaboutthiserror,tryrustcexplainE0502。error:couldnotcompileownershipduetopreviouserror 错误提示我们也不能在拥有不可变引用的同时拥有可变引用。 不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。 注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用(println!),发生在声明可变引用之前,所以如下代码是可以编译的:letmutsString::from(hello);letr1s;没问题letr2s;没问题println!({}and{},r1,r2);此位置之后r1和r2不再使用letr3没问题println!({},r3); 不可变引用r1和r2的作用域在println!最后一次使用之后结束,这也是创建可变引用r3的地方。它们的作用域没有重叠,所以代码是可以编译的。编译器在作用域结束之前判断不再使用的引用的能力被称为非词法作用域生命周期(NonLexicalLifetimes,简称NLL)。 尽管这些错误有时使人沮丧,但请牢记这是Rust编译器在提前指出一个潜在的bug(在编译时而不是在运行时)并精准显示问题所在。这样你就不必去跟踪为何数据并不是你想象中的那样。3。2。悬垂引用(DanglingReferences) 在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针(danglingpointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在Rust中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。 让我们尝试创建一个悬垂引用,Rust会通过一个编译时错误来避免: 文件名:srcmain。rsfnmain(){letreferencetonothingdangle();}fndangle()String{letsString::from(hello);s} 这里是错误:cargorunCompilingownershipv0。1。0(file:projectsownership)error〔E0106〕:missinglifetimespecifiersrcmain。rs:5:165fndangle()String{expectednamedlifetimeparameterhelp:thisfunctionsreturntypecontainsaborrowedvalue,butthereisnovalueforittobeborrowedfromhelp:considerusingthestaticlifetime5fndangle()staticString{Formoreinformationaboutthiserror,tryrustcexplainE0106。error:couldnotcompileownershipduetopreviouserror 错误信息引用了一个我们还未介绍的功能:生命周期(lifetimes)。第十章会详细介绍生命周期。不过,如果你不理会生命周期部分,错误信息中确实包含了为什么这段代码有问题的关键信息:thisfunctionsreturntypecontainsaborrowedvalue,butthereisnovalueforittobeborrowedfrom 让我们仔细看看我们的dangle代码的每一步到底发生了什么: 文件名:srcmain。rsfndangle()String{dangle返回一个字符串的引用letsString::from(hello);s是一个新字符串s返回字符串s的引用}这里s离开作用域并被丢弃。其内存被释放。危险! 因为s是在dangle函数内创建的,当dangle的代码执行完毕后,s将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的String,这可不对!Rust不会允许我们这么做。 这里的解决方法是直接返回String:fnnodangle()String{letsString::from(hello);s} 这样就没有任何错误了。所有权被移动出去,所以没有值被释放。3。3。引用的规则 让我们概括一下之前对引用的讨论: 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。 引用必须总是有效的。 接下来,我们来看看另一种不同类型的引用:slice。四。Slice类型 slice允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice是一类引用,所以它没有所有权。 这里有一个编程小习题:编写一个函数,该函数接收一个用空格分隔单词的字符串,并返回在该字符串中找到的第一个单词。如果函数在该字符串中并未找到空格,则整个字符串就是一个单词,所以应该返回整个字符串。 让我们推敲下如何不用slice编写这个函数的签名,来理解slice能解决的问题:fnfirstword(s:String)? firstword函数有一个参数String。因为我们不需要所有权,所以这没有问题。不过应该返回什么呢?我们并没有一个真正获取部分字符串的办法。不过,我们可以返回单词结尾的索引,结尾由一个空格表示。试试如示例7中的代码。 文件名:srcmain。rsfnfirstword(s:String)usize{letbytess。asbytes();for(i,item)inbytes。iter()。enumerate(){ifitemb{}}s。len()} 示例7:firstword函数返回String参数的一个字节索引值 因为需要逐个元素的检查String中的值是否为空格,需要用asbytes方法将String转化为字节数组:letbytess。asbytes(); 接下来,使用iter方法在字节数组上创建一个迭代器:for(i,item)inbytes。iter()。enumerate(){ 上述代码中,iter方法返回集合中的每一个元素,而enumerate包装了iter的结果,将这些元素作为元组的一部分来返回。enumerate返回的元组中,第一个元素是索引,第二个元素是集合中元素的引用。这比我们自己计算索引要方便一些。 因为enumerate方法返回一个元组,我们可以使用模式来解构,我们将在第六章中进一步讨论有关模式的问题。所以在for循环中,我们指定了一个模式,其中元组中的i是索引而元组中的item是单个字节。因为我们从。iter()。enumerate()中获取了集合元素的引用,所以模式中使用了。 在for循环中,我们通过字节的字面值语法来寻找代表空格的字节。如果找到了一个空格,返回它的位置。否则,使用s。len()返回字符串的长度:ifitemb{}}s。len() 现在有了一个找到字符串中第一个单词结尾索引的方法,不过这有一个问题。我们返回了一个独立的usize,不过它只在String的上下文中才是一个有意义的数字。换句话说,因为它是一个与String相分离的值,无法保证将来它仍然有效。考虑一下示例8中使用了示例7中firstword函数的程序。 文件名:srcmain。rsfnmain(){letmutsString::from(helloworld);letwordfirstword(s);word的值为5s。clear();这清空了字符串,使其等于word在此处的值仍然是5,但是没有更多的字符串让我们可以有效地应用数值5。word的值现在完全无效!} 示例8:存储firstword函数调用的返回值并接着改变String的内容 这个程序编译时没有任何错误,而且在调用s。clear()之后使用word也不会出错。因为word与s状态完全没有联系,所以word仍然包含值5。可以尝试用值5来提取变量s的第一个单词,不过这是有bug的,因为在我们将5保存到word之后s的内容已经改变。 我们不得不时刻担心word的索引与s中的数据不再同步,这很啰嗦且易出错!如果编写这么一个secondword函数的话,管理索引这件事将更加容易出问题。它的签名看起来像这样:fnsecondword(s:String)(usize,usize){ 现在我们要跟踪一个开始索引和一个结尾索引,同时有了更多从数据的某个特定状态计算而来的值,但都完全没有与这个状态相关联。现在有三个飘忽不定的不相关变量需要保持同步。 幸运的是,Rust为这个问题提供了一个解决方法:字符串slice。4。1。字符串slice 字符串slice(stringslice)是String中一部分值的引用,它看起来像这样:letsString::from(helloworld);lethellos〔0。。5〕;letworlds〔6。。11〕; 不同于整个String的引用,hello是一个部分String的引用,由一个额外的〔0。。5〕部分指定。可以使用一个由中括号中的〔startingindex。。endingindex〕指定的range创建一个slice,其中startingindex是slice的第一个位置,endingindex则是slice最后一个位置的后一个值。在其内部,slice的数据结构存储了slice的开始位置和长度,长度对应于endingindex减去startingindex的值。所以对于letworlds〔6。。11〕;的情况,world将是一个包含指向s索引6的指针和长度值5的slice。 图6展示了一个图例。 图6:引用了部分String的字符串slice 对于Rust的。。range语法,如果想要从索引0开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:letsString::from(hello);letslices〔0。。2〕;letslices〔。。2〕; 依此类推,如果slice包含String的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:letsString::from(hello);letlens。len();letslices〔3。。len〕;letslices〔3。。〕; 也可以同时舍弃这两个值来获取整个字符串的slice。所以如下亦是相同的:letsString::from(hello);letlens。len();letslices〔0。。len〕;letslices〔。。〕; 注意:字符串slicerange的索引必须位于有效的UTF8字符边界内,如果尝试从一个多字节字符的中间位置创建字符串slice,则程序将会因错误而退出。出于介绍字符串slice的目的,本部分假设只使用ASCII字符集;第八章的使用字符串存储UTF8编码的文本部分会更加全面的讨论UTF8处理问题。 在记住所有这些知识后,让我们重写firstword来返回一个slice。字符串slice的类型声明写作str: 文件名:srcmain。rsfnfirstword(s:String)str{letbytess。asbytes();for(i,item)inbytes。iter()。enumerate(){ifitemb{returns〔0。。i〕;}}s〔。。〕} 我们使用跟示例7相同的方式获取单词结尾的索引,通过寻找第一个出现的空格。当找到一个空格,我们返回一个字符串slice,它使用字符串的开始和空格的索引作为开始和结束的索引。 现在当调用firstword时,会返回与底层数据关联的单个值。这个值由一个slice开始位置的引用和slice中元素的数量组成。 secondword函数也可以改为返回一个slice:fnsecondword(s:String)str{ 现在我们有了一个不易混淆且直观的API了,因为编译器会确保指向String的引用持续有效。还记得示例8程序中,那个当我们获取第一个单词结尾的索引后,接着就清除了字符串导致索引就无效的bug吗?那些代码在逻辑上是不正确的,但却没有显示任何直接的错误。问题会在之后尝试对空字符串使用第一个单词的索引时出现。slice就不可能出现这种bug并让我们更早的知道出问题了。使用slice版本的firstword会抛出一个编译时错误: 文件名:srcmain。rsfnmain(){letmutsString::from(helloworld);letwordfirstword(s);s。clear();错误!println!(thefirstwordis:{},word);} 这里是编译错误:cargorunCompilingownershipv0。1。0(file:projectsownership)error〔E0502〕:cannotborrowsasmutablebecauseitisalsoborrowedasimmutablesrcmain。rs:18:516letwordfirstword(s);immutableborrowoccurshere1718s。clear();error!mutableborrowoccurshere1920println!(thefirstwordis:{},word);immutableborrowlaterusedhereFormoreinformationaboutthiserror,tryrustcexplainE0502。error:couldnotcompileownershipduetopreviouserror 回忆一下借用规则,当拥有某值的不可变引用时,就不能再获取一个可变引用。因为clear需要清空String,它尝试获取一个可变引用。在调用clear之后的println!使用了word中的引用,所以这个不可变的引用在此时必须仍然有效。Rust不允许clear中的可变引用和word中的不可变引用同时存在,因此编译失败。Rust不仅使得我们的API简单易用,也在编译时就消除了一整类的错误!4。1。1。字符串字面值就是slice 还记得我们讲到过字符串字面值被储存在二进制文件中吗?现在知道slice了,我们就可以正确地理解字符串字面值了:letsHello,world!; 这里s的类型是str:它是一个指向二进制程序特定位置的slice。这也就是为什么字符串字面值是不可变的;str是一个不可变引用。4。1。2。字符串slice作为参数 在知道了能够获取字面值和String的slice后,我们对firstword做了改进,这是它的签名:fnfirstword(s:String)str{ 而更有经验的Rustacean会编写出示例9中的签名,因为它使得可以对String值和str值使用相同的函数:fnfirstword(s:str)str{ 示例9:通过将s参数的类型改为字符串slice来改进firstword函数 如果有一个字符串slice,可以直接传递它。如果有一个String,则可以传递整个String的slice或对String的引用。定义一个获取字符串slice而不是String引用的函数使得我们的API更加通用并且不会丢失任何功能: 文件名:srcmain。rsfnmain(){letmystringString::from(helloworld);firstword适用于String(的slice),整体或全部letwordfirstword(mystring〔0。。6〕);letwordfirstword(mystring〔。。〕);firstword也适用于String的引用,这等价于整个String的sliceletwordfirstword(mystring);firstword适用于字符串字面值,整体或全部letwordfirstword(mystringliteral〔0。。6〕);letwordfirstword(mystringliteral〔。。〕);因为字符串字面值已经是字符串slice了,这也是适用的,无需slice语法!letwordfirstword(mystringliteral);}4。2。其他类型的slice 字符串slice,正如你想象的那样,是针对字符串的。不过也有更通用的slice类型。考虑一下这个数组:leta〔1,2,3,4,5〕; 就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分。我们可以这样做:leta〔1,2,3,4,5〕;letslicea〔1。。3〕;asserteq!(slice,〔2,3〕); 这个slice的类型是〔i32〕。它跟字符串slice的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。你可以对其他所有集合使用这类slice。第八章讲到vector时会详细讨论这些集合。五。总结 所有权、借用和slice这些概念让Rust程序在编译时确保内存安全。Rust语言提供了跟其他系统编程语言相同的方式来控制你使用的内存,但拥有数据所有者在离开作用域后自动清除其数据的功能意味着你无须额外编写和调试相关的控制代码。Rust自带的这些机制虽然牺牲了一些灵活性,但也从根本上保证了内存的安全,只要遵循这些规则,就能轻松写出安全的代码。六。引用 〔1〕Rust教程菜鸟教程(runoob。com) 〔2〕除了RUST,还有国产架构:Linux6。1内核稳定版首发布!中文科技资讯提供快捷产业新资讯创新驱动商业(citnews。com。cn) 〔3〕crates。io:RustPackageRegistry 〔4〕字节跳动在Rust微服务方向的探索和实践QCon代码问题时候(sohu。com) 〔5〕Rust程序设计语言Rust程序设计语言简体中文版(kaisery。github。io)