一、PImpl 惯用法
PImpl(Pointer to implementation)是一种比较常见的C++编程技巧,采用这种技巧能够减少代码依赖以及编译时间,具体思想是:将类的实现细节(如一些非虚的私有成员)从对象的表示中移除,放到另外的一个类中,并以一个指针(建议是一个独享的指针,如
unique_ptr
)指向它进行访问。
1.1 Pimpl出现的背景?原因?
在C++中,当头文件中的类定义发生变化,该类所有被使用的地方都需要重新被编译,甚至于更改的地方仅仅是外部无法访问的私有成员数据,主要原因在于私有成员数据在以下两个方面会影响一个类:
- 大小和布局:代码调用者需要知道类的大小和布局(这会包括私有数据成员),换句话说,它始终要能够看到实现,这种约束会导致调用者和被调用者之间存在更紧密的耦合性。当然这是C++对象模型和哲学的核心,因为需要保证编译器默认情况下可以直接访问对象是使C++实现其著名的高度优化效率的要素。
- 函数:类的私有成员函数也会参与重载决议。
为了减少这写编译依赖,一般会采用指针来隐藏一些实现细节,在C++11中,可以采用如下的Pimpl
惯用法写法:
#include<memory>
class A{//一些其他需要放在该处的内容,如虚函数什么的
private:class Impl; // 细节类的前向声明// 采用unique_ptr 是因为Impl的所有权是A对象所独占的,采用该指针包装器可以更好地表达这一意思std::unique_ptr<Impl> impl_ptr; // 指向具体的实现
};
需要提到的是,提到Pimpl
惯用法,一般也会提到“编译防火墙”。
被称作编译防火墙的原因在于,采用这种技巧能够很好地避免由于更改部分成员而导致编译级联(多处源文件重新编译)。有一个例外,如果实现类是类模板特化,那么就会丧失编译防火墙的优势:接口的用户必须观测到整个模板定义,以实例化正确的特化。
总的来说,它可带来两个很明显的好处:
- 编译防火墙,打破编译依赖,省时
- 隐藏实现细节,即接口与实现分离
1.2 C++11中Pimpl惯用法的最佳实践
前面提到,在C++11及以后的标准,应该尽量避免采用原生的指针(这也是贯彻了RAII
的思想)。
以下面的代码为例子:
/*在头文件中*/
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;// 一些细节
};// 须在外面进行定义 确保Impl 是完整类型
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
/********************************************************************************
* @author: Huang Pisong
* @email: huangpisong@foxmail.com
* @date: 2023/8/25 20:48
* @version: 1.0
* @description:
********************************************************************************/#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; // 前置声明/*propagate_const 会传递const从而保证调用的指针是一致的*/std::experimental::propagate_const<std::unique_ptr<Impl>> impl_ptr; // 指向实现类的指针};
#endif //TEST_CPP_WORK1_WIDGET_H
Widget.cpp
/********************************************************************************
* @author: Huang Pisong
* @email: huangpisong@foxmail.com
* @date: 2023/8/25 20:48
* @version: 1.0
* @description:
********************************************************************************/#include <iostream>
#include "Widget.h"// 具体实现
class Widget::Impl{
public:
// Impl():name("test"),width(0.0),height(0.0){};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);}// 需在外面进行定义 确保Impl 是完整类型
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
上的一个例子
std::mutex m;void bad()
{m.lock(); // 请求互斥体f(); // 如果 f() 抛出异常,那么互斥体永远不会被释放if(!everything_ok()) return; // 提早返回,互斥体永远不会被释放m.unlock(); // 只有 bad() 抵达此语句,互斥体才会被释放
}void good()
{std::lock_guard<std::mutex> lk(m); // RAII类:互斥体的请求即是初始化f(); // 如果 f() 抛出异常,那么就会释放互斥体if(!everything_ok()) return; // 提早返回也会释放互斥体
} // 如果 good() 正常返回,那么就会释放互斥体
像open()/close()
、lock()/unlock()
等就是非RAII
类的例子,显然其没有利用到对象的生命周期。
而lock_guard
是标准库中提供的RAII
包装器,用于管理互斥体,在这里使用,可以看到它管理的是std::mutex
,当跳出这个函数时,这个资源就会随着lock_guard
对象的释放而释放,无需手动去管理。
再举一个例子:
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时间、核心等)
标准库中也提供了很多包装器来管理用户资源:
- std::unique_ptr 及 std::shared_ptr 用于管理动态分配的内存,或以用户提供的删除器管理任何以普通指针表示的资源;
- std::lock_guard、std::unique_lock、std::shared_lock 用于管理互斥体。
参考文章
- GotW #100: Compilation Firewalls (Difficulty: 6/10) – Sutter’s Mill (herbsutter.com)
- PImpl - cppreference.com
- C++编程技巧: Pimpl - 知乎 (zhihu.com)
- RAII - cppreference.com
- RAII 惯用法 - Blog (simonzgx.github.io)
- c++经验之谈一:RAII原理介绍 - 知乎 (zhihu.com)