设计 C++ 接口文件的小技巧之 PIMPL

C++ 里面有一些惯用法(idioms),如 RAII,PIMPL,copy-swap、CRTP、SFINAE 等。今天要说的是 PIMPL,即 Pointer To Implementation,指向实现的指针。

问题描述

在实际的项目中,经常需要定义和第三方/供应商的 C++ 接口。假如有这样一个接口:

#include <string>
#include <list>
#include "dds.h" class MyInterface {
public:
int publicApi1();
int publicApi2(); private:
int privateMethod1();
int privateMethod2();
int privateMethod3(); private:
std::string name_;
std::list<int> list_;
DDSDomainPariciant dp_;
DDSTopic topic_;
DDSDataWriter dw_;
};

该接口头文件存在以下问题:

  • 暴露了 MyInterface 内部实现。所有的 private/protected 的方法、成员变量都暴露给接口的使用者
  • 由此带来的另一个问题是接口不稳定。比如我们修改类的内部实现,即使不改变 public 接口,接口的使用者也需要跟着更新头文件:
    • 比如 list_ 成员之前用的是 std::list 容器,现在打算改用 std::vector 容器
    • 再比如,之前有 3 个 private 方法,现在重构实现部分,拆成更多的小函数
  • 增加了使用者的依赖。接口的使用者想要使用上述头文件,必须要 #include "dds.h" 这个文件,而 "dds.h" 通常又会 #include 很多其他文件。最终的结果往往是要向接口的使用者提供很多额外的头文件。如果将来重构,不用 DDS,改用 SOME/IP 或其他中间件,接口的使用者也要跟着改变。不仅如此,为 private 成员而额外 #include 的头文件也会增加编译时间

解决方案 —— PIMPL

PIMPL 就是 C++ 里专门用来解决这些问题的惯用法。PIMPL 将 MyInterface 类的具体实现(private/protected 方法、成员)转移到另外一个嵌套类 Impl 中,然后利用前向声明(forward declaration)声明 Impl,并在原有的 MyInterface 接口类中增加一个指向 Impl 对象的指针。再次强调,在 MyInterface 中的 Impl 仅仅是一个前向声明,MyInterface 类只知道有 Impl 这么个类,但是对 Impl 有哪些方法、哪些成员变量一无所知,因此能做的事情非常有限(声明一个指向该类的指针就是其中之一)。而这恰恰就是 PIMPL 将接口和实现解耦的关键所在。

应用 PIMPL 后的 MyInterface.h 文件:

class MyInterface {
public:
MyInterface();
~MyInterface(); int publicApi1();
int publicApi2(); private:
struct Impl;
Impl* impl_;
};

现在 MyInterface.h 接口文件变得非常清爽,看不到任何 private/protected 的方法和成员变量,也不需要 #include 任何和 private 成员相关的头文件,隐藏实现细节,降低使用者的依赖,提高接口稳定性

MyInterface.cpp

#include <string>
#include <list>
#include "dds.h" struct MyInterface::Impl {
int publicApi1();
int publicApi2(int i); int privateMethod1();
int privateMethod2();
int privateMethod3(); std::string name_;
std::list<int> list_;
DDSDomainPariciant dp_;
DDSTopic topic_;
DDSDataWriter dw_;
}; MyInterface::MyInterface()
: pimpl_(new Impl()) {} MyInterface::~MyInterface() {
delete pimpl_;
} int MyInterface::publicApi1() {
impl_->publicApi1();
}
int MyInterface::publicApi2(int i) {
impl_->publicApi2(i);
} // 其他 MyInterface::Impl 类的方法实现
// 原本 MyInterface 中的逻辑挪到 MyInterface::Impl 中
int MyInterface::Impl::publicApi1() {...}

可以看到,MyInterface 类的实现本身只是单纯地将请求委托/转发给 MyInterface::Impl 的同名方法。对于参数的传递,也可以适当使用 std::move 提升效率(关于 std::move 今后也可以展开说说)。

也可以把嵌套类 MyInterface::Impl 放到单独 MyInterfaceImpl.h/cpp 中,如此一来 MyInterface.cpp 就会变得非常简洁,就像下面这样:

MyInterface.cpp

#include "MyInterface.h"
#include "MyInterfaceImpl.h" MyInterface::MyInterface()
: pimpl_(new Impl()) {} MyInterface::~MyInterface() {
delete pimpl_;
} int MyInterface::publicApi1() {
return impl_->publicApi1();
} int MyInterface::publicApi2(int i) {
return impl_->publicApi2(i);
}

MyInterfaceImpl.h

#include <string>
#include <list>
#include "dds.h" struct MyInterface::Impl {
int publicApi1();
int publicApi2(int i); int privateMethod1();
int privateMethod2();
int privateMethod3(); std::string name_;
std::list<int> list_;
DDSDomainPariciant dp_;
DDSTopic topic_;
DDSDataWriter dw_;
};

MyInterfaceImpl.cpp

#include "MyInterfaceImpl.h"

int MyInterface::Impl::publicApi1() {
// ...
} // 其他 MyInterface::Impl 类的方法定义

注意不要在 MyInterface.h 中 #include "MyInterfaceImpl.h",否则就前功尽弃了。

现代 C++ 中的 PIMPL

以上是传统 C++ 中的 PIMPL 的实现,现代 C++ 应尽量避免使用裸指针,而使用智能指针。具体的原因见这篇文章「裸指针七宗罪」。

Impl 对象的所有权应该是 MyInterface 独有 ,unique_ptr 是合情合理的选择。如果直接将上述的裸指针替换成 unique_ptr

#include <memory>

class MyInterface {
public:
MyInterface();
int publicApi1();
int publicApi2(); private:
struct Impl;
std::unique_ptr<Impl> impl_;
}; // main.cpp
int main() {
MyInterface if;
}

gcc 下会看到这样的报错:

/opt/compiler-explorer/gcc-13.1.0/include/c++/13.1.0/bits/unique_ptr.h: In instantiation of 'constexpr void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = MyInterface::Impl]':
/opt/compiler-explorer/gcc-13.1.0/include/c++/13.1.0/bits/unique_ptr.h:404:17: required from 'constexpr std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = MyInterface::Impl; _Dp = std::default_delete<MyInterface::Impl>]'
<source>:118:7: required from here
/opt/compiler-explorer/gcc-13.1.0/include/c++/13.1.0/bits/unique_ptr.h:97:23: error: invalid application of 'sizeof' to incomplete type 'MyInterface::Impl'
97 | static_assert(sizeof(_Tp)>0,
| ^~~~~~~~~~~

揭晓答案前,先思考一下,问题出在哪里。

问题出在 MyInterface 的析构函数。在没有显式声明析构函数的情况下,编译器会默认合成一个隐式内联的析构函数(编译器在什么条件下,自动合成哪些函数也有不少学问,后面会单独发一篇),即等效如下代码:

class MyInterface {
public:
int publicApi1();
int publicApi2();
~MyInterface(){} // 是实现,不是声明! private:
struct Impl;
std::unique_ptr<Impl> impl_;
};

在 MyInterface.h 中,编译器会自动合成 MyInterface 的析构函数的实现(而非声明),在这个析构函数实现里,会进行以下操作:

  1. 执行空的析构函数体
  2. 按照构造的相反顺序,依次销毁 MyInterface 的成员
  3. 销毁 unique_ptr impl_ 成员
  4. 调用 unique_ptr 的析构函数
  5. unique_ptr 的析构函数调用默认的删除器(delete),删除指向的 Impl 对象

我们所看到报错,就出在第 5 步。unique_ptr 的实现代码在删除前,会进行 static_assert(sizeof(_Tp)>0 断言,而编译器执行该断言的时候,Impl 还是一个不完整类型(Incomplete Type)。因为编译器此时只看到了 MyInterface::Impl 的前向声明,还没有看到定义,不知道 Impl 有哪些成员,也不知 Impl 类占用多大内存,所以在进行 sizeof(Impl) 的时候报错。

知道了背后的原理,解决起来也很简单,就是保证在 MyInterface 析构函数实现的地方,能看到 Impl 类的定义即可:

MyInterface.h

#include <memory>

class MyInterface {
public:
int publicApi1();
int publicApi2();
MyInterface();
~MyInterface(); // 使用 unique_ptr 的关键:只声明,不实现! private:
struct Impl;
std::unique_ptr<Impl> impl_;
};

MyInterface.cpp

#include <memory>
#include "MyInterface.h"
#include "MyInterfaceImpl.h" MyInterface::MyInterface()
: pImpl_(std::make_unique<Impl>()) {} MyInterface::~MyInterface() = default; int MyInterface::publicApi1() {
return impl_->publicApi1();
}
int MyInterface::publicApi2(int i) {
return impl_->publicApi2(i);
}

这样,一个正确的 PIMPL 就搞定啦!虽然 PIMPL 多了一层封装,稍微增加了一点点复杂度,但我认为这么做是绝对的利大于弊。以一个我曾参与的项目为例,在将近一年的时间里,实现库更新了很多版,但是接口文件从释放以来一直没变过,大大减少了和第三方/供应商的沟通、调试成本。

最后,留一个思考题:为什么将 unique_ptr 换成 shared_ptr 不会遇到上面的 static_assert(sizeof(_Tp)>0 编译错误?如果你能解释其中的原因,那说明你对 shared_ptr、unique_ptr 的理解相当深入了

原文地址:https://i.cnblogs.com/posts/edit;postId=17473602

设计 C++ 接口文件的小技巧之 PIMPL的更多相关文章

  1. python 设计及调试的一些小技巧

    在“笨办法学习python”中介绍了一些设计函数以及调试技巧: 参考网址:http://www.jb51.net/shouce/Pythonbbf/latest/ex36.html If 语句的规则¶ ...

  2. 谈谈UI设计的6个实用小技巧

    从事UI设计的朋友们,肯定知道我们在做UI设计时,其实是可以通过一些小技巧来帮我们设计的界面更加的漂亮.实用.交互性强,用户体验更好.今天的话,上海艾艺在互联网上面搜寻了几个小技巧.在这里跟大家一起来 ...

  3. 使用Beyond Compare对比大篇幅文件的小技巧

    我们在编辑较大篇幅的文章时,无可避免地要经过多次的修改.有时候修改的版本过多时,我们很可能就会记不清最新版的文章对比上一版的文章,究竟修改了哪些地方.但有了Beyond Compare(Windows ...

  4. 在spring中映射X.hbm.xml文件的小技巧

    通常在spring中会这么写代码: <bean id="sessionFactory" class="org.springframework.orm.hiberna ...

  5. Linux查找文件内容小技巧

    目录 grep ag linux系统查找文件内容最常见的命令有grep和ag grep grep是比较常见的查找命令 # 在当前目录的py文件里查找所有相关内容 grep -a "broad ...

  6. 编写.gitignore文件的几个小技巧

    记录几个编写.gitignore文件的小技巧,可能你早就知道了,但我是google了一番才找到写法. 忽略所有名称为bin的文件夹 bin/ 只忽略第一级目录中,名称为bin的文件夹 /bin/ 忽略 ...

  7. 掌握这个Python小技巧,轻松构建cytoscape导入文件

    今天小编和大家分享如何借助Python脚本轻松构建cytoscape导入文件.Cytoscape是一个非常适合展示各种相互作用关系的可视化软件. 具体来说就是可以用于蛋白互作网络的展示,miRNA与蛋 ...

  8. IOS开发之代理的设计小技巧

    1.关于代理对象的设计小技巧 在设计一个类,需要通过代理和协议来从外部获取需要的动态的数据.那么在这里设计使用代理会有两种方法. <第一种方法> 也是比较常见的: 在你设计的类中,声明一个 ...

  9. 文件上传小技巧/原生态【html篇】

    引语:大家都知道,html中上传文件就一个input,type=file就搞定了.但是,这个标签的样式,实在不值得提点什么,要改动他的样式,恐怕也是较难的.但是其实挺简单,今天就来说说上传文件小技巧吧 ...

  10. 组件接口(API)设计指南-文件夹

    组件接口(API)设计指南-文件夹 组件接口(API)设计指南[1]-要考虑的问题 组件接口(API)设计指南[2]-类接口(class interface) 组件接口(API)设计指南[3]-托付( ...

随机推荐

  1. day03-搭建微服务基础环境02

    搭建微服务基础环境02 3.创建使用会员微服务模块-service consumer 3.1需求分析 浏览器向service consumer请求某个数据,service consumer会去向ser ...

  2. 方差分析2——双因素方差分析(R语言)

    双因素方差分析(Double factor variance analysis) 有两种类型:一个是无交互作用的双因素方差分析,它假定因素A和因素B的效应之间是相互独立的,不存在相互关系:另一个是有交 ...

  3. 通过python修改本地ip

    写在前面, 1 对于个人公司需要固定ip,而回家需要用到家里的ip, 2对于公司it人员,每台电脑都需要设置ip,,尤其批量的时候,这个作为it的自己知道 3运维人员,可以通过ip测试哪些ip可以用, ...

  4. [数据库/MYSQL]#解决缺陷#设置Unique索引时:"[Err] 1071 - Specified key was too long; max key length is 767 bytes"

    1 问题复现 原表结构: CREATE TABLE `XX_TEMPERATURE` ( `FLOW_ID` int(11) NOT NULL COMMENT '独立的数据表或FTP唯一标识', -- ...

  5. linux shell 自动化部署 npm vue 项目

    此 shell 是提供给前端登录服务器自动化部署 vue 项目的 用此命令,工具化部署项目,可以杜绝前端自己部署项目时,对服务器违规操作 如有其它问题,可在下方留言! #!/bin/sh # url: ...

  6. php对接snmp设备详细讲解

    1.Php安装snmp扩展 1.基础环境准备 Php7.2版本 yum -y install php72w-snmp Php7.4版本 yum install net-snmp php-snmp ne ...

  7. 活动预告 | Jax Diffusers 社区冲刺线上分享(还有北京线下活动)

    我们的 Jax Diffuser 社区冲刺活动已经截止报名,全球有 200 多名参赛选手成功组成了约 70 支队伍共同参赛. 为了帮助参赛者更好的完成自己的项目,也为了与更多社区成员们分享扩散模型和生 ...

  8. Typecho<=1.2.0 存储型XSS 复现

    Typecho<=1.2.0 存储型XSS 影响版本 漏洞影响版本:Typecho <= 1.2.0 漏洞复现 cookie.js // 定义一个全局变量 website,值为一个具体的网 ...

  9. vue中获取所有路由

    在router实例上有options属性:

  10. 用Aspose-Java免费实现 PDF、Word、Excel、Word互相转换并将转换过得文件上传OSS,返回转换后的文件路径

    嘿嘿嘿.嘿嘿,俺又回来了! github代码地址 https://github.com/Tom-shushu/work-study 接口文档有道云 https://note.youdao.com/s/ ...