1.提出问题:正常工作代码中,异常逻辑需要耗费大量时间,异常逻辑如果没有写好,或者没写,就会造成封装的不安全性,导致程序崩溃。并且会导致程序的使用复杂度变高,编写复杂度变高。
如下面的代码中,forward函数有个问题,如果没有加载模型的时候就进行forward该怎么办。同样也会出现在load_model这个问题上,如果我已经load过模型怎么办,所以就要写异常逻辑。或许你会决定这个context_本身就会被覆盖掉,不用destroy也是可以的。但现实工程中通常不是这样的,现实工程中,destroy通常包含线程的退出,等待线程的退出和结束 ,做很多释放的工作,所以不是一个简单覆盖就结束的事情,工作量还是挺多的。
#include
#include
#include
#include
#include
#include
#include
#include //带条件的变量
#include
#include
using namespace std;
//RAII + 接口模式
//提出问题
//正常工作代码中,异常逻辑需要耗费大量时间
//异常逻辑如果没有写好,或者没写,就会造成封装的不安全性,导致程序崩溃
//并且会导致程序的使用复杂度变高,编写复杂度变高。
class Infer{
public:
//加载模型
bool load_model(const string& file){
//异常逻辑代码
if(!context_.empty()){
destroy()
}
//正常逻辑
context_ = file;
return true;
}
//推理
void forward(){
//异常逻辑
if(context_.empty()){
//说明模型没有加载上
//咱们对异常处理情况的定义很恼火
printf("模型没有加载。\n");
return;
}
//正常逻辑
printf("使用%s进行推理\n",context_.c_str());//c_str()就是将C++的string转化为C的字符串数组
}
//释放
void destory(){
context_.clear();
}
private:
//实际的工作当时context_通常是一个很复杂的对象,它包括有vector,有cpu内存,gpu显存,
//包括加载时要初始化的东西也非常多。所以这里的案例只是用string来表示一下。
string context_;
};
int main(){
Infer infer;
infer.forward();
return 0;
}
2.如何解决上面的问题呢,所以就引出RAII + 接口模式,RAII就是资源获取以及初始化。 接口模式是一种封装模式(设计模式),实现类与接口类分离的模式。
这个例程里面,infer实例的定义就可以认为是资源的获取,因为获取到了一个实例。load_model就是初始化,因为加载模型就是初始化。RAII的解决方案就是将资源获取以及初始化就是把两步合为一步,那合为一步的目的是什么呢?目的就是减少异常逻辑代码的编写!目的就是加载模型失败,则表示资源获取失败,他们二者强绑定。获取的模型一定初始化成功,因此forward函数不必判断模型是否加载成功,这样就少了很多异常逻辑。load_model中可以删除掉对于重复load的判断,forward函数中,可以删掉对是否加载成功的判断。
这个时候由于load_model是public函数,我在外面接着调用load_model不是也是可以的吗?所以这个时候就来了接口模式封装,接口模式封装主要解决以下两个问题:
1). 解决load_model还能被外部看到的问题,拒绝外面调用load_model。
2). 解决成员变量对外可见的问题,这里可见指的是代码被展示出来了,代码被展示出来就意味着如果某个成员变量是某个类型的变量,那么这个类型的头文件就必须被包含,不然编译就会报错,那么包含的头文件多了就会造成头文件污染。(具体解释可以参见代码注释)。
那么怎么解决的呢?引入了接口类InferInterface,原则是只暴露调用者需要的函数,其他一概不暴露。这个时候InferImpl类里无论有什么特殊类型的成员变量,都不会影响到我头文件中暴露的符号。
#include
#include
#include
#include
#include
#include
#include
#include //带条件的变量
#include
#include
using namespace std;
//接口类,他是一个纯虚类,那它有纯虚函数在里面
//原则是:只暴露调用者需要的函数,其他一概不暴露
//比如说load_model,咱们通过RAII做了定义,因此load_model属于不需要的范畴
//内部如果有启动线程等等,如start,stop,也不需要暴露,而是初始化的时候就自动启动,都是RAII的定义
class InferInterface{
public:
virtual void forward() = 0;
};
shared_ptr<InferInterface> creat_infer(const string& file);
//RAII + 接口模式
class InferImpl:public InterInterface{
public:
//加载模型
bool load_model(const string& file){
//异常逻辑代码
if(!context_.empty()){
destroy()
}
//正常逻辑
context_ = file;
return true;
}
//推理
virtual void forward() override{
//异常逻辑
if(context_.empty()){
//说明模型没有加载上
//咱们对异常处理情况的定义很恼火
printf("模型没有加载。\n");
return;
}
//正常逻辑
printf("使用%s进行推理\n",context_.c_str());//c_str()就是将C++的string转化为C的字符串数组
}
//释放
void destory(){
context_.clear();
}
private:
//实际的工作当时context_通常是一个很复杂的对象,它包括有vector,有cpu内存,gpu显存,
//包括加载时要初始化的东西也非常多。所以这里的案例只是用string来表示一下。
string context_;
};
//RAII
//获取infer实例,即表示加载模型
//加载模型失败,则表示资源获取失败,他们二者强绑定
//加载模型成功,则表示资源获取成功
//通过creat_infer来获取资源
//1.避免外部执行load_model,因为永远只有在creat_infer即模型初始化时执行load_model,不可能出现在其他地方(RAII没有完全限制,只是做到一部分)
//2.一个实例的load_model不会执行超过一次
//3.获取的模型一定初始化成功,因此forward函数不必判断模型是否加载成功,这样就少了很多异常逻辑。
//load_model中可以删除掉对于重复load的判断
//forward函数中,可以删掉对是否加载成功的判断
//接口模式(解决以下两个核心问题)
//1.解决load_model还能被外部看到的问题,拒绝外面调用load_model
//2.解决成员变量对外可见的问题
// 那么你们或许会问成员变量本来就是private,反正外部也访问不了呀,可见不可见有什么问题吗?
// 对于成员变量是特殊类型的,不是string类型,因为string类型的头文件我们一般都要include,所以很正常。比如成员变量是 cudaStream_t 类型,那么使用者必定会包含cuda_runtime.h,否则语法解析失败呀,因为这个符号找不到。
// 这个时候因为使用者必须得包含cuda_runtime.h这个头文件,如果这个类的实现里面还不止包含了cudaStream_t这个类型,还包含了一大堆其他特殊类型的变量,那么使用者势必会include这些头文件,不然这些类型的声明就找不到,编译时就会报错。但是加了这么多头文件进去就会造成头文件污染,这时候程序就很乱不干净,不干净的结果就很造成程序错误,异常,容易出现各种编译错误等等非预期的结果。
shared_ptr<InferInterface> create_infer(const string& file){
shared_ptr<InferImpl> instance(new InferImpl());
if(!instance->load_model(file))//如果load失败
instance.reset();
return instance;
}
int main(){
//资源的获取,infer要么成功要么失败,失败了就是一个空指针
auto infer = create_infer("a");
if(infer == nullptr){
print("failed\n");
return -1;
}
infer->forward();
return 0;
}
最后的成品就是如下:
infer.hpp头文件:
这个时候你会发现这个头文件里只依赖了最简单的东西,这里就只有memory,string很常见因为基本都要包含,所以直接无视。能做成这样的原因是因为实现真正功能的类(class InferImpl)里的那些很多成员变量在infer.hpp头文件这里是没有被暴露出来的。
为了帮助理解这一点,我举个例子,就是对应下面代码注释掉的地方。如果按照之前的写法,就要在这个类里加上stream_这个类的成员变量,这个时候头文件就会变多了,就得加上include
// infer.hpp
#ifndef INFER_HPP //避免重复包含,标准结构
#define INFER_HPP
#include
#include
//#include
class InferInterface {
public:
virtual void forward() = 0;
//private:
// cudaStream_t stream_;
};
std::shared_ptr<InferInterface> create_infer(const std::string& file);
#endif
infer.cpp源文件:
#include "infer.hpp"
using namespace std;
class InferImpl : public InferInterface {
public:
bool load_model(const string& file) {
context_ = file;
return true;
}
virtual void forward() override {
printf("正在使用 %s 进行推理.\n", context_.c_str());
}
void destory() {
context_.clear();
}
private:
string context_;
};
shared_ptr<InferInterface> create_infer(const string& file) {
shared_ptr<InferImpl> instance(new InferImpl());
//在if条件里执行load_model函数
if (!instance->load_model(file)) instance.reset();
return instance;
}
main.cpp
#include "infer.hpp"
using namespace std;
int main() {
string file = "model a";
auto infer = create_infer(file);
if (infer == nullptr) {
printf("模型加载失败\n");
return -1;
}
infer->forward();
return 0;
}
最后对以上接口内容进行总结,得到以下几个原则,也就是封装的原则!:
refer(封装常用的语法知识点):
C++智能指针详解:shared_ptr
c++智能指针中的reset成员函数
instance()
C++虚函数详解:虚函数用于接口封装,注意指针是分基类指针和派生类指针的。只有虚函数才能实现:声明基类指针,利用指针指向任意一个子类对象,调用相关的虚函数。
C++中虚析构函数的作用及其原理分析:虚析构函数使得在删除指向子类对象的基类指针时可以调用子类的析构函数达到释放子类中堆内存的目的,而防止内存泄露的。否则会内存泄漏。
C/C++中枚举类型enum使用