现代 C++ 分享(1)—— 生命周期、所有权和资源管理
这是整个现代 C++ 分享系列第一篇,关于生命周期、所有权和资源管理。主题包括但不限于指针、智能指针、引用、类型系统、移动语义以及完美转发和引用折叠相关的主题。
C++ 在演进过程中逐渐增强和扩展了对类型处理的能力:
- C++11 中引入右值引用,通过重新定义值类别对表达式进行分类,右值引用能表达移动语义,解决了 C++11 之前产生的中间临时对象需要多次拷贝的问题;
- C++11 中引入 auto 关键字,对初始化变量进行推导,并且引入 decltype 关键字,通过已有对象、变量获得类型;
- C++17 引入 optional 类型表达对象是否存在,并且引入 variant 作为类型安全的 union,类型表达更灵活。
- C++20 中引入 concept 特性对类型在编译期做约束,增强类型的表达和检查能力。
在 C++ 中,容器和指针抽象后想要被正确且高效的使用,在工程中通常需要封装一组数据及函数来访问和操作。举例来说,指针是通用和有效抽象的机器地址,但正确使用指针来表示资源的所有权是非常困难的,因此标准库提供了智能指针类管理资源和生命周期。指针的更泛化的概念是,任何允许我们引用对象并根据其类型访问。
指针和引用
指针是从 C 语言中延续下来的概念,而引用是在指针基础上结合资源管理进一步在编译器层做的管理手段,其本质在汇编层面并无区别。但是在使用时,需要注意以下几点:
- 指针是一个变量,它保存了另一个变量的内存地址;引用是另一个变量的别名,与原变量共享内存地址;
- 指针可以被重新赋值,指向不同的变量;引用在初始化后不能更改,始终指向同一个变量;
- 指针可以为 nullptr,表示不指向任何变量;引用必须绑定到一个变量,不能为 nullptr;
- 指针需要对其进行解引用以获取或修改其指向的变量的值;引用可以直接使用,无需解引用。
1 |
|
引用是 C++ 编译器的约定,那和指针到底有什么区别?
答:本质上没有区别
堆栈变量生命周期管理
在 C++ 中创建变量一般有两种形式:
1 | A a; |
1、静态建立类对象:编译器为对象在栈空间中分配内存,是通过直接计算 A 的空间,移动栈顶指针,挪出适当的空间,然后在这片内存空间上直接调用构造函数形成一个栈对象,在局部作用域退出时,自动调用类的析构函数;
2、动态建立类对象,使用 new 运算符将对象建立在堆空间。先在堆内存中搜索合适的内存并进行分配,再是调用构造函数构造对象,初始化堆内存,生命周期需要手动管理(delete)。
RAII
RAII 是 Resource Acquisition Is Initialization 的简称,其翻译过来就是“资源获取即初始化”,即在构造函数中申请分配资源,在析构函数中释放资源,它是 C++ 语言中的一种管理资源、避免泄漏的良好的设计方法。
C++ 语言的机制保证了,当创建一个类对象时,会自动调用构造函数,当对象超出作用域时会自动调用析构函数。RAII 正是利用这种机制,利用类来管理资源,将资源与类对象的生命周期绑定,即在对象创建时获取对应的资源,在对象生命周期内控制对资源的访问,使之始终保持有效,最后在对象析构时,释放所获取的资源。
1 | std::mutex mut; |
上述展示了一个写函数,而在多线程并行调用下,内部可能共用的文件描述符必须用一个互斥锁保护,否则不同线程的字符串会混在一起。这段代码看起来没有问题,但是如果当写线程抛出了异常,调用栈会被直接释放,unlock 方法永远不会执行,造成死锁,其他的线程再也拿不到资源。
1 | std::mutex mut; |
lock_guard 保证在函数返回之后或者异常后释放互斥锁,因此无需担心异常情况下手动释放锁的问题。而 lock_guard 的实现,就是使用了“资源在构造函数中定义,在析构中释放的原则”。
1 | template <typename T> |
lock_guard 在构造函数中锁住了引用传入的资源(mutex),并且在析构函数中释放锁。其异常安全的保障就是析构函数一定会在对象归属的 scope 退出时自动被调用。
拷贝赋值&构造
拷贝构造函数是一种特殊构造函数,具有单个形参,该形参(常用 const 修饰)是对该类类型的引用。
拷贝赋值函数,也叫赋值操作符重载,因为赋值必须作为类成员,那么它的第一个操作数隐式绑定到 this 指针,赋值操作符接受单个形参,且该形参是同一类类型的对象。右操作数一般作为 const 引用传递。
问:为啥拷贝构造函数的形参必须是引用?
1 | class Person { |
拷贝构造函数和赋值运算符的行为比较相似,都是将一个对象的值复制给另一个对象,但是其结果却有些不同。拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符是将对象的值复制给一个已经存在的实例。具体来说,拷贝构造函数是构造函数,其功能就是创建一个新的对象实例;赋值运算符是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。
调用拷贝构造函数主要有以下场景:
- 对象作为函数的参数,以值传递的方式传给函数;
- 对象作为函数的返回值,以值的方式从函数返回;
- 使用一个对象给另一个对象初始化。
深拷贝&浅拷贝
拷贝构造函数和赋值函数并非每个对象都会使用,另外如果不主动编写的话,编译器将以“位拷贝”的方式自动生成缺省的函数。在类的设计当中,“位拷贝”是应当防止的,可以通过“= delete”删除拷贝构造和拷贝复制函数。倘若类中含有指针变量, 并且这两个指针指向重叠的位置,那么这两个缺省的函数就会发生错误。
深拷贝和浅拷贝区分主要是针对类中的指针成员变量,因为对于指针只是简单的值复制并不能分割开两个对象的关联,任何一个对象对该指针的操作都会影响到另一个对象。这时候就需要提供自定义的深拷贝的拷贝构造函数,消除这种影响。通常的原则是:
- 指针成员应该提供自定义的拷贝构造函数;
- 在提供拷贝构造函数的同时实现自定义的赋值运算符。
对于拷贝构造函数的实现要确保以下几点:
- 对于值类型的成员进行值复制;
- 对于指针和动态分配的空间,在拷贝中应重新分配分配;
- 对于基类,要调用基类合适的拷贝方法,完成基类的拷贝。
移动赋值&构造
(见下文移动语义)
智能指针
标准库中指针在经过多年发展后,不仅有耳熟能详的 share_ptr 和 unique_ptr 还多了一些其他的类型,为了对比,我这里也把普通指针和引用放在一起:
- T*: 内置指针类型,指向类型 T 的对象,或者指向类型 T 的连续内存空间序列;
- T&:内置引用类型,引用类型 T 的对象(为别名),是一个隐式解引用的指针;
- unique_ptr
:拥有 T 的所有权,指向 T 的独占指针,在离开作用域时,unique_ptr 的析构会销毁其指向的对象; - shared_ptr
:指向类型 T 对象的共享指针,所有权在所有指向类型 T 对象的 shared_ptr 之间共享,最后一个共享 T 对象的 shared_ptr 离开最后的作用域时负责析构销毁 T 对象; - weak_ptr
:指向 shared_ptr 拥有的对象,但是不引用计数,需要用 weak 访问对象时,需要提升为 shared_ptr 才可以; - span
:指向连续序列的 T 元素的指针,为 std::vector 等容器的“视图”; - string_view
:指向字符串子串的视图; - X_iterator
: 来自 C 容器的对象序列,“X”代表具体的迭代器类型(map、set…)。
unique_ptr
shared_ptr
C++ 智能指针详解(二)——shared_ptr 与 weak_ptr
值类型
在 C++11 之前,很多 C++ 程序里存在大量的临时对象,又称无名对象。主要出现在如下场景:
- 函数的返回值
- 用户自定义类型经过一些计算后产生的临时对象
- 值传递的形参
C++11 之后,左值和右值分为了三个具体的子值类型,和两个混合类型,这里清晰起见,不对泛左值和右值展开。
左值具有以下特征:
- 可通过取地址运算符获取其地址;
- 可修改;
- 可用作内建赋值和内建符合赋值运算符的左操作数;
- 可以用来初始化左值引用。
举例:
1 | int a = 1; // a是左值 |
将亡值可以理解为通过“盗取”其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。
将亡值只能通过两种方式来获得,这两种方式都涉及到将一个左值赋给(转化为)一个右值引用:
- 返回右值引用的函数的调用表达式,static_cast<T&&>(T);
- 转换为右值引用的转换函数的调用表达式,std::move(t)。
1 | std::string fun() { |
C++11 之前,s = fun()会调用拷贝构造函数,会将整个 str 复制一份,然后再把 str 销毁。str 比较大则会造成巨大开销。一旦 str 被 s 复制后,将被销毁,无法获取和修改。
C++11 后,引入了 move 语义,编译器会将这部分优化成 move 操作,str 会被进行隐式右值转换,等价于 static_caststd::string&&(str),进而此处的 s 会将 func 局部返回的值进行移动。
纯右值本身就是纯粹的字面值,如 3,false,12.13,或者求值结果相当于字面值或是一个不具名的临时对象。
具体来说:
- 纯右值不具有多态:它所标识的对象的动态类型始终是该表达式的类型;
- 纯右值不能具有不完整类型;
- 纯右值不能具有抽象类类型或它的数组类型。
左值引用和右值引用
1 | int a = 1; |
左值引用使用“&”标记,右值引用使用“&&”标记。具体来说,在 C++11 之前的引用,都是左值引用,而:
- 在语句执行完毕之后被销毁的临时对象;
- std::move()后的非 const 对象
考虑下列代码,应该使用哪个 foo 函数的重载版本:
1 | void foo(int&); // 1 |
答案是使用“1”的函数。虽然这里的变量定义的是右值引用类型,然后 foo(value)中的表达式 value 确是一个左值,而不是由定义 value 时的类型来决定值类型。简单来说,如果表达式能取地址,则为左值表达式,否则为右值表达式。
根据引用和常量性质进行组合,有几种情况:
- 左值引用 Value&,只能绑定左值表达式,如 1 中形参;
- 右值引用 Value&&,只能绑定右值表达式,如 2 中形参;
- 左值常引用 const Value&,可以绑定左、右值表达式,但是后续无法修改值;
- 右值常引用 const Value&&,只能绑定常量右值表达式,实际中不使用。
问:如何将上述代码中 value 以右值引用的形式传递给 foo,从而调用“2”的函数,两个方法:一是直接 foo(42),二是通过 foo(static_cast<int&&>(value))。
编译器会匹配为右值引用。
让编译器将对象匹配为右值引用,是移动语义的基础!!!
移动语义
先来说明为什么需要移动语义:
case1:
1 | class Stuff { |
这是一个较为常见的开发 case,创建了一个容器 std::vector 以及自定义类 Stuff,并且添加到容器中两次,注意我们这里并没有对 Stuff 类自定义拷贝构造和拷贝赋值,使用编译器默认实现。tmp 添加到容器中两次,每次添加时都会发生一次拷贝操作,最终内存结构可能是:
tmp 对象在添加到容器中两次后,生命周期随之结束。
case2:
1 | std::string process1(const std::string& str) { |
回到 case1,现在修改容器的操作为下面的代码:
1 | std::vector<Stuff> stuff; |
现在可以明确,移动操作执行对象数据转移,拷贝操作复制对象数据。为了能够将拷贝操作与移动操作区分执行,需要不同于拷贝的标记,“&&”应运而生。
在不考虑模板的情况下,针对两种 push_back 函数需要有两种重载实现:
1 | class vector { |
通过传递左值引用或右值引用,根据需要调用不同的 push_back 重载函数。
现代 C++ 分享(1)—— 生命周期、所有权和资源管理