本文是笔者在学习C++的Impl模式过程中的个人理解,本篇文章主要干活内容源自参考文章。

本文参考文章如下:

C++ IMPL 模式解析(上)

C++ IMPL 模式解析(下)

为什么使用Impl模式?

学习设计模式一定不能离开具体的应用场景(该场景来自参考文章C++ IMPL 模式解析(上))。

试想在这样一个应用场景下,我们使用传统的C++设计思路开发了一个工具库,然后将这个工具库对应的头文件和库文件交给用户后供用户使用。也就是下面这种形式。

(此处以网络工具库为例)

网络工具库V1版本头文件:

// network库头文件(network.h)
#pragma once
#include <vector>
#include <cstdint>

class NetworkV1 {
public:
	int send_all();
	int recv_all();
private:
	int m_socket;
	std::vector<std::uint8_t> m_buffer;
};

网络工具库V1版本源文件:

// network库源文件(network.cpp)
#include "network.h"

int NetworkV1::send_all() {
	// 将缓冲区m_buffer中的内容通过m_socket发送出去
	// return 成功发送的字节数
}

int NetworkV1::recv_all() {
	// 通过m_socket将内容读取到m_buffer中
	// return 成功读取的字节数
}

用户在自己的程序中包含了头文件并链接库之后,就可以成功使用该网络工具库。但是用户通过头文件查看类的私有成员变量  m_socket  和  m_buffer  之后,很容易就能猜到这个库的实现方式。

这有时并不是我们希望发生的事情。

除此之外,如果在这个网络工具库的维护过程中,我们通过   增加或修改该类的私有成员变量   的方式优化了某些函数的实现细节(例如,通过在network类中增加一些私有成员变量的方式加快send和recv的速度或实现某种定制化业务),那么用户在使用这个库时,不光需要重新链接我们优化后的网络工具库文件,还需要修改头文件,增加或修改对应的私有成员变量。

这显然是不利于维护的。

为了避免库实现细节的泄漏。

避免修改库源文件时,对应的头文件也要发生不必要的修改。

可以使用Impl模式。

什么是Impl模式?

(以下内容来自C++ IMPL 模式解析(上)和C++ IMPL 模式解析(下),笔者仅在此基础上增加部分个人理解)

Impl是implement的缩写,意思为实现。

在C++中,使用Impl模式,并不仅仅是为了将类的声明和定义放在不同的文件中,更重要的是隐藏类的实现细节和增加库的可维护性。

第一种Impl模式的实现方式

以下给出一个通过Impl模式实现的网络工具库。

网络工具库V2版本头文件:

// network库头文件(network.h)
#pragma once
#include <memory>

class NetworkV2 {
public:
	NetworkV2();
	int send_all();
	int recv_all();
private:

	// 声明一个内部类封装NetworkV2的所有私有成员变量  (相当于类的前向声明,并不会通过友元的方式泄漏NetworkV2Impl实现)
	struct NetworkV2Impl; 
	std::shared_ptr<NetworkV2Impl> m_impl; // 指向该内部类的实现
};

网络工具库V2版本源文件:

// network库源文件(network.cpp)
#include "network2.h"
#include <vector>
#include <cstdint>
#include <iostream>
#include <string>

struct NetworkV2::NetworkV2Impl {
    // 封装NetworkV2所有的私有成员
	int m_socket;
	std::string m_buffer;
};


NetworkV2::NetworkV2() {
	m_impl = std::make_shared<NetworkV2::NetworkV2Impl>();
}


int NetworkV2::send_all() {
	m_impl->m_socket = 624;
	m_impl->m_buffer = "hello world";

	std::cout << "通过 " << m_impl->m_socket << " 将 " << m_impl->m_buffer << " 发送出去\n";

	return 1;
}

int NetworkV2::recv_all() {
	m_impl->m_socket = 624;
	m_impl->m_buffer = "hello world";

	std::cout << "通过 " << m_impl->m_socket << " 将消息读取到buffer中\n";

	return 1;
}

使用方式:

#include <iostream>
#include "network2.h"

int main() {

	NetworkV2 network;

	network.send_all();
	network.recv_all();

	return 0;
}

运行结果:

​

可以发现,在通过Impl模式实现的V2版本网络工具库  NetworkV2  中,所有的私有成员变量封装到了一个内部类  NetworkV2Impl  中。  NetworkV2  只需要持有一个指向  NetworkV2Impl  类的指针即可访问自己的全部私有变量。

在  NetworkV2  的构造函数中,实例化  NetworkV2Impl  类对象,也就相当于为自己所有的私有成员变量分配了空间。

NetworkV2  只需要持有指向  NetworkV2Impl  的指针即可访问自己所有的私有成员变量。

可以发现,在使用了Impl模式之后,只要我们不增加或删除  NetworkV2  中的函数,不修改函数声明,就不需要对头文件进行修改,甚至  NetworkV2  的私有成员函数同样可以封装到  NetworkV2Impl  中。

第二种Impl模式的实现方式

在C++ IMPL 模式解析(上)中给出了第二种Impl模式的实现方式,在这种实现方式中完全消除了网络工具库类的声明中的私有成员部分。

网络工具库V3版本头文件:

// network库头文件(network.h)
#pragma once
#include <memory>

class NetworkV3 {
public:

	virtual int send_all() = 0;
	virtual int recv_all() = 0;

	static NetworkV3* New();
	static void Delete(NetworkV3* network);
};

网络工具库V3版本头文件:

#include "network3.h"
#include <string>
#include <iostream>

class NetworkV3Impl : public NetworkV3{
public:
	int send_all() override;
	int recv_all() override;
private:
	int m_socket = 625;
	std::string m_buffer = "hello world";
};

int NetworkV3Impl::send_all() {
	std::cout << "通过 " << this->m_socket << " 将buffer中的内容 " << this->m_buffer << "发送出去\n";
	return m_buffer.size();
}


int NetworkV3Impl::recv_all() {
	std::cout << "通过 " << this->m_socket << " 将信息接收存储到buffer中\n";
	return m_buffer.size();
}


NetworkV3* NetworkV3::New() {
	return (new NetworkV3Impl());
}


void NetworkV3::Delete(NetworkV3* network) {
	delete (NetworkV3Impl*)network;
}

使用方式:

#include <iostream>
#include "network3.h"

int main() {

	NetworkV3* network = NetworkV3::New();

	network->send_all();
	network->recv_all();

	NetworkV3::Delete(network);

	return 0;
}

运行结果:

可以发现,在这种实现方式中,头文件中只有抽象接口类  NetworkV3  的声明,在  NetworkV3  中以纯虚函数的形式声明了该库暴露在外的所有接口。

而这些纯虚函数接口的实现在派生类  NetworkV3Impl  中进行定义,巧妙的地方在于,这个派生类NetworkV3Impl  的声明和定义都在  NetworkV3  的源文件中。在编译成库之后,外界完全无法获取到源文件中的  NetworkV3Impl  的声明和定义,

抽象接口类  NetworkV3  中提供了两个静态成员函数  New  和  Delete  ,用于创建和释放NetworkV3Impl类对象。通过在  New  函数中,将基类  NetworkV3  类型指针指向派生类  NetworkV3Impl  类型对象的形式实现多态,有了多态之后,就能实现对该网络工具库中  send_all  和  recv_all  具体实现版本的调用。

这个版本的Impl实现方式会导致什么问题?

1.毋庸置疑,引入虚函数后,调用接口函数时一定会产生虚函数开销。即虚函数表导致的内存开销和运行时通过虚函数表确定虚函数地址的CPU开销。

2.二进制不兼容。

在  NetworkV3  版本的实现方式中,一旦交换了两个虚函数  send_all  和  recv_all  的声明顺序(即先声明  recv_all  函数再声明  send_all  函数),会导致虚函数表发生变化。

而虚函数表是存在于对象中的,一旦虚函数表发生变化,也就意味着该网络工具类型  NetworkV3Impl  所实例化出的对象的内存结构发生了变化。

如果我们在使用了  NetworkV3  网络工具库的主程序中使用了这个NetworkV3Impl类型的对象,此时就必须重新对主程序进行编译,以生成具有新的内存结构的NetworkV3Impl类型的对象(此处也就是生成具有新的虚函数表的NetworkV3Impl类型的对象)。

什么是二进制兼容,什么是二进制不兼容的详细解释请参考文章:C++ IMPL 模式解析(下)

第三种Impl模式的实现方式

那么如何解决二进制不兼容的问题呢?

对于NetworkV3版本的Impl实现方式中,修改头文件中虚函数的声明顺序会导致虚函数表发生变化,那么我们不使用虚函数就可以了,改为使用普通成员函数。(注意:修改普通成员函数的函数名,函数签名依然会导致二进制不兼容)

网络工具库V4版本头文件

#pragma once
class NetworkV4 {
public:
	int send_all();
	int recv_all();

	static NetworkV4* New();
	static void Delete(NetworkV4* network);

// 必须是protected
// 如果是public,会在类外创建出对象,不用New创建对象执行send_all和recv_all函数会发生访问越界
// 如果是private,派生类无法调用基类的构造函数,无法实例化基类子对象
protected:
	NetworkV4();
	~NetworkV4();
};

网络工具库V4版本源文件

#include "network4.h"
#include <string>
#include <iostream>

class NetworkV4Impl : public NetworkV4 {
	friend class NetworkV4;
private:
	int m_socket = 626;
	std::string m_buffer = "Hi NetworkV4Impl";

};

NetworkV4::NetworkV4() {

}

NetworkV4::~NetworkV4() {

}


NetworkV4* NetworkV4::New() {
	return new NetworkV4Impl();
}
void NetworkV4::Delete(NetworkV4* network) {
	delete ((NetworkV4Impl*)network);
}

int NetworkV4::send_all() {
	// 当send_all函数可以被调用时,this指针一定指向了派生类对象
	NetworkV4Impl* network = (NetworkV4Impl*)(this);
	std::cout << "通过 " << network->m_socket << " 将buffer中的内容 " << network->m_buffer << " 发送出去\n";
	return network->m_buffer.size();
}


int NetworkV4::recv_all() {
	// 当recv_all函数可以被调用时,this指针一定指向了派生类对象
	NetworkV4Impl* network = (NetworkV4Impl*)(this);
	std::cout << "通过 " << network->m_socket << "将数据接收到buffer中\n";
	return network->m_buffer.size();
}

使用方法

#include <iostream>
#include "network4.h"

int main() {

	NetworkV4* network = NetworkV4::New();

	network->send_all();
	network->recv_all();

	NetworkV4::Delete(network);

	return 0;
}

打印结果

在V4版本的网络工具库  NetworkV4  中,通过将基类  NetworkV4  声明为派生类  NetworkV4Impl  的友元的方式,来利用派生类  NetworkV4Impl  的私有成员变量作为基类  NetworkV4  的私有成员变量。

同时,将基类的构造函数和析构函数设为保护类型,避免在类外创建出基类对象。转而提供  New  和  Delete  作为基类的实例化方法。

在  New  函数中,创建出的实际上是派生类对象,而返回的是基类指针,形成基类指针指向派生类对象的效果。

只不过因为并没有虚函数,因此并不会形成多态。因此在通过基类指针调用  send_all  和  recv_all  函数时,调用的实际上是基类的 send_all  和  recv_all  函数。

而在  send_all  和  recv_all  函数中,第一步是先将基类指针转换成派生类指针,扩大指针可以访问的内存的大小,以达到通过友元访问派生类私有成员,利用其作为基类的私有成员的效果,因为我们提前确定基类指针一定是指向派生类的,因此访问一定不会越界。

写在最后

本文描述了C++中Impl模式的三种实现方式,三种实现方式各有优劣。

笔者本人拙见,第一种实现方式最简单,也足够使用(也许是因为笔者本人工程还不够)。

第二种实现方式在第一种的基础上完全隐藏了私有成员,但是增加了虚函数开销,容易引发二进制不兼容的问题。

第三种实现方式则在第二种的基础上,不光隐藏了所有私有成员,还避免了虚函数开销,也降低了引发二进制不兼容问题的可能(只是降低了,并没有消除)。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐