Modern Effective C++ 条款二十二:当使用Pimpl惯用法,请在实现文件中定义特殊成员函数

发布于:2024-11-29 ⋅ 阅读:(21) ⋅ 点赞:(0)

Pimpl惯用法是一种用于减少编译依赖并隐藏实现细节的技术。它通过将类的数据成员移动到一个私有结构体中,并且在主类中仅保留指向该结构体的指针,从而达到封装的目的。这样做的好处是使用者不需要包含那些定义了数据成员类型的头文件,减少了编译时间和依赖性。通过将Impl的定义移到.cpp文件中,减少了用户代码对<string>、<vector>和gadget.h等头文件的直接依赖,从而减少了编译时间。

在C++98中,可以使用原始指针来实现Pimpl惯用法。

// widget.h
#ifndef WIDGET_H
#define WIDGET_H

#include <string>
#include <vector>
#include "gadget.h"  // 假设Gadget类型定义在此头文件中

class Widget {
public:
    Widget();
    ~Widget();

private:
    struct Impl; // 声明但未定义
    Impl* pImpl; // 指向实现结构体的指针
};

#endif // WIDGET_H

// widget.cpp
#include "widget.h"
struct Widget::Impl {  // 实现结构体的定义
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(new Impl) {}  // 构造函数

Widget::~Widget() {  // 析构函数
    delete pImpl;  // 释放内存
}

封装性:通过将数据成员移到私有结构体Impl中,可以隐藏这些数据成员的具体类型和实现细节。这样即使Impl的内部发生变化,只要对外接口不变,用户代码就不需要重新编译。

减少编译依赖:由于Impl是在.cpp文件中定义的,所以用户不需要包含那些定义了std::string、std::vector或Gadget的头文件,从而减少了编译时间和依赖性。

使用原始指针时,需要手动管理内存,如在构造函数中分配,在析构函数中释放。这虽然有效,但不如智能指针安全。如果想要提高安全性,可以考虑使用std::unique_ptr替代原始指针。

但是直接替换原始指针会导致编译错误,因为std::unique_ptr的默认删除器需要完整类型才能工作。因此,需要确保在销毁std::unique_ptr之前,Impl已经是一个完整类型。

// widget.h
class Widget {
public:
    Widget();
    ~Widget();//只声明析构函数
    Widget(Widget&&) = default;//移动构造
    Widget& operator=(Widget&&) = default;//移动赋值
private:
    struct Impl;//声明但未定义
    std::unique_ptr<Impl> pImpl;//使用智能指针
};
// widget.cpp
#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>()) {}
Widget::~Widget() = default; // 定义析构函数

// 如果需要支持复制操作,还需添加.
Widget::Widget(const Widget& rhs):
    pImpl(std::make_unique<Impl>(*rhs.pImpl)){}

Widget& Widget::operator=(const Widget& rhs) {
    *pImpl=*rhs.pImpl;
    return *this;
}

Pimpl惯用法:通过将数据成员移至一个私有的、不完整的结构体中,并在主类中保持一个指向该结构体的指针,可以减少编译依赖。为了更安全地管理资源,可以使用std::unique_ptr替代原始指针。

在Pimpl惯用法中,Impl结构体通常只在类的私有部分进行声明,而在头文件中并没有给出它的定义。这样做的目的是为了隐藏实现细节,并减少编译依赖。但是,这会导致一个问题:如果std::unique_ptr<Impl>试图销毁一个Impl对象,而此时Impl仍然是一个不完整类型,那么编译器就会报错,因为不知道如何正确地销毁这个对象。

为了解决这个问题,需要确保在std::unique_ptr<Impl>尝试销毁Impl对象之前,Impl已经被定义为完整类型。

.cpp文件中定义Impl:.cpp文件中任何可能使用std::unique_ptr<Impl>的地方之前,先定义Impl结构体。当std::unique_ptr<Impl>尝试销毁Impl对象时,编译器已经有了完整的类型信息。

在头文件中声明析构函数,但不要在头文件中定义它。在.cpp文件中定义析构函数,这样可以确保在析构函数被执行时,Impl已经是一个完整类型。如果使用std::unique_ptr,需要显式地声明析构函数并在实现文件中定义它,以确保在销毁std::unique_ptr前Impl已被定义。对于移动操作,也需采取类似措施。

使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意:对其中的std::unique_ptr进行移动。 正如Item17所解释的那样,声明一个类Widget的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,必须自己声明相关的函数。考虑到编译器自动生成的版本会正常运行,可能会按如下方式实现它们:

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

struct Widget::Impl {
//跟之前一样,定义Widget::Impl
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
}

Widget::Widget()//跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget()//析构函数的定义
{}

这样的做法会导致同样的错误,和之前的声明一个不带析构函数的类的错误一样,并且是因为同样的原因。 编译器生成的移动赋值操作符,在重新赋值之前,需要先销毁指针pImpl指向的对象。然而在Widget的头文件里,pImpl指针指向的是一个不完整类型。移动构造函数的情况有所不同。 移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁pImpl的代码。然而,销毁pImpl需要Impl是一个完整类型。

因为这个问题同上面一致,解决方案:移动操作的定义移动到实现文件里:

class Widget {                          //仍然在“widget.h”中
public:
    Widget();
    ~Widget();
    Widget(Widget&& rhs);               //只有声明
    Widget& operator=(Widget&& rhs);
private:                                //跟之前一样
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

#include <string>                   //跟之前一样,仍然在“widget.cpp”中
…
    
struct Widget::Impl { … };          //跟之前一样

Widget::Widget()                    //跟之前一样
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default;        //跟之前一样

Widget::Widget(Widget&& rhs) = default;             //这里定义
Widget& Widget::operator=(Widget&& rhs) = default;

使用std::shared_ptr作为替代方案时的一些不同之处

头文件 (widget.h)

#ifndef WIDGET_H
#define WIDGET_H
#include <memory>
#include <string>
#include <vector>
#include "gadget.h"// 假设Gadget类定义在此头文件中
class Widget {
public:
    Widget();
    ~Widget();  // 只声明析构函数
    // 拷贝构造函数和赋值操作符
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs);
private:
    struct Impl;  // 声明但未定义
    std::unique_ptr<Impl> pImpl;  // 使用智能指针
};
#endif // WIDGET_H

Widget类声明了构造函数、析构函数以及拷贝构造函数和赋值操作符。Impl结构体仅被声明而没有定义,以隐藏实现细节。pImpl是一个指向Impl的unique_ptr用于独占所有权。

实现文件 (widget.cpp)

#include "widget.h"
#include <memory>  // 包含std::make_unique
#include <string>
#include <vector>
#include "gadget.h"  // 包含Gadget类定义
// 定义Impl结构体
struct Widget::Impl {
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
};
// 构造函数
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
// 析构函数
Widget::~Widget() = default;
// 拷贝构造函数
Widget::Widget(const Widget& rhs) : pImpl(std::make_unique<Impl>(*rhs.pImpl)) {}
// 赋值操作符
Widget& Widget::operator=(const Widget& rhs) {
    if (this != &rhs) {  // 防止自赋值
        *pImpl = *rhs.pImpl;  // 利用编译器生成的Impl的复制操作
    }
    return *this;
}

拷贝构造函数和赋值操作符:由于std::unique_ptr是移动语义的(即只可移动),所以需要手动实现拷贝构造函数和赋值操作符。在拷贝构造函数中,使用std::make_unique创建一个新的Impl对象,并将其初始化为rhs.pImpl所指向的对象的副本。在赋值操作符中,检查自赋值情况,并使用*pImpl = *rhs.pImpl进行深拷贝。

析构函数:析构函数在头文件中声明,在实现文件中定义为默认析构函数~Widget() = default;。这确保了在销毁Widget对象时,pImpl所指向的内存会被正确释放。

使用std::shared_ptr的差异:如果使用std::shared_ptr而不是std::unique_ptr,则不需要在头文件中声明析构函数,因为std::shared_ptr的删除器类型不是智能指针的一部分,因此它可以在不完整类型上工作。std::shared_ptr会导致更大的运行时开销,并且通常用于共享所有权的情况,而Widget和Impl之间的关系更适合独占所有权。

std::unique_ptr 与 std::shared_ptr 的区别

所有权模型

unique_ptr提供独占所有权,一个对象只能由一个std::unique_ptr管理。当unique_ptr被销毁或重置时,它所管理的对象也会被销毁。shared_ptr提供共享所有权,允许多个std::shared_ptr实例共同管理同一个对象。只有当所有shared_ptr实例都被销毁时,它们所管理的对象才会被销毁。

删除器类型

unique_ptr默认的删除器是delete操作符,但用户可以自定义删除器。删除器类型是unique_ptr的一部分,因此编译器需要知道完整的类型信息来生成正确的删除代码。

shared_ptr默认的删除器也是delete操作符,但删除器类型不是std::shared_ptr的一部分。即使类型不完整,std::shared_ptr也可以工作,因为它可以在运行时动态地确定删除器。

unique_ptr由于独占所有权,不需要维护引用计数,因此具有较小的运行时开销。shared_ptr由于需要维护引用计数,因此具有较大的运行时开销。每个std::shared_ptr实例都需要额外的内存来存储引用计数,并且每次创建或销毁std::shared_ptr时都需要更新引用计数。

使用std::shared_ptr 时不需要声明析构函数的原因

由于std::unique_ptr的删除器类型是其一部分,编译器需要知道完整的类型信息来生成正确的删除代码。因此,在头文件中声明析构函数并在实现文件中定义它是非常重要的。这样可以确保在析构函数执行时,Impl已经是一个完整类型。

由于std::shared_ptr的删除器类型不是其一部分,编译器不需要完整的类型信息来生成删除代码。因此,即使Impl仍然是不完整类型,std::shared_ptr也可以正常工作。这意味着你不需要在头文件中声明析构函数,因为编译器生成的默认析构函数可以正确地处理std::shared_ptr的销毁。

适用场景

虽然std::shared_ptr可以用于Widget和Impl之间的关系,但由于其较大的运行时开销和不必要的共享所有权特性,通常不推荐在这种情况下使用。