什么是所有权
所有权是一组规则,它决定了 Rust 程序如何管理内存。所有运行中的程序都必须管理它们对计算机内存的使用方式。某些语言使用垃圾回收(GC),在程序运行时定期查找不再使用的内存;另一些语言则要求程序员显式地分配和释放内存。Rust 采用第三种方式:通过一套编译期检查的“所有权系统”来管理内存。一旦违反这些规则,程序就无法通过编译。所有权机制的任何特性都不会在运行时拖慢程序。

对许多开发者来说,所有权是一个全新概念,确实需要一定时间适应。好消息是:随着你对 Rust 及所有权规则愈发熟悉,你会自然而然写出既安全又高效的代码。坚持下去!

理解了所有权,你就掌握了理解 Rust 独特特性的基石。本章将通过一种非常常见的数据结构——字符串(String)——的示例来学习所有权。

栈(Stack)与堆(Heap)
很多高级语言很少要求你关注栈和堆。但在像 Rust 这样的系统编程语言里,值位于栈还是堆,会直接影响语言的行为以及你为何必须做出某些决策。后面讲解所有权时,会结合栈和堆的概念,因此先简要说明。

栈和堆都是可供代码在运行时使用的内存区域,但组织方式不同。

  • 栈按“后进先出”顺序存取值:就像一摞盘子,后放的在上,先取最上面的。往栈里加数据叫“压栈”(push),移除叫“弹栈”(pop)。栈上所有数据的大小必须在编译期已知且固定。
  • 堆则没那么有序:把数据放入堆时,先向内存分配器申请一块足够大的空间,分配器标记该空间为“已用”,并返回指向此位置的指针。这个过程叫“堆分配”(简称分配),压栈不算分配。由于指针大小固定,可以把指针存在栈上;真正访问数据时,必须顺着指针去堆里拿。就像进餐厅时,告诉服务员你们几个人,他找一张空桌领你们过去,后来者问服务员即可找到你们。

压栈比堆分配更快,因为无需寻找空闲位置;栈顶永远是下一个位置。堆分配需要找到足够大的空间,并记录元数据以备后续分配,工作量更大。

访问堆数据也比栈慢,需要一次指针跳转。现代处理器在内存连续时更快。类似地,服务员若逐桌收齐一桌的订单再换下一桌,效率最高;若来回穿插,则慢得多。同理,处理器处理栈上紧密排布的数据更高效。

当函数被调用,传入的值(可能包括指向堆数据的指针)以及局部变量都会压栈;函数结束时,这些值被弹栈。

追踪哪些代码正在使用堆上的哪些数据、减少堆上重复数据、及时清理不再使用的数据以免耗尽内存——这些正是所有权要解决的问题。理解所有权后,你无须时刻惦记栈和堆,但明白“所有权主要用来管理堆数据”有助于理解其设计初衷。

所有权规则
先记住三条核心规则,后面示例会逐一阐释:

  1. Rust 中每个值都有且仅有一个所有者(owner)。
  2. 同一时间只能有一个所有者。
  3. 所有者离开作用域(scope)时,该值被丢弃(drop)。

变量的作用域
我们不再在每个示例中写 fn main() { ... },请自行把代码放进 main 函数。先看变量作用域:作用域指一个项在程序中有效的范围。例如:

{                      // s 尚未声明,不可用let s = "hello";   // 从这里开始 s 有效// 使用 s
}                      // 作用域结束,s 失效

重点:

  • s 进入作用域时生效。
  • 离开作用域时失效。

这与多数语言类似。接下来引入 String 类型,以进一步说明所有权。

String 类型
为了展示所有权规则,我们需要比第 3 章更复杂的数据类型。之前提到的类型大小已知,可放栈上,作用域结束时弹出,且易于按位复制出独立副本。现在我们想研究存放在堆上的数据,以及 Rust 如何决定何时清理它们——String 是很好的例子。

我们已见过字符串字面量("hello"),其值在编译期已知并直接写入可执行文件,速度快、效率高,但不可变,且无法在编译期确定所有文本(如用户输入)。于是 Rust 提供第二种字符串类型 String,它在堆上管理数据,允许存储编译期大小未知的文本。可用 String::from 由字面量创建:

let s = String::from("hello");

:: 语法把 from 置于 String 命名空间下,第 5 章与第 7 章会再谈。
String 可被修改:

let mut s = String::from("hello");
s.push_str(", world!");
println!("{s}"); // 输出 `hello, world!`

为何 String 可变,而字面量不行?关键在于二者内存处理方式不同。

内存与分配

  • 字面量内容在编译期已知,直接嵌入可执行文件,因此不可变。
  • String 需支持可增长文本,于是:
    1. 在运行时向内存分配器申请未知大小的堆内存。
    2. 用完后需将此内存归还(释放)。

第一步由 String::from 完成,与多数语言相同。第二步则不同:

  • 有 GC 的语言由 GC 清理;
  • 无 GC 的语言通常需程序员显式释放,易出错:忘了解放会泄漏,过早释放为悬垂指针,重复释放是 bug。

Rust 的做法:变量离开作用域时自动归还内存。例如:

{let s = String::from("hello"); // 申请内存// 使用 s
} // 作用域结束,Rust 自动调用 drop 释放内存

C++ 中类似模式叫 RAII(资源获取即初始化)。Rust 的 drop 函数即此思想的体现。

变量与数据:移动(Move)
Rust 中多个变量可与同一数据交互。先看整数示例:

let x = 5;
let y = x;

整数大小固定,直接复制值压栈,于是 xy 均为 5。

再看 String

let s1 = String::from("hello");
let s2 = s1;

看起来相似,实则不然。如图 4-1 所示,String 由三部分组成(存栈上):指向堆内容的指针、长度、容量;右侧堆上才是真正的字符数据。
4-1
图 4-1:变量 s1 绑定到值为 "hello"String 在内存中的表示

  • length(长度)表示该 String 的内容当前占用的字节数。
  • capacity(容量)表示该 String 从分配器处获得的堆内存总字节数。
    二者有区别,但在本节并不重要,可先忽略容量。

当我们执行 let s2 = s1; 时,复制的是栈上的那三部分数据(指针、长度、容量),而不会复制指针所指向的堆上的实际内容。换句话说,内存中的数据表示如图 4-2 所示。
4-2
图 4-2:变量 s2 复制了 s1 的指针、长度和容量后的内存示意图
(并没有复制堆上的实际数据)

这种表示并不是图 4-3 所展示的情况——图 4-3 表示的是“连堆上的数据也一并深拷贝”后的内存布局。
如果 Rust 真的那样做,当堆上的数据很大时,s2 = s1 这一操作在运行时就会变得非常昂贵。
4-3
图4-3:如果Rust也复制堆数据,s2 = s1可能的另一种行为

我们之前提到,当一个变量超出作用域时,Rust会自动调用drop函数并清理该变量的堆内存。但图4-2显示两个数据指针指向同一个位置。这是一个问题:当s2和s1超出作用域时,它们都会尝试释放相同的内存。这被称为双重释放错误,是我们之前提到的内存安全漏洞之一。释放内存两次可能导致内存损坏,进而可能引发安全漏洞。

为了确保内存安全,在执行let s2 = s1;这行代码后,Rust认为s1不再有效。因此,当s1超出作用域时,Rust不需要释放任何东西。看看在创建s2之后尝试使用s1会发生什么;它不会工作:

这段代码无法编译!

let s1 = String::from("hello");
let s2 = s1;println!("{s1}, world!");

你会得到这样的错误,因为Rust阻止你使用无效的引用:

$ cargo runCompiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`--> src/main.rs:5:15|
2 |     let s1 = String::from("hello");|         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;|              -- value moved here
4 |
5 |     println!("{s1}, world!");|               ^^^^ value borrowed here after move|= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable|
3 |     let s2 = s1.clone();|                ++++++++For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

如果你在使用其他语言时听说过浅拷贝和深拷贝的概念,那么只复制指针、长度和容量而不复制数据的想法听起来可能像是浅拷贝。但由于Rust还会使第一个变量失效,因此它不被称为浅拷贝,而是被称为移动(move)。在这个例子中,我们会说s1被移动到了s2。因此,实际发生的情况如图4-4所示。
4-4
图4-4:s1失效后内存中的表示

这解决了我们的问题!只有s2是有效的,当它超出作用域时,它将独自释放内存,任务完成。

此外,这里还隐含了一个设计选择:Rust永远不会自动创建数据的“深拷贝”。因此,任何自动拷贝都可以假设在运行时性能方面是廉价的。

作用域与赋值

这一规则的反面也适用于作用域、所有权以及通过drop函数释放内存之间的关系。当你将一个全新的值赋给一个已存在的变量时,Rust会立即调用drop并释放原始值的内存。考虑以下代码,例如:

let mut s = String::from("hello");
s = String::from("ahoy");println!("{s}, world!");

我们最初声明了一个变量s,并将其绑定到一个值为"hello"的String。然后我们立即创建了一个值为"ahoy"的新String,并将其赋给s。此时,没有任何东西引用堆上的原始值了。
4-5
图 4-5:初始值被完全替换后在内存中的表示。

因此,原始字符串会立即超出作用域。Rust 会调用 drop 函数来释放它的内存。当我们打印最终的值时,它将是“ahoy, world!”。

变量和数据的克隆操作
如果我们确实需要深度复制 String 的堆数据,而不仅仅是栈数据,我们可以使用一个常见的方法,称为 clone。我们将在第 5 章讨论方法的语法,但由于方法是许多编程语言中的常见特性,你可能之前已经见过。

以下是一个 clone 方法的示例:

let s1 = String::from("hello");
let s2 = s1.clone();println!("s1 = {s1}, s2 = {s2}");

这可以正常工作,并明确地表现出图 4-3 中所示的行为,即堆数据确实被复制了。

当你看到对 clone 的调用时,你应该知道正在执行一些任意代码,而这些代码可能代价高昂。它是一个视觉提示,表明正在发生一些不同的事情。

仅在栈上的数据:Copy 特性
我们还没有提到的另一个细节是,使用整数的代码——其中一部分在清单 4-2 中展示过——可以正常工作且有效:

let x = 5;
let y = x;println!("x = {x}, y = {y}");

但这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone,但 x 仍然有效,并且没有被移动到 y 中。

原因是像整数这样在编译时已知大小的类型完全存储在栈上,因此实际值的副本可以快速生成。这意味着我们没有理由阻止在创建变量 yx 仍然有效。换句话说,在这里浅拷贝和深拷贝没有区别,因此调用 clone 与通常的浅拷贝没有什么不同,我们可以省略它。

Rust 有一个特殊的注解,称为 Copy 特性,我们可以将其应用于存储在栈上的类型,例如整数(我们将在第 10 章中更多地讨论特性)。如果一个类型实现了 Copy 特性,使用它的变量不会被移动,而是被简单地复制,这使得它们在赋值给另一个变量后仍然有效。

如果类型本身或其任何部分实现了 Drop 特性,Rust 不会允许我们为该类型添加 Copy 注解。如果类型在值超出作用域时需要执行一些特殊操作,而我们为该类型添加了 Copy 注解,那么我们将会得到一个编译时错误。要了解如何为你的类型添加 Copy 注解以实现该特性,请参阅附录 C 中的“可派生特性”。

那么,哪些类型实现了 Copy 特性呢?你可以查看给定类型的文档来确认,但一般来说,任何一组简单的标量值都可以实现 Copy,而任何需要分配内存或是一种资源的类型都不能实现 Copy。以下是一些实现了 Copy 的类型:

  • 所有整数类型,例如 u32
  • 布尔类型 bool,其值为 truefalse
  • 所有浮点数类型,例如 f64
  • 字符类型 char
  • 如果元组只包含也实现了 Copy 的类型,则元组也实现 Copy。例如,(i32, i32) 实现了 Copy,但 (i32, String) 则没有。

所有权和函数
将值传递给函数的机制与将值赋给变量时的机制类似。将变量传递给函数会移动或复制,就像赋值一样。清单 4-3 有一个带有注释的示例,显示了变量进入和超出作用域的位置。

文件名:src/main.rs

fn main() {let s = String::from("hello");  // s 进入作用域takes_ownership(s);             // s 的值被移动到函数中...// ...因此在这里不再有效let x = 5;                      // x 进入作用域makes_copy(x);                  // 因为 i32 实现了 Copy 特性,// x 没有被移动到函数中,println!("{}", x);              // 因此之后仍然可以使用 x} // 这里,x 超出作用域,然后是 s。但由于 s 的值被移动了,所以没有// 特殊的事情发生。fn takes_ownership(some_string: String) { // some_string 进入作用域println!("{some_string}");
} // 这里,some_string 超出作用域,并且调用 `drop`。后端// 内存被释放。fn makes_copy(some_integer: i32) { // some_integer 进入作用域println!("{some_integer}");
} // 这里,some_integer 超出作用域。没有特殊的事情发生。

清单 4-3:带有所有权和作用域注释的函数

如果我们试图在调用 takes_ownership 之后使用 s,Rust 会在编译时抛出错误。这些静态检查可以保护我们免于犯错。尝试在 main 中添加使用 sx 的代码,看看你可以在哪里使用它们,以及所有权规则阻止你在哪里使用它们。

返回值和作用域
返回值也可以转移所有权。清单 4-4 展示了一个返回某些值的函数的示例,其注释与清单 4-3 中的类似。

文件名:src/main.rs

fn main() {let s1 = gives_ownership();        // gives_ownership 将其返回值移动到 s1 中let s2 = String::from("hello");    // s2 进入作用域let s3 = takes_and_gives_back(s2); // s2 被移动到// takes_and_gives_back 中,该函数也// 将其返回值移动到 s3 中
} // 这里,s3 超出作用域并被释放。s2 被移动了,因此没有// 发生任何事情。s1 超出作用域并被释放。fn gives_ownership() -> String {       // gives_ownership 将其返回值移动到调用它的函数中let some_string = String::from("yours"); // some_string 进入作用域some_string                        // some_string 被返回并移动到调用函数中
}// 这个函数接收一个 String 并返回一个 String。
fn takes_and_gives_back(a_string: String) -> String {// a_string 进入作用域a_string  // a_string 被返回并移动到调用函数中
}

清单 4-4:返回值的所有权转移

变量的所有权每次遵循相同的模式:将值赋给另一个变量会移动它。当包含堆数据的变量超出作用域时,除非数据的所有权被移动到另一个变量,否则值将通过 drop 被清理。

虽然这可以工作,但每次函数都获取所有权然后再返回所有权会有些繁琐。如果我们想让函数使用一个值但不获取所有权怎么办?我们传递的任何东西都需要再次返回,这相当烦人,尤其是当我们还想返回函数体中可能产生的任何数据时。

幸运的是,Rust 允许我们使用元组返回多个值,如清单 4-5 所示。

文件名:src/main.rs

fn main() {let s1 = String::from("hello");let (s2, len) = calculate_length(s1);println!("'{}' 的长度是 {}。", s2, len);
}fn calculate_length(s: String) -> (String, usize) {let length = s.len(); // len() 返回一个 String 的长度(s, length)
}

清单 4-5:返回参数的所有权

但这仍然过于繁琐,对于一个应该很常见的概念来说,工作量太大了。幸运的是,Rust 有一个特性,可以在不转移所有权的情况下使用值,称为引用。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/pingmian/90333.shtml
繁体地址,请注明出处:http://hk.pswp.cn/pingmian/90333.shtml
英文地址,请注明出处:http://en.pswp.cn/pingmian/90333.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

破解哈希极化:基于主动路径规划的智算网络负载均衡方案

如今人工智能(AI)和大模型训练的蓬勃发展,大规模AI算力集群(智算集群)已成为关键基础设施。这类集群对网络性能,特别是高吞吐、低延迟和无损特性有着严苛要求,RoCE因此被广泛应用。然而&#xf…

Vue工程化 ElementPlus

一、Vue工程化1、环境准备create-vue是Vue官方提供的最新的脚手架工具,用于快速生成一个工程化的Vue项目。提供了以下功能:统一的目录结构本地调试热部署单元测试集成打包上线依赖环境:Node JS 是一个免费、开源、跨平台的JavaScript运行时环…

深入解析TCP:可靠传输的核心机制与实现逻辑

Linux 系列 文章目录Linux 系列前言一、TCP协议的概念1.1 TCP协议的特点1.2 TCP又叫做传输控制协议二、TCP协议段格式2.1、TCP的流量控制----------窗口大小(16位)2.2 TCP的确认应答机制2.2.1 什么是确认应答机制2.2.2 确认应答机制的优化2.3 超时重传机…

通缩浪潮中的 “测量防线”:新启航如何用国产 3D 白光干涉仪筑牢半导体成本护城河?

一、通缩浪潮下半导体行业的成本困局在通缩浪潮冲击下,半导体行业面临市场需求疲软、产品价格下滑的严峻挑战。为维持竞争力,降低生产成本成为企业生存发展的关键。而 3D 白光干涉仪作为半导体晶圆检测、制程监控的核心设备,传统进口产品价格…

[网安工具] 自动化威胁检测工具 —— D 盾 · 使用手册

🌟想了解其它网安工具?看看这个:[网安工具] 网络安全工具管理 —— 工具仓库 管理手册 D盾防火墙D盾,D盾_防火墙,D盾_IIS防火墙,D盾_web查杀,IIS防火墙,webshell查杀,https://www.d99net.net/ 0x01:D 盾 —— 工具简介 D 盾防火…

Spring AI 系列之二十二 - ImageModel

之前做个几个大模型的应用,都是使用Python语言,后来有一个项目使用了Java,并使用了Spring AI框架。随着Spring AI不断地完善,最近它发布了1.0正式版,意味着它已经能很好的作为企业级生产环境的使用。对于Java开发者来说…

Redis集群高可用与性能优化实战指南

Redis集群高可用与性能优化实战指南 一、业务场景描述 在大型分布式系统中,Redis不仅承担缓存职责,还常用于限流、排行榜、会话管理等高并发场景。随着访问量的激增和集群规模的扩展,如何保证Redis服务的高可用性与高性能,成为后端…

基于SpringBoot+Vue的高校特长互助系统(WebSocket实时聊天、协同过滤算法、ECharts图形化分析)

“ 🎈系统亮点:WebSocket实时聊天、协同过滤算法、ECharts图形化分析”01系统开发工具与环境搭建前后端分离架构项目架构:B/S架构运行环境:win10/win11、jdk17前端:技术:框架Vue.js;UI库&#x…

于纵横交错的矩阵间:二维数组与多维数据的默契和鸣

大家好啊,我是小象٩(๑ω๑)۶ 我的博客:Xiao Xiangζั͡ޓއއ 很高兴见到大家,希望能够和大家一起交流学习,共同进步。* 接着上节课的内容,这一节我们来学习二维数组,学习二维数组的概念和创建,明白二维数组的初始化,学会不完全初始化,完全初始化,按照行初始化的…

SHA-3算法详解

SHA-3(Secure Hash Algorithm 3)是美国国家标准与技术研究院(NIST)于 2015 年发布的新一代密码哈希算法标准,其核心基于比利时密码学家团队设计的Keccak 算法。SHA-3 的诞生旨在应对 SHA-1 和 SHA-2 系列算法可能面临的…

前端笔记:同源策略、跨域问题

只有前端才会有跨域问题后端不受限制 一、什么是“同源策略”(Same-Origin Policy) ✅ 定义: 浏览器的 同源策略 是一种 安全机制,限制一个源的 JavaScript 访问另一个源的资源,以防止恶意网站窃取用户敏感信息。 ✅ “…

java通过com进行pdf转换docx丢失

使用,通过com调用,发现pdf转换成docx后,没有看到docx输出到指定目录。直接说解决方案:关闭的保护模式即可,打开工具,编辑->首选项 找到安全性(增强),关闭启动时启用保护模式关闭后,docx正常输…

SQL基础⑫ | 视图篇

0 序言 本文将系统讲解数据库中视图的相关知识,包括视图的定义、作用、创建(单表、多表、基于视图创建)、查看、更新、修改与删除操作,以及视图的优缺点。 通过学习,你能够掌握视图的基本概念,理解何时及如…

移动云×华为昇腾:“大EP+PD分离”架构实现单卡吞吐量跨越式提升!

在面向下一代AI基础设施的关键技术攻关中,移动云与华为昇腾计算团队深度协同,实现了大模型推理引擎的架构级突破。双方基于昇腾AI基础软硬件平台,针对DeepSeek大模型完成了大规模专家并行(Expert Parallelism,简称“大…

配电自动化终端中电源模块的设计

配电自动化终端中电源模块的设计 引言 配电终端设备的可靠性和自动化程度,直接影响到整个配电自动化系统的可靠性和自动化水平。由于配电终端设备一般安装于户外或比较偏僻的地方,不可能有直流电源提供,因此,配电网终端设备的直流供电方式成为各配网自动化改造中必须要研究…

性能测试-groovy语言1

课程:B站大学 记录软件测试-性能测试学习历程、掌握前端性能测试、后端性能测试、服务端性能测试的你才是一个专业的软件测试工程师 Jmeter之Groovy语言Groovy简介为何性能测试中选择Groovywindows下载Groovy进入官网配置环境变量Groovy的数据类型groovy的保留字字符…

天邑TY1613_S905L3SB_安卓9-高安非-高安版-通刷-TTL线刷固件包

天邑TY1613_S905L3SB_安卓9-高安非-高安版-通刷-TTL线刷固件包刷机说明:本固件为TTL刷机方式,需要准备如下工具;电烙铁TTL线刷机优盘TTL接触点位于处理器左侧,从上往下数第二脚GND、3TXD、4RXD跑码工具-【工具大全】-putty跑码工具…

【硬件-笔试面试题】硬件/电子工程师,笔试面试题-7,(知识点:晶体管放大倍数计算)

目录 1、题目 2、解答 3、相关知识点 晶体管的电流分配关系 直流电流放大系数\(\overline{\beta}\) 交流电流放大系数\(\beta\) 晶体管的放大条件 总结 【硬件-笔试面试题】硬件/电子工程师,笔试面试题汇总版,持续更新学习,加油&…

力扣-152.乘积最大子数组

题目链接 152.乘积最大子数组 class Solution {public int maxProduct(int[] nums) {int[] dpMax new int[nums.length]; //包括nums[i]的乘积最大值int[] dpMin new int[nums.length]; //包括nums[i]的乘积最小值int res nums[0];dpMax[0] nums[0];dpMin[0] nums[0];fo…

HTTP/1.0、HTTP/1.1 和 HTTP/2.0 主要区别

一句话总结 HTTP/1.0: 短连接,每次请求都需要建立一个新的 TCP 连接,性能较差。HTTP/1.1: 长连接,默认开启 Keep-Alive,连接可复用,解决了 1.0 的大部分问题,是目前使用最广泛的版本。HTTP/2.0: 二进制、多…