编译防火墙--C++的Pimpl惯用法解析
编译防火墙–C++的Pimpl惯用法解析
原文链接:https://blog.csdn.net/lihao21/article/details/47610309
Pimpl(pointer to implementation, 指向实现的指针)是一种常用的,用来对“类的接口与实现”进行解耦的方法。这个技巧可以避免在头文件中暴露私有细节(见下图 1),因此是促进 API 接口与实现保持完全分离的重要机制。但是 Pimpl 并不是严格意义上的设计模式(它是受制于 C++ 语言特定限制的变通方案),这种惯用法可以看作桥接设计模式的一种特例。
图 1: Pimpl 惯用法:这里的公有类拥有一个私有指针,该指针指向隐藏的实现类
在类中使用 Pimpl 惯用法,具有如下优点:
- 降低耦合
- 信息隐藏
- 降低编译依赖,提高编译速度
- 接口与实现分离
为了实现 Pimpl,我们先来看一种普通的类的设计方法。
假如我们要设计一书籍类 Book,Book 包含目录属性,并提供打印书籍信息的对外接口,Book 设计如下:
class Book
{
public:
void print();
private:
std::string m_Contents;
};
对 Book 的使用者来说,他只需要知道print() 接口,便可以使用Book类,看起来一切都很美好。
然而,当某一天,发现Book需要增加一标题属性,对Book类的修改如下:
class Book
{
public:
void print();
private:
std::string m_Contents;
std::string m_Title;
};
虽然使用print()接口仍然可以直接输出书籍的信息,但是Book 类的使用者却不得不重新编译所有包含Book类头文件的代码。
为了隐藏Book类的实现细节,实现接口与实现的真正分离,可以使用 Pimpl 方法。
我们依然对Book类提供相同的接口,但Book类中不再包含原有的数据成员,其所有操作都由BookImpl类实现。
/* public.h */
#ifndef PUBLIC_H_INCLUDED
#define PUBLIC_H_INCLUDED
class Book
{
public:
Book();
~Book();
void print();
private:
class BookImpl; // Book 实现类的前置声明
BookImpl* pimpl;
};
#endif
在对外的头文件public.h 中,只包含Book 类的外部接口,将真正的实现细节被封装到BookImpl类。为了不对外暴露BookImpl类,将其声明为Book类的内嵌类,并声明为private。
BookImpl类的头文件如下。
/* private.h */
#ifndef PRIVATE_H_INCLUDED
#define PRIVATE_H_INCLUDED
#include "public.h"
#include <iostream>
class Book::BookImpl
{
public:
void print();
private:
std::string m_Contents;
std::string m_Title;
};
#endif
private.h并不需要提供给Book类的使用者,因此,如果往后需要重新设计书籍类的属性,外界对此一无所知,从而保持接口的不变性,并减少了文件之间的编译依赖关系。
/* book.cpp */
#include "private.h" // 我们需要调用 BookImpl 类的成员函数,
// 所以要包含 BookImpl 的定义头文件
#include "public.h" // 我们正在实现 Book 类,所以要包含 Book 类
// 的头文件
Book::Book()
{
pimpl = new BookImpl();
}
Book::~Book()
{
delete pimpl;
}
void Book::print()
{
pimpl->print();
}
/* BookImpl 类的实现函数 */
void Book::BookImpl::print()
{
std::cout << "print from BookImpl" << std::endl;
}
使用Book 类的接口的方法如下:
/* main.cpp */
#include "public.h"
int main()
{
Book book;
book.print();
return 0;
}
像Book类这样使用 Pimpl 的类,往往被称为 handle class,BookImpl类作为实现类,被称为 implementation class。
为简单实现起见,Book类省略了复制构造函数和复制赋值函数。在实际应用中,一般有两种可选方案解决Book的复制和赋值的语义问题。
(1)禁止复制类
如果不打算让用户创建对象的副本,那么可以将对象声明为不可复制的。可以将复制构造函数和复制赋值函数声明为私有的,这样在复制或者赋值时就会产生编译错误。
以下代码通过声明私有的复制构造函数和复制赋值函数来使得对象不可以复制,不需要修改相关的.cpp文件。
/* public.h */
#ifndef PUBLIC_H_INCLUDED
#define PUBLIC_H_INCLUDED
class Book
{
public:
Book();
~Book();
void print();
private:
// 禁止复制类
Book(const Book&);
const Book &operator = (const Book &);
class BookImpl; // Book 实现类的前置声明
BookImpl* pimpl;
};
#endif
(2)显示定义复制语义
如果希望用户能够复制采用 Pimpl 的对象,就应该声明并定义自己的复制构造函数和复制赋值函数。它们可以执行对象的深复制,即创建对象的副本,而非复制指针。
Pimpl 惯用法最主要的缺点是,必须为你创建的每个对象分配并释放实现对象,这使对象增加了一个指针,handle class 成员函数的每次调用都必须通过implementation class,这会增加一层间接性。在实际中你需要对这些开销进行权衡。
另外,采用了 Pimpl 的对象,编译器将不再能够捕获 const 方法中对成员变量的修改。这是由于成员变量现在存在于独立的对象中,编译器仅检查const方法中的pimpl 指针是否发生变化,而不会检查 pimpl 指向的任何成员。
可以使用下图 2 来说明 Pimpl 方法在以上Book类设计的作用:
图2: Pimpl 作为编译防火墙
由于 Pimpl 解除了接口与实现之间的耦合关系,从而降低文件间的编译依赖关系,Pimpl 也因此常被称为“编译期防火墙“ 。
本文的示例代码可以通过以下链接下载: https://github.com/haozlee/pimpl (如果觉得本文对您有帮助,麻烦动手点个 star 哈)。
下载源代码后,编译步骤:
g++ -c book.cpp
g++ -o pimpl main.cpp book.o
执行:
./pimpl
输出:
print from BookImpl
设计模式之pimpl惯用法C++版
https://blog.csdn.net/zhouzhenhe2008/article/details/74047881
类D使用类A,类B作为成员变量,
以下是头文件
pattern.h
#ifndef PATTERN_H
#define PATTERN_H
class A
{
public:
A(int m);
A();
void print();
private:
int m_a;
};
class B
{
public:
B(int m);
B();
void print();
private:
int m_b;
};
class D
{
public:
D(int a,int b);
void print();
private:
A m_classA;
B m_classB;
};
#endif
pattern.cpp
#include <iostream>
#include "pattern.h"
A::A(int m)
{
m_a = m;
}
A::A()
{
m_a = 0;
}
void A::print()
{
std::cout<<"A "<<m_a<<std::endl;
}
B::B(int m)
{
m_b = m;
}
B::B()
{
m_b =0 ;
}
void B::print()
{
std::cout<<"B "<<m_b<<std::endl;
}
D::D(int a,int b):m_classA(a),m_classB(b)
{
}
void D::print()
{
m_classA.print();
m_classB.print();
}
int main()
{
D classD(1,2);
classD.print();
}
A、B与D强耦合。非常不利于后期维护
#ifndef PATTERN2_H
#define PATTERN2_H
class Impl;
class D
{
public:
D(int a,int b);
void print();
~D();
private:
Impl *m_Impl;
};
#endif
引入impl指针
pattern2.h
pattern2.cpp
#include <iostream>
#include "pattern2.h"
class A
{
public:
A(int m);
A();
void set(int m);
void print();
private:
int m_a;
};
class B
{
public:
B(int m);
B();
void set(int m);
void print();
private:
int m_b;
};
A::A(int m)
{
m_a = m;
}
A::A()
{
m_a = 0;
}
void A::set(int m)
{
m_a = m;
}
void A::print()
{
std::cout<<"A "<<m_a<<std::endl;
}
B::B(int m)
{
m_b = m;
}
B::B()
{
m_b =0 ;
}
void B::set(int m)
{
m_b = m;
}
class Impl
{
public:
A m_classA;
B m_classB;
};
void B::print()
{
std::cout<<"B "<<m_b<<std::endl;
}
D::D(int a,int b):m_Impl(new Impl())
{
m_Impl->m_classA.set(a);
m_Impl->m_classB.set(b);
}
D::~D()
{
if(m_Impl)
{
delete m_Impl;
m_Impl = NULL;
}
}
void D::print()
{
m_Impl->m_classA.print();
m_Impl->m_classB.print();
}
int main()
{
D classD(2,1);
classD.print();
}
通过Impl *m_Impl;大大降低了A、B与D类的耦合,并且A类和B类的内部的修改不会导致包含D类头文件的其他模块重新编译。
跟我学c++中级篇—pimpl
原文链接:https://blog.csdn.net/fpcc/article/details/107589374
一、何方神圣—pimpl
Private Implementation,私有化实现。在c++中,由于语言本身的限制,没有纯粹的接口定义。这就导致了在接口的使用上很多c++的人员都是随心而动。有用抽象类的纯虚函数的,有直接用C类型的接口的。有干脆提供接口类的…不一而足吧。根据实际情况,实事求是的选择才是一个好的标准。
在c++中,大量的头文件的安全包含,本身就是一个重要的问题,普通的重复定义这都是小问题。一些莫名的,甚至头文件的顺序都引起的“血案”也是经常发生的。正所谓,代码量大了,啥情况都遇得到。而使用PIMPL机制,可以优化减少头文件中出现类似问题的风险。同时,在c++系统中还有一个重要问题,比如一个头文件被广泛包含,如果修改其中一些内容,可能导致大规模的代码重编译。但是如果采用PIMPL机制就会大幅降低这种情况, 看下面的例子:
//a.h
class Content
{
public:
void Test();
private:
int d;
};
//b.h
#include "a.h"
class UseContent1
{
public:
void UseContentFunc();
private:
Content c;
};
在上面的例子中,UseContent1可能会被上层更广泛的调用,这样,一旦Content增加了私有和保护这些本来不会影响对外接口的的代码时,仍然会引起上述的大规模的编译单元的重构。这个代价是很大的。但是,假如做一下下面的代码修改:
//b.h
//#include "a.h"
class Content;
class UseContent
{
public:
void UseContentFunc();
private:
Content* c; //std::shared_ptr<Content> c;更好
};
你就会发现,在这个文件里,已经看不到Content的头文件了,这样,如果不对公有接口进行修改,就不会出现上述的情况。指针化,同时将实现移动到CPP文件中,就实现了降低耦合,提高编译速度,隐藏实现细节的好处。在小的工程里可能这样做的意义确实并不是多大,但是如果工程量大到编译需要小时以上时,他的意义就体现出来。
这也是软件编程原则里,提到的接口只能增加,不能修改的原因。因为如果只增加,老版本就可以正常使用,而新版本使用新接口,实现很好的版本兼容。
从本质上讲,PIMPL有些类似于设计模式中的桥接模式,也就是常说的,接口隔离变化。
头文件的递归包含,编译器类似“惊群”一样的重编译源码,都可以利用PIMPL进行有效安全的降低风险,当然,PIMPL也不是万能的,还是要根据情况来处理。
二、应用场景和注意点
主要的应用场景有下面三种:
1、代码解耦
这个很容易理解,通过指针来实现不同的类之间的互相隐藏,非常容易实现代码层次上的隔离,从而实现降低耦合。
2、降低编译依赖和头文件的嵌套层级,降低头文件应用风险
正如上面所述,减少了在头文件中,对其它头文件的包含,如此下去,就会有效的减少头文件的嵌套层级。同样,头文件的减少,就意味着编译的互相依赖性降低。大家在编程时,可能经常会遇到XXX.h无法发现的编译错误,完美的状态下,代码文件中除了自己的头文件不包含其它任何其它头文件,出了错也极其容易定位。
3、隔离接口变化,稳定接口实现版本兼容
外界只看到指针,而看不到指针具体实现,而具体的实现由接口来提供,这样,接口稳定,具体的指针实现可以灵活掌握。
使用pimpl,对于新手来说,一个最常见的问题就是“不完整类型定义”,很多新手在遇到这种问题,特别是代码量大的情况下,可能瞬间就懵了。在上面例子中,就可能隐藏着类似的问题。看下面的代码:
//b.cpp
#include "b.h"
void UseContent::UseContentFunc()
{
c = new Content();
c->Test();
}
这就会出现不完整类型定义?为什么出现呢。因为编译器看不到c,即Content这个类的定义。也就是说,编译器只知道有c,但不知道c里面到底有没有Test,不清楚。其实编译器都考虑不到Test就报错了,因为它还看不到c这个对象的构造函数。编译器就是这么傻,为啥看不到?因为没包含头文件,现在在上面的代码中增加头文件包含,就OK了。
//b.cpp
#include "a.h"
#include "b.h"
void UseContent::UseContentFunc()
{
c->Test();
}
需要说明的是,pimpl的一个容易被人忽略的问题,那就是不同的编译器和不同的编译级别情况下,可能产生的问题有不同。这个就需要编程的实践经验了,但是其基本的原理都是上面所说。
另外需要注意的是,如果不使用智能指针,要注意指针的释放问题。
三、总结
通过上面分析可能看出来,pimpl的优势还是比较明显的,但是也不代表这种方式就是完美的,引入这个机制,必然增加设计的复杂性,相对来说降低了代码的易读性和维护性。这也是本文开头总是提到要实事求是,根据实际情况来引入各种设计的原因。既不能因噎食,更不能刻板教条。这其实是编程思想的一部分了。