问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

C++中的 PIMPL:强大的设计模式

创作时间:
作者:
@小白创作中心

C++中的 PIMPL:强大的设计模式

引用
CSDN
1.
https://blog.csdn.net/friendlzw/article/details/143479305

PIMPL(Pointer to Implementation)是C++中一种重要的设计模式,通过使用指针的方式将实现的细节进行隐藏,主要作用是将两个文件间的编译依存关系降至最低。本文将从PIMPL的简介、应用实践、优势、公有类和实现类的隔离以及实现注意事项等多个方面进行深入阐述。

一、PIMPL 简介

PIMPL,即 Pointer to Implementation,是 C++ 中一种重要的设计模式。它通过使用指针的方式将实现的细节进行隐藏,主要作用是将两个文件间的编译依存关系降至最低。

PIMPL 的作用主要体现在多个方面。首先,它能解开类的使用接口和实现的耦合。在传统的 C++ 编程中,类的实现细节往往与接口紧密相连,这导致了一旦实现细节发生变化,可能会引起大量的编译依赖,进而影响整个项目的编译速度。而 PIMPL 通过将实现细节隐藏在一个单独的类中,使得类的接口更加稳定,减少了外部对实现细节的依赖。

其次,PIMPL 可以降低编译依赖,提高编译速度。当类的实现发生变化时,由于接口与实现分离,只有实现部分需要重新编译,而使用该类的其他部分无需重新编译。例如,在一个大型项目中,如果一个类的实现发生了多次修改,使用 PIMPL 可以大大减少编译时间。

此外,PIMPL 还能提高接口的稳定性。因为接口与实现分离,即使实现部分发生了重大变化,只要接口保持不变,使用该类的其他部分就不会受到影响。这对于维护大型项目的稳定性非常重要。

总的来说,PIMPL 在 C++ 编程中具有重要的地位,它能够提高代码的可维护性、可扩展性和编译速度,是一种非常实用的设计模式。

二、PIMPL 的应用实践

(一)原始指针实现 PIMPL

在 C++ 中,可以使用原始指针来实现 PIMPL。以一个Widget类为例,首先在Widget类的头文件中声明一个指向实现结构体的指针:

class Widget {
public:
    Widget() = default;
private:
    struct Impl;
    Impl *pImpl;
};

然后在实现文件中定义Impl结构体,并实现Widget类的构造函数和析构函数:

#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget() : pImpl(new Impl) {}
Widget::~Widget() {
    delete pImpl;
}

通过这种方式,将Widget类的实现细节放到了Impl类中,减少了头文件的依赖。如果Gadget等类型定义发生变化,只有Widget的实现文件需要重新编译,而头文件无需重新编译,从而减少了编译时间。

(二)std::unique_ptr 实现 PIMPL

使用std::unique_ptr实现 PIMPL 是一种常见的方法。在Widget类的头文件中,引入std::unique_ptr并指向Impl结构体:

#include <memory>

class Widget {
public:
    Widget() = default;
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

在实现文件中,使用std::make_unique来创建Impl对象:

#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget() : pImpl(std::make_unique<Impl>()) {}

使用std::unique_ptr的好处在于它会自动管理资源,在Widget对象被销毁时,自动释放Impl对象,无需手动编写析构函数。然而,需要注意的是,如果在Widget类中使用了std::unique_ptr,却没有声明一个析构函数,并且Impl是一个不完全类型,那么在客户端尝试编译时可能会出现错误。例如,std::unique_ptr的析构函数调用delete时会调用sizeof操作符来确保内部的原生指针不是指向一个不完全类型的对象。如果出现这种情况,可以考虑在Widget类中显式声明析构函数,或者按照特定的规则进行模板实例化等方法来解决问题。

三、PIMPL 的优势

(一)信息隐蔽

PIMPL 能够有效地将私有成员隐藏在共有接口之外。对于闭源 API 的设计来说,这一点尤为重要。通过 PIMPL,开发者可以将实现细节完全封装起来,只向用户提供简洁明了的接口。同时,很多与平台相关的宏控制代码也可以隐藏在实现类当中。这样一来,用户无需了解这些琐碎的平台依赖细节,就能轻松使用 API。例如,在开发跨平台软件时,不同平台可能需要不同的实现方式,通过 PIMPL 可以将这些特定于平台的代码隐藏在实现类中,使得接口更加通用和稳定。

(二)加速编译

PIMPL 在加速编译方面有着显著的优势。它阻断了类的实现和类的编译依赖性。通常情况下,一个类的实现发生变化时,所有包含该类头文件的代码都需要重新编译。然而,使用 PIMPL 后,由于类的实现细节被隐藏在单独的实现文件中,只有当类的接口发生变化时,才需要重新编译使用该类的代码。这大大减少了不必要的头文件包含,提高了编译速度。据统计,在一些大型项目中,使用 PIMPL 可以将编译时间缩短数倍甚至更多。

(三)更好的二进制兼容性

PIMPL 能够保证在实现变更时良好的二进制兼容性。在传统的 C++ 编程中,对一个类的修改可能会影响到类的大小、对象的表示和布局等信息,这就导致任何使用该类的用户都需要重新编译。但是,对于使用 PIMPL 的类,如果实现变更被限制在实现类中,那么公有类只持有一个实现类的指针,即使实现发生重大变更,也能够保证良好的二进制兼容性。这意味着用户无需重新编译他们的代码,就可以使用更新后的库或模块。

(四)惰性分配

PIMPL 还支持惰性分配,实现类可以做到按需分配或者在实际使用时再分配。这一特性可以节省资源并提高响应速度。例如,在一个大型软件系统中,某些功能可能只有在特定条件下才会被使用。如果不使用 PIMPL,这些功能的实现代码可能会在程序启动时就被加载,占用不必要的内存资源。而使用 PIMPL 后,可以在需要使用这些功能时才分配实现类的内存,从而提高系统的资源利用率和响应速度。

四、公有类和实现类的隔离

(一)推荐的隔离方式

公有类是实现类的抽象,实现类是公有类的封装隐藏。推荐的隔离方式是将所有非 virtual 的 private 成员都放置到 impl 中去,同时将 private 成员函数需要调用的公有函数也放置到 impl 中去。

(二)原因分析

  1. 信息隐藏:非 virtual 的 private 成员通常包含具体的实现细节,将它们放置到 impl 中可以更好地隐藏这些细节,防止外部直接访问和修改。这样可以提高代码的安全性和稳定性,符合信息隐蔽的原则。

  2. 减少耦合:通过将这些成员放置到 impl 中,可以减少公有类与实现类之间的耦合度。公有类只通过指针与实现类进行交互,而不需要了解实现类的具体内部结构。这使得实现类的修改不会直接影响到公有类的外部接口,从而提高了代码的可维护性。

  3. 避免继承问题:对于 protected 的成员,由于其是相对于继承关系而生效的,放置到 impl 中没有任何意义。同样,virtual 成员也不应该放到 impl 中去,因为 virtual 函数需要被继承链中的派生类去 override。如果将 virtual 成员放置到 impl 中,可能会导致继承关系的混乱,影响代码的可读性和可维护性。

(三)注意事项

  1. 函数调用开销:将 private 函数需要调用到的 public 方法也放到 impl 中去,是为了避免出现所谓的 “back pointer” 带来的开销。但是,这样做也会增加一层间接性,使得公有类成员函数的每次调用都必须通过 implementation class,从而增加了函数调用的开销。在实际应用中,需要权衡这种开销与信息隐藏和减少耦合的好处。

  2. 极端情况处理:还有一种极端的方式是将所有的 public 成员都丢到 impl 中去,此时公有类就相当于一个接口类,进而所有接口都需要一个 wrapper 进行调用的转发。这种方式会使公有类实现得比较无趣和杂乱,而且无法被继承复用。因此,在实际应用中,需要谨慎考虑这种极端情况,根据具体的需求和设计目标来选择合适的隔离方式。

五、PIMPL 实现注意事项

(一)资源管理

在 PIMPL 的实现中,资源管理是一个重要的方面。应尽可能避免使用原始指针来创建和释放实现类对象。使用原始指针可能会导致内存泄漏、悬挂指针等问题,并且需要手动管理内存的分配和释放,增加了代码的复杂性和出错的可能性。

相比之下,推荐使用智能指针来管理实现类对象,如std::unique_ptr、std::shared_ptr和boost::scoped_ptr等。这些智能指针能够自动管理资源,在适当的时候释放内存,大大降低了内存管理的难度。

std::unique_ptr是一种独占所有权的智能指针,它对所管理的资源拥有唯一的所有权,不允许资源的共享。在 PIMPL 中使用std::unique_ptr可以确保实现类对象的唯一所有权,避免资源的重复释放和悬挂指针的问题。例如,在Widget类中,可以使用std::unique_ptr来管理实现类对象,当Widget对象被销毁时,std::unique_ptr会自动释放Impl对象,无需手动编写析构函数。

std::shared_ptr是一种共享所有权的智能指针,它允许多个std::shared_ptr对象共享同一个资源。在某些情况下,如果需要实现类对象的共享,可以使用std::shared_ptr。但是,需要注意的是,std::shared_ptr的实现相对复杂,可能会带来一定的性能开销。

boost::scoped_ptr也是一种独占所有权的智能指针,它的功能与std::unique_ptr类似,但在某些方面可能略有不同。总的来说,boost::scoped_ptr和std::unique_ptr在实现上要比std::shared_ptr高效得多。

(二)拷贝语义

在 PIMPL 中,共有类的复制语义是一个需要特别关注的问题。由于实现类是以指针的方式作为共有类的一个成员,而默认 C++ 生成的拷贝操作只会执行对象的浅复制,这显然违背了 PIMPL 的原本意图。

默认的拷贝操作只会复制指针的值,而不会复制指针所指向的对象。这意味着,如果对一个使用 PIMPL 的对象进行拷贝,那么两个对象将指向同一个实现类对象。当其中一个对象被销毁时,另一个对象的指针将变为悬挂指针,可能会导致程序崩溃。

为了解决这个问题,可以采取以下两种方法:

  1. 禁止复制操作:将所有的复制操作定义为 private 的,或者在新标准中将这些复制操作定义为 delete 的即可。这样可以确保对象不能被复制,避免了浅复制带来的问题。

  2. 显式定义复制语义:创建新的实现类对象,执行深度复制操作。这种方法需要手动编写复制构造函数和赋值运算符,确保在复制对象时,创建新的实现类对象,并复制其内容,而不是仅仅复制指针的值。

例如,可以在共有类中定义如下的复制构造函数和赋值运算符:

class Widget {
public:
    Widget(const Widget& other) : pImpl(std::make_unique<Impl>(*other.pImpl)) {}
    Widget& operator=(const Widget& other) {
        if (this != &other) {
            *pImpl = *other.pImpl;
        }
        return *this;
    }
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

通过这种方式,可以确保在复制Widget对象时,执行深度复制操作,避免了浅复制带来的问题。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号