C++漫谈:继承、dynamic_cast、纯虚函数、protected,一场多态之旅

    2022年03月20日 Cpp Cpp 字数:8999

背景

做算法策略过程中,常常会存在这样一种情形:由于业务压力或者岗位性质的缘故,做算法、策略的同学(也包括我自己),对于一些工程上的实现,往往只追求尽快实现、拿到收益,而忽略了实现过程中,代码的复用性、可读性。

举个例子,某模型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...

请他喝一杯

取消

感谢您的支持,如有任何问题,您可以在打赏留言中留下您的微信

微信扫码
支付宝扫码

打开微信扫一扫,即可进行在打赏中留言哦