C++中的Pimpl
、RAII
是两种比较常见的编程技巧,之前学过几次,经常只知道名称,具体内容又忘记了,在这里简单做个学习记录。
一、PImpl 惯用法
PImpl(P ointer to impl ementation)是一种比较常见的C++编程技巧,采用这种技巧能够减少代码依赖以及编译时间,具体思想是:将类的实现细节(如一些非虚的私有成员)从对象的表示中移除,放到另外的一个类中,并以一个指针(建议是一个独享的指针,如unique_ptr
)指向它进行访问。
1.1 Pimpl出现的背景?原因? 在C++中,当头文件中的类定义发生变化,该类所有被使用的地方都需要重新被编译 ,甚至于更改的地方仅仅是外部无法访问的私有成员数据 ,主要原因在于私有成员数据在以下两个方面会影响一个类:
大小和布局 :代码调用者需要知道类的大小和布局(这会包括私有数据成员),换句话说,它始终要能够看到实现,这种约束会导致调用者和被调用者之间存在更紧密的耦合性。当然这是C++对象模型和哲学的核心,因为需要保证编译器默认情况下可以直接访问对象是使C++实现其著名的高度优化效率的要素。
函数 :类的私有成员函数也会参与重载决议。
为了减少这写编译依赖,一般会采用指针来隐藏一些实现细节 ,在C++11中,可以采用如下的Pimpl
惯用法写法:
1 2 3 4 5 6 7 8 #include <memory> class A { private : class Impl ; std::unique_ptr<Impl> impl_ptr; };
需要提到的是,提到Pimpl
惯用法,一般也会提到“编译防火墙 ”。
被称作编译防火墙的原因在于,采用这种技巧能够很好地避免由于更改部分成员而导致编译级联(多处源文件重新编译) 。有一个例外,如果实现类是类模板特化,那么就会丧失编译防火墙的优势:接口的用户必须观测到整个模板定义,以实例化正确的特化。
总的来说,它可带来两个很明显的好处:
编译防火墙,打破编译依赖,省时
隐藏实现细节,即接口与实现分离
1.2 C++11中Pimpl惯用法的最佳实践 前面提到,在C++11及以后的标准,应该尽量避免采用原生的指针(这也是贯彻了RAII
的思想)。
以下面的代码为例子:
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 class Widget {public : Widget (); ~Widget (); Widget (Widget &&) noexcept ; Widget& operator = (Widget &&) noexcept ; Widget (const Widget &) =delete ; Widget& operator =(const Widget&) = delete ; private : class Impl ; std::unique_ptr<Impl> impl_ptr; }class Widget : :Impl{ int n; }; Widget::Widget (int n):impl_ptr (std::make_unique<Impl>(n)){ } Widget::Widget (Widget &&) noexcept = default ; Widget &Widget::operator =(Widget &&) noexcept = default ; Widget::~Widget ()=default ;
在这个例子中,其实也就是这套Pimpl
模板中:
采用unique_ptr
是为了准确表达Widget
对象对Impl
对象的所有权是独占的,而不是共享的
由于unique_ptr
要求指向的类型在任何实例化删除器的语境中均为完整类型,故特殊成员函数需要用户声明且需在Impl
定义后定义
构造函数也需要在类外定义并且分配Pimpl
对象资源
由于用户自定义了析构函数,所以编译不会生成可移动构造函数和移动赋值运算符(赋值同样),这就需要用户根据需求决定是否提供
额外提一点,建议将所有私有非虚成员 移动到具体的实现类中 ,虚函数需要在继承链中可见,故不建议在Pimpl
惯用法将其移动到实现类中。
1.3 一个具体的例子 Widget.h
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 34 35 36 37 38 39 40 #ifndef TEST_CPP_WORK1_WIDGET_H #define TEST_CPP_WORK1_WIDGET_H #include <memory> #include <string> #include <experimental/propagate_const> class Widget {public : void draw () const ; void draw () ; bool shown () const {return true ;} explicit Widget (int n) ; Widget (Widget &&) noexcept ; Widget& operator = (Widget &&) noexcept ; Widget (const Widget &) =delete ; Widget& operator =(const Widget&) = delete ; ~Widget ();private : class Impl ; std::experimental::propagate_const<std::unique_ptr<Impl>> impl_ptr; };#endif
Widget.cpp
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 34 35 36 37 38 39 40 41 42 43 44 45 46 #include <iostream> #include "Widget.h" class Widget : :Impl{public : void draw (const Widget& w) const { if (w.shown ()) std::cout << "drawing a const component" << "\n" ; } void draw (const Widget& w) { if (w.shown ()) std::cout << "drawing a non const component" << "\n" ; } explicit Impl (int n) :n(n){ };private : int n; };void Widget::draw () const {impl_ptr->draw (*this );}void Widget::draw () {impl_ptr->draw (*this );} Widget::Widget (int n):impl_ptr (std::make_unique<Impl>(n)){ } Widget::Widget (Widget &&) noexcept = default ; Widget &Widget::operator =(Widget &&) noexcept = default ; Widget::~Widget ()=default ;
二、 RAII惯用法 RAII惯用法的使用能够很好地避免由于手动管理资源带来资源泄漏的问题。
RAII(Resource Acquisition Is Initialization,资源获取即初始化),是一种 将必须在使用前请求的资源(如分配的堆内存、执行线程、打开的套接字、打开的文件等)的生命周期与一个对象的生存期相绑定的C++编程技术。
RAII机制保证资源能够用于任何会访问该对象的函数 ,同时还保证对象在自己生存期结束时会以获取顺序的逆序释放它控制的所有资源。
总的来说,其实就是:
设计类封装资源(资源绑定对象,生命周期一致性)
构造函数分配资源
析构函数销毁资源
cppreference
上的一个例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 std::mutex m; void bad () { m.lock (); f (); if (!everything_ok ()) return ; m.unlock (); } void good () { std::lock_guard<std::mutex> lk (m) ; f (); if (!everything_ok ()) return ; }
像open()/close()
、lock()/unlock()
等就是非RAII
类的例子,显然其没有利用到对象的生命周期。
而lock_guard
是标准库中提供的RAII
包装器,用于管理互斥体,在这里使用,可以看到它管理的是std::mutex
,当跳出这个函数时,这个资源就会随着lock_guard
对象的释放而释放,无需手动去管理。
再举一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class HeapObjectWrapper {public : explicit HeapObjectWrapper (int size) { if (size <= 0 || size > 1024 * 1024 * 1024 ) size = 1024 ; m_p = new char [size]; } ~HeapObjectWrapper (){ delete [] m_p; m_p = nullptr ; std::cout << "自动释放资源..." << "\n" ; }private : char * m_p; };int main () { HeapObjectWrapper obj (1024 ) ; return 0 ; }
在main
函数中向堆中申请了1024个堆上的字节,在main
函数结束就会调用obj
对象的析构函数进行资源的销毁,依然是无需用户手动管理资源,紧紧地跟对象生命周期绑定了。
RAII非常适用于在使用前就需要分配的资源 ,不适用于不会在使用前请求的资源 (如CPU时间、核心等)
标准库中也提供了很多包装器来管理用户资源:
参考文章