堆栈
本文谈一谈C++程序中变量存储的方式——堆和栈。
在C++程序中,每个线程都拥有各自的栈内存,用于局部变量的存储和构造,同时也保存了传递给函数的参数。它的工作方式非常像std::stack
,参数入栈后,函数即可从栈顶取出它需要的参数;同样地,函数也可以将局部变量送入函数栈中,在返回时利用栈的特性依次自动析构(实际工作中,编译器优化和inline调用使得函数调用方式可能有所变化)。
堆上的内存由用户主动申请,不同线程共享堆上的资源。当程序申请内存时,操作系统会寻找内存中可用空间并提供给程序,系统不会主动释放这些资源(除非程序退出)。
一般来说,对栈上对象的操作会更快一些,因为它们一般都存在于CPU缓存中。但栈上的资源毕竟有限,分配相对较大的对象时,可能会出现栈溢出(Stack buffer overflow)。除非在栈上读取文件(例如图片),或有嵌套的函数调用,否则这样的问题很少遇到。在Linux系统中,下述命令可以查看栈空间的大小。
1 | $ ulimit -s |
与栈上对象相对的,用new
操作符创建的对象或者malloc
申请的一块内存即位于堆上,当可用内存全部耗尽时,程序会抛出std::bad_allo
异常。比起栈上有限的资源,堆上可以更自由地分配内存、创建对象;当然,这也对资源的管理提出了一些挑战。
关于堆栈与内存地址排布,可以参考Stack and heap memory in C++.
什么是RAII
RAII是Resource Acquisition Is Initialization(wiki上面翻译成 “资源获取就是初始化”)的简称。它利用C++构造的对象最终会被销毁的原则,通过将对象和资源绑定,实现了一种自动管理资源、避免泄漏的方法。
具体做法是使用一个对象,在其构造时获取对应的资源(比如:网络套接字、互斥锁、文件句柄和内存等等),在对象生命期内控制对资源的访问,使之始终保持有效,最后在对象析构的时候,释放构造时获取的资源。
一组简单的例子:
1 |
|
上述的例子非常简单,我们可以很轻松地管理申请的内存资源。但是如果程序很复杂的时候,需要为所有的new 分配的内存delete掉,导致极度臃肿,效率下降,更可怕的是,程序的可理解性和可维护性明显降低了,当操作增多时,处理资源释放的代码就会越来越多,越来越乱。如果某一个操作发生了异常而导致释放资源的语句没有被调用,怎么办?这个时候,RAII机制就可以派上用场了。
1 | class FileHandle { |
FileHandle
类的构造函数调用fopen()
获取资源,FileHandle
类的析构函数调用fclose()
释放资源。请注意,考虑到FileHandle``对象代表一种资源,它并不具有拷贝语义,因此我们将拷贝构造函数和赋值运算符声明为私有成员。如果利用FileHandle
类的局部对象表示文件句柄资源,那么前面的UseFile
函数便可简化为:
1 | void UseFile(char const* fn) |
综上所述,RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。由此可见,RAII惯用法是进行资源管理的有力武器。C++程序员依靠RAII写出的代码不仅简洁优雅,而且做到了异常安全。
智能指针
在谈论智能指针之前,我们先谈一下在C++中为什么要使用指针,以及应该如何使用指针。
指针的作用:
动态分配内存——根据生存期的不同,对象有两种分配方式。栈上对象超出其作用域时会自动析构;而通过
new Object()
方式分配对象时,对象的生存期是动态的,这意味着若不主动释放对象,对象将一直存在。接口设计——尽管在编译期通过函数重载和模板,同名函数可以针对不同类型拥有不同实现,以实现静态多态。但对于库设计者来说,在编译库时并不知道库的使用者会通过什么类型的对象调用编译好的函数。程序通过运行时查找虚函数表,来确定要调用的函数的具体实现,即*动态多态*。
其实不光cpp这样,c语言也是这样的,比如驱动框架已经写好了,linux kernel本身提供的,而我的驱动可以动态inmod进来,我的驱动框架就不需要知道我的设备写函数的硬地址,你只需要把函数挂在我这里,我通过函数指针调用就行了,这种问题设计的初衷还是利用已经存在的二进制,也就是lib不应该和caller强耦合的。
多态的本意也是在此。利用小的性能代价实现解偶一直是抬高软件生产和协作的主题。
一些典型应用场景:
- 引用语义——有时你可能需要通过传递对象的指针(不管对象是如何分配的)以便你可以在函数中去访问/修改这个对象的数据(而不是它的一份拷贝),但是在大多数情况下,你应该优先考虑使用引用方式,而不是指针,因为引用就是被设计出来实现这个需求的。
- 运行时多态——通过传递对象的指针或引用,虚函数在程序运行时可以拥有不同的实现。
- 可选参数——常见的通过传递空指针表示忽略入参。如果只有一个参数的情况,应该优先考虑使用缺省参数或是对函数进行重载。或者考虑使用一种可封装此行为的类型,比如
boost::optional
或者std::optional
。 - 解耦类型——使用指针的另一个好处在于可以用于前向声名(forward declaration)指向特定类型(如果使用对象类型,则需要定义对象),这种方式可以减少参与编译的文件,从而显著地提高编译效率,具体可以看 Pimpl idiom 用法。
- 与C库或者C风格库交互——此时只能够使用指针,这种情况下,你要确保的是指针使用只限定在必要的代码段中。指针可以通过智能指针的转换得到,比如使用智能指针的
get()
成员函数。如果C库操作分配的内存需要你在代码中维护并显式地释放时,可以将指针封装在智能指针中,通过实现deleter
从而可以有效的地释放对象。
与使用裸指针相比,在程序中使用智能指针会给我们带来一些好处:
- 明确资源的ownership;
- 避免忘记delete这种人类容易犯的错误;
- 更好地handle exception。
标准库提供的智能指针主要有:
std::unique_ptr
- 小巧、高速、具有移动语义的智能指针,对托管的资源拥有专属所有权。
- 默认地,资源析构采用delete运算符来实现,但可以指定自定义删除器。有状态的删除器和采用函数指针实现的删除器会增加
std::unique_ptr
型别的对象尺寸。 std::unique_ptr
可以非常容易转换成std::shared_ptr
。
std::shared_ptr
提供了拷贝语义的智能指针,对任意资源在共享所有权语义下进行生命周期管理的垃圾回收。
与
std::unique_ptr
相比,std::shared_ptr
的尺寸通常是裸指针尺寸的两倍,它还会带来控制块的开销,并要求原子化的引用计数操作。默认的资源析构通过delete运算符进行,但同时也支持定制删除器。删除器的型别对
std::shared_ptr
的型别没有影响。
std::weak_ptr
- 使用
std::weak_ptr
来代替可能空悬的std::shared_ptr
。 - 广泛地用于缓存,观察者列表,以及避免
std::shared_ptr
指针环路。
一个使用shared_ptr
和weak_ptr
来共享数据的例子:
1 | //Sometimes you need to share data between instances, |
下面是如何使用:
1 | struct FooSharedData |