Effective Modern C++ Notes (1)
Deducing Types
Item 1: Understand template type deduction
模板函数类型推导
1 | template<typename T> |
对于以上的通用模板,其类型推导(即确定模板参数T
的类型)同时取决于ParamType
和expr
的类型,遵循以下的规则:
ParamType
是引用/指针,但不是通用引用(universal reference),例如template<typename T> void f(T& param)
。从字面意思看,我们想要进行引用传值,因此:
T
的类型不应该包括expr
类型中(可能)的引用/指针,因为引用/指针已经包含在ParamType
中了。- 如果
ParamType
没有包含cv限定符,T
的类型应该包含expr
类型中(可能)的cv限定符,否则expr
的cv限定符在引用传值之后就丢失了。
因此,这种情况下类型推导的行为是比较符合直觉的:
- 如果
expr
的类型是引用,忽略引用部分。(如int& => int
) expr
的类型与ParamType
做模式匹配以确定T
。
以下注释中的
typeof(X) == YY
仅表示X的类型是YY,不代表typeof()
的实际行为,下同。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34template<typename T>
void f(T& param);
template<typename T>
void g(const T& param);
template<typename T>
void h(const T&& param);
template<typename T>
void i(T* param);
int x = 233;
const int cx = x;
const int& rx = x;
const int* px = &x;
f(x); // typeof(x) == int, typeof(T) == int, ParamType == int&
f(cx); // typeof(cx) == const int, typeof(T) == const int, ParamType == const int&
f(rx); // typeof(rx) == const int&, typeof(T) == const int, ParamType == const int&
f(233); // typeof(233) == int&&, 左值引用无法绑定右值,编译失败
g(x); // typeof(x) == int, typeof(T) == int, ParamType == const int &
g(cx); // typeof(cx) == const int, typeof(T) == int, ParamType == const int&
g(rx); // typeof(rx) == const int&, typeof(T) == int, ParamType == const int&
g(233); // typeof(233) == int&&, typeof(T) == int, ParamType == const int&
h(x); // typeof(x) == int, 右值引用无法绑定左值,编译失败
h(cx); // typeof(cx) == const int, 右值引用无法绑定左值,编译失败
h(rx); // typeof(rx) == const int&, 右值引用无法绑定左值,编译失败
h(233); // typeof(233) == int&&, typeof(T) == int, ParamType == const int&&
i(&x); // typeof(&x) == int*, typeof(T) == int, ParamType == const int*
i(px); // typeof(px) == const int*, typeof(T) == const int, ParamType == const int*ParamType
是通用引用,例如template<typename T> void f(T&& param)
。通用引用希望既能绑定左值,也能绑定右值。即当
expr
的类型为左值时,ParamType
为左值引用;当expr
的类型为右值时,ParamType
为右值引用。为了实现这一目的,此时类型推导的行为是:
- 如果
expr
为左值,T
被推导为左值引用。由于引用折叠,ParamType
的最终类型也为左值引用。 - 如果
expr
为右值,使用正常的推导规则。(即:去掉expr
类型的引用部分,再与T&&
做模式匹配。最后T
为expr
的原始类型,ParamType
为右值引用)
1
2
3
4
5
6
7
8
9
10
11template<typename T>
void f(T&& param);
int x = 233;
const int cx = x;
const int& rx = x;
f(x); // typeof(x) == int, typeof(T) == int&, ParamType == int&
f(cx); // typeof(cx) == const int, typeof(T) == const int&, ParamType == const int&
f(rx); // typeof(rx) == const int&, typeof(T) == const int&, ParamType == const int&
f(233); // typeof(233) == int&&, typeof(T) == int, ParamType == int&&- 如果
ParamType
非指针也非引用。此时的语义应该是进行值传递,因此:
param
不应该成为一个引用- 如果
ParamType
不含cv限定符,param
也不应该继承expr
(可能)包含的cv限定符,因为它们是两个不同的变量
于是此时的类型推导行为是:
- 如果
expr
包含引用,忽略引用部分。 - 如果
expr
包含cv限定符,忽略cv限定符。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23template<typename T>
void f(T param);
template<typename T>
void g(const T param);
int x = 233;
const int cx = x;
const int& rx = x;
f(x); // typeof(x) == int, typeof(T) == int, ParamType == int
f(cx); // typeof(cx) == const int, typeof(T) == int, ParamType == int
f(rx); // typeof(rx) == const int&, typeof(T) == int, ParamType == int
f(233); // typeof(233) == int&&, typeof(T) == int, ParamType == int
g(x); // typeof(x) == int, typeof(T) == int, ParamType == const int
g(cx); // typeof(cx) == const int, typeof(T) == int, ParamType == const int
g(rx); // typeof(rx) == const int&, typeof(T) == int, ParamType == const int
g(233); // typeof(233) == int&&, typeof(T) == int, ParamType == const int
const char *const ptr = "Hello World";
f(ptr); // typeof(ptr) == const char* const, typeof(T) == const char*, ParamType == const char*注意最后
ptr
的例子:param
只是移除了top-level cv限定符,即指针本身的constness。指针指向的仍然是const char*
。
数组实参
当数组实参被值传递给函数模板时,T
和ParamType
会退化为指针;当数组实参被引用传递给函数模板时,T
会被推导为数组类型,形参类型则会是对数组的引用:
1 | template<typename T> |
这一特性使我们可以通过模板参数推导获得数组大小:
1 | template<typename T, size_t N> |
函数实参
当函数类型的实参被传递给函数模板时,若ParamType
不是引用,param
的类型为函数指针;若ParamType
为引用,param
的类型为函数的引用。
1 | template<typename T> |
Item 2: Understand
auto
type deduction
auto
类型推导的规则和模板类型推导基本一样,因为:
1 | const auto& rx = cx; |
就相当于:
1 | template<typename T> |
模板参数T
的类型就是auto
推导出的类型,以此类推。
auto
类型推导不同于模板类型推导的唯一例外来自于std::initializer_list
,在使用花括号列表初始化auto
变量时:
- 如果使用了等号,
auto
被推导为std::initializer_list<T>
。(如果列表中元素类型不一致,无法推导出T
,编译失败) - 如果不使用等号,初始化列表中只能有一个元素,并用它推导
auto
。如果初始化列表中有多个值,编译失败。
这是现行标准的行为,与书中描述的并不一致,见New Rules for auto deduction from braced-init-list. (open-std.org)
1 | int i1{1}; // decltype(i1) == int |
而模板类型推导不会假设花括号初始化列表是std::initializer_list<T>
类型,除非显式指定ParamType
为std::initializer_list<T>
:
1 | template<typename T> |
在C++14中,auto
允许被用来推导函数返回类型,也可以在lambda函数中推导形参类型。但是在这两种场景下,auto
都适用模板类型推导的规则。这就意味着它不会假设花括号初始化列表是std::initializer_list<T>
类型。因此以下写法无法编译:
1 | auto foo() { |
Item 3: Understand decltype
decltype
获得一个变量名/表达式的类型:
- 如果参数是一个变量名,
decltype
返回其完整类型(包含cv限定符和引用) - 如果参数是一个类型为
T
的表达式,decltype
根据表达式的值类型返回T
或T
的引用类型:- 表达式为将亡值(xvalue),返回
T&&
- 表达式为左值(lvalue),返回
T&
- 表达式为纯右值(prvalue),返回
T
- 表达式为将亡值(xvalue),返回
这意味着decltype(x)
和decltype((x))
的结果可能不同,例如:
1 | const int &&x = 1; |
x
是变量名,decltype(x)
返回其完整类型const int&&
;而(x)
是类型为const int
的左值表达式,因此decltype((x))
返回其左值引用类型,即const int&
。
decltype
的一个典型用途是声明函数模板的返回类型,这个返回类型依赖于形参的类型,没法直接写出来。例如,由于std::vector<bool>
这个特例的存在,std::vector<T>
的operator []
的返回值并不一定是T&
。
假设我们有一个wrapper,需要返回std::vector
某下标处元素的引用:
1 | template <typename T, typename Index> |
我们会发现,getVector(vb, 3)
的类型并不是bool&
,而是std::vector<bool>
内部的某个代理类,不能被绑定到bool
类型的左值引用上,因此编译无法通过。
我们无法写出getVector
的返回类型,因为它和T
具体是什么有关。当然,我们可以为T == bool
提供一个特化版本,但更优雅的解决方法是使用decltype
推导返回类型:
1 | template <typename T, typename Index> |
这一写法下,auto
用来占位,真正的返回类型尾置,通过decltype(v[i])
推导得出。当T
不为bool
时,这一推导会得到T&
。
但这一写法有个小小问题:v[i]
在return
语句和decltype
中出现了两次,如果这个表达式很长,代码就会显得很啰嗦。
在C++14中,编译器可以根据返回语句直接推导返回类型,而不需要尾置类型,即:
1 | template <typename T, typename Index> |
但此时应用的是模板函数类型推导规则(而非decltype
的推导规则),因此当T
不为bool
时,v[i]
的类型为T&
,真正的返回类型为T
。(因为返回类型auto
并不是引用/指针类型,发生值传递)
这显然不是我们期望的行为,这会导致getVector(vi, 3) = 4;
这样的语句将无法通过编译,因为赋值运算符左边是一个右值。
如何既不使用尾置返回类型,又使用decltype
的推导规则呢?C++14允许我们使用decltype(auto)
作为返回类型:
1 | template <typename T, typename Index> |
这一写法表明,我们希望编译器根据返回语句推导返回类型,但使用decltype
的推导规则,而非模板函数类型的推导规则。这一写法的效果与尾置返回类型是等价的。
Item 4: Know how to view deduced types
我们可以从IDE报告的信息,编译器报错信息,或者typeid(x).name
等方式查看类型推导的结果,但它们的保证都很弱,不一定准确,不要轻信它们。例如类型的cv限定符,引用部分等可能会丢失,见书上的例子。
在我的实践中,通过
is_same_v<T, U>
(C++17之后)或is_same<T, U>::value
将待测类型T
与已知类型U
进行比较,结果还是比较准确的。
auto
Item 5: Prefer
auto
to explicit type declarations
作者建议能使用auto
声明变量的地方,就尽量使用auto
,因为:
有些类型名称非常冗长:
1
2
3
4
5
6
7
8
9template<typename It> //对从b到e的所有元素使用
void dwim(It b, It e) //dwim(“do what I mean”)算法
{
while (b != e) {
// typename std::iterator_traits<It>::value_type currValue = *b;
auto currValue = *b;
…
}
}C++14中,lambda表达式的形参也可以使用
auto
,起到一定的泛型效果。在下面的例子里,如果不使用auto
声明形参,就无法使用两个double
变量调用foo()
。1
2
3
4
5
6
7
8
9
10
11
12
13
14int main() {
// auto foo = [](const int *a, const int *b) {
auto foo = [](const auto *a, const auto *b) {
return *a < *b;
};
int a = 1;
int b = 2;
double c = 3.0;
double d = 4.0;
foo(&a, &b);
foo(&c, &d);
}避免发生一些隐式类型转换。例如
std::vector<int>::size()
的返回类型其实是std::vector<int>::size_type
,如果习惯性用一个比较小的变量类型去存它,在某些平台上可能发生溢出,使用auto
规避了这个问题。1
2
3std::vector<int> v(10000, 0);
// u_int8_t size = v.size();
auto size = v.size();另一个例子如下:
ump
中的元素类型其实是std::pair<const std::string, int>
,而我们在第一轮循环中错误地遗漏了const
。因此,编译器在每轮迭代中都会通过拷贝构造ump
中的元素,生成一个类型为std::pair<std::string, int>
的临时对象,再将p
绑定为这个临时对象的引用。这带来了拷贝开销,并且不是我们期望的行为,我们希望p
直接绑定为ump
中元素的引用。使用auto
就不会有这个问题。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int main() {
std::unordered_map<std::string, int> ump;
ump.insert(std::make_pair("a", 1));
ump.insert(std::make_pair("b", 2));
ump.insert(std::make_pair("c", 3));
for (const std::pair<std::string, int>& p: ump) {
cout << &p << endl; // 输出临时对象的地址
}
for (const auto &p: ump) {
cout << &p << endl; // 输出ump中元素的地址
}
}使用
auto
能略微减少一些重构开销。如果一些变量使用auto
声明,并使用某个函数的返回值初始化。一旦这个函数的返回类型发生改变,这些变量的类型也会一起改变。如果不使用auto
声明,就要手动修改这些变量的类型。
Item
6: Use the explicitly typed initializer idiom when auto
deduces undesired types
auto
当然也不是万能的,有时候我们需要显式指定auto
变量的类型。
一个例子是std::vector<bool>
带来的。在下面的代码中,我们调用features()
生成一个std::vector<bool>
类型的临时对象,并在其上调用operator []
,用其结果初始化一个auto
变量,然后将该变量值传递给handle()
函数。
1 | std::vector<bool> features() { |
这段代码的问题是:
std::vector<bool>::operator[]
的返回类型并不是bool&
,而是一个用于模拟bool&
行为的std::vector<bool>::reference
类型。- 这个类型可以隐式转换为
bool
,因此如果我们用bool
来声明status
没有任何问题。 - 然而,由于我们用
auto
声明了status
,隐式转换并没有发生,status
就是一个std::vector<bool>::reference
类型的值,它的行为类似一个指向features()
返回的std::vector<bool>
中某个位置的引用。 - 但
features()
的返回值是一个右值,在第10行语句执行完后,这个引用(status
)就是空悬的,因此我们将它传给handle()
函数会导致未定义行为。
要解决这个问题,我们可以不使用auto
,或者通过一个static_cast
显式指出我们需要的变量类型:
1 | auto status = static_cast<bool>(features()[1]); |
Moving to Modern C++
Item 7:
Distinguish between ()
and {}
when creating
objects
花括号初始化不允许隐式narrowing conversion:
1 | int main() { |
如果有的话,花括号初始化总是优先匹配接收std::initializer_list
的构造函数(即使由于narrowing
conversion,这样的调用无法成功):
1 | class A { |
Item 8: Prefer
nullptr
to 0
and NULL
C++98之前使用0
或NULL
表示空指针,前者的类型是int
,后者一般也是某个整型类型。使用它们可能会被当作整形处理,丢失”空指针“的语义。
C++11之后最好使用nullptr
,它的类型是std::nullptr_t
。这个类型不是指针类型,但是可以隐式转换成任何指针类型。
1 | void foo(char* cp) {} |
Item 9: Prefer
alias declarations to typedef
s
C++11后,声明类型别名时应该优先采用using
,而不是C-style的typedef
。
1 | typedef std::ios_base::fmtflags flags; |
定义一个函数指针类型时,使用using
的语法比typedef
容易理解:
1 | using FuncType = void (*)(int, double); |
using
还可以用来声明类型别名模板(alias
template),typedef
要想实现同样的目的,得定义一个模板类。同时,using
还能避免出现nested
dependent type name,可以少写一个typename
:
1 | template <typename T> |
Item 10: Prefer
scoped enum
s to unscoped enum
s
C-style的enum
被称为unscoped
enum
,因为枚举名会泄漏出所在的作用域,污染全局名称:
1 | enum Color { red, green, blue }; |
在C++11中,使用scoped enum
(又称为枚举类enum
class)是更好的选择,它不污染全局名称。
在enum class中,枚举名是强类型,不像unscoped
enum
的枚举名可以隐式转换为整型或浮点类型:
1 | enum class Color { red, green, blue }; |
enum
class的枚举名类型默认为int
,可以显式指定这一类型:
1 | enum class Color { red, green, blue }; |
enum class允许前置声明,unscoped
enum
则必须指定了枚举值类型才能前置声明:
1 | enum class Color; |
unscoped
enum
在少数场景下也是有用的,比如我们就是需要枚举名泄漏到自身作用域之外:
1 | enum UserInfoFields { uiName, uiEmail, uiReputation }; |
这个例子里,我们将std::get<0>(uinfo)
变成std::get<uiName>(uinfo)
,以增加代码可读性。
如果要使用enum class,这个例子就不得不变成:
1 | enum class UserInfoFields { uiName, uiEmail, uiReputation }; |
不仅需要加上枚举类的名字,还得做一次类型转换。
Item 11: Prefer deleted functions to private undefined ones.
C++11之前,我们防止调用成员函数的方法是将其声明为private。现在可以使用=delete
标记这些函数。
=delete
还可以用来删除非成员函数,比如删除函数的某些重载以防止非预期的隐式类型转换:
1 | void isLucky(int n) {} |
=delete
还可以用来标记偏特化的模板函数,禁止其实例化。下面的例子里,foo()
只接收非指针类型的实参:
1 | template <typename T> |
Item 12: Declare
overriding functions override
override
用于显式指出派生类函数覆写了对应的基类函数:
1 | class Base { |
引用限定符指定成员函数只能被左值/右值对象调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 class Widget {
public:
using DataType = std::vector<double>;
DataType data() & { return values; }
DataType data() && { return std::move(values); }
private:
DataType values;
};
int main() {
Widget w;
auto vals1 = w.data(); // vals1 从左值生成
auto vals2 = Widget().data(); // vals2 从右值生成
return 0;
}
Item 13: Prefer
const_iterator
s to iterator
s
对不需要修改容器元素的场景,都应该使用const_iterators
,它是常量迭代器,不能修改所指向的元素。
STL容器(如std::vector
)提供类似于cbegin()
cend()
crbegin()
crend()
的成员函数,用于返回const_iterators
。
遍历裸数组也可以使用迭代器,C++11提供了非成员函数的std::begin()
和std::end()
:
1 | int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9}; |
但是C++14才有非成员版本的cbegin()
cend()
rbegin()
crbegin()
等函数,如果编译器只支持到C++11,可以这么实现:
1 | template <class C> |
如果container
是STL容器,std::begin
将调用continer.begin()
,返回const_iterator
;如果container
是裸数组,std::begin
返回指向数组元素类型的常量指针,它们都相当于常量迭代器。
Item
14: Declare functions noexcept
if they won’t emit
exceptions
C++11后,应该使用noexcept
表示函数不抛出异常。原有的throw(exception list)
语法已经在C++17后被移除,但是throw()
仍可以使用,(在C++17以后)等价于noexcept
。
在C++17以前,noexcept
的性能可能比throw()
好一点,因为:
noexcept
is an improved version of throw(), which is deprecated in C++11. Unlike pre-C++17 throw(),**noexcept**
will not call std::unexpected, may or may not unwind the stack, and will call std::terminate, which potentially allows the compiler to implementnoexcept
without the runtime overhead of throw(). As of C++17, throw() is redefined to be an exact equivalent of noexcept(true).
标记noexcept
可能可以提升性能:
- 编译器不需要考虑为异常处理路径生成代码。
- 库函数可能视调用的函数是否为
noexcept
,表现出不同的行为。例如,一个接收右值的vector::push_back
不一定能用移动语义代替拷贝语义,因为如果容器发生了扩容,旧元素已经被移动走了,而在新位置移动构造新元素时抛了异常,容器的exception safe就被破坏了。如果移动构造函数被标记为noexcept
,push_back
就可以放心使用移动语义。(举个例子,不代表真实实现)
默认构造函数,析构函数,拷贝构造/拷贝赋值,移动构造/移动赋值,和内存释放函数(如operator delete
)都被隐式声明成noexcept
,除非显式通过noexcept(false)
指定它们可能抛出异常。
Item 15: Use
constexpr
whenever possible
constexpr
声明的变量是编译期常量,其值在编译期可知。因此constexpr
变量必须被常量表达式初始化:
1 | int i = 1; |
constexpr
对象一定是const
对象,反之不然。
constexpr
可以声明函数,该函数的所有实参如果都在编译期可知,并且只调用了constexpr
函数,就可以在编译期求值;否则,该函数会在运行时进行计算,就像普通函数一样:
1 | constexpr int f(int x) { |
以下的所有计算都可以在编译期完成:
1 | class Point { |
Item 16: Make
const
member functions thread safe
本节是想说明,在使用mutable
实现logical
constness的成员函数时,尽管这个成员函数被标记为const
,但却不是只读的,需要修改mutable
成员,因此是并发不安全的。如果需要多线程调用,需要上锁或者用std::atomic
保证并发安全。
1 | class Polynomial { |
Item 17: Understand special member function generation
如果不声明任何特殊函数(默认构造,析构,拷贝构造,拷贝赋值,移动构造,移动赋值),编译器会生成它们的默认实现,但是:
如果声明了拷贝构造函数或者拷贝赋值运算符,编译器将不会生成默认的移动构造函数/移动赋值运算符,通过右值调用构造函数/赋值运算符将会匹配到拷贝构造/拷贝赋值的版本。
声明拷贝行为往往意味着trivial的拷贝行为不合适,此时编译器认为trivial的移动行为也不合适,因此不生成默认实现。
照理来说,声明了拷贝构造函数/拷贝赋值,意味着拷贝赋值/拷贝构造函数的trivial行为也不合适,这是Rule of Three的体现。但是C++98时代的编译器没有践行这个原则,还是会生成另一个拷贝操作的默认实现。
C++11标准认为这是不合理的,写道:
The generation of the implicitly-defined copy constructor is deprecated if
T
has a user-defined destructor or user-defined copy assignment operator.但实现上,编译器往往还是会出于兼容性考虑,保持和C++98一样的行为,继续生成另一个拷贝操作的默认实现。
如果声明了移动构造函数,拷贝构造/拷贝赋值/移动赋值都不会自动生成;如果声明了移动赋值,拷贝构造/拷贝赋值/移动构造都不会自动生成。
如果声明了移动行为,说明拷贝的trivial行为也不合适,因此拷贝构造/拷贝赋值不会自动生成。
如果声明了移动构造/移动赋值,说明移动的trivial行为不合适,编译器也不会生成另一个移动操作(即移动赋值/移动构造)的默认实现。(这里不同于拷贝操作,因为移动操作没有历史包袱)
如果声明了析构函数,编译器不会生成默认的移动构造函数/移动赋值运算符。
Rule of Three要求用户同时声明析构函数,拷贝构造和拷贝赋值,这是因为声明析构函数往往意味着类需要管理其他资源,以至于需要在析构函数中释放,这暗示了拷贝构造/拷贝赋值也不能使用默认行为。但C++98时代并没有充分重视这个推论,因此尽管声明了析构函数,编译器还是会生成默认的拷贝构造/拷贝赋值运算符;但对于移动语义就没必要前向兼容了,因此编译器不会自动生成移动构造/移动赋值运算符。
现在应该遵循的原则是Rule of Five:同时声明析构函数,拷贝构造/赋值,移动构造/赋值。
Smart Pointers
Item
18: Use std::unique_ptr
for exclusive-ownership resource
management
std::unique_ptr
的大小等同于原始指针,而且对于包括解引用在内的大多数操作,他们执行的指令完全相同。
std::unique_ptr
只能移动,不能拷贝。
std::unique_ptr
可以被指定不同的deleter用于替代默认的对象销毁行为,deleter必须是一个接受unique_ptr<T, Deleter>::pointer
类型参数的callable
object。
deleter是std::unique_ptr
类型的一部分,因此使用不同类型的deleter将会影响std::unique_ptr
对象的大小。下面的例子使用了4种不同的callable
object作为deleter,并打印出std::unique_ptr
在我的平台下的大小:
- 使用lambda表达式和函数对象基本不会增加
std::unique_ptr
的大小,因为此时deleter是一个空类,编译器可以做空基类优化(EBO),通过private继承的方式把deleter嵌入std::unique_ptr
类型中。参考知乎用户LeeCarry的文章。 - 使用函数指针让
std::unique_ptr
增加了一个指针大小,这是直观的结果,因为多存了一个指向deleter的指针。 std::function
太重了,直接导致std::unique_ptr
的大小变成了40字节。
因此使用lambda表达式作为deleter是一个兼顾易用性和内存占用的选择。
1 | class A {}; |
std::unique_ptr
还有一个用于数组的特化形式std::unique_ptr<T[]>
,只有这个形式才有operator []
。同时,只有用于非数组的一般形式才有operator *
和operator ->
。
1 | int main() { |
不过,使用std::array
等容器是比std::unique_ptr<T[]>
更好的选择。
Item
19: Use std::shared_ptr
for shared-ownership resource
management
std::shared_ptr
确保对象在引用计数为0时被析构,相较于std::unique_ptr
,这是要付出性能代价的:
std::shared_ptr
需要包含一个指向引用计数(其实是控制块)的指针,因此大小往往是裸指针的两倍。- 这个引用计数独立于对象存在,需要额外分配内存来存它。
- 对引用计数的修改必须是原子的,因为多个
std::shared_ptr
可能并发修改它。
std::shared_ptr
也可以接收deleter用于自定义对象销毁行为。与std::unique_ptr
不同的是,std::shared_ptr
的deleter不是类型的一部分,而是通过构造函数传入。
1 | auto deleter = [](int* p) { |
为了存储引用计数和(可能的)deleter等元数据,std::shared_ptr
会维护一个指向控制块(control
block)的指针。每个被std::shared_ptr
管理的对象都应该有且仅有一个控制块。为了确保控制块的唯一性,std::shared_ptr
按照以下规则创建控制块:
std::make_shared
总是创建控制块。- 从
std::unique_ptr
或裸指针构建std::shared_ptr
时,总是创建控制块。 - 从
std::shared_ptr
或std::weak_ptr
构建std::shared_ptr
时,不会创建控制块。
因此,使用同一个裸指针初始化两个std::shared_ptr
是一种糟糕的行为,这会导致一个对象拥有两个控制块,很可能会被析构两次。
1 | int main() { |
我们一般不会写出这样的代码,因为使用裸指针接收new
的返回值并不合适,违背了RAII原则。但是,在使用类的this
指针时,我们可能会意外犯下这个错误:
1 | class Widget; |
在Widget::process()
中,我们使用裸指针this
创建了一个指向Widget
对象的std::shared_ptr
,然而在它之前已经有一个名为spw
的std::shared_ptr
指向它了,因此这一用法是错误的。
问题的本质是我们想在成员函数中使用一个当前对象的std::shared_ptr
。针对这一目的,标准库中提供了工具类enable_shared_from_this
。Widget
需要public继承std::enable_shared_from_this<Widget>
,并使用该类中的shared_from_this()
成员函数替代this
指针。
1 | class Widget: public std::enable_shared_from_this<Widget>{ |
这里的继承方式是Effective C++ Item 49中提及的CRTP(Curiously Recurring Template Pattern)
std::enable_shared_from_this
的实现细节:
std::enable_shared_from_this<T>
包含一个std::weak_ptr<T>
类型的成员变量。- 如果
T
继承了std::enable_shared_from_this<T>
,在构造std::shared_ptr<T>
和T
类型对象时,这个std::weak_ptr
会被赋值并指向当前对象。这样一来,当前对象就可以通过这个std::weak_ptr
拿到对应的控制块。- 使用
shared_from_this()
时,通过这个std::weak_ptr
初始化一个std::shared_ptr
并返回。更多细节可以参考这篇文章及评论。
在C++17之前,不同于std::unique_ptr
,std::shared_ptr
并没有一个std::shared_ptr<T[]>
的特化版本,它也没有operator[]
。尽管可以让T
被推导为数组类型,并提供一个使用delete []
的自定义deleter,但还是不建议用std::shared_ptr
管理裸数组,而应该使用stl容器。
C++17之后,std::shared_ptr
拥有operator []
,也能在指向数组时默认使用delete []
作为deleter,可以管理裸数组:
1 | std::shared_ptr<int[]> p(new int[10]); |
Item
20: Use std::weak_ptr
for std::shared_ptr
-like
pointers that can dangle
本节介绍了std::weak_ptr
的应用场景:
- Observer设计模式,一个subject对象有若干个observer对象,subject的状态改变时需要通知observer。由于subject不需要管理也不关心observer的生命周期,只是需要一个指针来通知observer,这个指针可以是
std::weak_ptr
,保证observer生命周期结束后,subject不会错误地访问它。 - 打破
std::shared_ptr
的循环引用。
std::weak_ptr
虽然不影响指向对象的(强)引用计数,但它需要维护弱引用计数,因此也需要控制块。std::weak_ptr
和std::shared_ptr
使用相同的控制块,因此它们的性能开销也基本相同。
Q:为什么需要弱引用计数?
A:弱引用计数是控制块的引用计数。对象失去最后一个弱引用计数意味着没有
std::weak_ptr
指向它了,控制块才可以被销毁。
Item
21: Prefer std::make_unique
and
std::make_shared
to direct use of new
更推荐通过std::make_shared
和std::make_unique
创建智能指针,而不是在智能指针的构造函数中使用new
。
1 | auto p1 = std::shared_ptr<int>(new int(10)); |
这样做的好处有:
不需要把类型名写两遍
异常安全。在
std::shared_ptr<Widget>(new Widget)
这种写法中,new
和创建智能指针两个操作不是原子的,中间一旦被异常打断就会发生内存泄漏。使用std::make_shared
不存在这个问题。1
2// computePriority()如果抛出异常,可能导致内存泄漏
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());C++17后已经不存在这样的问题,见这里。
std::make_shared
会在一次内存分配中同时分配存储对象的空间,和存储std::shared_ptr
控制块的空间,有性能优势。
但是make系列函数也有局限之处:
- 不支持自定义删除器,如果有这种需求必须使用构造函数+new。
- 由于需要分配控制块,
std::make_shared
会分配超过对象大小的内存空间,因此它使用::new
,而不是对象自定义的operator new
。这一行为与构造函数+new不同。 - 由于
std::make_shared
分配的对象和它的控制块来自于同一次内存分配,只有当对象失去最后一个std::weak_ptr
引用(弱引用)后(此时控制块可以被回收),这块内存才会被回收。如果对象很大,可能导致析构和内存回收之间出现较大延迟;如果使用构造函数+new创建指向对象的std::shared_ptr
,控制块和对象本身位于不同内存中,因此当对象失去最后一个std::shared_ptr
引用(强引用)后,对象内存就可以被回收。
Item 22: When using the Pimpl Idiom, define special member functions in the implementation file
我们希望用std::unique_ptr
作为裸指针的平替实现pimpl
idiom,并写出这样的代码:
1 | // widget.h |
这段代码无法通过编译。问题在于编译器会为Widget
生成默认析构函数(在widget.h
中,class Widget
的定义处),该析构函数需要析构pImpl
变量,即使用delete
销毁内置于std::unique_ptr
的裸指针。但是在std::unique_ptr
的实现中,销毁裸指针之前会通过static_assert
确保它指向的不是一个incomplete
type,而Widget::Impl
就是一个incomplete type,导致错误。
使用
std::shared_ptr
代替std::unique_ptr
则不会有这个问题,因为std::shared_ptr
的删除器不是指针类型的一部分,允许指向一个不完整类型,而std::unique_ptr
只能指向完整类型。
1
2
3
4
5
6
7
8 class A;
int main() {
auto up = std::unique_ptr<A>(); // 错误
auto sp = std::shared_ptr<A>(); // 正确
return 0;
}当然,实现pimpl idiom用
std::shared_ptr
太重了,因此我们不考虑用std::shared_ptr
实现pimpl idiom的方案。
为了解决这个问题,我们在class Widget
的定义中声明析构函数Widget::~Widget()
,但在Widget::Impl
的定义出现后才定义它(其实只是使用了默认定义)。这样一来,std::unique_ptr
的析构函数就不会使用不完整的类型Widget::Impl
了。
当然,由于我们声明了析构函数,编译器不会自动生成其他特殊函数,需要我们手动声明。它们同样要求Widget::Impl
是完整类型,因此我们按照同样的方式处理,在class Widget
中声明这些函数,但在Widget::Impl
被定义后才定义。
- 移动构造/移动赋值可以使用默认行为。
- 拷贝构造/拷贝赋值不能使用默认行为(因为
std::unique_ptr
不可拷贝),我们需要手动实现,让它们拷贝pImpl
指针指向的Widget::Impl
对象。
因此最后的代码是:
1 | // widget.h |