1

设计 C++ 接口文件的小技巧之 PIMPL - Zijian/TENG

 11 months ago
source link: https://www.cnblogs.com/tengzijian/p/17473602.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

1.设计 C++ 接口文件的小技巧之 PIMPL06-18

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

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

MyInterface.h

#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 接口文件变得非常清爽。原本在 MyInterface.h 中的那些依赖、实现细节,现在通通转移到了 MyInterface.cpp 内部,对接口的使用者彻底隐藏,降低使用者的依赖,提高接口稳定性

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++ 应尽量避免使用裸指针,而使用智能指针。具体的原因见这篇文章「裸指针七宗罪」。

MyInterface 拥有 Impl 对象的专属所有权,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;
}

会看到这样的报错:

/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:
    MyInterface();
    ~MyInterface(){} // 是实现,不是声明!
    int publicApi1();
    int publicApi2();

   private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};

在 MyInterface.h 中,编译器自动合成的析构函数会进行以下操作:

  1. 执行空的析构函数体
  2. 按照构造的相反顺序,依次销毁 MyInterface 的成员
  3. 销毁 unique_ptr<Impl> 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:
    MyInterface();
    ~MyInterface();  // 使用 unique_ptr 的关键:只声明,不实现!
    int publicApi1();
    int publicApi2();

   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://www.cnblogs.com/tengzijian/p/17473602.html


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK