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++ 的语言特性,从两个方面进行改造:
- 遍历所有可以的类,依次检查每个类是否是需要创建的
- 使用 map 存储名字与特例对象创建函数的关系
二、模板方案
从第一个思考方向出发,如果我能在开始时,能简单地指定可创建导出器类的列表,而且有一个匹配输入类型名字的方法,就能够利用模板参数包展开技术,实现上述逻辑。具体地:
- 定义一个接收单个类型的
CreateExporterImpl()
的函数模板 - 定义一个接收 1+N 个类型的
CreateExporterImpl()
的函数模板,每次处理第一个类型,如果不匹配则递归展开 - 每个 Expoter 派生类需要定义一个静态成员函数
yaml_label_name()
用于跟输入的类型匹配 - 封装一个
std::shared_ptr<Exporter> CreateExporter(const std::string& name)
用于实例化函数模板,传入所有可能的导出器类列表
// 模板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);
}
}
这里用到两点的技术:
- 可变参数模板展开参数包,参考 parameter pack
- SFINAE(替换失败并非错误),enable_if_t
通过上述两个函数模板,我们能够将任意个导出器,作为参数列表去实例化一个模板,然后简单封装一层,得到 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_if
和 enable_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 typedeftype
, equal toT
; 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>
-
第三个类型默认值为
true
,调用者不用显式的填第三个参数 -
第三个类型得有
type
才能匹配到这个函数-
当
sizeof...(ExporterTypes) > 0
时,模板的第三个参数类型中有的type
(定义为 bool 型) -
当
ExporterTypes
为{}
时,模板第三个参数没有type
-
所以,当 ExporterTypes
为 {}
时,是无法匹配 模板二 的,回到上边 ExporterTypes = {FileExporter}
并调用 CreateExporterImpl<ExporterTypes...>(exporter_type)
进行展开时,只能选择模板一,而不再有歧义。
SFINAE-替换失败并非错误 (Substitution failure is not an error) 是指 C++ 语言在模板参数匹配失败时不认为这是一个编译错误。可用于编译时内省(introspection)。具体说,在模板实例化时允许模板确定模板参数的特定性质,比如这里用于确定一个类型是否包含特定 typedef,如果不包含则匹配其他的模板。
2.3 特点
- 本质上还是循环比较各种可能类型,代码其他方式相对复杂
- 创建 Exporter 类的时候,需要分散在各个导出器实现
yaml_label_name()
;同时需要增加模板形参列表
三、注册方案
改造最初代码的第二个思考方向是:使用 map 存储名字与特例对象创建函数的关系。这里主要有两点需要考虑:
- 存储名字与特例对象创建函数的 map 放在哪
- 注册的调用放在哪
3.1 静态变量持续存储
定义一个工厂类 ExporterFactory
:
- 定义静态变量存储 导出器类型名和创建函数之间的关系
- 定义一个静态注册函数,利用这个函数将 导出器类型名和创建函数插入到该 map,利用到了函数模板和 lambda 函数
- 定义一个静态创建函数,利用这个函数读取 map 进行实际的创建
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()
函数而避免其他函数效用。可以采取两种方式实现:
- 将
ExporterFactory
定义在.cc
文件中 - 将
ExporterFactory
的构造函数定义为私有函数,将CreateExporter()
加入到友元列表中
四、小结
从几方面对三种方式进行简单比较:
模板展开 | 集中注册 | 分散注册 | |
---|---|---|---|
低复杂度 | × | √ | √ |
不需要修改创建器 | × | × | √ |
不需要修改导出器 | × | √ | × |
映射关系集中 | × | √ | × |
虽然模板展开的方式看着很炫,但是他并没有带来太多的好处;比起注册的方式,反而要做的修改更多。
在剩下的两种方式中,我更倾向选择集中注册,因为我想能通过代码清晰的展示名称和类之间的关系,另外一方面,我的代码存在一个源文件中定义多个导出器的情况,这样进行注册的代码就需要定义不同的变量,显得不那么优雅。