C++源码中司空见惯的PIMPL是什么?
前言: C++源码中司空见惯的PIMPL是什么?用原始指针、std::unique_ptr和std::shared_ptr指向Implementation,会有什么不同?优缺点是什么?读完这篇文章,相信你能搞懂这种设计方式并将其运用于实践,也将更容易阅读源码。
1. PIMPL
是什么?
PIMPL
是Pointer to IMPLementation
的缩写,意思是指向实现的指针。 PIMPL是C++中的一种惯用法,也叫做编译期实现模式,其目的是为了减少类的头文件的依赖,从而减少编译时间,但并不会改变类本身所呈现的内容。
因为类的私有数据成员参与其对象表示,从而影响大小和布局,并且因为类的私有成员函数参与重载决策(在成员访问检查之前发生),所以对这些实现细节的任何更改都需要重新编译所有类的用户。pImpl
删除了这个编译依赖;对实现的更改不会导致重新编译。
比如在std::vector
的源码中,可以看到下面这样的代码:
struct _Vector_impl
: public _Tp_alloc_type
{
pointer _M_start;
pointer _M_finish;
pointer _M_end_of_storage;
_Vector_impl()
: _Tp_alloc_type(), _M_start(), _M_finish(), _M_end_of_storage()
{ }
//...
public:
_Vector_impl _M_impl;
//...
其中,vector的实现在_Vector_impl
结构体中,其继承了分配器类型的模板类,包含了存储地址的起始地址、结束地址以及分配容量的结束地址。
2. 应用实践
#include <string>
#include <vector>
#include "Gadget.h"
// in header "widget.h"
class Widget {
public:
Widget();
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget is some user-
}; // defined type
以上这个Widget类,name、data和g1、g2、g3都是Widget类的实现细节。Widget类依赖于std::string、 std::vector和Gadget类的头文件,如果依赖的Gadget等类型定义发生了变化,那么Widget类的头文件也需要重新编译。如果Widget类的头文件被其他类引用,那么其他类的头文件也需要重新编译。这样一来,那么整个项目的编译时间就会变长。
2.1 使用原始指针实现PIMPL
这里我们使用了原始指针,将Widget类的实现放到了Impl类中,这样Widget类的头文件就不再依赖于std::string、std::vector和Gadget类的头文件了。而Impl类的定义放到了Widget类的实现文件中。这样一来,即使Gadget类的定义发生了变化,Widget类的头文件也不需要重新编译了。
被声明却没有被定义的类,称为不完全类型。Widget::Impl
就是这样一种类型。不完全类型只能在很少的情况下使用,声明指向不完全类型的指针就是使用场景之一。
// in header "widget.h"
class Widget {
public:
Widget()=default;
private:
struct Impl; // declare implementation struct
Impl *pImpl; // and pointer to it
};
// in impl. file "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl
{ // definition of Widget::Impl
std::string name; // with data members formerly
std::vector<double> data; // in Widget
Gadget g1, g2, g3;
};
Widget::Widget() // allocate data members for
: pImpl(new Impl) // this Widget object
{
}
Widget::~Widget() // destroy data members for
{
delete pImpl;
} // this object
2.2 使用std::unique_ptr实现PIMPL
实现PIMPL是std::unique_ptr
最常见的用法之一。我们只需要在widget中引入std::unique_ptr替换原始指针。
#include <memory>
// in header "widget.h"
class Widget {
public:
Widget()=default;
private:
struct Impl; // declare implementation struct
std::unique_ptr<Impl> pImpl; // and pointer to it
};
在widget的实现文件中,使用std::make_unique来创建Impl对象。这时候,我们就不需要再手动释放Impl对象了。std::unique_ptr
会在Widget对象被销毁时,自动释放Impl对象。所以我们也不需要再定义析构函数了。
// in impl. file "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl
{ // definition of Widget::Impl
std::string name; // with data members formerly
std::vector<double> data; // in Widget
Gadget g1, g2, g3;
};
Widget::Widget() // allocate data members for
: pImpl(std::make_unique<Impl>()) // this Widget object
{
}
- 注意析构函数和析构函数声明和定义的分离
在客户端尝试编译
#include "Widget.h"
int main() {
Widget widget;
return 0;
}
会发现编译失败:
error: invalid application of 'sizeof' to incomplete type 'Widget::Impl'
static_assert(sizeof(_Tp))>0,
这是因为我们在Widget类中使用了std::unique_ptr<Impl>
,却没有声明一个析构函数。而Impl是一个不完全类型,std::unique_ptr
的析构函数调用delete时会调用sizeof操作符来确保std::unique_ptr
内部的原生指针不是指向一个不完全类型的对象。所以编译失败了。
为了解决这个问题,我们需要在Widget类中声明一个析构函数。这个析构函数的定义放到了Widget类的实现文件中。这样一来,Widget类的头文件就不再依赖于Impl类的定义了。
#include <memory>
class Widget { // in header "widget.h"
public:
Widget();
~Widget();
private:
struct Impl; // declare implementation struct
std::unique_ptr<Impl> pImpl; // and pointer to it
};
#include "widget.h" // in impl. file "widget.cpp"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl
{ // definition of Widget::Impl
std::string name; // with data members formerly
std::vector<double> data; // in Widget
Gadget g1, g2, g3;
};
Widget::Widget() // allocate data members for
: pImpl(std::make_unique<Impl>()) // this Widget object
{
}
Widget::~Widget() = default;
现在我们尝试编译客户端代码Widget widget;
,会发现可以编译成功了。
- 声明移动构造函数和移动赋值函数
使用了基于std::unique_ptr
实现PIMPL惯用法的类理应支持移动语义。
Widget w1;
Widget w2 = std::move(w1);
以上代码,实际不会调用Widget的移动构造函数,而是会尝试调用Widget的拷贝构造函数,但是其不能被拷贝,所以编译失败了。
'Widget::Widget(const Widget&)' is implicitly deleted because the default definition would be ill-formed:
这是因为声明了析构函数后,编译器不会再为我们生成移动构造函数和移动赋值函数了。(关于编译器对特殊成员函数的生成规则,详细内容参考 Effective Modern C++,Item 17)。
所以我们需要自己在类中声明移动构造函数和移动赋值函数, 并在实现文件中添加默认实现。(定义和声明同样需要分离)
#include <memory>
class Widget { // in header "widget.h"
public:
Widget();
~Widget();
Widget(Widget&& rhs); // move constructor
Widget& operator=(Widget&& rhs); // move assignment operator
private:
struct Impl; // declare implementation struct
std::unique_ptr<Impl> pImpl;
}; // defined type
#include "widget.h" // in impl. file "widget.cpp"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl
{ // definition of Widget::Impl
std::string name; // with data members formerly
std::vector<double> data; // in Widget
Gadget g1, g2, g3;
};
Widget::Widget() // allocate data members for
: pImpl(std::make_unique<Impl>()) // this Widget object
{
}
Widget::~Widget(){}
Widget::Widget(Widget &&rhs)=default; // move constructor
Widget &Widget::operator=(Widget &&rhs) = default;// move assignment
- 实现拷贝构造函数和拷贝赋值函数
Widget w1;
Widget w2 = w1;
如果要让Widget类支持拷贝语义,我们需要在类中声明拷贝构造函数和拷贝赋值函数。这是因为对于含有仅支持移动语义类的成员变量,编译器不会为我们生成拷贝构造函数和拷贝赋值函数。
#include <memory>
// still in header "widget.h"
class Widget {
public:
...
Widget(const Widget& rhs); // copy constructor
Widget& operator=(const Widget& rhs); // copy assignment operator
private:
struct Impl; // declare implementation struct
std::unique_ptr<Impl> pImpl;
}; // defined type
#include "widget.h" // in impl. file "widget.cpp"
...
struct Widget::Impl
{ // definition of Widget::Impl
std::string name; // with data members formerly
std::vector<double> data; // in Widget
Gadget g1, g2, g3;
};
...
Widget::Widget(const Widget& rhs) // copy constructor
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{
}
Widget& Widget::operator=(const Widget& rhs) // copy assignment operator
{
*pImpl = *rhs.pImpl;
return *this;
}
2.3 使用std::shared_ptr
实现PIMPL
如果使用std::shared_ptr
代替std::unique_ptr
,就不需要再声明析构函数了。没有了用户声明的析构函数,编译器就会为我们生成移动构造函数和移动赋值函数了。以下代码即可以支持Widget类的移动语义:
#include <memory>
class Widget { // in header "widget.h"
public:
Widget();
private:
struct Impl; // declare implementation struct
std::shared_ptr<Impl> pImpl; // and pointer to it
}; // defined type
#include "widget.h" // in impl. file "widget.cpp"
...
struct Widget::Impl
{ // definition of Widget::Impl
std::string name; // with data members formerly
std::vector<double> data; // in Widget
Gadget g1, g2, g3;
};
Widget::Widget() // allocate data members for
: pImpl(std::make_shared<Impl>()) // this Widget object
{
}
Widget w1;
auto w2(std::move(w1));
w1 = std::move(w2);
std::unique_ptr 和 std::shared_ptr 之间的行为差异在于自定义删除器的不同。
对于std::unique_ptr,删除器是智能指针类型的一部分,这使得可以产生更小的运行时数据结构和更快的运行时代码,更高效的结果是当使用编译器生成函数时,其指向的类型必须是完整类型。
对于std::shared_ptr,删除器不是智能指针类型的一部分,其删除器存在于控制块中,这导致了更大的运行时数据结构和更慢的运行时代码,但是其指向的类型可以是不完整类型。
3. IMPL的优缺点
3.1 优点
降低编译依赖:pImpl 降低了编译依赖,因为它将实现细节从类的接口中分离出来。这意味着对实现的更改不会导致重新编译类的用户。这对于库开发人员来说尤其有用,因为它允许他们在不破坏二进制兼容性的情况下对库进行更改。这也有助于减少编译时间,因为用户不必重新编译他们的代码。
pImpl 类是移动友好的;将大型类重构为可移动 pImpl 可以提高操作持有此类对象容器的算法性能。
3.2 运行时开销
访问开销:在 pImpl 中,每次对私有成员函数的调用都通过指针间接进行。私有成员对公共成员的每次访问都是通过另一个指针间接进行的。这两种间接都跨越翻译单元边界,因此只能通过链接时优化来优化。
空间开销:pImpl 添加一个指向公共组件的指针,如果任何私有成员需要访问公共成员,则另一个指针要么添加到实现组件,要么作为每次调用的参数传递给需要它的私有成员。如果支持有状态的自定义分配器,则还必须存储分配器实例。
生命周期管理开销:pImpl(以及 OO 工厂)将实现对象放置在堆上,这在构造和销毁时带来了显着的运行时开销。这可能会被自定义分配器部分抵消,因为 pImpl(但不是 OO 工厂)的分配大小在编译时已知。
总结
本文介绍了C++中PIMPL惯用法的基本用法,以及使用原始指针、std::unique_ptr和std::shared_ptr指向Implementation的区别, 以及PIMPL的优缺点。在应用实践中,我们需要根据软件对运行时开销和编译效率的要求,来选择合适的类设计方式。
参考:
- Effective C++, Scott Meyers, Item 31. Minimize compilation dependencies between files.
- Effective Modern C++ Item 22. When using the Pimpl Idiom, define special member functions in the implementation file.
- PIMPL Idiom
你好,我是七昂,致力于分享C++、操作系统、软件架构、机器学习、效率提升等系列文章。希望我们能一起探索程序员修炼之道,高效学习、高效工作。如果我的创作内容对您有帮助,请点赞关注。如果有问题,欢迎随时与我交流。感谢你的阅读。
公众号、知乎: 七昂的技术之旅
C++源码中司空见惯的PIMPL是什么?的更多相关文章
- Qt源码解析之-从PIMPL机制到d指针
一.PIMPL机制 PIMPL ,即Private Implementation,作用是,实现 私有化,力图使得头文件对改变不透明,以达到解耦的目的 pimpl 用法背后的思想是把客户与所有关于类的私 ...
- 从express源码中探析其路由机制
引言 在web开发中,一个简化的处理流程就是:客户端发起请求,然后服务端进行处理,最后返回相关数据.不管对于哪种语言哪种框架,除去细节的处理,简化后的模型都是一样的.客户端要发起请求,首先需要一个标识 ...
- Android 网络框架之Retrofit2使用详解及从源码中解析原理
就目前来说Retrofit2使用的已相当的广泛,那么我们先来了解下两个问题: 1 . 什么是Retrofit? Retrofit是针对于Android/Java的.基于okHttp的.一种轻量级且安全 ...
- Eclipse与Android源码中ProGuard工具的使用
由于工作需要,这两天和同事在研究android下面的ProGuard工具的使用,通过查看android官网对该工具的介绍以及网络上其它相关资料,再加上自己的亲手实践,算是有了一个基本了解.下面将自己的 ...
- String源码中的"avoid getfield opcode"
引言: 之前一篇文章梳理了String的不变性原则,还提到了一段源码中注释"avoid getfield opcode",当时通过查阅资料发现,这是为了防止 getfield(获取 ...
- android源码中修改wifi热点默认始终开启
在项目\frameworks\base\wifi\java\android\net\wifi\WifiStateMachine.java里面,有如下的代码,是设置wifi热点保持状态的:如下: pri ...
- rxjava源码中的线程知识
rxjava源码中的线程知识 rx的最精简的总结就是:异步 这里说一下以下的五个类 1.Future2.ConcurrentLinkedQueue3.volatile关键字4.AtomicRefere ...
- MMS源码中异步处理简析
1,信息数据的查询,删除使用AsycnQueryHandler处理 AsycnQueryHandler继承了Handler public abstract class AsyncQueryHandle ...
- Jquery源码中的Javascript基础知识(三)
这篇主要说一下在源码中jquery对象是怎样设计实现的,下面是相关代码的简化版本: (function( window, undefined ) { // code 定义变量 jQuery = fun ...
- Jquery源码中的Javascript基础知识(一)
jquery源码中涉及了大量原生js中的知识和概念,文章是我在学习两者的过程中进行的整理和总结,有不对的地方欢迎大家指正. 本文使用的jq版本为2.0.3,附上压缩和未压缩版本地址: http://a ...
随机推荐
- mac idea 更换主题
使用 主题一 xcode-dark-theme:点我直达 主题二 one-dark-theme:点我直达 主题三 dark-purple-theme:点我直达 主题四(推荐) vuesion-them ...
- yb课堂实战之轮播图接口引入本地缓存 《二十一》
轮播图接口引入缓存 CacheKeyManager.java package net.ybclass.online_ybclass.config; /** * 缓存key管理类 */ public c ...
- NIO操作文件读写
第一章 第一节,Buffuer 案例一 从buffur 读出数据, 创建了一个 FileInputStream 对象,并通过调用 getChannel() 方法获取了与之关联的 FileChanne ...
- spark基础了解—运行层次结构、standalone与onyarn
spark程序运行层次结构 standalone即主从机制,后续添加了standaloneHA,zk管理master的存活,一旦master挂了会在候选master中诞生新的 HAstandalone ...
- RHCA rh442 001 调优本质 调优方法 监控
调优是一种感知 调优按照成本和性能 一.架构及调优 二.代码及调优 三.配置类调优 从调优效果和成本成正比 设计电商,日访问百万级,未来可能千万级 数据库 系统 服务器多少台 缓存 appache,n ...
- Java解压rar5兼容rar4
RAR文件格式由WinRAR开发,广泛用于文件压缩和归档.随着技术的发展,RAR5作为更新的版本,引入了多项改进以提高压缩效率和数据安全性. 压缩效率:RAR5通过增大字典大小至32MB,相较于RAR ...
- Python中FastAPI项目使用 Annotated的参数设计
在FastAPI中,你可以使用PEP 593中的Annotated类型来添加元数据到类型提示中.这个功能非常有用,因为它允许你在类型提示中添加更多的上下文信息,例如描述.默认值或其他自定义元数据. F ...
- JavaScript 中的闭包和事件委托
包 (Closures) 闭包是 JavaScript 中一个非常强大的特性,它允许函数访问其外部作用域中的变量,即使在该函数被调用时,外部作用域已经执行完毕.闭包可以帮助我们实现数据的私有化.封装和 ...
- 【Mybatis-Plus】使用QueryWrapper作为自定义SQL的条件参数
发现同事的自定义SQL写法是这样的 连表之后使用的条件是 ${ew.customSqlSegment} @Param声明的常量: /** * wrapper 类 */ String WRAPPER = ...
- 【Zookeeper】Win平台伪集群搭建
下载稳定版Zookeeper https://downloads.apache.org/zookeeper/stable/ GZ包: apache-zookeeper-3.6.3-bin.tar.gz ...