首页 > 编程语言 >C++源码中司空见惯的PIMPL是什么?

C++源码中司空见惯的PIMPL是什么?

时间:2024-01-13 22:55:28浏览次数:35  
标签:std Widget pImpl C++ PIMPL 源码 include ptr Impl

前言: C++源码中司空见惯的PIMPL是什么?用原始指针、std::unique_ptr和std::shared_ptr指向Implementation,会有什么不同?优缺点是什么?读完这篇文章,相信你能搞懂这种设计方式并将其运用于实践,也将更容易阅读源码。

1. PIMPL是什么?

PIMPLPointer 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++、操作系统、软件架构、机器学习、效率提升等系列文章。希望我们能一起探索程序员修炼之道,高效学习、高效工作。如果我的创作内容对您有帮助,请点赞关注。如果有问题,欢迎随时与我交流。感谢你的阅读。

公众号、知乎: 七昂的技术之旅

标签:std,Widget,pImpl,C++,PIMPL,源码,include,ptr,Impl
From: https://www.cnblogs.com/qiangz/p/17963162

相关文章

  • C++多线程并发(一)--- 线程创建与管理
    目录进程和线程的区别何为并发?C++11线程基本操作C++11新标准多线程支持库std::thread类成员函数std::thread的关键总结C++中多线程创建C++的多线程可以充分利用计算机资源,提高代码运行效率。在这里总结了一些多线程应用过程中的基本概念和用法。进程和线程的区别进程是一......
  • C++实现文件内查找字符串
    实现概要:读取放入buf后查找匹配的第一个字符然后使用seek()移动文件指针,peek()查看剩余的字符是否匹配如果剩余的字符匹配把该字符串在文件中的位置push进一个vector<int>中再继续查看剩余的文件内容//str2.cpp--capacity()andreserve()#include<iostream>......
  • 从C向C++4——对象特性
    一.构造函数1.构造函数 在C++中,有一种特殊的成员函数,它的名字和类名相同,没有返回值,不需要用户显式调用(用户也不能调用),而是在创建对象时自动执行。这种特殊的成员函数就是构造函数(Constructor)。 我们通过成员函数setname()、setage()、setscore()分别为成员变量name、age、score......
  • C++ --- 智能指针
    一、智能指针存在的意义智能指针主要解决以下问题:(1)内存泄漏:内存手动释放,使用智能指针可以自动释放。(2)共享所有权指针的传播和释放,比如多线程使用同一个对象时析构问题。 智能指针的实现依赖于C++语言的RAII(资源获取即初始化)技术,即资源的获取和释放应该与对象的构造和析构分......
  • AQS源码解析
    AQS结构特性内部包含Node、ConditionObject静态内部类,Node用来存储没竞争到锁的线程状态、CondidtionObject是对条件变量的封装;volatileintstate变量记录锁的状态,1表示锁被持有、0表示锁被释放,同时对应三个方法来更改/获取锁的状态:getState()、setState(intnewState......
  • 从零开始的源码搭建:详解连锁餐饮行业中的点餐小程序开发
    时下,点餐小程序成为了许多餐饮企业引入的一种创新工具,不仅方便了顾客的用餐体验,同时也提高了餐厅的运营效率。本文将详细探讨如何从零开始搭建一个源码,并深入解析连锁餐饮行业中的点餐小程序开发过程。 一、需求分析与规划在开始源码搭建之前,首先需要明确点餐小程序的具体需求。这......
  • 源码开发实战:连锁餐饮数字化转型中的点餐小程序
    如今,商家通过引入点餐小程序,不仅可以提高服务速度,还能够增加用户粘性,实现数字化运营的目标。为了实现这一愿景,源码开发成为一种高效的手段。 一、技术选型在开发点餐小程序时,选择合适的技术是关键一环,结合小程序开发框架,实现了前后端分离,提高了开发效率。此外,数据库采用了高性能的......
  • KY199 查找C++
      二分查找,没什么好说的。关键在于排成有序数组。然而C++调用sort就可以了。#include<iostream>#include<algorithm>#include<cstdlib>usingnamespacestd;booljudge(int*A,intn,intt){inthead=0;inttail=n-1;while(head<=tail){......
  • KY158 找xC++
    摆了几天,重新再来学习。‘把数据输入数组,然后遍历数组就行了,没什么难度。#include<iostream>#include<cstdlib>usingnamespacestd;intmain(){intn;while(cin>>n){int*A=(int*)malloc(sizeof(int)*n);for(inti=0;i<n;i++){......
  • Android 14 新特性代码 UUID.fromString & Matcher.matches 的细节改动(扒源码)
    文章目录前言UUID处理的更改正则表达式的更改结束前言Android14已经出来好久好久了…今天其他的暂且不论,单纯的讲一下OpenJDK17更新的两点变更(扒源代码)~对正则表达式的更改UUID处理首先,正则表达式的更改:现在,为了更严格地遵循OpenJDK的语义,不允许无效的组引用。您可能会......