【C++】PIMPL模式
一、PIMPL模式引入
PIMPL是指pointer to implementation,又称作“编译防火墙”。它通过将类B放置在单独的类A中,使用B的不透明指针进行访问实现,从而隐藏了A类的实现细节。是实现“将文件间的编译依存关系降至最低”的方法之一。
实例:
常规实现book类
book.h
#pragma once
#include <iostream>
class book
{
public:
book(std::string name,double price);
~book();
void display();
void set_price(double price);
private:
std::string book_name;
double book_price;
};
book.cpp
#include "book.h"
book::book(std::string name, double price) :book_name(name), book_price(price)
{
}
book::~book()
{
}
void
book::display()
{
std::cout << "book name:" << book_name << std::endl;
std::cout << "book price:" << book_price << std::endl;
}
void
book::set_price(double price)
{
book_price = price;
}
main.cpp
#include "book.h"
int main()
{
book b("Grimm's Fairy Tales",49.9);
b.display();
b.set_price(45.9);
b.display();
}
常规实现的缺点:
①头文件暴露了私有成员
②接口和实现耦合,存在严重编译依赖性(当另一个库使用了这个库,而book库实现变了,头文件就会变,而头文件一旦变动,所有使用了这个库的程序都要重新编译,这会花费很多编译时间。)
PIMPL模式实现
book.h
#pragma once
#include <iostream>
class impl;
class book
{
public:
book();
book(std::string , double );
~book();
/*私有成员为指针,禁止使用C++默认浅拷贝*/
book(const book&) = delete;
book& operator=(const book&) = delete;
//移动拷贝构造
book(book&&);
book& operator=(book&&);
void display();
private:
std::unique_ptr<impl> pImpl;
};
book.cpp
#include "book.h"
class impl
{
public:
impl() {}
impl(std::string name,double price);
~impl();
void display();
void set_price(double);
double get_price();
private:
std::string book_name;
double book_price;
};
impl::impl(std::string name,double price):book_name(name),book_price(price)
{
}
impl::~impl()
{
}
void
impl::display()
{
std::cout << "book name:" << book_name << std::endl;
std::cout << "book price:" << book_price << std::endl;
}
void
impl::set_price(double price)
{
book_price = price;
}
double
impl::get_price()
{
return book_price;
}
book::book() :pImpl(std::make_unique<impl>())
{
}
book::book(std::string name,double price) : pImpl{ std::make_unique<impl>(name,price) }
{
}
book::~book() = default;
book& book::operator=(book&&) = default;
void
book::display()
{
pImpl->display();
}
main.cpp
#include "book.h"
int main()
{
book b("Grimm's Fairy Tales",49.9);
b.display();
}
pimpl实现的特点:
① 在头文件中只有一个私有成员变量pImpl,用户无法从头文件获取到更多的信息,起到了一个信息隐藏的作用,同时访问book的display()方法,也无法直接查看到其具体实现,成功隐藏实现;
② 接口和实现形成了一个松耦合,降低了编译依赖,当需要改动方法book的display(),例如:删除输出价格,只需要修改impl的display()方法即可,book.h头文件没有发生变动,不会重新编译。
二、PIMPL模式优点与目的
1、信息隐藏
私有成员完全可以隐藏在共有接口之外,尤其对于闭源API的设计尤其的适合。同时,很多代码会应用平台依赖相关的宏控制,这些琐碎的东西也完全可以隐藏在实现类当中,给用户一个简洁明了的使用接口。
2、加速编译
这通常是用pImpl手法的最重要的收益,称之为编译防火墙(compilation firewall),主要是阻断了类的接口和类的实现两者的编译依赖性。这样,类用户不需要额外include不必要的头文件,同时实现类的成员可以随意变更,而公有类的使用者不需要重新编译。
3、更好的二进制兼容性
通常对一个类的修改,会影响到类的大小、对象的表示和布局等信息,那么任何该类的用户都需要重新编译才行。而且即使更新的是外部不可访问的private部分,虽然从访问性来说此时只有类成员和友元能否访问类的私有部分,但是私有部分的修改也会影响到类使用者的行为,这也迫使类的使用者需要重新编译。
而对于使用pImpl手法,如果实现变更被限制在实现类中,那公有类只持有一个实现类的指针,所以实现做出重大变更的情况下,pImpl也能够保证良好的二进制兼容性,这是pImpl的精髓所在。
4、惰性分配
实现类可以做到按需分配或者实际使用时候再分配,从而节省资源提高响应。
三、PIMPL模式注意事项
1、资源管理
尽可能避免使用原始指针类创建和释放(delete)实现类对象(pimpl对象),使用 boost::scoped_ptr 或 std::unique_ptr 来管理实现类对象,而且如果确实需要实现类共享,可以使用 std::shared_ptr来管理,但是scoped_ptr、unique_ptr实现上要比shared_ptr高效的多。
如果使用智能指针unique_ptr管理实现类对象,则需要手动在实现文件中定义共有类的析构函数。这是因为虽然unique_ptr和shared_ptr都可以在类型不完全的情况下定义其智能指针,但是unique_ptr其析构函数则需要具有持有类型的完全定义,而shared_ptr比较智能则没有这个限制。
delete不完整类型的指针问题:
对于foo,编译器无法知道X类的完整类型(即无法感知其成员函数和变量、内存布局等),这导致foo无法确定两件事:
① X是否有non-trivial析构函数(非平凡析构函数)
② X是否自定义operator delete函数
在不确定这两件事的情况下,编译器只能按最普通的方式处理delete:
①没有调用任何析构函数
②调用全局的operator delete (直接释放内存)
而上述book实例,使用std::unique_ptr<impl> Impl,book类对象在作用域结束时,编译器会自动生成一个析构函数,在析构函数中,impl类是不完整定义,编译器没有调用其析构函数。
所以要解决这个问题就必须必须手动实现~book,这样就能在book.cpp中查看到impl的完整定义,就能调用其析构函数并释放对象内存。
2、拷贝语义
pImpl最需要关注的就是共有类的复制语义,因为实现类是以指针的方式作为共有类的一个成员,而默认C++生成的拷贝操作只会执行对象的浅拷贝,这显然违背了pImpl的原本意图,除非是真的想要底层共享一个实现对象。针对这个问题,解决方式有:
a. 禁止复制操作 :将所有的复制操作定义为private的,或者继承 boost::noncopyable,或者在新标准中将这些复制操作定义为delete;
b. 显式定义复制语义:创建新的实现类对象,执行深拷贝。此处需要记住三五法则,要么不定义拷贝、移动操作符,要定义就需要将他们全部重新定义。
- 当定义一个类时,我们显式地或隐式地指定了此类型的对象在拷贝、赋值和销毁时做什么。一个类通过定义三种特殊的成员函数来控制这些操作,分别是拷贝构造函数、赋值运算符和析构函数。
- 拷贝构造函数定义了当用同类型的另一个对象初始化新对象时做什么,赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,析构函数定义了此类型的对象销毁时做什么。我们将这些操作称为拷贝控制操作。
- 由于拷贝操作是由三个特殊的成员函数来完成的,所以我们称此为“C++三法则”。在较新的C++11标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数,所以又称为“C++五法则”。也就是说,“三法则”是针对较旧的C++89标准说的,“五法则”是针对较新的C++11标准说的。为了统一称呼,后来人们干把它叫做“C++ 三/五法则”。
3、impl对共有类的反向引用(思考点)
实现类中的私有成员如果需要访问公有类的公共、保护的成员,就必须要能够引用到公有类对象,实现其手段有:
a. impl持有一个对公有类对象的指针或者引用。虽然方便但是往往会有问题:如果持有的是引用,则拷贝赋值就难以实现;如果持有的是指针,则需要小心指针有效性的同步负担(比如移动操作)。
b. 推荐的方式,是impl中的这些函数都增加一个对公有类的引用或者指针,那么其调用方法类似于:
1 |
|
4、pimpl的缺点
a. PIMPL模式需要在调用和实现之间插入了一个指针,公有类在访问私有成员的时候都需要增加pImpl->前缀的方式,使用、阅读和调试都可能有所不便;
b. pImpl对拷贝操作比较敏感,要么禁止拷贝操作,要么就需要自定义拷贝操作;
c. 编译器将不再能够捕获const方法中对成员变量的修改,因为私有成员变量已经从公有类脱离到了实现类当中了,公有类的const只能保护指针值本身是否改变,而不再能进一步保护其所指向的数据。如果要达到类似的保护效果,可以使用std::experimental::propagate_const
技术。
四、 类的哪些部分可以放入impl对象?
所有的private members? 并不是。
① 虚函数。即使虚函数是私有的,也无法在Pimpl中隐藏虚拟成员函数。 如果虚函数覆盖了从基类继承的虚函数,则它必须出现在实际的派生类中。
② 如果Pimpl中的函数需要依次使用可见函数,则它们可能需要指向可见对象的“后向指针”,这又增加了一个间接层次。 通常最好的折衷方法是放入私有成员,并仅将那些需要由私有函数调用的非私有函数放入Pimpl。