Jason Pan

C++ 对象工厂

潘忠显 / 2022-07-10


本文针对一个比较常见的场景,介绍在 C++ 实现对象工厂的方式。除了模板展开部分外,大部分比较基础。如有谬误,欢迎指正。

一、背景

我自己写了一个处理日志的代理,实现通过配置创建多个接收器监听网络请求/本地文件变更,也能够配置多个导出器将经过不同处理的消息发送给不同的接收端。通过不同的配置能快速地搭建出 TlogServer, CLS Agent 等功能的服务。

在实现通过配置创建对象时,为了复用代码,所有类型的导出器都派生自一个 Exporter 的基类(接收器、处理器同样如此)。这里需要实现一个函数 CreateExporter:输入是字符串,返回 Exporter 的指针,实际指向的是不同派生类对象的地址。

class Exporter {
 public:
  void set_name(const std::string& name) { name_ = name; }
  std::string name_;
};

class ClsExporter : public Exporter {};
class KafkaExporter : public Exporter {};
class FileExporter : public Exporter {};

// 需要实现的函数
std::shared_ptr<Exporter> CreateExporter(const std::string& name);

1.1 C++ 简单方案

因为最初的时候导出器比较少,所以选择直接使用 if 分支匹配输入的导出器类型名,然后创建不同的对象。这么写没有什么太大的问题,但是看起来不太优(高)雅(级):

在《重构-改善既有代码的设计》一书中的 11.8-以工厂函数取代构造函数、12.6-以子类取代类型码 两小节也解释了如此重构的动机。

具体的代码如下:

std::shared_ptr<Exporter> CreateExporter(const std::string& name) {
  std::string exporter_type = GetTypeFromName(name);
  std::shared_ptr<Exporter> ret;
  if (exporter_type == "cls") {
    ret = std::make_shared<ClsExporter>();
  }
  if (exporter_type == "kafka") {
    ret = std::make_shared<KafkaExporter>();
  }
  if (exporter_type == "file") {
    ret = std::make_shared<FileExporter>();
  }
  if (ret) {
    ret->set_name(name);
  }
  return nullptr;
}

1.2 其他语言实现方式

在面向对象编程中,大量类型派生自同一个基类很常见。有些语言是提供反射机制,在运行时可以获得一些对象和类型的结构信息。

比如在 Python 中可以直接通过字符串查找到类名,然后创建一个对象:

instance = globals()["class_name"]()

或在 Java 中(据说可以):

Class.forName(className).getConstructor(String.class).newInstance(arg);

1.3 思考方向

我这个项目参考的 OTEL Collector 的代码使用 Go 实现的,其源代码 opentelemetry-collector-contrib 在增加新的导出器的时候,需要导出器自己实现一个工厂类型,工厂实现其导出器的方法和创建导出器的方法。在程序启动时,创建一个map,依次调用工厂类型的两个方法,将名称与导出器的映射存储起来。

接下来,针对 C++ 的语言特性,从两个方面进行改造:

二、模板方案

从第一个思考方向出发,如果我能在开始时,能简单地指定可创建导出器类的列表,而且有一个匹配输入类型名字的方法,就能够利用模板参数包展开技术,实现上述逻辑。具体地:

// 模板1
template <class ExporterType>
std::shared_ptr<Exporter> CreateExporterImpl(const std::string& exporter_type) {
  if (exporter_type == ExporterType::yaml_label_name()) {
    return std::make_shared<ExporterType>();
  } else {
    return nullptr;
  }
}

// 模板2
template <class ExporterType, class... ExporterTypes,
          std::enable_if_t<(sizeof...(ExporterTypes) > 0), bool> = true>
std::shared_ptr<Exporter> CreateExporterImpl(const std::string& exporter_type) {
  if (exporter_type == ExporterType::yaml_label_name()) {
    return std::make_shared<ExporterType>();
  } else {
    return CreateExporterImpl<ExporterTypes...>(exporter_type);
  }
}

这里用到两点的技术:

通过上述两个函数模板,我们能够将任意个导出器,作为参数列表去实例化一个模板,然后简单封装一层,得到 CreateExporter 函数。

std::shared_ptr<Exporter> CreateExporter(const std::string& name) {
  std::string exporter_type = GetTypeFromName(name);
  auto ret = CreateExporterImpl<  //
      ClsExporter,                //
      KafkaExporter,              //
      FileExporter                //
      >(exporter_type);
  if (ret) {
    ret->set_name(name);
  }
  return ret;
}

2.1 模板歧义

模板二上有个奇怪的 std::enable_if_t<(sizeof...(ExporterTypes) > 0), bool> = true ,如果不加它,模板二的定义会变成这样:

template <class ExporterType, class... ExporterTypes>
std::shared_ptr<Exporter> CreateExporterImpl(const std::string& name)  {
...
  p = CreateExporterImpl<ExporterTypes...>(name);
...
}

在模板递归进行展开的时候的,上边的代码在最后一次展开的时候会有编译错误,不清楚该用哪个CreateExporterImpl

demo.cc:72:48: error: call of overloaded ‘CreateExporterImpl<FileExporter>(const string&)’ is ambiguous
   72 |     return CreateExporterImpl<ExporterTypes...>(exporter_type);
      |            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~

详细地讲,编译器在遇到 ExporterTypes = {FileExporter} 并调用 CreateExporterImpl<ExporterTypes...>(exporter_type) 进行展开时,并不知道该选择哪个模板一还是模板二(两个都能匹配):

template <class ExporterType = FileExporter>
std::shared_ptr<Exporter> CreateExporterImpl(const std::string& name)
template <class ExporterType = FileExporter, class... ExporterTypes = {}>
std::shared_ptr<Exporter> CreateExporterImpl(const std::string& name)

原因是参数包在展开时,可以被展开成 0 个或多个

A pattern followed by an ellipsis, in which the name of at least one parameter pack appears at least once, is expanded into zero or more comma-separated instantiations of the pattern, where the name of the parameter pack is replaced by each of the elements from the pack, in order.

2.2 有点绕的 enable_if 和 SFINAE

利用 cppreference 上提到 enable_if 的一种可能实现方式,来理解一下 enable_ifenable_if_t 的作用:

template<bool B, class T = void>
struct enable_if {};
 
template<class T>
struct enable_if<true, T> { typedef T type; };

template< bool B, class T = void >
using enable_if_t = typename enable_if<B,T>::type;

If B is true, std::enable_if has a public member typedef type, equal to T; otherwise, there is no member typedef.

template <class ExporterType, class... ExporterTypes,
          std::enable_if_t<(sizeof...(ExporterTypes) > 0), bool> = true>

可以翻译成:

template <class ExporterType, class... ExporterTypes,
typename enable_if<(sizeof...(ExporterTypes) > 0),bool>::type = true>

所以,当 ExporterTypes{} 时,是无法匹配 模板二 的,回到上边 ExporterTypes = {FileExporter} 并调用 CreateExporterImpl<ExporterTypes...>(exporter_type) 进行展开时,只能选择模板一,而不再有歧义。

SFINAE-替换失败并非错误 (Substitution failure is not an error) 是指 C++ 语言在模板参数匹配失败时不认为这是一个编译错误。可用于编译时内省(introspection)。具体说,在模板实例化时允许模板确定模板参数的特定性质,比如这里用于确定一个类型是否包含特定 typedef,如果不包含则匹配其他的模板。

2.3 特点

三、注册方案

改造最初代码的第二个思考方向是:使用 map 存储名字与特例对象创建函数的关系。这里主要有两点需要考虑:

3.1 静态变量持续存储

定义一个工厂类 ExporterFactory

class ExporterFactory {
 private:
  static std::map<std::string, std::function<std::shared_ptr<Exporter>()> >
      registry_;

 public:
  template <class ExporterType>
  static void Register(const std::string& name) {
    ExporterFactory::registry_[name] =
        [](void) -> std::shared_ptr<ExporterType> {
      return std::make_shared<ExporterType>();
    };
  }
  
  static std::shared_ptr<Exporter> CreateExporter(const std::string& name) {
    std::string exporter_type = GetTypeFromName(name);
    auto it = ExporterFactory::registry_.find(exporter_type);
    std::shared_ptr<Exporter> ret;
    if (it != ExporterFactory::registry_.end()) {
      ret = it->second();
      ret->set_name(name);
    }
    return ret;
  }
};

std::map<std::string, std::function<std::shared_ptr<Exporter>()> >
    ExporterFactory::registry_;

3.2 在工厂中统一注册

在工厂类的构造函数中将所有的导出器都进行注册。

ExporterFactory::ExporterFactory() {
  ExporterFactory::Register<ClsExporter>("cls");
  ExporterFactory::Register<KafkaExporter>("kafka");
  ExporterFactory::Register<FileExporter>("file");
}

接下来的问题是:要保证只有一个 ExporterFactory 对象被创建;而且为了方便,最好是不用调用者自己创建。这两点要求又跟静态变量的性质不谋而合,这里定义一个创建函数,在其中定义一个静态变量,他将被只构造一次,而后所有的函数调用都会使用同一个变量

std::shared_ptr<Exporter> CreateExporter(const std::string& name) {
  static ExporterFactory t;
  return ExporterFactory::CreateExporter(name);
}

3.3 在导出器源文件中注册

在工厂类中进行统一注册的方式,在每次增加新导出器的时候,需要修改工厂类的代码。也有方式在每个导出器自己的代码中进行注册。同样需要利用到静态函数的定义,才能在调用创建函数前就将导出器注册进去:

template <class T>
class Registrar {
 public:
  Registrar(const std::string& name) { ExporterFactory::Register<T>(name); }
};

在各个导出器的源文件中分别定义静态变量,每次调用 Registrar 构造函数的时候,就会注册一次:

static Registrar<ClsExporter> registrar("cls");

当然,这时候就不需要额外在定义 ExporterFactory 的静态成员变量了,而是直接调用 ExporterFactory::CreateExporter() 即可:

std::shared_ptr<Exporter> CreateExporter(const std::string& name) {
  return ExporterFactory::CreateExporter(name);
}

3.4 隐藏工厂细节

这里我们想隐藏掉 ExporterFactory 类的细节,而只想暴露 CreateExporter() 函数而避免其他函数效用。可以采取两种方式实现:

四、小结

从几方面对三种方式进行简单比较:

模板展开 集中注册 分散注册
低复杂度 ×
不需要修改创建器 × ×
不需要修改导出器 × ×
映射关系集中 × ×

虽然模板展开的方式看着很炫,但是他并没有带来太多的好处;比起注册的方式,反而要做的修改更多。

在剩下的两种方式中,我更倾向选择集中注册,因为我想能通过代码清晰的展示名称和类之间的关系,另外一方面,我的代码存在一个源文件中定义多个导出器的情况,这样进行注册的代码就需要定义不同的变量,显得不那么优雅。