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

立山  论坛元老 | 2023-6-18 08:59:57 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 1982|帖子 1982|积分 5946

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

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

在实际的项目中,经常需要定义和第三方/供应商的 C++ 接口。假如有这样一个接口文件:
MyInterface.h
  1. #include <string>
  2. #include <list>
  3. #include "dds.h"
  4. class MyInterface {
  5.    public:
  6.     int publicApi1();
  7.     int publicApi2();
  8.    private:
  9.     int privateMethod1();
  10.     int privateMethod2();
  11.     int privateMethod3();
  12.    private:
  13.     std::string name_;
  14.     std::list<int> list_;
  15.     DDSDomainPariciant dp_;
  16.     DDSTopic topic_;
  17.     DDSDataWriter dw_;
  18. };
复制代码
该接口头文件存在以下问题:

  • 暴露了 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 文件:
  1. class MyInterface {
  2.    public:
  3.     MyInterface();
  4.     ~MyInterface();
  5.    
  6.     int publicApi1();
  7.     int publicApi2();
  8.    private:
  9.     struct Impl;
  10.     Impl* impl_;
  11. };
复制代码
现在 MyInterface.h 接口文件变得非常清爽,看不到任何 private/protected 的方法和成员变量,也不需要 #include 任何和 private 成员相关的头文件,隐藏实现细节,降低使用者的依赖,提高接口稳定性
MyInterface.cpp
  1. #include <string>
  2. #include <list>
  3. #include "dds.h"
  4. struct MyInterface::Impl {
  5.     int publicApi1();
  6.     int publicApi2(int i);
  7.     int privateMethod1();
  8.     int privateMethod2();
  9.     int privateMethod3();
  10.     std::string name_;
  11.     std::list<int> list_;
  12.     DDSDomainPariciant dp_;
  13.     DDSTopic topic_;
  14.     DDSDataWriter dw_;
  15. };
  16. MyInterface::MyInterface()
  17.     : pimpl_(new Impl()) {}
  18. MyInterface::~MyInterface() {
  19.     delete pimpl_;
  20. }
  21. int MyInterface::publicApi1() {
  22.     impl_->publicApi1();
  23. }
  24. int MyInterface::publicApi2(int i) {
  25.     impl_->publicApi2(i);
  26. }
  27. // 其他 MyInterface::Impl 类的方法实现
  28. // 原本 MyInterface 中的逻辑挪到 MyInterface::Impl 中
  29. int MyInterface::Impl::publicApi1() {...}
复制代码
可以看到,MyInterface 类的实现本身只是单纯地将请求委托/转发给 MyInterface::Impl 的同名方法。对于参数的传递,也可以适当使用 std::move 提升效率(关于 std::move 今后也可以展开说说)。
也可以把嵌套类 MyInterface::Impl 放到单独 MyInterfaceImpl.h/cpp 中,如此一来 MyInterface.cpp 就会变得非常简洁,就像下面这样:
MyInterface.cpp
  1. #include "MyInterface.h"
  2. #include "MyInterfaceImpl.h"
  3. MyInterface::MyInterface()
  4.     : pimpl_(new Impl()) {}
  5. MyInterface::~MyInterface() {
  6.     delete pimpl_;
  7. }
  8. int MyInterface::publicApi1() {
  9.     return impl_->publicApi1();
  10. }
  11. int MyInterface::publicApi2(int i) {
  12.     return impl_->publicApi2(i);
  13. }
复制代码
MyInterfaceImpl.h
  1. #include <string>
  2. #include <list>
  3. #include "dds.h"
  4. struct MyInterface::Impl {
  5.     int publicApi1();
  6.     int publicApi2(int i);
  7.     int privateMethod1();
  8.     int privateMethod2();
  9.     int privateMethod3();
  10.     std::string name_;
  11.     std::list<int> list_;
  12.     DDSDomainPariciant dp_;
  13.     DDSTopic topic_;
  14.     DDSDataWriter dw_;
  15. };
复制代码
MyInterfaceImpl.cpp
  1. #include "MyInterfaceImpl.h"
  2. int MyInterface::Impl::publicApi1() {
  3.     // ...
  4. }
  5. // 其他 MyInterface::Impl 类的方法定义
复制代码
注意不要在 MyInterface.h 中 #include "MyInterfaceImpl.h",否则就前功尽弃了。
现代 C++ 中的 PIMPL

以上是传统 C++ 中的 PIMPL 的实现,现代 C++ 应尽量避免使用裸指针,而使用智能指针。具体的原因见这篇文章「裸指针七宗罪」。
Impl 对象的所有权应该是 MyInterface 独有 ,unique_ptr 是合情合理的选择。如果直接将上述的裸指针替换成 unique_ptr:
  1. #include <memory>
  2. class MyInterface {
  3.    public:
  4.     MyInterface();
  5.     int publicApi1();
  6.     int publicApi2();
  7.    private:
  8.     struct Impl;
  9.     std::unique_ptr<Impl> impl_;
  10. };
  11. // main.cpp
  12. int main() {
  13.     MyInterface if;
  14. }
复制代码
会看到这样的报错:
  1. /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]':
  2. /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>]'
  3. <source>:118:7:   required from here
  4. /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'
  5.    97 |         static_assert(sizeof(_Tp)>0,
  6.       |                       ^~~~~~~~~~~
复制代码
问题出在哪里呢?
问题就出在 MyInterface 的析构函数。在没有显式声明析构函数的情况下,编译器会自动合成一个隐式内联的析构函数(编译器在什么条件下,自动合成哪些函数也有不少学问,后面会单独发一篇),等效代码如下:
  1. class MyInterface {
  2.    public:
  3.     MyInterface();
  4.     ~MyInterface(){} // 是实现,不是声明!
  5.     int publicApi1();
  6.     int publicApi2();
  7.    private:
  8.     struct Impl;
  9.     std::unique_ptr<Impl> impl_;
  10. };
复制代码
在 MyInterface.h 中,编译器自动合成的析构函数会进行以下操作:

  • 执行空的析构函数体
  • 按照构造的相反顺序,依次销毁 MyInterface 的成员
  • 销毁 unique_ptr impl_ 成员
  • 调用 unique_ptr 的析构函数
  • 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
  1. #include <memory>
  2. class MyInterface {
  3.    public:
  4.     MyInterface();
  5.     ~MyInterface();  // 使用 unique_ptr 的关键:只声明,不实现!
  6.     int publicApi1();
  7.     int publicApi2();
  8.    private:
  9.     struct Impl;
  10.     std::unique_ptr<Impl> impl_;
  11. };
复制代码
MyInterface.cpp
  1. #include <memory>
  2. #include "MyInterface.h"
  3. #include "MyInterfaceImpl.h"
  4. MyInterface::MyInterface()
  5.     : pImpl_(std::make_unique<Impl>()) {}
  6. MyInterface::~MyInterface() = default;
  7. int MyInterface::publicApi1() {
  8.     return impl_->publicApi1();
  9. }
  10. int MyInterface::publicApi2(int i) {
  11.     return impl_->publicApi2(i);
  12. }
复制代码
这样,一个正确的 PIMPL 就搞定啦!虽然 PIMPL 多了一层封装,稍微增加了一点点复杂度,但我认为这么做是绝对的利大于弊。以一个我曾参与的项目为例,在将近一年的时间里,实现库更新了很多版,但是接口文件从释放以来一直没变过,大大减少了和第三方/供应商的沟通、调试成本。

最后,留一个思考题:为什么将 unique_ptr 换成 shared_ptr 不会遇到上面的 static_assert(sizeof(_Tp)>0 编译错误?如果你能解释其中的原因,那说明你对 shared_ptr、unique_ptr 的理解相当深入了
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

立山

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表