高端网站建设公司服务好吗,网站开发合同及报价,网站加速器免费安卓,阿里巴巴1688大企业采购平台所有权可以说是Rust中最为独特的一个功能了。正是所有权概念和相关工具的引入#xff0c;Rust才能够在没有垃圾回收机制的前提下保障内存安全。 因此#xff0c;正确地了解所有权概念及其在Rust中的实现方式#xff0c;对于所有Rust开发者来讲都是十分重要的。在本文中… 所有权可以说是Rust中最为独特的一个功能了。正是所有权概念和相关工具的引入Rust才能够在没有垃圾回收机制的前提下保障内存安全。 因此正确地了解所有权概念及其在Rust中的实现方式对于所有Rust开发者来讲都是十分重要的。在本文中我们会详细地讨论所有权及其相关功能借用、切片以及Rust在内存中布局数据的方式。 先预览下本文要讲的主要内容 1. 栈与堆 由于所有权的某些内容会涉及栈与堆所以让我们先来简单地了解一下它们。 栈和堆都是代码在运行时可以使用的内存空间不过它们通常以不同的结构组织而成。栈会以我们放入值时的顺序来存储它们并以相反的顺序将值取出。 这也就是所谓的 “后进先出” 策略。你可以把栈上的操作想象成堆放盘子当你需要放置盘子时你只能将它们放置在栈的顶部而当你需要取出盘子时你也只能从顶部取出。 换句话说你没有办法从中间或底部插入或移除盘子。用术语来讲添加数据这一操作被称作入栈移除数据则被称作出栈。 所有存储在栈中的数据都必须拥有一个已知且固定的大小。对于那些在编译期无法确定大小的数据你就只能将它们存储在堆中。 堆空间的管理是较为松散的当你希望将数据放入堆中时你就可以请求特定大小的空间。操作系统会根据你的请求在堆中找到一块足够大的可用空间将它标记为已使用并把指向这片空间地址的指针返回给我们。 这一过程就是所谓的堆分配它也常常被简称为分配。将值压入栈中不叫分配。由于指针的大小是固定的且可以在编译期确定所以可以将指针存储在栈中。当你想要访问指针所指向的具体数据时可以通过指针指向的地址来访问。 你可以把这个过程想象为到餐厅聚餐。当你到达餐厅表明自己需要的座位数后服务员会找到一张足够大的空桌子并将你们领过去入座。 即便这时有小伙伴来迟了他们也可以通过询问你们就座的位置来找到你们。向栈上推入数据要比在堆上进行分配更有效率一些因为操作系统省去了搜索新数据存储位置的工作这个位置永远处于栈的顶端。 除此之外操作系统在堆上分配空间时还必须首先找到足够放下对应数据的空间并进行某些记录工作来协调随后进行的其余分配操作。 由于多了指针跳转的环节所以访问堆上的数据要慢于访问栈上的数据。一般来说现代处理器在进行计算的过程中由于缓存的缘故指令在内存中跳转的次数越多性能就越差。 继续使用上面的餐厅来作类比。假设现在同时有许多桌的顾客正在等待服务员的处理。那么最高效的处理方式自然是报完一张桌子所有的订单后再接着服务下一张桌子的顾客。 而一旦服务员每次在单个桌子前只处理单个订单那么他就不得不浪费较多的时间往返于不同的桌子之间。 出于同样的原因处理器在操作排布紧密的数据比如在栈上时要比操作排布稀疏的数据比如在堆上有效率得多。另外分配命令本身也可能消耗不少时间周期。 许多系统编程语言都需要你记录代码中分配的堆空间最小化堆上的冗余数据并及时清理堆上的无用数据以避免耗尽空间。 而所有权概念则解决了这些问题。一旦你熟练地掌握了所有权及其相关工具就可以将这些问题交给Rust处理减轻用于思考栈和堆的心智负担。不过知晓如何使用和管理堆内存可以帮助我们理解所有权存在的意义及其背后的工作原理。 2. 所有权规则 现在让我们来具体看一看所有权规则。你最好先将这些规则记下来我们会在随后的内容中通过示例来解释它们 Rust中每一个值都有一个对应的变量作为它的所有者。 在同一时间内值有且仅有一个所有者。 当所有者离开自己的作用域时它持有的值就会被释放掉。 3. 变量作用域 作为所有权的第一个示例我们先来了解一下变量的作用域。简单来讲作用域是一个对象在程序中有效的范围。假设有这样一个变量
let s hello; 这里的变量 s 指向了一个字符串字面量它的值被硬编码到了当前的程序中。变量从声明的位置开始直到当前作用域结束都是有效的。下面示例中的注释对变量的有效范围给出了具体的说明
{ // 由于变量 s 还未被声明所以它在这里是不可用的 let s hello; // 从这里开始变量 s 变成可用// 执行与 s 相关的操作
} // 作用域到这里结束变量 s 再次不可用 这里有两个重点 s 在进入作用域后变得有效。 它会保持自己的有效性直到自己离开作用域为止。 到目前为止Rust语言中变量的有效性与作用域之间的关系跟其他编程语言中的类似。现在让我们继续在作用域的基础上学习 String 类型。 4. String类型 为了演示所有权的相关规则我们需要一个特别的数据类型它要比之前文章的“数据类型”中涉及的类型都更加复杂。 之前接触的那些类型会将数据存储在栈上并在离开自己的作用域时将数据弹出栈空间。我们需要一个存储在堆上的数据类型来研究Rust是如何自动回收这些数据的。 我们将以 String 类型为例并将注意力集中到 String 类型与所有权概念相关的部分。这些部分同样适用于标准库中提供的或你自己创建的其他复杂数据类型。 我们会在后面文章中更加深入地讲解 String 类型。你已经在上面的示例中接触过字符串字面量了它们是那些被硬编码进程序的字符串值。 字符串字面量的确是很方便但它们并不能满足所有需要使用文本的场景。原因之一在于字符串字面量是不可变的。而另一个原因则在于并不是所有字符串的值都能够在编写代码时确定假如我们想要获取用户的输入并保存应该怎么办呢 为了应对这种情况Rust提供了第二种字符串类型 String 。这个类型会在堆上分配到自己需要的存储空间所以它能够处理在编译时未知大小的文本。你可以调用 from 函数根据字符串字面量来创建一个 String 实例
let s String::from(hello); 这里的双冒号::运算符允许我们调用置于 String 命名空间下面的特定 from 函数而不需要使用类似于 string_from 这样的名字。上面定义的字符串对象能够被声明为可变的
let mut s String::from(hello);s.push_str(, world!); // push_str() 函数向String空间的尾部添加了一段字面量println!({}, s); // 这里会输出完整的hello, world! 你也许会问为什么 String 是可变的而字符串字面量不是这是因为它们采用了不同的内存处理方式。 5. 内存与分配 对于字符串字面量而言由于我们在编译时就知道其内容所以这部分硬编码的文本被直接嵌入到了最终的可执行文件中。 这就是访问字符串字面量异常高效的原因而这些性质完全得益于字符串字面量的不可变性。 不幸的是我们没有办法将那些未知大小的文本在编译期统统放入二进制文件中更何况这些文本的大小还可能随着程序的运行而发生改变。 对于 String 类型而言为了支持一个可变的、可增长的文本类型我们需要在堆上分配一块在编译时未知大小的内存来存放数据。这同时也意味着 我们使用的内存是由操作系统在运行时动态分配出来的。 当使用完 String 时我们需要通过某种方式来将这些内存归还给操作系统。 这里的第一步由我们也就是程序的编写者在调用 String::from 时完成这个函数会请求自己需要的内存空间。 在大部分编程语言中都有类似的设计由程序员来发起堆内存的分配请求。然而对于不同的编程语言来说第二步实现起来就各有区别了。 在某些拥有垃圾回收Garbage CollectorGC机制的语言中GC 会代替程序员来负责记录并清除那些不再使用的内存。 而对于那些没有 GC 的语言来说识别不再使用的内存并调用代码显式释放的工作就依然需要由程序员去完成正如我们请求分配时一样。 按照以往的经验来看正确地完成这些任务往往是十分困难的。假如我们忘记释放内存那么就会造成内存泄漏假如我们过早地释放内存那么就会产生一个非法变量假如我们重复释放同一块内存那么就会产生无法预知的后果。 为了程序的稳定运行我们必须严格地将分配和释放操作一一对应起来。与这些语言不同Rust提供了另一套解决方案内存会自动地在拥有它的变量离开作用域后进行释放。下面的代码类似于上面示例中的代码不过我们将字符串字面量换成了 String 类型
}let s String::from(hello); // 从此处开始变量 s 开始生效// 执行与 s 相关的操作} // 作用域到此处结束变量 s 失效 审视上面的代码有一个很适合用来回收内存给操作系统的地方变量 s 离开作用域的地方。Rust在变量离开作用域时会调用一个叫作drop的特殊函数。 String 类型的作者可以在这个函数中编写释放内存的代码。记住Rust会在作用域结束的地方即 } 处自动调用 drop 函数。
注意 在 C 中这种在对象生命周期结束时释放资源的模式有时也被称作 资源获取即初始化Resource Acquisition Is Initialization RAII。假如你使用过类似的模式那么你应该对Rust中的特殊函数 drop 并不陌生。 这种模式极大地影响了Rust中的许多设计抉择并最终决定了我们现在编写Rust代码的方式。 在上面的例子中这套释放机制看起来也许还算简单然而一旦把它放置在某些更加复杂的环境中代码呈现出来的行为往往会出乎你的意料特别是当我们拥有多个指向同一处堆内存的变量时。 让我们接着来看一看其中一些可能的使用场景。
5.1 变量和数据交互的方式移动 Rust中的多个变量可以采用一种独特的方式与同一数据进行交互。让我们看一看下面示例中的代码这里使用了一个整型作为数据
let x 5;
let y x; 这个示例是将变量 x 绑定的整数值重新绑定到变量 y 上。 你也许能够猜到这段代码的执行效果将整数值 5 绑定到变量 x 上然后创建一个 x 值的拷贝并将它绑定到 y 上。结果我们有了两个变量 x 和 y它们的值都是 5。 这正是实际发生的情形因为整数是已知固定大小的简单值两个值 5 会同时被推入当前的栈中。现在让我们看一看这段程序的 String 版本
let s1 String::from(hello);
let s2 s1; 以上两段代码非常相似你也许会假设它们的运行方式也是一致的。也就是说第二行代码可能会生成一个 s1 值的拷贝并将它绑定到 s2 上。不过事实并非如此。 下图展示了 String 的内存布局它实际上由 3 部分组成如图左侧所示一个指向存放字符串内容的指针ptr、一个长度len及一个容量capacity这部分的数据存储在了栈中。图片右侧显示了字符串存储在堆上的文本内容。 图1绑定到变量s1上、拥有值“hello”的String的内存布局 长度字段被用来记录当前 String 中的文本使用了多少字节的内存。而容量字段则被用来记录 String 向操作系统总共获取到的内存字节数量。 长度和容量之间的区别十分重要但我们先不去讨论这个问题简单地忽略容量字段即可。当我们将 s1 赋值给 s2 时便复制了一次 String 的数据这意味着我们复制了它存储在栈上的指针、长度及容量字段。 但需要注意的是我们没有复制指针指向的堆数据。换句话说此时的内存布局应该类似于图2。 由于Rust不会在复制值时深度地复制堆上的数据所以这里的布局不会像图3中所示的那样。假如Rust依照这样的模式去执行赋值那么当堆上的数据足够大时类似于 s2 s1 这样的指令就会造成相当可观的运行时性能消耗。 图2变量s2在复制了s1的指针、长度及容量后的内存布局 图3当Rust也复制了堆上的数据时执行完s2 s1语句后可能产生的内存布局 前面我们提到过当一个变量离开当前的作用域时Rust会自动调用它的 drop 函数并将变量使用的堆内存释放回收。 不过图2中展示的内存布局里有两个指针指向了同一个地址这就导致了一个问题当 s2 和 s1 离开自己的作用域时它们会尝试去重复释放相同的内存。 这也就是我们之前提到过的内存错误之一臭名昭著的二次释放。重复释放内存可能会导致某些正在使用的数据发生损坏进而产生潜在的安全隐患。 为了确保内存安全同时也避免复制分配的内存Rust在这种场景下会简单地将 s1 废弃不再视其为一个有效的变量。 因此Rust也不需要在 s1 离开作用域后清理任何东西。试图在 s2 创建完毕后使用 s1如下所示会导致编译时错误。
let s1 String::from(hello);
let s2 s1;println!({}, world!, s1); 假如你在其他语言中接触过浅度拷贝shallow copy和深度拷贝deep copy这两个术语那么你也许会将这里复制指针、长度及容量字段的行为视作浅度拷贝。 但由于Rust同时使第一个变量无效了所以我们使用了新的术语移动move来描述这一行为而不再使用浅度拷贝。 在上面的示例中我们可以说 s1 被移动到了 s2 中。在这个过程中所发生的操作如图4所示。 图4s1变为无效之后的内存布局 这一语义完美地解决了我们的问题既然只有 s2 有效那么也就只有它会在离开自己的作用域时释放空间所以再也没有二次释放的可能性了。 另外这里还隐含了另外一个设计原则Rust永远不会自动地创建数据的深度拷贝。因此在Rust中任何自动的赋值操作都可以被视为高效的。
5.2 变量和数据交互的方式克隆 当你确实需要去深度拷贝 String 堆上的数据而不仅仅是栈数据时就可以使用一个名为 clone 的方法。 我们将在后面文章中讨论类型方法的语法但你应该在其他语言中见过类似的东西。下面是一个实际使用 clone 方法的例子
let s1 String::from(hello);
let s2 s1.clone();println!(s1 {}, s2 {}, s1, s2); 这段代码在Rust中完全合法它显式地生成了图3中的行为复制了堆上的数据。 当你看到某处调用了 clone 时你就应该知道某些特定的代码将会被执行而且这些代码可能会相当消耗资源。你可以很容易地在代码中察觉到一些不寻常的事情正在发生。
5.3 栈上数据的复制 上面的讨论中遗留了一个没有提及的知识点。我们在之前的代码示例中曾经使用整型编写出了如下所示的合法代码
let x 5;
let y x;println!(x {}, y {}, x, y); 这与我们刚刚学到的内容似乎有些矛盾即便代码没有调用 clonex 在被赋值给 y 后也依然有效且没有发生移动现象。 这是因为类似于整型的类型可以在编译时确定自己的大小并且能够将自己的数据完整地存储在栈中对于这些值的复制操作永远都是非常快速的。 这也同样意味着在创建变量 y 后我们没有任何理由去阻止变量 x 继续保持有效。换句话说对于这些类型而言深度拷贝与浅度拷贝没有任何区别 调用 clone 并不会与直接的浅度拷贝有任何行为上的区别。因此我们完全不需要在类似的场景中考虑上面的问题。 Rust提供了一个名为 Copy 的 trait它可以用于整数这类完全存储在栈上的数据类型我们会在后面文章中详细地介绍 trait。一旦某种类型拥有了 Copy 这种 trait那么它的变量就可以在赋值给其他变量之后保持可用性。 如果一种类型本身或这种类型的任意成员实现了 Drop 这种 trait那么Rust就不允许其实现 Copy 这种 trait。尝试给某个需要在离开作用域时执行特殊指令的类型实现 Copy 这种 trait 会导致编译时错误。 那么究竟哪些类型是 Copy 的呢你可以查看特定类型的文档来确定不过一般来说任何简单标量的组合类型都可以是 Copy 的任何需要分配内存或某种资源的类型都不会是 Copy 的。 下面是一些拥有 Copy 这种trait的类型 所有的整数类型诸如 u32。 仅拥有两种值true 和 false的布尔类型bool。 字符类型char。 所有的浮点类型诸如 f64。 如果元组包含的所有字段的类型都是 Copy 的那么这个元组也是 Copy 的。例如(i32, i32)是 Copy 的但(i32, String)则不是。 6. 所有权与函数 将值传递给函数在语义上类似于对变量进行赋值。将变量传递给函数将会触发移动或复制就像是赋值语句一样。下面示例展示了变量在这种情况下作用域的变化过程 示例1函数中所有权和作用域的变化过程 尝试在调用 takes_ownership 后使用变量 s 会导致编译时错误。这类静态检查可以使我们免于犯错。你可以尝试在main函数中使用 s 和 x 变量来看一看在所有权规则的约束下能够在哪些地方合法地使用它们。 7. 返回值与作用域 函数在返回值的过程中也会发生所有权的转移。示例2像示例1一样详细地标注了这种情况下变量所有权和作用域的变化过程 示例2函数在返回值时所有权的转移过程 变量所有权的转移总是遵循相同的模式将一个值赋值给另一个变量时就会转移所有权。 当一个持有堆数据的变量离开作用域时它的数据就会被 drop 清理回收除非这些数据的所有权移动到了另一个变量上。 在所有的函数中都要获取所有权并返回所有权显得有些烦琐。假如你希望在调用函数时保留参数的所有权那么就不得不将传入的值作为结果返回。 除了这些需要保留所有权的值函数还可能会返回它们本身的结果。当然你可以利用元组来同时返回多个值如下面代码所示
fn main() { let s1 String::from(hello); let (s2, len) calculate_length(s1); println!(The length of {} is {}., s2, len);
} fn calculate_length(s: String) - (String, usize) { let length s.len(); // len()会返回当前字符串的长度 (s, length)
} 但这种写法未免太过笨拙了类似的概念在编程工作中相当常见。幸运的是Rust针对这类场景提供了一个名为引用的功能这个我们下篇文章再讲。 最后码字不易如果大家能给我一个赞我也会动力满满万分感谢你们的点赞支持