讲动人的故事,写懂人的代码
C++是一门应用广泛的编程语言。在2023年JetBrains全球开发者生态问卷调查中,C++在受访程序员过去一年中的使用率,占25%,紧跟JavaScript、Python和Java之后。在本书撰写时,根据JetBrains的统计,程序员使用最多的是C++17。
遗憾的是,C++这门应用广泛的编程语言,长期受悬垂指针(dangling pointer)问题的困扰。悬垂指针是C/C++等手动内存管理语言中的常见问题,可能导致内存泄漏、程序崩溃等严重后果。
悬垂指针是指在程序运行过程中,满足以下条件的指针。
简言之,悬垂指针是一种 "名不副实" 的指针,其所指向的内存要么已经无效,要么已经被重新分配给其他数据。再次解引用(dereference)这样的指针会导致未定义行为。这是一种常见的C/C++编程错误,如下代码所示。
int* createInt() {
int local = 42;
return &local; // 返回局部变量的地址
}
int* p = createInt(); // p是悬垂指针
在上面的例子中,local
是createInt()
函数内的局部变量,其内存在函数返回后就无效了。但p
仍然指向这块无效的内存,成为了悬垂指针。
C++语言在发展过程中对悬垂指针(dangling pointer)的处理也经历了一个逐步完善的过程。在C++早期(C++98之前),内存管理完全靠手动new
和delete
。对象所有权和生命周期管理完全依赖程序员,容易引入悬垂指针。悬垂指针导致的问题如内存泄漏、非法访问等难以调试和定位。C++98/03引入了auto_ptr作为智能指针来管理动态分配的对象。它采用独占所有权模型,复制时转移所有权。这在一定程度上简化了动态内存管理,但auto_ptr存在缺陷,不能用于STL容器。开启现代C++的C++11引入了unique_ptr
、shared_ptr
和weak_ptr
等智能指针。unique_ptr
采用严格的所有权语义,保证对象只被单个指针所有。shared_ptr
采用引用计数,允许共享所有权。weak_ptr
不影响引用计数,用于解决shared_ptr的循环引用问题。配合移动语义、完美转发等特性,大大减少了悬垂指针问题。C++14/17/20通过make_unique
和弃用auto_ptr
(C++17)进一步推广智能指针的使用。结合其他新特性如range-based for循环、结构化绑定等,减少手动内存管理的需求。
C++11中引入了智能指针来帮助管理动态内存,在一定程度上缓解了悬垂指针问题,但并没有从根本上消除它。程序员需要谨慎管理指针,遵循良好的编程实践(如RAII【脚注:Resource Acquisition Is Initialization,资源获取即初始化,是C++编程中的一种惯用法。其核心思想是将资源如内存、文件句柄、锁等的生命周期与一个对象的生命周期绑定。在对象构造时获取资源,在对象析构时释放资源。从而保证资源的正确分配与释放。】)来避免悬垂指针的产生。
然而,即使使用智能指针,C++仍然存在一些潜在的坑。比如在C++中,智能指针和裸指针可以相互转换,程序员可以从智能指针获取裸指针,也可以将裸指针交给智能指针管理。这种灵活性虽然方便,但如果不谨慎,可能导致悬垂指针问题。
下面所讨论的C++踩坑悬垂指针,以及之后的Rust避坑悬垂指针,并不是暗示C++不如Rust好,而仅仅是为了提升自学者入门Rust的动力而已。C++赋予程序员极大的灵活性,是极富影响力的编程语言。
C++自C++11以来引入了智能指针,提供了自动内存管理的能力,这在一定程度上减少了内存泄漏和悬垂指针等问题的发生。然而,即使使用智能指针,C++仍然存在一些潜在的坑。比如在C++中,智能指针和裸指针可以相互转换,程序员可以从智能指针获取裸指针,也可以将裸指针交给智能指针管理。这种灵活性虽然方便,但如果不谨慎,可能导致悬垂指针问题。
下面看一个不慎将从C++智能指针获取的裸指针变成悬垂指针的代码实例,如代码清单1-1所示。
代码清单1-1 从C++智能指针获取的裸指针变成悬垂指针
1 #include <iostream>
2 #include <memory>
3
4 int main()
5 {
6 std::cout << "C++ 悬垂指针示例开始运行..." << std::endl;
7
8 int* rawPtr = nullptr;
9
10 {
11 std::shared_ptr<int> smartPtr = std::make_shared<int>(42);
12 rawPtr = smartPtr.get(); // 获取裸指针
13
14 std::cout << "智能指针管理的值: " << *smartPtr << std::endl;
15 std::cout << "裸指针指向的值: " << *rawPtr << std::endl;
16 } // smartPtr 在此作用域结束后被销毁,内存被释放
17
18 // 此时 rawPtr 成为悬垂指针
19 std::cout << "尝试访问悬垂裸指针的值: " << *rawPtr
20 << std::endl; // 未定义行为,可能崩溃
21
22 return 0;
23 }
// Output:
// C++ 悬垂指针示例开始运行...
// 智能指针管理的值: 42
// 裸指针指向的值: 42
// 尝试访问悬垂裸指针的值: 0
代码清单1-1相应的没有行号的代码在github代码库(github.com/wubin28/book_LRBACP)中文件夹位置为book_LRBACP/ch01/dangling_danger_cpp_dangling_pointer_issue。
如何运行代码清单1-1中的C++代码?
❓如何运行代码清单1-1中的C++代码?
最省事的方法是把没有行号的代码,复制粘贴到网页mycompiler.io/new/cpp左侧。然后点击右上Run按钮,即可运行,并在右侧看到运行结果。如果你想在本地电脑运行,那么可以根据你的电脑的操作系统,使用下面相应的方法(为了节省篇幅,下面只提供要点。细节可以询问你最爱用的生成式AI聊天工具)。
clang++ --version
(将
hello_world_cpp`替换为你的C++项目名,注意该命令会生成带有.git的文件夹,会妨碍基于上层文件夹中.git的git命令执行,此时需要将其删除)clang-format -i ./source/main.cpp
clang++ -fsyntax-only ./source/main.cpp
mkdir build
。第2行cd build
。第3行cmake -DCMAKE_BUILD_TYPE=Debug ..
。第4行cmake --build .
./hello_world_cpp
./source/main.cpp
文件中原有的代码,再重复上面的格式化代码、检查语法错误、编译和运行步骤g++ --version
clang-format -i ./source/main.cpp
g++ -fsyntax-only ./source/main.cpp
cl -v
clang-format -i ./source/main.cpp
clang++ -fsyntax-only ./source/main.cpp
mkdir build
。第2行cd build
。第3行cmake ..
。第4行cmake --build . --config Debug
。代码清单1-1的主要功能是演示如何从C++智能指针获取裸指针,并展示当智能指针超出作用域后,裸指针变成悬垂指针的情况。代码通过智能指针管理一个整数,当智能指针被销毁后,原先获取的裸指针仍然指向已释放的内存,导致悬垂指针的产生。最后,代码尝试访问这个悬垂指针指向的值,展示了未定义行为的可能结果。
第1行包含输入输出流库,用于使用std::cout
进行控制台输出。
第2行包含内存管理库,提供智能指针如std::shared_ptr
的支持。
第4行到第5行定义程序的入口点main
函数。
第6行输出提示信息,表明悬垂指针示例开始运行。
第8行初始化一个裸指针rawPtr
,并将其设置为nullptr
,表示当前不指向任何有效内存。
第10行开始一个新的作用域,用于限定smartPtr
的生存期。
第11行创建一个std::shared_ptr<int>
智能指针smartPtr
,并初始化为指向值为42
的整数。std::
指的是shared_ptr
是C++标准库中提供的一种智能指针。
❓什么是C++的智能指针?什么是shared_ptr
?
智能指针是C++中用于自动管理动态分配内存的对象。它们的主要目的是防止内存泄漏并简化资源管理。智能指针是行为类似于普通指针的类模板,但提供了额外的功能,如自动内存管理。C++11引入了三种主要的智能指针:unique_ptr
、shared_ptr
和weak_ptr
。
unique_ptr
是独占所有权的智能指针。同一时刻只能有一个unique_ptr
指向给定对象。它不可复制,但可以移动。当unique_ptr
被销毁时,它所指向的对象也会被自动删除。unique_ptr
适用以下场景。不需要共享所有权的情况。实现独占资源的转移。作为函数返回类型,表示函数转移了对象的所有权。
shared_ptr
是共享所有权的智能指针。多个shared_ptr
可以指向同一个对象。使用引用计数来跟踪有多少个shared_ptr
共享同一个对象。当最后一个指向对象的shared_ptr
被销毁时,对象会被删除。shared_ptr
适用于以下场景。需要在多个对象间共享资源。实现观察者模式等设计模式。管理有多个所有者的资源。
weak_ptr
是一种不控制所指向对象生存期的智能指针,它指向一个由shared_ptr
管理的对象。它不增加引用计数。可以从一个shared_ptr
或另一个weak_ptr
创建。可以通过lock()
方法来获取一个shared_ptr
。weak_ptr
适用于以下场景。解决shared_ptr
循环引用问题。观察者模式中的弱引用。缓存实现。
第12行通过smartPtr.get()
方法获取智能指针所管理的裸指针,并赋值给rawPtr
。
第14行输出智能指针管理的值,通过解引用smartPtr
得到42
。
第15行输出裸指针指向的值,通过解引用rawPtr
同样得到42
。
第16行作用域结束,smartPtr
被销毁,智能指针的引用计数归零,管理的内存被释放。
第19行到第20行尝试访问rawPtr
指向的值。这是未定义行为,可能导致程序崩溃或输出不可预期的结果。
第22行返回0
,表示程序正常结束。
第23行结束main
函数。
代码后的Output输出第四行,尝试访问悬垂裸指针rawPtr
指向的值,输出为0
。这是在某台电脑上的运行结果。如果在mycompiler.io网页上运行,结果或许是42
。这一结果表明,尽管rawPtr
原本指向的值是42
,但在智能指针被销毁后,内存被释放,导致rawPtr
成为悬垂指针,访问其内容产生了未定义行为。在不同的运行环境或编译器设置下,这一行为可能会导致程序崩溃、输出不同的值,甚至引发安全漏洞。
通过这个示例,可以清楚地看到从智能指针获取的裸指针在智能指针生存期结束后如何变成悬垂指针,从而引发潜在的风险。因此,在使用智能指针时,应谨慎管理裸指针的使用,避免悬垂指针的产生。
Rust如何避坑上面从C++智能指针获取的裸指针变成悬垂指针的问题?通过运用引用来避坑,如代码清单1-2所示。
代码清单1-2 从Rust智能指针获取不可变引用避坑悬垂指针问题
1 fn main() {
2 println!("Rust 避免悬垂指针示例开始运行...");
3
4 let reference;
5
6 {
7 let smart_ptr = Box::new(42);
8 reference = &*smart_ptr;
9
10 println!("智能指针管理的值: {}", smart_ptr);
11
12 println!("引用指向的值: {}", reference);
13 } // smart_ptr 在此作用域结束后被销毁
14
15 // 尝试使用 reference 会导致编译错误
16 println!("引用指向的值: {}", reference);
17 }
// 'cargo run' Output (注释掉第16行):
// Rust 避免悬垂指针示例开始运行...
// 智能指针管理的值: 42
// 引用指向的值: 42
//
// 'cargo build' Output (去掉第16行注释):
// error[E0597]: `*smart_ptr` does not live long enough
// --> src/main.rs:8:21
// |
// 7 | let smart_ptr = Box::new(42);
// | --------- binding `smart_ptr` declared here
// 8 | reference = &*smart_ptr;
// | ^^^^^^^^^^^ borrowed value does not live long enough
// ...
// 13 | } // smart_ptr 在此作用域结束后被销毁
// | - `*smart_ptr` dropped here while still borrowed
// ...
// 16 | println!("引用指向的值: {}", reference);
// | --------- borrow later used here
//
// For more information about this error, try `rustc --explain E0597`.
// error: could not compile `dangling_danger_rust` (bin "dangling_danger_rust") due to 1 previous error
代码清单1-2相应的没有行号的代码在github代码库(github.com/wubin28/book_LRBACP)中文件夹位置为book_LRBACP/ch01/dangling_danger_rust_avoid_dangling_pointers_with_references。
如何运行代码清单1-2中的Rust代码?
❓如何运行代码清单1-2中的Rust代码?
最省事的方法是把没有行号的代码,复制粘贴到网页mycompiler.io/new/rust左侧。然后点击右上Run按钮,即可运行,并在右侧看到运行结果。如果你想在本地电脑运行,那么可以使用下面的方法(为了节省篇幅,下面只提供要点。细节可以询问你最爱用的生成式AI聊天工具)。
rustc --version
cargo new hello_world_rust
(将hello_world_rust
替换为你的Rust项目名cargo fmt
cargo check
cargo build
,会在target/debug文件夹中编译和构建cargo run
./src/main.rs
文件中原有的代码,再重复上面的格式化代码、检查语法错误、编译和运行步骤代码清单1-2的主要功能是演示Rust如何避免悬垂指针问题,特别是在使用智能指针和引用时。它展示了Rust的借用检查器如何在编译时捕获潜在的悬垂指针错误,从而保证内存安全。
第1行定义主函数 main()
。
第2行打印程序开始运行的提示信息。
第4行声明一个名为 reference
的不可变变量(因为reference
左边不带修饰符mut
),但暂不初始化。所以它的类型直到后面第8行初始化时,才能确定。Rust使用类型推断系统。在这种情况下,编译器会等到变量被初始化时才推断其类型。这种声明后延迟初始化的模式在Rust中是允许的,但要确保在使用变量之前对其进行赋值。编译器此时会进行流程分析,确保变量在被使用前已经被初始化。
第6行开始一个新的作用域,用花括号 {}
包围。
第7行绑定一个 Box<i32>
类型的智能指针 smart_ptr
,存储整数值 42。Box
在堆上分配内存。
上面提到,Box<i32>
是一个智能指针,什么是智能指针?什么是Box<T>
?
❓什么是Rust的智能指针?什么是Box<T>
?
Rust的智能指针是一种数据结构,行为类似于指针,但具有额外的元数据和功能。在Rust中,智能指针通常实现了Deref
和Drop
trait。
Rust中常用的智能指针有以下7种。
Box<T>
:用于在堆上分配值Rc<T>
:引用计数智能指针,允许多个所有者共享同一数据的不可变所有权Arc<T>
:原子引用计数智能指针,用于在并发场景下以不可变访问来避免数据竞争Cell<T>
:提供内部可变性,只适用于实现了Copy
trait的类型RefCell<T>
:提供内部可变性,能够处理没有实现Copy
trait的类型Mutex<T>
:提供(读写)互斥锁,用于在并发场景下安全地共享和修改数据RwLock<T>
:提供读写锁,在并发场景下允许多个读操作同时进行,或者单个写操作独占访问其中的<T>
在Rust中表示一个泛型类型参数。简单来说,T
是一个占位符,可以代表任何类型。比如在使用Box<T>
时,需要指定具体的类型来替换T
,例如Box<i32>
或Box<String>
。这种设计让Box
能够灵活地存储各种不同类型的值。泛型允许代码重用,避免为每种类型都写一个专门的Box
实现。这种语法在Rust的其他泛型类型中也很常见,比如Vec<T>
、Option<T>
等。
智能指针最大的优势,是实现了自动内存管理,避免内存泄漏。另外它还提供额外功能,如共享所有权、内部可变性等。它还使用方便,语法类似于普通引用。最后是编译时检查,提高安全性。
智能指针也有一些劣势。它可能引入轻微的运行时开销。在某些情况下可能导致性能下降。学习曲线相对陡峭,尤其是对新手来说。
智能指针适用以下场景。
Box<T>
。Rc<T>
(单线程)或Arc<T>
(多线程)。Cell<T>
。RefCell<T>
。Mutex<T>
。RwLock<T>
。Box<T>
是Rust中最简单的智能指针类型,提供了最基本的堆分配功能,即将数据存储在堆上而不是栈上。它保证不会出现悬垂指针。当Box<T>
被丢弃时,它指向的堆内存也会被自动释放。可以使用 *
运算符来解引用访问Box<T>
中存储的值。它是单一所有权。
Box<T>
具有以下优势。它解决了光凭变量和引用无法创建递归数据结构的问题。用于转移大型数据的所有权而不进行复制。允许存储大小在编译时未知或过大的数据。一般情况下不会引入额外的运行时开销。Box<T>
实现了Deref
和DerefMut
trait,允许透明地访问被包装的值。
Box<T>
也有以下劣势。相比直接在栈上存储数据,使用Box<T>
会引入少量的运行时开销(堆分配和指针间接寻址)。不支持共享所有权。在某些情况下可能影响缓存效率。
Box<T>
适用于以下场景。存储递归数据结构(如链表、树)。需要在堆上分配数据,尤其是编译时大小未知的类型。当需要使用指针语义,但保持单一所有权时。可以用来创建trait对象,实现运行时多态。当需要确保数据有固定的内存地址时(例如,用于长生存期的数据)。在实现某些设计模式(如状态模式)时很有用。
第8行从 smart_ptr
中获取一个不可变引用,并赋值给 reference
。&*smart_ptr
首先解引用 Box
,然后再创建引用。下面逐步解释。smart_ptr
是一个 Box<i32>
类型的智能指针。Box<T>
是Rust中最简单的智能指针,它允许我们在堆上分配内存。*smart_ptr
是解引用操作。*
操作符用于解引用,它获取 smart_ptr
指向的值。在这个情况下,它得到存储在 Box
中的 i32
值42
。&
操作符用于创建引用。它取得值42
的引用,而不是值本身。所以,&*smart_ptr
这个表达式做了两件事。首先,它解引用 smart_ptr
,获取存储在 Box
中的实际整数值。然后,它立即创建了这个值的引用。最终,reference
被赋予了这个引用。reference
的类型是 &i32
,即一个指向 i32
的不可变引用。
这种模式(&*smart_ptr
)在Rust中很常见,特别是当需要从智能指针中获取普通引用时。它允许我们在不转移所有权的情况下访问智能指针管理的数据。
重要的是要注意,这个操作并不会延长 smart_ptr
的生存期。引用的有效性仍然受限于 smart_ptr
的生存期,这就是为什么在后面 smart_ptr
离开作用域后使用 reference
会导致编译错误。
这个模式展示了Rust如何允许程序员安全地处理复杂的内存情况。程序员可以使用智能指针在堆上分配内存。可以从这些智能指针创建临时引用。借用检查器确保这些引用不会比它们指向的数据活得更久。通过这种方式,Rust在提供灵活性的同时保证了内存安全,有效地防止了悬垂指针和其他常见的内存错误。
第10行打印智能指针管理的值。
第12行打印引用指向的值。
第13行作用域结束,smart_ptr
被销毁,它所管理的内存被释放。
第16行尝试使用 reference
打印值,但这会导致编译错误,如代码后面注释中的cargo build
命令输出所示。
代码后的注释给出了两种输出。
当第16行被注释掉时,程序可以成功编译和运行。输出显示智能指针和引用都正确地访问了值 42。
当第16行未被注释时,编译器会报错。错误信息指出smart_ptr
的生存期不够长,无法满足 reference
的借用要求。smart_ptr
在第13行结束时被销毁,但 reference
在第16行仍然被使用。这个错误发生在第8行,借用检查器检测到潜在的悬垂指针。
这个输出体现了Rust的核心优势,即通过借用检查器在编译时捕获潜在的内存安全问题,而不是在运行时产生未定义行为。Rust编译器这种“不修复不罢休”的“护栏”机制,能在编译阶段有效地驱使程序员修复悬垂指针的bug,不仅确保了程序的内存安全性,还能大幅缩短后期很晚才在生产环境发现bug所造成的返工时长,有效减少程序员修bug的工作压力。
在Rust中,如果使用不慎,也会踩类似C++那样将从智能指针获取的裸指针变成悬垂指针的坑,如代码清单1-3所示。
代码清单1-3 从Rust智能指针获取的裸指针变成悬垂指针
1 fn main() {
2 println!("Rust 避免悬垂指针示例开始运行...");
3
4 let raw_ptr;
5
6 {
7 let smart_ptr = Box::new(42);
8 raw_ptr = &*smart_ptr as *const i32; // 获取裸指针
9
10 println!("智能指针管理的值: {}", smart_ptr);
11 unsafe {
12 println!("裸指针指向的值: {}", *raw_ptr);
13 }
14 } // smart_ptr 在此作用域结束后被销毁
15
16 // 尝试使用 raw_ptr 但编译器并未禁止
17 unsafe {
18 println!("尝试访问悬垂裸指针的值: {}", *raw_ptr); // 编译通过
19 }
20 }
// Output:
// Rust 避免悬垂指针示例开始运行...
// 智能指针管理的值: 42
// 裸指针指向的值: 42
// 尝试访问悬垂裸指针的值: 1692729408
代码清单1-3相应的没有行号的代码在github代码库(github.com/wubin28/book_LRBACP)中文件夹位置为book_LRBACP/ch01/dangling_danger_rust_dangling_pointer_from_smart_pointer。
代码清单1-3主要演示了如何从Rust智能指针获取裸指针,并在智能指针被销毁后,该裸指针如何变成悬垂指针的过程。代码展示了Rust在安全性和灵活性之间的平衡,以及使用unsafe代码块时可能带来的潜在风险。
第4行声明一个裸指针变量,但暂不初始化。
第6-14行创建一个新的作用域。
第7行同代码清单1-2,绑定一个Box<T>
智能指针,指向堆上的整数42
。
第8行从智能指针获取裸指针。这行代码前半部分与代码清单1-2第8行类同,只是后面多了as *const i32
。这多出来的部分是类型转换(type casting)操作。*const i32
表示一个指向 i32
的常量(不可变)裸指针。as
关键字用于执行显式类型转换。在 *const i32
中,*
在类型上下文中表示这是一个指针类型。const
表示这个指针指向的数据是常量(不可通过此指针修改)。i32
是指针指向的数据类型。这种转换将安全的 Rust 引用转换为不安全的裸指针。裸指针不受 Rust 的借用规则限制,但使用时需要格外小心。
这行代码从 Box<i32>
智能指针创建了一个 const i32
类型的裸指针。裸指针不增加引用计数,也不影响 Box
所拥有的数据的生存期。
这种转换本身是安全的,但使用裸指针是不安全的操作。在后续代码中,使用这个裸指针需要在 unsafe
块中进行。
这种技术通常用于与不使用 Rust 内存安全特性的外部代码(如 C 语言库)交互。在纯 Rust 代码中,通常应避免使用裸指针,除非有特殊需求。
第10-13行打印智能指针和裸指针指向的值。 第14行作用域结束,smart_ptr
被销毁,其指向的内存被释放。
第16-19行尝试使用已经变成悬垂指针的raw_ptr
,即在unsafe
块中尝试解引用并打印raw_ptr
指向的值。
从代码后面的输出可以看出,"尝试访问悬垂裸指针的值: 1692729408"这个输出很关键。它显示了访问悬垂指针的危险性。输出的不是42,而是一个看似随机的大数,而且在每次运行程序都会发生变化。这表明我们正在访问已经被释放的内存,可能是被重新分配给了其他数据。这种行为是未定义的,可能导致程序崩溃或产生不可预测的结果。
这个输出强调了在Rust中正确使用裸指针的重要性,以及为什么Rust通常会阻止这种危险操作。只有在unsafe块中,我们才能执行这种不安全的操作,而且应该非常谨慎地使用。
虽然在Rust里,我们也踩了悬垂指针的坑,但这个坑是在unsafe
代码块中踩的。相比C++在混用裸指针时不做任何标记,Rust用unsafe
块提醒程序员,要担负好内存安全的责任。
Rust既然能帮程序员避现代C++的悬垂指针的坑,那它能帮程序员避Java什么坑?
如果喜欢这篇文章,别忘了给文章点个“赞”,好鼓励小吾继续写哦~😃
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。