内存管理在CPP,Rust和Mojo语言中的实践
本文探讨了不同编程语言中内存管理的实践,特别是在 C++、Rust 和 Mojo 语言中的不同策略。我们将讨论栈和堆的概念,变量的存储和生命周期,以及内存安全问题。此外,我们还将详细介绍各语言中的赋值、函数参数传递和函数返回值处理方式。
目录
- 栈与堆
- 变量与内存
- 内存管理
栈与堆
在许多编程语言中,开发者不需要经常考虑栈和堆的区别。但在系统编程语言中,数据是否存储在栈上还是堆上,对语言的行为和开发者的决策有着显著影响。理解所有权(ownership)的概念,尤其是它如何与栈和堆相关联,对于编写高效和安全的代码至关重要。
在程序运行时,栈(Stack)和堆(Heap)是两个主要的内存区域,它们以不同的方式来管理静态大小和动态大小的变量。
栈内存
- 栈以后进先出(LIFO)的方式管理数据,高效处理静态大小的变量。这些变量的大小在编译时已知且固定,例如基本数据类型(整数、字符等)。
- 在栈上,数据的添加(推入)和移除(弹出)操作简单快速,因为位置总是在栈的顶部,无需在内存中搜索合适的存储位置。
堆内存
堆用于存储动态大小的变量,即那些在编译时大小未知或在运行时可能改变大小的变量,如字符串或向量。
当数据存放到堆上时,需要请求特定大小的空间。内存分配器寻找足够大的空闲区域,标记为已使用,并返回一个指向该位置的指针。这个过程被称为在堆上分配,并有时简称为分配(将值推入堆栈不被视为分配)。
堆上的内存分配和管理比栈更为复杂和耗时,因为涉及到搜索合适的存储位置并进行内存管理。
性能考虑
跟踪代码中使用堆上数据的部分,最小化堆上重复数据的数量,并清理未使用的堆上数据,以防止空间不足,这些都是所有权解决的问题。一旦你理解了所有权,你就不需要经常考虑栈和堆了。我们将在后面解释所有权的概念。
变量与内存
在编程中,变量的存储和生命周期与它们在内存中的位置紧密相关。了解不同类型的变量如何与栈内存和堆内存交互,对于编写高效和可靠的代码至关重要。
变量的存储
局部变量
- 局部变量通常存储在栈内存中。它们的生命周期与其所在的函数调用密切相关,一旦函数调用结束,这些变量的内存就会被自动释放。
- 例如,一个函数内部声明的整数或字符变量会在函数执行结束时从栈上弹出,无需手动管理。
全局/静态变量
动态分配的变量
作用域与生命周期
作用域(Scope)
- 作用域是编程中的一个基本概念,指的是变量可被访问的程序区域。通常,一个变量的作用域是由它被声明的位置决定的。
- 在一个函数内部声明的变量,它的作用域通常限制在这个函数内。当程序执行离开这个作用域时,这些变量就不再可用。
- 这种紧密的作用域和生命周期关系有助于自动管理内存,减少内存泄漏风险。
生命周期(Lifetime)
生命周期是指变量存在于内存中的时间段。它从变量创建开始,到变量被销毁结束。
对于栈分配的变量,它们的生命周期通常与它们的作用域一致;一旦它们的作用域结束,它们就会从栈上移除。
对于堆分配的变量,它们的生命周期不由其作用域直接控制(!),因为它们通常由程序员显式地分配和释放。例如,一个函数可能创建一个对象并返回它的指针,虽然这个函数的作用域结束了,但对象在堆上的生命周期仍然继续。
在这种情况下,需要特别注意内存的管理和释放,以避免内存泄漏和其他相关问题。
理解作用域和生命周期如何影响内存管理是编程中的一个关键方面。在不同的编程语言和应用场景中,正确地管理这两个概念对于保证程序的可靠性和效率至关重要。Rust通过所有权和生命周期实现了语言级别的,我们在下面也会介绍一下,最后再讨论mojo的资源管理模型。我们也会看到mojo和rust有惊人的相似之处。
内存安全
手动管理
内存泄漏
- 内存泄漏发生在堆内存中,当程序动态分配了内存之后,不再需要这部分内存,却没有正确地释放。这导致在程序的整个执行过程中,这部分内存仍然被占用,无法被其他部分使用。
- 长期的内存泄漏会导致程序消耗过多的内存资源,可能引起性能下降甚至程序崩溃。
野指针
垂悬指针
这些问题突显了精确和谨慎的内存管理的重要性。在系统级编程中,特别是在手动管理内存的语言中,理解这些概念并采取相应的预防措施是非常重要的。
资源获取即初始化(RAII)
RAII(Resource Acquisition Is Initialization)是一种自动管理内存的技术,用于在对象的生命周期中管理资源。RAII 包含三个步骤:
- 将内存资源包装在一个类中
- 通过局部结构体的实例来访问内存资源
- 当对象超出作用域或被销毁时,其析构函数会自动释放这些资源。
在内存管理上,RAII确保了当对象不再被使用时,分配给它的内存会被适时释放。这减少了内存泄漏的风险,因为内存的分配和释放是自动进行的。
下面是一个简单的例子
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
// 向向量添加一些元素
for (int i = 0; i < 10; ++i) {
vec.push_back(i);
}
// 使用vector的元素...
// 当vec离开作用域时,它包含的所有元素都会被自动销毁。
// 没有必要手动释放内存
return 0;
}
当然,RAII并不能完全保证内存安全,我们在下面可以看到垂悬引用的例子。
内存管理
涉及到内存管理时,有三个核心场景至关重要:赋值、函数参数传递和函数返回值。在这些场景中,复制、引用和移动是三种主要的内存操作方式。
复制、引用和移动
- 复制(Copying):当数据从一个变量复制到另一个时,可能涉及到两种主要形式:深拷贝和浅拷贝。
- 深拷贝(Deep Copying):如果数据存储在堆内存中,深拷贝会创建数据的完整副本。这种操作在栈内存中通常更高效,因为栈上的数据通常有固定的大小和简单的结构。深拷贝保证了数据的完全独立性,但在堆上可能导致额外的内存分配和性能开销。
- 浅拷贝(Shallow Copying):与深拷贝不同,浅拷贝仅复制数据的引用,而不是数据本身。这意味着原始数据和复制后的数据指向同一个内存地址。浅拷贝在处理复杂的数据结构时更加节省内存和提高效率,但原始数据和复制后的数据之间存在依赖关系,原始数据的变化会影响到浅拷贝的结果。
引用(Referencing)
- 通过引用,可以在不复制实际数据的情况下访问堆上的对象。这在栈内存中也适用,但特别有用于管理堆上的大型或复杂数据结构。
- 引用减少了内存占用和提高了性能,但在堆内存中使用时需要特别注意生命周期管理,以避免垂悬指针和其他内存安全问题。
移动(Moving)
我们用The Rust Book中的示例来演示这三种行为
首先我们初始化一个s1字符串:

浅拷贝:

深拷贝:

移动:

s1不再”有效“。CPP和Rust在移动上的行为稍有不同。具体来说,CPP只是把s1的指针成为置成nullptr
,在离开作用域时仍然会调用两次析构函数。Rust则会阻止你继续访问s1,离开作用域只调用一次析构函数。这两个方式都可以避免double free.
CPP
当进行赋值,传参和返回值时,C++ 默认会创建该对象的副本,即复制,除非明确使用引用或移动语义。
现在我们用两个向量相加说明复制,引用和移动的含义。我们借用std中的string类,但是假装它是一个用户自定义的没有任何优化的类。
复制
- 这是最简单的方法,通过值传递将字符串传入函数,并返回它们的拼接结果。
- 这种方法在传递和返回时都会创建副本,因此可能会影响性能,尤其是在字符串较大时。
#include <iostream>
#include <string>
using std::string;
// 通过复制传递并返回两个字符串的拼接
string addByCopy(string s1, string s2) {
// s1 和 s2 是原始字符串的副本,在函数内部创建和销毁。
string result = s1 + " + " + s2; // result 在函数作用域内创建
std::cout << "Inside function (copy): " << result << std::endl;
return result; // 返回 result 的副本,函数内的 result 随后销毁
}
int main() {
string original1 = "Original1"; // original1 在 main 函数作用域内创建
string original2 = "Original2"; // original2 在 main 函数作用域内创建
string copied = addByCopy(original1, original2); // copied 是 addByCopy 返回的副本
std::cout << "After copy: " << copied << " (copied)" << std::endl;
return 0;
}
引用
- 为了减少不必要的复制,我们通过引用而不是值来传递字符串。
- 这种方法避免了传递参数时的复制,但返回新字符串时仍然会发生一次复制(假设编译器不做优化)。
// 通过引用传递并返回两个字符串的拼接
string addByReference(string& s1, string& s2) {
// s1 和 s2 是对原始字符串的引用,没有创建副本。
string result = s1 + " + " + s2; // result 在函数作用域内创建
std::cout << "Inside function (reference): " << result << std::endl;
return result; // 返回 result 的副本,函数内的 result 随后销毁
}
// 在 main 函数中添加对 addByReference 的调用
string addedByRef = addByReference(original1, original2); // addedByRef 是 addByReference 返回的副本
悬垂引用
移动
- 为了解决返回时复制的问题,同时避免悬挂引用,引入移动语义。
- 通过移动语义,函数可以安全地将局部变量的资源“移动”到调用者,从而避免不必要的复制。
- 当函数返回一个局部对象且该对象定义了移动构造函数时,C++ 编译器会尝试使用移动语义而非复制。
- 在这个例子中,当我们返回一个新的
std::vector
对象时,如果 std::vector
定义了移动构造函数,编译器会选择使用移动语义。
以下是一个简化版的 Vector
类移动构造函数的实例。
Vector(Vector&& a) : elem(a.elem), sz(a.sz) {// "抓取"来自 a 的元素
a.elem = nullptr;
a.sz = 0; // 现在 a 没有元素
}
std::vector<int> addVectors(std::vector<int>& v1, std::vector<int>& v2) {
std::vector<int> result;
// ... 向量相加的逻辑 ...
return result; // 使用移动语义,无需显式调用 std::move
}
result
的内容会被“移动”到函数外部的一个新对象中。这种情况下,result
本身仍然会在函数结束时销毁,但其内容已经转移到另一个对象中。
&&在cpp中被称为右值引用,如果你查找什么是右值,你会得到一个令人费解
的解释:“不是左值的就是右值,左值是可以放在左边的值”。其实右值就是一个标记,表示在进行类似复制的操作时,将指向的内容转移到新的对象,同时把原对象置空,就像上面的a.elem = nullptr
。
Rust
与 C++ 类似,Rust 语言在赋值、传参和返回值时也有特定的行为,但它们是通过所有权系统、移动语义、借用规则、以及 Copy
和 Clone
traits 来管理和操作的。
Rust 遵循三条规则来管理内存安全和并发安全:
所有权(Ownership):在 Rust 中,每个值都有一个称为其“所有者”的变量。一次只能有一个所有者。当所有者离开作用域时,值将被丢弃。
借用(Borrowing):通过引用(&
)可以借用值,但这些借用必须遵循借用规则。Rust 中有两种类型的引用:不可变引用(&T
)和可变引用(&mut T
)。不可变引用允许你读取数据但不能修改,而可变引用允许修改数据。
生命周期(Lifetimes):Rust 通过生命周期来确保所有的引用都是有效的。生命周期是一个引用能够指向数据的作用域。
- 不可变引用在 C++ 中相当于常量引用(
const T&
),它允许你查看但不修改引用的对象。
- 可变引用在 C++ 中则对应非常量引用(
T&
),允许修改引用的对象。
在 Rust 中,赋值、传递参数和返回值的默认行为取决于类型是否实现了 Copy
trait,该类型总是进行浅拷贝。如果没有实现,如 String
或 Vec<T>
,在赋值或传参时,所有权会被转移。深拷贝被故意设置的比较麻烦,需要1. 实现了Clone trait; 2. 必须显式调用 clone()
方法。否则会回退到浅拷贝和移动操作上。这暗示了一种设计选择:Rust永远不会自动进行深拷贝。
示例
fn main() {
let x = 5; // i32 实现了 Copy trait
let y = x; // x 被复制到 y
let s = String::from("hello"); // String 没有实现 Copy trait
takes_ownership(s); // s 的所有权被转移
// println!("{}", s); // 这会导致编译错误,因为 s 的所有权已经被转移
let z = returns_ownership(); // 返回值的所有权转移到 z
}
fn takes_ownership(some_string: String) { // some_string 获得所有权
println!("{}", some_string);
} // some_string 离开作用域并被丢弃
fn returns_ownership() -> String {
let some_string = String::from("hello");
some_string // some_string 被返回并移动到调用函数
}
以下是对上述 C++ 示例在 Rust 中的体现。
fn add_by_move(s1: String, s2: String) -> String {
// s1 和 s2 的所有权在这里被移动到函数内部。
// 执行字符串拼接,产生新的 String 实例 result。这个过程消耗了 s1 的所有权。
let result = s1 + " + " + s2;
// result 在这里被打印。
println!("Inside function (move): {}", result);
// 返回 result,其所有权被移动到调用者。
result
}
fn main() {
// original1 和 original2 在这里创建,拥有 String 实例的所有权。
let original1 = String::from("Original1");
let original2 = String::from("Original2");
// 调用 add_by_move,original1 和 original2 的所有权被移动到函数内。
let moved = add_by_move(original1, original2);
// 此时 original1 和 original2 已经不再有效,因为它们的所有权已经移动到函数中。
println!("After move: {} (moved)", moved);
// println!(original1); // 这里尝试使用 original1 将导致编译错误,因为它的所有权已经被移动。
}
由于 String
类型不实现 Copy
trait,在 add_by_move
函数中,String
的所有权首先被移动到函数中,然后当返回 result
时,这个所有权再次被移动到调用者。如果希望保留原始
使用引用(&
)可以避免所有权的转移。不过,如果要修改引用的内容,则需要可变引用(&mut
)。
fn add_by_reference(s1: &String, s2: &String) -> String {
// s1 和 s2 是对 String 的不可变引用,不涉及所有权的转移。
// s1 被克隆以创建一个新的 String 实例,因为字符串拼接需要消耗(consume)第一个参数的所有权。
let result = s1.clone() + " + " + s2;
// result 在这里被打印。
println!("Inside function (reference): {}", result);
// 返回 result,其所有权被移动到调用者。
result
}
fn main() {
// original1 和 original2 在这里创建,拥有 String 实例的所有权。
let original1 = String::from("Original1");
let original2 = String::from("Original2");
// 调用 add_by_reference,传递 original1 和 original2 的引用。
let added_by_ref = add_by_reference(&original1, &original2);
// original1 和 original2 仍然有效,因为只是传递了引用,没有发生所有权的转移。
println!("After reference: {} (added by reference)", added_by_ref);
// 此时可以安全地使用 original1 和 original2。
}
Mojo
我们直接通过下表来对比Mojo和CPP及Rust的语法:
| | 深拷贝 | 浅拷贝 | 不可变引用 | 可变引用 | 移动 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| C++ | void f(T x)
<br>f(x)
| void f(T x)
<br>f(x)
| void f(const T& x)
<br>f(&x)
| void f(T& x)
<br>f(&x)
| void f(T&& x)
<br>f(std::move(x))
|
| Rust | fn f(x: T)
f(x.clone())
| fn f(x: T)
<br> f(x)
| fn f(x: &T)
<br> f(&x)
| fn f(x: &mut T)
f(&mut x)
| fn f(x: T)
<br>f(x)
|
| Mojo | fn f(owned x: T)
<br>f(x)
| fn f(owned x: T)
<br> f(x)
| fn f(x: T)
f(x)
| fn f(inout x: T)
f(x)
| fn f(owned x: T)
f(x^)
|
Mojo有点像是两个语言的杂糅,我们尝试理解一下。
- 首先,Mojo默认使用不可变引用进行传参。可变引用只需要修改函数定义的参数约定为
inout
- 移动操作需要在调用端和被调用段同时显式的写
owned
和^
- 如果不写
^
,则选择复制操作,具体是深拷贝还是浅拷贝取决于x
的复制构造函数的定义。
Mojo的编译器还有一个小优化,也不难理解,即:如果x
是最后一次使用,那么尽可能的使用移动操作而不是复制,原理是既然是最后一次使用了,那么进行了复制还是移动对用户来说是没有区别的。