本节可以看清楚C++复杂的本质,理解程序运行机制和面向对象编程思想。
在上图中的编译过程中,编译器还会根据语言规则检查程序的语法、语义是否正确,进行相应的处理,这就是最基本的C++”静态检查“。当我们以编译器为目标进行编程,有意识控制编译器的行为,就叫模板元编程,后文会说到。
运行阶段,常做GDB调试、日志追踪、性能分析等,然后收集动态的数据、调整设计思路,再返回编码节点,实现“螺旋式上升”的开发。
“编程范式”是一种“方法论”,就是指导你编写代码的一些思路、规则、习惯、定式和常用语。
C++是一种多范式的编程语言。具体来说,现代C++(11/14以后)支持五种主要的编程范式。
1.面向过程是C++里最基本的一种编程范式。它的核心思想是“命令”,通常就是顺序执行的语句、子程序(函数),把任务分解成若干个步骤去执行,最终达成目标。
2.面向对象是C++里另一个基本的编程范式。它的核心思想是“抽象”和“封装”,倡导的是把任务分解成一些高内聚低耦合的对象,这些对象互相通信协作来完成任务。它强调对象之间的关系和接口,而不是完成任务的具体步骤。
在C++里,面向对象范式包括class、public、private、virtual、this等类相关的关键字,还有构造函数、析构函数、友元函数等概念。
3.泛型编程是自STL(标准模板库)纳入到C++标准以后才逐渐流行起来的新范式,核心思想是“一切皆为类型”,或者说是**“参数化类型”“类型擦除”**,使用模板而不是继承的方式来复用代码,所以运行效率更高,代码也更简洁。
在C++里,泛型的基础就是template关键字,然后是庞大而复杂的标准库,里面有各种泛型容器和算法,比如vector、map、sort,等等。
4.模板元编程,这个词听起来好像很新,其实也有十多年的历史了,不过相对于前三个范式来说,确实“资历浅”。它的核心思想是“类型运算”,操作的数据是编译时可见的“类型”,所以也比较特殊,**代码只能由编译器执行,而不能被运行时的CPU执行。**模板元编程是一种高级、复杂的技术,C++语言对它的支持也比较少,更多的是以库的方式来使用,比如type_traits、enable_if等。
5.函数式编程,它几乎和“面向过程”一样古老,但却直到近些年才走入主流编程界的视野。所谓的“函数式”并不是C++里写成函数的子程序,而是数学意义上、无副作用的函数,核心思想是“一切皆可调用”,通过一系列连续或者嵌套的函数调用实现对数据的处理。函数式直到C++11引入了Lambda表达式,它才真正获得了可与其他范式并驾齐驱的地位。
这五种编程范式,虽然彼此有重叠,但是在理念、关键字、实现机制、运行阶段等方面差异还是挺大的。
所以要认识、理解这些范式的优势和劣势,在程序里适当混用,取长补短才是“王道”。
如果是开发直接面对用户的普通应用(Application),那么你可以再研究一下“泛型”和“函数式”,就基本可以解决90%的开发问题了;如果是开发面向程序员的库(Library),那么你就有必要深入了解“泛型”和“模板元”,优化库的接口和运行效率。
从程序的生命周期和编程范式的角度来看,C++是静态编程语言,存在代码编译过程即把字符代码转化为机器码(二进制文件)。python是动态(脚本)语言,执行代码可以不经过编译,执行过程为解释执行,相比c++程序最终的二进制直接执行来说解释执行效率会低得多。但从编程范式的角度,python支持的主要的编程方式(过程、函数、‘面向对象’(鸭子对象))该有的都有,比c++更简单易用,官方库强大的特点(通用性强)。解析器可以理解一个中间层,它的存在可以提高python等动态语言的可移植性,而c++的可移植性相比之下是要弱一些,需要考虑abi和平台相关。另外c++的代码安全性高,反汇编难度很大。Java在编译时期被编译成字节码,被Java虚拟机进行解释,不是真正的机器码;另外Java是动态类型安全语言,意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存,在实现层面上,虚拟机必须要频繁的进行动态检查,如是否为空指针,数组是否越界,类型转换关系等,这些都需要耗费不少运行时间,但是C++不需要,空指针,越界,果断coredump掉。
混用这几种中能够让名字辨识度最高的那些优点,就是四条规则:
1.变量、函数名和名字空间用snake_case全小写,全局变量加“g_”前缀;
2.自定义类名用CamelCase驼峰式,成员函数用snake_case全小写,成员变量加“m_”前缀;
3.宏和常量应当全大写,单词之间用下划线连接;
4.尽量不要用下划线作为变量的前缀或者后缀(比如_local、name_),很难识别。
#define MAX_PATH_LEN 256 //常量,全大写
int g_sys_flag; // 全局变量,加g_前缀
namespace linux_sys { // 名字空间,全小写
void get_rlimit_core(); // 函数,全小写
}
class FilePath final // 类名,首字母大写
{
public:
void set_path(const string& str); // 函数,全小写
private:
string m_path; // 成员变量,m_前缀
int m_level; // 成员变量,m_前缀
};
预处理指令都以符号“#”开头,虽然都在一个源文件里,但它不属于C++语言,它走的是预处理器,不受C++语法规则的约束。
所以,预处理编程也就不用太遵守C++代码的风格。一般来说,预处理指令不应该受C++代码缩进层次的影响,不管是在函数、类里,还是在if、for等语句里,永远是顶格写。
单独的一个“#”也是一个预处理指令,叫“空指令”,可以当作特别的预处理空行。而“#”与后面的指令之间也可以有空格,从而实现缩进,方便排版。
#include
在写头文件的时候,为了防止代码被重复包含,通常要加上“Include Guard”,也就是用“#ifndef/#define/#endif”来保护整个头文件,
#ifndef _XXX_H_INCLUDED_
#define _XXX_H_INCLUDED_
... // 头文件内容
#endif // _XXX_H_INCLUDED_
注意:可以包含任何文件,可以把源码、普通文本、甚至是图片、音频等都引进来。因为它就是死脑筋把数据合并进文件。
除了最常用的包含头文件,还可编写一些代码片段,存进*inc中,然后有选择地加载,用的好可以实现源码级别的抽象。比如有一个用于数值计算的大数组,里面有成千上百个数,放在文件占了很多地方,这个时候可以单独摘出来,另存为*.inc文件,这样能让代码更整洁。**
#define/#undef
宏定义是预处理编程里最重要、最核心的指令“#define”,它用来定义一个源码级别的“文本替换”。
使用宏的时候一定要谨慎,时刻记着以简化代码、清晰易懂为目标,不要“滥用”,避免导致源码混乱不堪,降低可读性。
首先,因为宏的展开、替换发生在预处理阶段,不涉及函数调用、参数传递、指针寻址,没有任何运行期的效率损失,所以对于一些调用频繁的小代码片段来说,用宏来封装的效果比inline关键字要更好,因为它真的是源码级别的无条件内联。
其次,你要知道,宏是没有作用域概念的,永远是全局生效。所以,对于一些用来简化代码、起临时作用的宏,最好是用完后尽快用“#undef”取消定义,避免冲突的风险。像下面这样:
#define CUBE(a) (a) * (a) * (a) // 定义一个简单的求立方的宏
cout << CUBE(10) << endl; // 使用宏简化代码
cout << CUBE(15) << endl; // 使用宏简化代码
#undef CUBE // 使用完毕后立即取消定义
使用宏时,关键是要“适当”,自己把握好分寸,不要把宏弄得“满天飞”。
用好“文本替换”的功能,
// Nginx中的宏函数
#define ngx_tolower(c) ((c >= 'A' && c <= 'Z') ? (c | 0x20) : c)
#define ngx_toupper(c) ((c >= 'a' && c <= 'z') ? (c & ~0x20) : c)
#define ngx_memzero(buf, n) (void) memset(buf, 0, n)
另一种做法是宏定义前先检查,如果之前有定义就先undef,然后再重新定义:
#ifdef AUTH_PWD // 检查是否已经有宏定义
# undef AUTH_PWD // 取消宏定义
#endif // 宏定义检查结束
#define AUTH_PWD "xxx" // 重新宏定义
#if/#else/#endif
可以在预处理阶段实现分支处理,通过判断宏的数值来产生不同的源码,改变源文件的形态,这就是“条件编译”。
**通常编译环境都会有一些预定义宏,**比如 CPU 支持的特殊指令集、操作系统 / 编译器 / 程序库的版本、语言特性等,使用它们就可以早于运行阶段,提前在预处理阶段做出各种优化,产生出最适合当前系统的源码。
你必须知道的一个宏是“__cplusplus”,它标记了 C++ 语言的版本号,使用它能够判断当前是 C 还是 C++,是 C++98 还是 C++11。你可以看下面这个例子。
#ifdef __cplusplus // 定义了这个宏就是在用C++编译
extern "C" { // 函数按照C的方式去处理
#endif
void a_c_function(int a);
#ifdef __cplusplus // 检查是否是C++编译
} // extern "C" 结束
#endif
#if __cplusplus >= 201402 // 检查C++标准的版本号
cout << "c++14 or later" << endl; // 201402就是C++14
#elif __cplusplus >= 201103 // 检查C++标准的版本号
cout << "c++11 or before" << endl; // 201103是C++11
#else // __cplusplus < 201103 // 199711是C++98
# error "c++ is too old" // 太低则预处理报错
#endif // __cplusplus >= 201402 // 预处理语句结束
除了这些内置宏,你也可以用其他手段自己定义更多的宏来实现条件编译。比如,Nginx 就使用 Shell 脚本检测外部环境,生成一个包含若干宏的源码配置文件,再条件编译包含不同的头文件,实现操作系统定制化:
#if (NGX_FREEBSD)
# include
#elif (NGX_LINUX)
# include
#elif (NGX_SOLARIS)
# include
#elif (NGX_DARWIN)
# include
#endif
编译是预处理之后的阶段,它的输入是(经过预处理的)C++源码,输出是二进制可执行文件(也可能是汇编文件、动态库或者静态库)。这个处理动作就是由编译器来执行的。
编译阶段的特殊性在于,它看到的都是C++语法实体,比如typedef、using、template、struct/class这些关键字定义的类型,而不是运行阶段的变量。
所以,这时的编程思维方式与平常大不相同。
可以把它理解为给变量、函数、类等“贴”上一个编译阶段的“标签”,方便编译器识别处理;
“属性”相当于编译阶段的“标签”,用来标记变量、函数或者类,让编译器发出或者不发出警告;
“属性”是用两对方括号的形式“[[…]]”,方括号的中间就是属性标签。
[[noreturn]] // 属性标签,表示这个函数没有返回值
int func(bool flag) {
throw std::runtime_error("XXX");
}
[[deprecated("deadline:2020-12-31")]] // C++14 or later,表示这个函数已过期
int old_func();
[[gnu::unused]] // 声明下面的变量暂不使用,不是错误
int nouse;
好在“属性”也支持非标准扩展,允许以类似名字空间的方式使用编译器自己的一些“非官方”属性。GCC的部分常用属性如下:
deprecated:与C++14相同,但可以用在C++11里。表示编译期的强制警告。
unused:用于变量、类型、函数等,表示虽然暂时不用,但最好保留着,因为将来可能会用。表示告知编译器不用发出警告。
constructor:函数会在main()函数之前执行,效果有点像是全局对象的构造函数。
destructor:函数会在main()函数结束之后执行,有点像是全局对象的析构函数。
always_inline:要求编译器强制内联函数,作用比inline关键字更强。
hot:标记“热点”函数,要求编译器更积极地优化。
动态断言:
assert用来断言一个表达式必定为真。当程序(也就是CPU)运行到assert语句时,就会计算表达式的值,如果是false,就会输出错误消息,然后调用abort()终止程序的执行。
assert虽然是一个宏,但在预处理阶段不生效,而是在运行阶段才起作用,所以又叫“动态断言”。
assert(i > 0 && "i must be greater than zero");
assert(p != nullptr);
assert(!str.empty());
静态断言:
叫“static_assert”,不过它是一个专门的关键字,而不是宏。因为它只在编译时生效,运行阶段看不见,所以是“静态”的。
它是在编译阶段计算常数和类型,如果断言失败就会导致编译错误;编译器看到static_assert也会计算表达式的值,如果值是false,就会报错,导致编译失败。
static_assert运行在编译阶段,只能看到编译时的常数和类型,看不到运行时的变量、指针、内存数据等,是“静态”的,所以不要简单地把assert的习惯搬过来用。
// 保证程序在64位系统上运行,使用静态断言在编译阶段检查long的大小
static_assert(sizeof(long) >= 8, "must run on x64");
// 下面的代码想检查空指针,由于变量只能在运行阶段出现,而在编译阶段不存在,所以静态断言无法处理。
char* p = nullptr;
static_assert(p == nullptr, "some error."); // 错误用法
静态断言最好与type_traits结合使用
建议你在设计类的时候尽量少用继承和虚函数。
特别的,如果完全没有继承关系,就可以让对象不必承受“父辈的重担”(父类成员、虚表等额外开销),轻装前行,更小更快。没有隐含的重用代码也会降低耦合度,让类更独立,更容易理解。
还有,把“继承”切割出去之后,可以避免去记忆、实施那一大堆难懂的相关规则,比如 public/protected/private 继承方式的区别、多重继承、纯虚接口类、虚析构函数,还可以绕过动态转型、对象切片、函数重载等很多危险的陷阱,减少冗余代码,提高代码的健壮性。
我还看到过很多人有一种不好的习惯,就是喜欢在类内部定义一些嵌套类,美其名曰“高内聚”。但恰恰相反,这些内部类反而与上级类形成了强耦合关系,也是另一种形式的“万能类”。
其实,这本来是名字空间该做的事情,用类来实现就有点“越权”了。正确的做法应该是,定义一个新的名字空间,把内部类都“提”到外面,降低原来类的耦合度和复杂度。
C++里类的四大函数:它们是构造函数、析构函数、拷贝构造函数、拷贝赋值函数。
C++11因为引入了右值(Rvalue)和转移(Move、移动),又多出了两大函数:转移构造函数和转移赋值函数。所以,在现代C++里,一个类总是会有六大基本函数:三个构造、两个赋值、一个析构。
final
多用final关键字,及时终止继承关系
= default
对于比较重要的构造函数和析构函数,应该用“= default”的形式,明确地告诉编译器(和代码阅读者):“应该实现这个函数,但我不想自己写。”这样编译器就得到了明确的指示,可以做更好的优化。
= delete
这种“= default”是C++11新增的专门用于六大基本函数的用法,相似的,还有一种“= delete”的形式。它表示明确地禁用某个函数形式,而且不限于构造/析构,可以用于任何函数(成员函数、自由函数),让外界无法调用。比如说,如果你想要禁止对象拷贝,就可以用这种语法显式地把拷贝构造和拷贝赋值“delete”掉,让外界无法调用。
class DemoClass final
{
public:
DemoClass(const DemoClass&) = delete; // 禁止拷贝构造
DemoClass& operator=(const DemoClass&) = delete; // 禁止拷贝赋值
};
explicit
因为C++有隐式构造和隐式转型的规则,如果你的类里有单参数的构造函数,或者是转型操作符函数,为了防止意外的类型转换,保证安全,就要使用“explicit”将这些函数标记为“显式”。
委托构造(delegating constructor)
如果你的类有多个不同形式的构造函数,为了初始化成员肯定会有大量的重复代码。为了避免重复,常见的做法是把公共的部分提取出来,放到一个 init() 函数里,然后构造函数再去调用。这种方法虽然可行,但效率和可读性较差,毕竟 init() 不是真正的构造函数。
在 C++11 里,你就可以使用“委托构造”的新特性,一个构造函数直接调用另一个构造函数,把构造工作“委托”出去,既简单又高效。
class DemoDelegating final
{
private:
int a; // 成员变量
public:
DemoDelegating(int x) : a(x) // 基本的构造函数
{}
DemoDelegating() : // 无参数的构造函数
DemoDelegating(0) // 给出默认值,委托给第一个构造函数
{}
DemoDelegating(const string& s) : // 字符串参数构造函数
DemoDelegating(stoi(s)) // 转换成整数,再委托给第一个构造函数
{}
};
成员变量初始化(In-class member initializer)
可以在类里声明变量的同时给它赋值,实现初始化,这样不但简单清晰,也消除了隐患。
使用using或typedef可以为类型起别名,既能够简化代码,还能够适应将来的变化。
类型别名不仅能够让代码规范整齐,而且因为引入了这个“语法层面的宏定义”,将来在维护时还可以随意改换成其他的类型。比如,把字符串改成string_view(C++17里的字符串只读视图),把集合类型改成unordered_set,只要变动别名定义就行了,原代码不需要做任何改动。
// 使用final避免被继承
class DemoClass final {
private:
int a = 0; // 整数成员,赋值初始化
string s = "hello"; // 字符串成员,赋值初始化
vector v{1,2,3}; // 容器成员,使用{}初始化列表
public:
DemoClass() = default; // 明确告诉编译器,使用默认实现
~DemoClass() = default; // 明确告诉编译器,使用默认实现
DemoClass(const DemoClass&) = delete; // 禁止拷贝构造
DemoClass& operator=(const DemoClass&) = delete; // 禁止拷贝赋值
explicit DemoClass(const string_type& str) { // 显式单参构造函数
}
explicit operator bool() { // 显式转型为bool
}
DemoClass(int x) : a(x) {} // 可以单独出似乎还成员,其它使用默认值
using uint_t = unsigned int; // using别名
typedef unsigned int uint_t; // 等价的typedef
using this_type = DemoClass; // 给自己起个别名
using string_type = std::string; // 字符串类型别名
using uint32_type = uint32_t; // 整数类型别名
using set_type = std::set; // 集合类型别名,方便以后替换成unordered_set;
using vector_type = std::vector;// 容器类型别名
};
本节重点讲C++中的自动类型推导、智能指针、Lambda表达式等重要特性。
除了简化代码,auto还避免了对类型的“硬编码”,也就是说变量类型不是“写死”的,而是能够“自动”适应表达式的类型。比如,你把map改为unordered_map,那么后面的代码都不用动。这个效果和类型别名有点像,但你不需要写出typedef或者using,全由auto“代劳”。
“自动类型推导”实际上和“attribute”一样,是编译阶段的特殊指令,指示编译器去计算类型。
auto的“自动推导”能力只能用在“初始化”的场合。
具体来说,就是赋值初始化或者花括号初始化(初始化列表、Initializer list),变量右边必须要有一个表达式(简单、复杂都可以)。这样你才能在左边放上auto,编译器才能找到表达式,帮你自动计算类型。
auto总是推导出“值类型”,绝不会是“引用”;
auto可以附加上const、volatile、*、&这样的类型修饰符,得到新的类型。
auto str = "hello"; // 自动推导为const char [6]类型,需要手动写std::string;
std::map m = {{1,"a"}, {2,"b"}}; // 自动推导不出来,需要手动声明std::map
auto iter = m.begin(); // 自动推导为map内部的迭代器类型,代码简洁很多
auto x = 10L; // auto推导为long,x是long
auto& x1 = x; // auto推导为long,x1是long&
auto* x2 = &x; // auto推导为long,x2是long*
const auto& x3 = x; // auto推导为long,x3是const long&
auto x4 = &x3; // auto推导为const long*,x4是const long*
vector v = {2,3,5,7,11}; // vector顺序容器
for(const auto& i : v) { // 常引用方式访问元素,避免拷贝代价
cout << i << ","; // 常引用不会改变元素的值
}
for(auto& i : v) { // 引用方式访问元素
i++; // 可以改变元素的值
cout << i << ",";
}
decltype的形式很像函数,后面的圆括号里就是可用于计算类型的表达式(和sizeof有点类似),其他方面就和auto一样了,也能加上const、*、&来修饰。
decltype不仅能够推导出值类型,还能够推导出引用类型,也就是表达式的“原始类型”。
int x = 0; // 整型变量
decltype(x) x1; // 推导为int,x1是int
decltype(x)& x2 = x; // 推导为int,x2是int&,引用必须赋值
decltype(x)* x3; // 推导为int,x3是int*
decltype(&x) x4; // 推导为int*,x4是int*
decltype(&x)* x5; // 推导为int*,x5是int**
decltype(x2) x6 = x2; // 推导为int&,x6是int&,引用必须赋值
int x = 0; // 整型变量
decltype(auto) x1 = (x); // 推导为int&,因为(expr)是引用类型
decltype(auto) x2 = &x; // 推导为int*
decltype(auto) x3 = x1; // 推导为int&
class DemoClass final
{
public:
using set_type = std::set; // 集合类型别名
private:
set_type m_set; // 使用别名定义成员变量
// 使用decltype计算表达式的类型,定义别名
using iter_type = decltype(m_set.begin());
iter_type m_pos; // 类型别名定义成员变量
};
const和宏定义的区别:const定义的常量在预处理阶段并不存在,而是直到运行阶段才会出现。即const修饰的实际上是运行时的“变量”,只不过不允许修改,是“只读”的(read only),叫“只读变量”更合适。
const修饰指针,const int *p,即常量指针,指针的指向不可改,即指向一个只读变量;
const也可以修饰引用,const int& rx = x; 即常量引用;const &可以引用任何类型,被称为万能引用;
在设计函数的时候,建议尽可能地使用它作为入口参数,一来保证效率,二来保证安全。
const成员函数,表示该函数的执行过程中不会修改类中的成员变量,
int x = 100;
const int& rx = x; // const修饰引用
const int* px = &x; // const修饰指针
class DemoClass final
{
private:
const long MAX_SIZE = 256; // const成员变量
int m_value; // 成员变量
mutable mutex_type m_mutex;
public:
int get_value() const // const成员函数,不能修改非mutable成员变量
{
// m_mutex,可以操作mutable修饰的成员变量
return m_value;
}
};
可以使用指针强制修改const值,但需要加上volatile,避免编译器优化。不添加volatile的话,编译期会将引用该常量的值直接替换成原始值;添加volatile后,禁止编译器对该值进行优化,必须在运行时去读取该值。
// 需要加上volatile修饰,运行时才能看到效果
const volatile int MAX_LEN = 1024;
auto ptr = (int*)(&MAX_LEN);
*ptr = 2048;
cout << MAX_LEN << endl; // 输出2048
你可能注意到了,const 后面多出了一个 volatile 的修饰,它是这段代码的关键。如果没有这个 volatile,那么,即使用指针得到了常量的地址,并且尝试进行了各种修改,但输出的仍然会是常数 1024。
这是为什么呢?
因为**“真正的常数”对于计算机来说有特殊意义,它是绝对不变的,所以编译器就要想各种办法去优化。**
const 常量虽然不是“真正的常数”,但在大多数情况下,它都可以被认为是常数,在运行期间不会改变。编译器看到 const 定义,就会采取一些优化手段,比如把所有 const 常量出现的地方都替换成原始值。
所以,对于没有 volatile 修饰的 const 常量来说,虽然你用指针改了常量的值,但这个值在运行阶段根本没有用到,因为它在编译阶段就被优化掉了。
现在就来看看 volatile 的作用。
它的含义是“不稳定的”“易变的”,在 C++ 里,表示变量的值可能会以“难以察觉”的方式被修改(比如操作系统信号、外界其他的代码),所以要禁止编译器做任何形式的优化,每次使用的时候都必须“老老实实”地去取值。
现在,再去看刚才的那段示例代码,你就应该明白了。**MAX_LEN 虽然是个“只读变量”,但加上了 volatile 修饰,就表示它不稳定,可能会悄悄地改变。**编译器在生成二进制机器码的时候,不会再去做那些可能有副作用的优化,而是用最“保守”的方式去使用 MAX_LEN。
也就是说,编译器不会再把 MAX_LEN 替换为 1024,而是去内存里取值(而它已经通过指针被强制修改了)。所以,这段代码最后输出的是 2048,而不是最初的 1024。
在我看来,mutable 像是 C++ 给 const 对象打的一个“补丁”,让它部分可变。因为对象与普通的 int、double 不同,内部会有很多成员变量来表示状态,但因为“封装”特性,外界只能看到一部分状态,判断对象是否 const 应该由这些外部可观测的状态特征来决定。
比如说,对象内部用到了一个 mutex 来保证线程安全,或者有一个缓冲区来暂存数据,再或者有一个原子变量做引用计数……这些属于内部的私有实现细节,外面看不到,变与不变不会改变外界看到的常量性。这时,如果 const 成员函数不允许修改它们,就有点说不过去了。
所以,对于这些有特殊作用的成员变量,你可以给它加上 mutable 修饰,解除 const 的限制,让任何成员函数都可以操作它。
学会使用智能指针,避免再使用裸指针、new和delete来操作内存了。
如果指针是“独占”使用,就应该选择unique_ptr,它为裸指针添加了很多限制,更加安全。如果指针是“共享”使用,就应该选择shared_ptr,它的功能非常完善,用法几乎与原始指针一样。应当使用工厂函数make_unique()、make_shared()来创建智能指针,强制初始化,而且还能使用auto来简化声明。shared_ptr有少量的管理成本,也会引发一些难以排查的错误,所以不要过度使用。
智能指针是代理模式的具体应用,它完全实践了RAII惯用法,把裸指针包装起来,在构造函数里初始化,在析构函数里释放。这样当对象失效销毁时,C++就会自动调用析构函数,完成内存释放、资源回收等清理工作。而且它还重载了*和->操作符,用起来和原始指针一模一样。
智能指针实际上并不是指针,而是一个对象。所以,不要企图对它调用delete,它会自动管理初始化时的指针,在离开作用域时析构释放内存。
智能指针也没有定义加减运算,不能随意移动指针地址,这就完全避免了指针越界等危险操作,可以让代码更安全
unique_ptr ptr1(new int(10)); // int智能指针
assert(*ptr1 == 10); // 可以使用*取内容
assert(ptr1 != nullptr); // 可以判断是否为空指针
unique_ptr ptr2(new string("hello")); // string智能指针
assert(*ptr2 == "hello"); // 可以使用*取内容
assert(ptr2->size() == 5); // 可以使用->调用成员函数
auto ptr3 = make_unique(42); // 工厂函数创建智能指针
assert(ptr3 && *ptr3 == 42);
auto ptr4 = make_unique("god of war"); // 工厂函数创建智能指针
assert(!ptr4->empty());
template // 可变参数模板
std::unique_ptr // 返回智能指针
my_make_unique(Args&&... args) // 可变参数模板的入口参数
{
return std::unique_ptr( // 构造智能指针
new T(std::forward(args)...)); // 完美转发
}
auto ptr1 = make_unique(42); // 工厂函数创建智能指针
assert(ptr1 && *ptr1 == 42); // 此时智能指针有效
auto ptr2 = std::move(ptr1); // 使用move()转移所有权
assert(!ptr1 && ptr2); // ptr1变成了空指针
shared_ptr所有权是可以被安全共享的,支持拷贝赋值,允许被多个“人”同时持有,就像原始指针一样。
shared_ptr支持安全共享的秘密在于内部使用了“引用计数”。
引用计数最开始的时候是1,表示只有一个持有者。如果发生拷贝赋值——也就是共享的时候,引用计数就增加,而发生析构销毁的时候,引用计数就减少。
只有当引用计数减少到0,也就是说,没有任何人使用这个指针的时候,它才会真正调用delete释放内存。
使用shared_ptr也是有代价的,引用计数的存储和管理都是成本,这方面是shared_ptr不如unique_ptr的地方。
如果不考虑应用场合,过度使用shared_ptr就会降低运行效率。不过,你也不需要太担心,shared_ptr内部有很好的优化,在非极端情况下,它的开销都很小。
析构函数里不要有非常复杂、严重阻塞的操作。
因为在运行阶段,引用计数的变动是很复杂的,很难知道它真正释放资源的时机,无法像Java、Go那样明确掌控、调整垃圾回收机制。一旦shared_ptr在某个不确定时间点析构释放资源,就会阻塞整个进程或者线程,stop the world。
shared_ptr依靠引用计数,可能会发生互相引用,导致内存泄露。
这时候就要使用weak_ptr,它专门为打破循环引用而设计,只观察指针,不会增加引用计数(弱引用),但在需要的时候,可以调用成员函数lock(),获取shared_ptr(强引用)。