- 原始指针的缺陷有:
- 声明中未指出指向的是单个对象还是一个数组
- 没有提示使用完对象后是否需要析构,从声明中无法看出指针是否拥有对象
- 不知道析构该使用delete还是其他方式(比如传入一个专门用于析构的函数)
- 即使知道了使用delete,也不知道delete的是单个对象还是数组(使用delete[])
- 难以保证所有路径上只产生一次析构
- 没有检查空悬指针的办法
- 智能指针解决了这些问题,它封装了原始指针,行为看起来和原始指针类似但大大减少了犯错的可能
- C++11中有四种智能指针:std::unique_ptr、std::shared_ptr、std::weak_ptr、std::auto_ptr
- std::auto_ptr是C++98遗留的废弃特性,它可以被std::unique_ptr完美替代,除了只能用C++98编译的情况外,都不应该再使用std::auto_ptr
18 对专属所有权的资源管理使用std::unique_ptr
- 每当需要使用智能指针时,std::unique_ptr基本是首选,它默认和原始指针有相同尺寸
- std::unique_ptr是move-only类型,不允许拷贝。资源的析构默认通过对std::unique_ptr内部的原始指针执行delete完成
- std::unique_ptr常用于继承体系中工厂函数的返回类型(另一个广泛用法是pimpl,见条款22)
class Investment { … }; // 投资
class Stock: public Investment { … }; // 股票
class Bond: public Investment { … }; // 债券
class RealEstate: public Investment { … }; // 不动产
- 这类工厂函数一般返回一个堆上分配对象的指针,不需要该对象时再由调用者删除。而std::unique_ptr正好能完美实现这个功能,并使得调用者无需对返回的资源负责。Investment继承体系的工厂函数声明如下
template<typename... Ts>
std::unique_ptr<Investment>
makeInvestment(Ts&&... params);
- 调用者可以在单个作用域中按如下使用返回的std::unique_ptr
{
…
auto pInvestment = // pInvestment is of type
makeInvestment( arguments ); // std::unique_ptr<Investment>
…
}
- 析构默认用delete实现,但可以自定义删除器,比如在删除前写入一条日志
auto delInvmt =
[](Investment* pInvestment) // 删除器需要接受Investment*类型实参
{
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt)> // 改进的返回类型
makeInvestment(Ts&&... params)
{
std::unique_ptr<Investment, decltype(delInvmt)> // 待返回指针
pInv(nullptr, delInvmt);
if ( /* a Stock object should be created */ )
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /* a Bond object should be created */ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /* a RealEstate object should be created */ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
- 因为删除器对继承体系中的任何对象调用的都是Investment的析构函数,因此必须将其声明为虚析构函数
class Investment {
public:
…
virtual ~Investment();
…
};
- C++14中,auto可以作为返回类型,makeInvestment可以用如下封装型更好的手法实现
template<typename... Ts>
auto makeInvestment(Ts&&... params) // C++14
{
auto delInvmt = // 删除器现在位于makeInvestment内部
[](Investment* pInvestment)
{
makeLogEntry(pInvestment);
pInvestment;
};
// 其他不变
std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
if ( /* a Stock object should be created */ )
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /* a Bond object should be created */ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /* a RealEstate object should be created */ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
- 默认情况下,std::unique_ptr和原始指针尺寸相同,自定义删除器后则不一样。如果删除器是函数指针,尺寸一般增加一到两个word。如果删除器是函数对象,则取决于其中存储的状态数,无状态的函数对象(如无捕获的lambda)不会浪费任何存储尺寸,这意味着自定义删除器时,无捕获的lambda是比函数更好的选择
auto delInvmt1 = // 使用无捕获lambda作为删除器
[](Investment* pInvestment)
{
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts>
std::unique_ptr<Investment, decltype(delInvmt1)> // 返回值尺寸与Investment*相同
makeInvestment(Ts&&... args);
void delInvmt2(Investment* pInvestment) // 使用函数作为删除器
{
makeLogEntry(pInvestment);
delete pInvestment;
}
template<typename... Ts>
std::unique_ptr<Investment, // 返回类型尺寸至少为Investment*加函数指针的尺寸
void (*)(Investment*)>
makeInvestment(Ts&&... params);
- std::unique_ptr提供两种形式,一种是单个对象std::unique_ptr<T>,另一种是数组std::unique_ptr<T[]>,这样对其指向的对象就不存在二义性,比如单个对象不提供operator[],数组不提供operator*和operator->
- std::unique_ptr数组形式的存在只需要了解即可,因为std::array、std::vector、std::string总是比原始指针好,std::unique_ptr<T[]>唯一合理的使用场景是使用返回堆上指针的C-like API
- std::unique_ptr作为返回类型可以直接转成std::shared_ptr,工厂函数并不知道返回的对象应该专属所有权还是共享所有权,因此std::unique_ptr是最合适的返回类型
auto f()
{
unique_ptr<int> x = make_unique<int>(42);
return x;
}
shared_ptr<int> p = f(); // OK
shared_ptr<int> q = make_unique<int>(42); // OK
unique_ptr<int> p = make_unique<int>(42);
shared_ptr<int> q = p; // 错误
19 对共享所有权的资源管理使用std::shared_ptr
- std::shared_ptr的构造函数会使引用计数递增(移动构造除外),析构函数使其递减,拷贝赋值运算符执行两种操作
sp1 = sp2; // sp1指向sp2所指的对象,sp1原先指向的对象计数递减,sp2递增
- 引用计数带来的影响
- std::shared_ptr的尺寸是原始指针的两倍,因为内部多了一个指向引用计数的原始指针
- 引用计数的内存必须动态分配。引用计数和对象关联,但对象并不知情,于是没有存储引用计数的位置,但正因如此任何类型的对象(包括内置类型)都可以用std::shared_ptr管理
- 引用计数的递增和递减必须是原子操作,比如线程1对某个std::shared_ptr执行析构(引用计数递减),线程2则对同一对象的std::shared_ptr执行拷贝(引用计数递增)。原子操作一般比非原子操作慢,即使引用计数只有一个字长也应该认为读写成本高昂
- 从一个已有std::shared_ptr移动构造一个新的std::shared_ptr会将源std::shared_ptr置空,因此不需要进行任何引用计数操作,而移动也因此比拷贝快
- 与std::unique_ptr类似,std::shared_ptr与std::unique_ptr类似默认使用delete析构,可以自定义删除器,但方式与std::unique_ptr有所不同
auto loggingDel = [](Widget *pw)
{
makeLogEntry(pw);
delete pw;
};
std::unique_ptr<Widget, decltype(loggingDel)> // 删除器是类型的一部分
upw(new Widget, loggingDel);
std::shared_ptr<Widget> // 删除器不是类型的一部分
spw(new Widget, loggingDel);
auto customDeleter1 = [](Widget *pw) { … };
auto customDeleter2 = [](Widget *pw) { … };
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);
// pw1和pw2虽然有不同的删除器,但有相同类型,方便放入容器
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
- 不同于std::unique_ptr的另一点是,删除器不会改变std::shared_ptr的尺寸,它总是原始指针的两倍
- 一个困惑是,如果删除器是包含任意数量数据的函数对象,尺寸可以是任意大小,那么std::shared_ptr如何不使用更多内存来指向任意尺寸的删除器?这当然是不可能的,只不过删除器使用的内存不属于std::shared_ptr对象的一部分,它位于堆上或自定义分配器的内存位置。引用计数是更大的数据结构control block的一部分,每个std::shared_ptr管理的对象都有一个control block,除了包含引用计数还包含一个自定义删除器的复制,如果有自定义分配器则也会被包含,此外还可能包含其他数据(如弱引用计数,见条款21)。可以想象std::shared_ptr<T>相关内存如下
std::shared_ptr<T>相关内存
- 一个对象的control block由创建首个指向该对象的std::shared_ptr的函数确定,因此control block的创建遵循如下规则
- std::make_shared总是创建一个control block,因为调用它时会生成一个新对象,此时显然不会有关于该对象的control block
- 从std::unique_ptr构造一个std::shared_ptr时,会创建一个control block,因为std::unique_ptr不使用control block。作为构造过程的一部分,std::shared_ptr接管了std::unique_ptr所指对象的所有权,std::unique_ptr将被置空
- std::shared_ptr构造函数用原始指针作为实参调用时,会创建一个control block
- 这些规则会导致一个后果:从同一个原始指针构造多个std::shared_ptr将创建多个control block,即有多个引用指针,当引用指针变为零时就会出现多次析构的错误
int main()
{
{
int* i = new int(42);
shared_ptr<int> p(i);
shared_ptr<int> q(i);
} // error
}
- 因此,不要从原始指针构造std::shared_ptr,替代做法是使用std::make_shared。如果自定义删除器则无法使用std::make_shared,这时应该直接传递new运算符的结果
std::shared_ptr<int> p(new int(42));
- 有一种使用原始指针作为std::shared_ptr构造函数实参的方式,将意外导致涉及this指针的多重control block
std::vector<std::shared_ptr<A>> v;
class A {
public:
…
void f();
…
};
void A::f()
{
… // 处理A对象本身
v.emplace_back(this); // 把原始指针加入元素类型为std::shared_ptr的容器
}
- emplace_back把一个原始指针传入容器,由此构造的std::shared_ptr将为指向的A类型对象(*this)创建一个新的control block,这样看似无害,但如果在指向该A类型对象的成员函数外部加一层std::shared_ptr,就将出现未定义行为
- std::shared_ptr API中为这种情况提供了std::enable_shared_from_this,一个基类模板。如果希望一个std::shared_ptr管理的类能安全地从this指针创建一个std::shared_ptr,它将为继承而来的基类提供一个模板
class A: public std::enable_shared_from_this<A> {
public:
…
void f();
…
};
- std::enable_shared_from_this是一个基类模板,参数类型总是派生类名称。基类用派生类作为模板实例化类型,这种模式就是CRTP
- std::enable_shared_from_this定义了一个成员函数shared_from_this,它能提供和this指向同一对象的std::shared_ptr
void A::f()
{
…
v.emplace_back(shared_from_this()); // 添加指向当前对象的std::shared_ptr到容器
}
- 内部实现上看,shared_from_this查询当前对象的control block,并为该control block创建一个新的std::shared_ptr,这个设计依赖于当前对象有一个已关联的control block,因此必须有一个已存在的指向当前对象的std::shared_ptr(比如某个调用了shared_from_this并位于成员函数外部的函数),如果不存在则行为未定义,一般shared_from_this就会抛异常
- 为了避免在std::shared_ptr指向对象前调用引发shared_from_this的函数,std::enable_shared_from_this派生的类一般会将构造函数声明为private,并只允许通过调用返回std::shared_ptr的工厂函数来创建对象
class A : public std::enable_shared_from_this<A> {
public:
template<typename... Ts>
static std::shared_ptr<A> create(Ts&&... params);
…
void f();
…
private:
… // 构造函数
};
- std::shared_ptr不能转换为std::unique_ptr,一旦选择了std::shared_ptr管理资源,两者就会建立至死方休的联系
- 另外不同于std::unique_ptr的是,std::shared_ptr不能处理数组,没有std::shared_ptr<T[]>这一写法。如果用std::shared_ptr<T>指向数组,并指定一个自定义删除器完成数组的删除(即delete[]),这样可以编译但却是个糟糕的主意。std::shared_ptr未提供operator[],另外std::shared_ptr提供的派生类到基类的转换只能用于单个对象,而且C++11已经提供了很好的内置数组(如std::array、std::vector、std::string等),非要声明一个指向数组的智能指针通常代表设计上的拙劣
- C++17中,std::shared_ptr添加了operator[]成员函数
shared_ptr<int[]> p(new int[4] {1,2,3,4});
std::cout << p[1];
20 使用std::weak_ptr替代可能空悬的std::shared_ptr
- std::weak_ptr不能解引用,也不能检查是否为空。它不是一种独立的智能指针,而是std::shared_ptr的一种扩充。std::weak_ptr一般通过std::shared_ptr创建,但std::weak_ptr不影响对象的引用计数
auto p = std::make_shared<A>(); // p构造完成后,指向A的引用计数为1
…
std::weak_ptr<A> q(p); // 引用计数仍为1
…
p = nullptr; // 引用计数为0,A被析构,q空悬
- std::weak_ptr的空悬也叫失效(expired),可以直接测试
if (q.expired()) …
- 如果std::weak_ptr不是expired,通常会希望返回其中的对象,但std::weak_ptr不能解引用,这个操作不能直接写出来。即使能写出来也是有风险的,在检查expired和解引用之间,如果另一个线程重新赋值或析构最后一个该对象的std::shared_ptr,解引用也是未定义行为
- 于是需要原子操作来检验std::weak_ptr是否失效,并在未失效时提供对所指向对象的访问,这个操作可以通过由std::weak_ptr构造std::shared_ptr实现
- 第一种方法是用std::weak_ptr::lock,它返回一个std::shared_ptr,如果失效则返回的std::shared_ptr为空
std::shared_ptr<A> p2 = q.lock(); // q失效则p2为nullptr
auto p3 = q.lock(); // 可以直接使用auto
- 另一种方法是用std::weak_ptr作为实参构造std::shared_ptr,如果std::weak_ptr失效则抛出std::bad_weak_ptr异常
std::shared_ptr<A> p3(q);
std::weak_ptr的用处
std::unique_ptr<const Widget> loadWidget(WidgetID id);
- 如果工厂函数的成本高昂(比如执行了文件或数据库的IO),并且ID被重复使用,一个合理的优化是缓存结果,并在缓存的Widget不再有用时析构。对这个带缓存的工厂函数来说,返回std::unique_ptr不太合适,因为用户用完对象后相应的缓存才空悬,为了检测空悬就需要std::weak_ptr,这就意味着应当返回std::shared_ptr
- 下面是一个快捷粗糙的实现。这个实现忽略了一个问题,由于相应的Widget不再使用(因此会被析构),缓存中失效的std::weak_ptr会越来越多
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock(); // objPtr is std::shared_ptr
if (!objPtr) { // if not in cache,
objPtr = loadWidget(id); // load it
cache[id] = objPtr; // cache it
}
return objPtr;
}
- 第二种用例是Observer设计模式,该模式的主要组件是subject和observer,每个subject包含一个数据成员,该成员持有指向observer的指针。subject不关心observer被析构的时机,但要确保不再访问被析构的observer,一种合理的设计就是让每个subject持有一个指向observer的std::weak_ptr的容器,这使得subject可以在使用指针之前确定其是否空悬
- 最后一种用法是打破循环引用
#include <iostream>
#include <memory>
using namespace std;
class B;
class A {
public:
A() { cout << "A\n"; }
~A() { cout << "-A\n"; }
shared_ptr<B> b;
};
class B{
public:
B() { cout << "B\n"; }
~B() { cout << "-B\n"; }
shared_ptr<A> a;
};
int main()
{
{
shared_ptr<A> a(new A);
a->b = shared_ptr<B>(new B);
a->b->a = a;
}
cout << "OK";
cin.get();
}
// output
A
B
OK
- 上例中,退出局部作用域时,a的use_count由2减为1,因此a不会析构,而a中的成员b也不析构,这样就导致了两次内存泄漏,可见使用智能指针不意味着不会内存泄漏。解决方法是把class B中的a成员改为weak_ptr,这样循环引用时不会增加a的use_count,因此结束时a的use_count由1减为0,将正确析构,成员b也将正确析构
#include <iostream>
#include <memory>
using namespace std;
class B;
class A {
public:
A() { cout << "A\n"; }
~A() { cout << "-A\n"; }
shared_ptr<B> b;
};
class B{
public:
B() { cout << "B\n"; }
~B() { cout << "-B\n"; }
weak_ptr<A> a;
};
int main()
{
{
shared_ptr<A> a(new A);
a->b = shared_ptr<B>(new B);
a->b->a = a;
}
cout << "OK";
cin.get();
}
// output
A
B
-A
-B
OK
21 使用std::make_unique和std::make_shared替代new
- std::make_unique是C++14加入的,如果用C++11则可以手动实现一个基础版本
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
- 如你所见,make_unique只是做了一次形参到目标对象构造函数的完美转发。这个基本形式的函数不支持数组和自定义删除器,但这些不难实现
- make系列函数会把一个任意实参集合完美转发给动态分配内存的构造函数,并返回一个指向该对象的智能指针。除了std::make_shared和std::make_unique,make系列的第三个函数是std::allocate_shared,它的行为和std::make_shared一样,只不过第一个实参是分配器对象
- 优先使用make函数。考虑下列对比
auto upw1(std::make_unique<A>()); // with make func
std::unique_ptr<A> upw2(new A); // without make func
auto spw1(std::make_shared<A>()); // with make func
std::shared_ptr<A> spw2(new A); // without make func
- 使用new需要写两次对象类型,这已经违反了避免代码冗余的软件工程原则
- 另一个原因则与异常安全相关
void f(std::shared_ptr<A> p, int n);
int fac() { return 1; }
f(std::shared_ptr<A>(new A), // 潜在的资源泄露
fac());
- 在函数执行前,new创建一个对象,然后调用std::shared_ptr<A>的构造函数,并且还需要执行另一个函数。但编译器不必按此顺序生成代码,另一个函数可能在new和std::shared_ptr的构造函数之间执行,如果该函数发生异常,就会导致new分配的对象泄漏,而std::make_shared则可以避免此问题,并且比起new的另一个优势是只需要一次内存分配
f(std::make_shared<A>(), // 不会发生潜在的资源泄露
fac()
- 但在一些情况下不应该选用make函数,比如make函数无法自定义删除器,只能用new
auto del = [](A* pa) { … };
std::unique_ptr<A, decltype(del)> p(new A, del);
std::shared_ptr<A> q(new A, del);
- make函数的第二个限制与之前提过的std::initializer_list有关,make函数会向对象的构造函数完美转发形参,而在make函数中完美转发使用的是圆括号
auto upv = std::make_unique<std::vector<int>>(10, 20);
auto spv = std::make_shared<std::vector<int>>(10, 20);
- 上述返回的指针指向的都是包含10个元素为20的vector,但在大括号创建时就必须使用new,因为不能完美转发大括号初始化值。不过有一个灵活的解决方法是,使用auto来创建一个std::initializer_list,然后再传递给make函数
// create std::initializer_list
auto initList = { 10, 20 };
// create std::vector using std::initializer_list ctor
auto spv = std::make_shared<std::vector<int>>(initList);
- 对于std::make_unique只存在上述两种情况(自定义删除器和转发大括号初始值)的限制,但对于std::make_shared和std::allocate_shared还有两种情况
- 如果类重载了operator new和operator delete,其针对的内存尺寸一般为类的尺寸,而std::shared_ptr还需要加上control block的尺寸,因此用make函数不适合为重载了operator new和operator delete的类创建对象
- std::make_shared使std::shared_ptr的control block和管理的对象在同一内存上分配,这就是比直接使用new在尺寸和速度上存在优势的原因。对象的引用计数为0时,对象被析构,但对象占用的内存直到与起关联的control block被析构时才被释放,因为同一动态分配的内存块同时包含两者
- 如前所述,control block除了引用计数本身还包含其他信息,比如对std::weak_ptr的弱计数,不过实际上弱计数并不始终等于std::weak_ptr的数量,因为库作者找到了向弱计数中加入额外信息以使代码能更好地生成。std::weak_ptr检查引用计数(而非弱计数)来校验自己是否失效
- std::weak_ptr会指向某个control block,因此包含该control block的内存就会持续存在,std::make_shared分配的内存就无法在最后一个std::shared_ptr和std::weak_ptr都被析构前释放
- 假如对象尺寸很大,且最后一个std::shared_ptr和std::weak_ptr析构之间的时间间隔不能忽略,就会产生对象析构和内存释放之间的延迟
class ReallyBigType { … };
auto pBigObj = std::make_shared<ReallyBigType>(); // 创建大尺寸对象
… // 创建指向该对象的多个std::shared_ptr和std::weak_ptr并做一些操作
… // 最后一个std::shared_ptr被析构,但std::weak_ptr仍存在
… // 此时,大尺寸对象占用内存仍未被回收
… // 最后一个std::weak_ptr被析构,control block和对象占用的同一内存块被释放
- 而如果直接使用new则内存在最后一个std::shared_ptr被析构时就能释放
class ReallyBigType { … };
std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType); // 创建大尺寸对象
… // 创建指向该对象的多个std::shared_ptr和std::weak_ptr并做一些操作
… // 最后一个std::shared_ptr被析构,但std::weak_ptr仍存在,不过大对象占用内存被回收
… // 此时,仅control block内存处于分配而未回收状态
… // 最后一个std::weak_ptr被析构,control block的内存块被释放
- 再回到之前的内存泄漏的问题,这次指定一个自定义删除器
void f(std::shared_ptr<A> p, int n);
void del(A *pa);
f(std::shared_ptr<A>(new A, del), fac()); // 潜在的资源泄漏
- 这里的问题是,自定义删除器就不能使用make函数,为了避免资源泄漏,解决方法是把std::shared_ptr的构造函数放到一条单独的语句中
std::shared_ptr<A> p(new A, del);
f(p, fac()); // 不存在资源泄漏,但不是最优化
- 这里std::shared_ptr的构造函数即使发生异常也不会有问题,删除器将析构new创建的对象
- 但存在一个性能隐患:在非异常安全的调用中,传递给函数的实参是右值,在异常安全的调用中传递的则是左值
f(std::shared_ptr<A>(new A, del), fac()); // 非异常安全调用:实参是右值
f(p, fac()); // 异常安全调用:实参是左值
- 由于f中std::shared_ptr的形参按值传递,从右值构造只要移动一次,而从左值构造则要拷贝一次。拷贝要求对引用计数进行一次递增的原子操作,而移动不需要,因此如果希望异常安全的代码达到非异常安全的性能,就要用std::move转换出右值
f(std::move(p), fac()); // 同时保证效率和异常安全
22 使用pimpl手法时,将特殊成员函数定义在实现文件中
- pimpl就是把用一个指向另一个实现类的指针替代主类的数据成员,从而把主类的数据成员放到实现类中,并通过指针间接访问
// file "widget.h"
class Widget {
public:
Widget();
…
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget是某个自定义类型
};
- 因为数据成员有多个类型,这些类型对应的头文件必须都存在才能编译,这就增加了Widget的客户的编译时间,且客户必须依赖于这些头文件的内容,如果某个头文件内容发生了改变,Widget的客户就要重新编译。使用C++98的pimpl就能解决这个问题
// file "widget.h"
class Widget {
public:
Widget();
~Widget(); // 必须有析构函数
…
private:
struct Impl; // 声明实现的struct
Impl *pImpl; // 指向实现类的指针
};
- 结构体Wiget::Impl虽然只是一个声明而非定义的非完整类型,用处有限,但足够声明一个指向它的指针。pimpl的第一部分是如上的声明一个指向非完整类型的指针,第二部分则是动态分配和回收数据成员的对象,这部分代码位于实现文件中
// file "widget.cpp"
#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() // 为Widget对象的数据成员分配内存
: pImpl(new Impl)
{}
Widget::~Widget() // 回收数据成员的内存
{ delete pImpl; }
- 这里仍然依赖原有的数据成员的头文件,但把它们从头文件转移到了实现文件
- 上述是C++98的代码,现在使用std::unique_ptr替代原始指针,不再使用析构函数的释放
// file "widget.h"
#include <memory>
class Widget {
public:
Widget();
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
// file "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>())
{}
// file "main.cpp"
#include "widget.h"
Widget w;
// 诊断信息
1>c:\program files (x86)\microsoft visual studio\2017\community\vc\tools\msvc\14.15.26726\include\memory(2082): error C2338: can't delete an incomplete type
1>c:\program files (x86)\microsoft visual studio\2017\community\vc\tools\msvc\14.15.26726\include\memory(2084): warning C4150: 删除指向不完整“Widget::Impl”类型的指针;没有调用析构函数
- 原因在于在析构pImpl时,会在std::unique_ptr内部调用默认删除器delete,但delete之前会用static_assert确保指针指向的不是非完整类型
<memory>中的源码
- 因此解决方法就是保证在生成析构std::unique_ptr的代码处,Widget::Impl是完整类型,即让析构函数的函数体位于widget.cpp中的Widget::Impl定义之后。可见最终还是需要写上析构函数
// file "widget.h"
#include <memory>
class Widget {
public:
Widget();
~Widget(); // 只声明
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
// file "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() // 析构函数的实现位于Widget::Impl的实现之后
{}
// file "main.cpp"
#include "widget.h"
Widget w; // OK
- 如果还想强调,声明析构函数的唯一理由是希望让定义出现在实现文件中,可以用=default来表示定义
// file "widget.cpp"
Widget::~Widget() = default;
- 使用pimpl的类自然支持移动操作,默认生成的移动操作完全符合预期,但声明析构函数会阻止生成移动操作,因此可能会想到如下解决方法
// file "widget.h"
class Widget {
public:
Widget();
~Widget();
Widget(Widget&& rhs) = default; // right idea,
Widget& operator=(Widget&& rhs) = default; // wrong code!
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
- 上述代码导致的问题和没有声明析构函数类似,移动赋值运算符要在重新赋值前析构pImpl,此时必须确保pImpl指向的对象不是非完整类型。移动构造函数的理由略有不同但殊途同归,编译器会在移动构造函数内抛出异常的事件中生成析构pImpl的代码。移动操作与析构函数的解决方法同理,在头文件中只声明,在实现文件中定义在Widget::Impl之后
// file "widget.h"
#include <memory>
class Widget {
public:
Widget();
~Widget(); // 只声明
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
// file "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(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;
- 如果Gadget和std::string、std::vector一样可以拷贝,则Widget也应该可以拷贝,而编译器不会为std::unique_ptr这类move-only类型生成拷贝操作,即使可以生成也只能拷贝指针(浅拷贝),因此需要自己编写拷贝指针指向内容(深拷贝)的拷贝操作
// file "widget.h"
#include <memory>
class Widget {
public:
Widget();
~Widget(); // 只声明
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
// file "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(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;
Widget::Widget(const Widget& rhs)
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}
Widget& Widget::operator=(const Widget& rhs) // copy operator=
{
*pImpl = *rhs.pImpl;
return *this;
}
- 上述选用的是std::unique_ptr,因为在对象内部的pImpl指针拥有Widget::Impl对象的所有权。如果换成std::shared_ptr,则本条款的建议(将特殊成员定义在实现文件中)不再适用,这样也无需声明析构函数,而不声明析构函数就不会阻止编译器生成移动操作,因此std::shared_ptr版本的pimpl如下
// file "widget.h"
#include <memory>
class Widget {
public:
Widget();
…
private:
struct Impl;
std::shared_ptr<Impl> pImpl;
};
// file "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_shared<Impl>())
{}
// file "main.cpp"
Widget w1; // 默认构造w1
auto w2(std::move(w1)); // 移动构造w2
w1 = std::move(w2); // 移动赋值w1
- 两种智能指针实现pimpl手法的区别是,std::unique_ptr的删除器是类型的一部分,于是编译器会生成尺寸更小的运行期数据结构,运行更快速,但为了使指向类型不是非完整类型,必须在实现文件中指定出特殊成员函数,std::shared_ptr不需要指定这些成员,但其删除器不属于智能指针的一部分,这就造成了更大尺寸的数据结构和更慢一些的运行代码。但对于pimpl手法来说,不需要权衡两者特性,因为Widget和Widget::Impl之间是专属所有权的关系,std::unique_ptr最为合适。但如果在需要共享所有权的特殊情况下,选用std::shared_ptr更为合适,还避免了伴随std::unique_ptr的繁琐写法
网友评论