背景
做算法策略过程中,常常会存在这样一种情形:由于业务压力或者岗位性质的缘故,做算法、策略的同学(也包括我自己),对于一些工程上的实现,往往只追求尽快实现、拿到收益,而忽略了实现过程中,代码的复用性、可读性。
举个例子,某模型AB版本不断迭代,当模型特征输入、推理、业务发生小的差异时,为了追求尽快实现拿到收益,会出现很多的冗余代码,代码量急剧膨胀,可行性也变得比较差,推高维护成本。比如在实际中遇到的一个例子:
class NNPredict {
bool RemoteNN();
bool RemoteNNAB();
bool RemoteNNKMM();
bool RemoteNNDailyMM();
bool RemoteNNKMMSg();
}
刚开始的时候,线上模型请求RemoteNN()方法,AB实验模型请求RemoteNNAB()方法。RemoteNN()和RemoteNNAB(),有比较多的冗余代码,并且后面随着模型、业务迭代,出现了RemoteNNKMM()、RemoteNNDailyMM()和RemoteNNKMMSg()。这些方法,可能只是输入、推理、业务存在一些差异,为了尽快实验看效果,就直接对方法进行copy改造,导致一个文件,代码超过五六千行,代码高度冗余。
基类设计
虽然模型版本在不断AB迭代,但不同版本的模型,正如前面所说的,只是特征输入、推理、业务不一样导致的差异。针对上面遇到的场景,我们可以对这样AB请求模型的不同版本,进行抽样,涉及如下基类接口:
- SetClient函数设置AB版本对应不同的远程调用grpc的client;
- Run函数暴露的最终接口,对于请求需要的所有信息,都可以把它放到context里面;
- PrepareData函数用于特征输入与预处理,RemoteApply函数远程调用,GetResult函数获取最终返回的结果,这3个函数,在基类中都定义为了纯虚函数,且都为private;
class BaseRemoteInfer {
public:
virtual ~ BaseRemoteInfer() {
}
// 初始化阶段调用
bool SetClient(const std::shared_ptr<InferKessClient>& infer_client);
// 执行函数
bool Run(NNContext* context, std::vector<NNResult>* score) const;
private:
// 准备数据
virtual bool PrepareData(NNContext* context) const = 0;
// 远程调用,可以设计成统一的非虚函数
virtual bool RemoteApply(NNContext* context) const = 0;
// 获取结果,填充 debug 信息
virtual bool GetResult(NNContext* context, std::vector<NNResult>* score) const = 0;
// 业务填充一些公共的执行函数,在上述阶段中使用 ...
private:
std::shared_ptr<InferKessClient> infer_client_;
};
bool BaseRemoteInfer::Run(NNContext* context, std::vector<NNResult>* score) const {
if (!context || !score) {
LOG(ERROR) << "BaseRemoteInfer input error";
return false;
}
if (!PrepareData(context)) {
LOG(ERROR) << "Prepare data error";
return false;
}
if (!RemoteApply(context)) {
LOG(ERROR) << "Remote apply error";
return false;
}
if (!GetResult(context, score)) {
LOG(ERROR) << "Get result error";
return false;
}
return true;
}
shared_ptr传引用or传值
在上面代码SetClient函数中,我们在使用shared_ptr作为传递参数的类型时,使用的是传引用方式,并且由于传递的参数,在函数内部不会改变,为了保持良好的习惯,对传递参数类型限定了const。在使用shared_ptr传递参数时,到时是传引用好,还是传值好?这是一个好问题,stackoverflow上有人对这个问题做了解释,可以详细阅读Should we pass a shared_ptr by reference or by value?。
纯虚函数、protected
对于上述设计的基类,由于GetResult方法、SetClient方法,在基类、子类实现方法无差异,将其放在基类中实现就可以,因而GetResult不必将其设计成纯虚函数,将GetResult方法设计为虚函数即可,GetResult方法、SetClient方法都放在基类里,作为基类方法供子类调用。此外,在子类中,如果需要访问到infer_client_
的话,如果将其设置为private属性,显然子类不能直接访问到该成员变量,因而将其设置为protected是比较合理的。最终,基类设计为如下:
class BaseRemoteInfer {
public:
virtual ~ BaseRemoteInfer() {
}
// 初始化阶段调用
bool SetClient(const std::shared_ptr<InferKessClient>& infer_client);
// 执行函数
bool Run(NNContext* context, std::vector<NNResult>* score) const;
private:
// 准备数据
virtual bool PrepareData(NNContext* context) const = 0;
// 远程调用,可以设计成统一的非虚函数
virtual bool RemoteApply(NNContext* context) const = 0;
// 获取结果,填充 debug 信息
virtual bool GetResult(NNContext* context, std::vector<NNResult>* score) const;
// 业务填充一些公共的执行函数,在上述阶段中使用 ...
protected:
std::shared_ptr<InferKessClient> infer_client_;
};
派生类
由于派生类和基类具有相同的GetResult方法、SetClient方法实现,派生类不用再单独实现GetResult方法、SetClient方法了。只需要实现PrepareData方法和RemoteApply。一个示意实现的派生类如下:
class DSNNRemoteInfer:public BaseRemoteInfer {
public:
~ DSNNRemoteInfer() {
}
private:
bool PrepareData(NNContext* context) const;
bool RemoteApply(NNContext* context) const;
};
上述命令会对项目进行编译,并将cvtk包安装在本地,在Python中导入包名验证即可。
dynamic_cast
dynamic_cast < type-id > ( expression)
,该运算符把expression转换成type-id类型的对象。使用的时候,应注意:
- Type-id必须是类的指针、类的引用或者void*;
- 如果type-id是类指针类型,那么expression也必须是一个指针,如果type-id是一个引用,那么expression也必须是一个引用;
- dynamic_cast运算符可以在执行期决定真正的类型。如果downcast是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果downcast不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象);
dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。将一个基类对象指针(或引用)cast到继承类指针,dynamic_cast会根据基类指针是否真正指向继承类指针来做相应处理:
- 对指针进行dynamic_cast,失败返回null,成功返回正常cast后的对象指针;
- 对引用进行dynamic_cast,失败抛出一个异常,成功返回正常cast后的对象引用;
在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的;在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。
ds_nn_infer_ = std::make_shared<DSNNRemoteInfer>();
status = ds_nn_infer_->SetClient(exp_client_);
succ = dynamic_cast<BaseRemoteInfer *>(ds_nn_infer_.get())->Run(ctr, &score);
在实际使用时,如果不需要使用dynamic_cast
,则尽量少用。比如上面做举的例子,完全可以将run()
定义为基类的普通成员函数或者基类的虚函数。下面的例子可以示意说明:
#include <iostream>
class BaseClass
{
public:
~BaseClass() {};
virtual void Print() {
std::cout << "BaseClass Print Function Called..." << std::endl;
}
};
class DerivedClass:public BaseClass
{
public:
~DerivedClass() {};
/*void Print() {
std::cout << "DerivedClass Print Function Called..." << std::endl;
}*/
};
int main(int argc, const char * argv[]) {
std::shared_ptr<DerivedClass> ptr_derived_class = nullptr;
ptr_derived_class = std::make_shared<DerivedClass>();
ptr_derived_class->Print();
ptr_derived_class->BaseClass::Print();
return 0;
}
如果注释掉子类中Print()
函数,则输出的结果为:
BaseClass Print Function Called...
BaseClass Print Function Called...
如果保留子类中Print()
函数,即子类进行重载,输出结果则为:
DerivedClass Print Function Called...
BaseClass Print Function Called...