C++ 抽象工厂分步实现
C++ 中抽象工厂设计模式的分步实现
引言
抽象工厂是软件设计模式中的绝对旗舰。它出现在“设计模式 - 可复用面向对象软件元素”经典书籍的23个模式列表的开头,出现在许多软件面试问题中,并且是许多现代技术(从COM对象到Web服务)的关键。
它如此重要的原因是它在分解依赖关系和减少耦合方面非常有效。
无数的书籍和文章详细介绍了该模式的不同方面。然而,将这些想法组装成可工作的实现仍然取决于您。在这篇文章中,我想从头到尾展示我个人对 C++ 中抽象工厂的理解。
设置
每个实现都始于一个接口。在我的例子中,我决定实现一个几何形状的继承树,所以父类看起来像这样
namespace Common
{
class IShape
{
public:
virtual double GetArea() const = 0;
// More virtual functions here
virtual ~IShape() {};
};
}
接下来,我们将实现几个具体的形状类// Rectangle.h
..
namespace SimpleShapes
{
class Rectangle : public Common::IShape
{
public:
Rectangle(double width, double height);
Rectangle(rapidxml::xml_node<>* node);
virtual double GetArea() const;
private:
double _width;
double _height;
};
}
// Rectange.cpp
..
namespace SimpleShapes
{
double Rectangle::GetArea() const
{
return _height * _width;
}
Rectangle::Rectangle(double width, double height)
: _width(width), _height(height)
{
}
Rectangle::Rectangle(rapidxml::xml_node<>* node)
: _width(0), _height(0)
{
rapidxml::xml_attribute<>* pWidthAttr = node->first_attribute("Width");
_width = atof(pWidthAttr->value());
rapidxml::xml_attribute<>* pHeightAttr = node->first_attribute("Height");
_height = atof(pHeightAttr->value());
}
}
请注意,所有具体实现都支持从 XML 进行构造。我选择了RapidXML进行解析。它易于使用,并且具有仅标头分发的额外好处。
我们的目标将是隐藏从 XML 构造子类别的具体方法,以便客户端无需了解或关心它们。
动机
在深入解决方案之前,让我们回顾一下为什么我们一开始就需要一个通用的机制来构造子类别。
- 它(希望)能够让我们和社区在部署后作为插件添加新形状,而无需更改任何客户端代码。
- 它使我们能够使用装饰器模式来增强我们的实现,添加新的功能,如日志记录、线程安全、性能监控等。
- 它使我们能够更好地测试我们的代码,通过在需要时提供模拟实现(依赖注入)。
- 总的来说,它将减少不同形状实现与客户端代码之间的耦合。
形状工厂类
抽象工厂通常实现为单例模式
// ShapeFactory.h
..
namespace Common
{
class ShapeFactory
{
public:
static ShapeFactory& Instance();
IShape * Create(rapidxml::xml_node<> * node) const;
};
}
// ShapeFactory.cpp
..
ShapeFactory& ShapeFactory::Instance()
{
static ShapeFactory factory;
return factory;
}
什么时候不应该将工厂实现为单例模式?
考虑我们的小形状项目。我们可能希望为每种形状提供多种实现,使用不同的第三方库。例如,在程序的某些部分,我们将希望底层实现依赖于boost::geometry,而在其他部分,我们可能更喜欢功能更强大的CGAL实现。此外,我们的单元测试可能受益于为某些测试用例提供的模拟实现。
这可以通过在某些全局配置中指定所需的策略来实现。或者,您可能更喜欢拥有多个工厂。
完全合法的解决方案
实现Create
最简单的方法是
IShape* ShapeFactory::Create(rapidxml::xml_node<> * node) const
{
if (key == "Circle") return new Circle(node);
else if (key == "Rectangle") return new Rectangle(node);
// ...
else throw new std::exception("Unrecognized object type!");
}
不要被误导——这是该模式的一个完全合法的实现。
它有限制(由于所有子类别都必须硬编码,因此无法进行运行时插件),但它是功能性的。
我对这个解决方案最大的担忧是,添加或删除子类别需要更改工厂实现。这增加了您的团队已经必须进行的仪式和祈祷的清单,在我看来,几乎总是会导致(愚蠢的)错误。
控制反转
与其让工厂依赖于所有不同的实现,不如让所有实现告知工厂如何构造它们自己。这将允许工厂通过新的子类别进行扩展,并且每个子类别都可以自成一体。
那么,我们如何将“一种构造自己的方法”转化为代码呢?没有反射,所以仅仅传递类名是不够的。在 C++ 中实现任何类型的事件、监听器或回调时,传统上可以走两条路之一:
- C 风格:使用函数指针
- 面向对象风格:使用多态
虽然您绝对可以从函数指针构建一个可扩展的工厂,但我个人无法忍受它们(基于函数指针的实现请参阅“C++ API Design”。C++ 11 标准通过函数元素赋予了函数式解决方案新的生命——查看这篇CodeProject 文章)。
所以,我更愿意定义一个新接口
namespace Common
{
class IShapeMaker
{
public:
virtual IShape * Create(rapidxml::xml_node<> * node) const = 0;
virtual ~IShapeMaker() {}
};
}
这个接口将封装调用具体构造函数的所有细节。我们可以扩展我们的工厂来利用它//ShapeFactory.h
class ShapeFactory
{
public:
..
void RegisterMaker(const std::string& key, IShapeMaker * maker);
private:
std::map<std::string, IShapeMaker*> _makers;
};
//ShapeFactory.cpp
void ShapeFactory::RegisterMaker(const std::string& key, IShapeMaker* maker)
{
if (_makers.find(key) != _makers.end())
{
throw new std::exception("Multiple makers for given key!");
}
_makers[key] = maker;
}
IShape* ShapeFactory::Create(rapidxml::xml_node<> * node) const
{
std::string key(node->name());
auto i = _makers.find(key);
if (i == _makers.end())
{
throw new std::exception("Unrecognized object type!");
}
IShapeMaker* maker = i->second;
return maker->Create(node);
}
您一定在想——谁将实现所有具体的形状制造者,以及何时将它们注册到工厂?
形状制造者
要使此解决方案生效,每个具体的形状类都必须在其旁边提供一个形状制造者,看起来像这样
// Circle.h
class CircleMaker : public IShapeMaker
{
public:
virtual IShape * Create(rapidxml::xml_node<> * node) const
{
return new Circle(node);
}
};
事实上,所有形状制造者都会看起来完全一样,唯一的区别是正在构造的类的名称。这听起来像是一个模板的工作// ShapeMaker.h
template<typename T>
class ShapeMaker : public IShapeMaker
{
public:
virtual IShape * Create(rapidxml::xml_node<> * node) const
{
return new T(node);
}
};
使用这个小的模板,我们可以避免手动为我们项目中的每种形状编写一个类。
这就是我们将制造者注册到工厂的方式
ShapeFactory::Instance().RegisterMaker("Circle", new ShapeMaker<Circle>());
由于我们仍然有义务注册每个形状制造者,因此注册过程越短越好。我们可以通过让制造者“照顾好自己”来节省几个额外的标记。template<typename T>
class ShapeMaker : public IShapeMaker
{
public:
ShapeMaker(const std::string& key)
{
ShapeFactory::Instance().RegisterMaker(key, this);
}
..
};
这样,我们可以将制造者注册减少到只需将制造者放入某个静态上下文中。// Circle.cpp
static Common::ShapeMaker<Circle> maker("Circle");
这是一个关键点:到目前为止,还不清楚谁负责执行注册制造者的代码。将此代码放在 main 中将打破使子类别自主的所有梦想。我们只会将问题从工厂实现转移到 main。
我们通过利用 C++ 运行时在模块加载时初始化任何静态变量的事实来解决这个问题。这是为模块的主函数或 dllmain 函数添加功能的好方法。
这里我们的工厂作为单例模式实现的这一事实发挥了作用。除非您正确实现单例模式,否则您会发现自己陷入困境。例如,如果我们声明 _makers 是一个静态数据成员而不是使用 Instance 函数。发生的情况是,一些制造者会请求在 _makers 映射有机会初始化之前注册自己。当然,这会适得其反。
为了进一步简化,我们可以声明一个宏
#define REGISTER_SHAPE(T) static Common::ShapeMaker<T> maker(#T);
这样 static Common::ShapeMaker<Circle> maker("Circle");
就会变成 REGISTER_SHAPE(Circle);
可扩展的架构
我们可以将代码分为三个功能部分
- 通用基础设施 - 包含开始使用形状所需的一切:
IShape, IShapeMaker, ShapeMaker
和ShapeFactory
- 形状实现 - 包含具体的子类别,仅依赖于通用基础设施。
- 客户端代码 - 通过使用通用基础设施来构造新形状来消耗我们库的功能。不依赖于形状实现。
我们已成功将客户端代码与实现解耦。现在我们可以将每个部分放在不同的模块中。这样,我们可以将我们小小的Common.dll
和一些 .h 和 .lib 文件发送给我们的合作伙伴,让他们继承 IShape
并丰富我们的框架。
这在理论上听起来不错,但在您将实现放入隔离模块的那一刻,您会遇到一个问题:尽管您已经编译并链接了您的测试应用程序到实现项目,但工厂将无法从该项目中构造任何类。
如果您查看模块窗口,您会发现实现模块尚未加载,因此静态变量的构造函数从未执行。
我建议解决此问题的方法是向我们的工厂实现添加一项新功能。
IShape* ShapeFactory::Create(rapidxml::xml_node<> * node) const
{
rapidxml::xml_attribute<>* pAttr = node->first_attribute("LibraryName");
if (pAttr != NULL)
{
std::string libraryName(pAttr->value());
LoadLibrary(libraryName.c_str());
}
..
}
这是它的用法。void main()
{
char * str = "<Rectangle LibraryName=\"SimpleShapes.dll\" Width=\"5.3\" Height=\"3.7\" />";
std::string content(str);
xml_document<> doc;
doc.parse(&content[0]);
auto shape = ShapeFactory::Instance().Create(doc.first_node());
auto area = shape->GetArea();
}
这样,您就可以为项目引入新功能,而无需重新编译客户端应用程序。请注意,使用 LoadLibrary
会带来潜在的安全风险。
有改进空间
虽然当前状态的实现功能齐全,但有几件事我们可以做得更好。
- 二进制兼容性 - 由于我没有费心隐藏实现细节,以至于潜在的客户端代码可能与使用不同版本的 C++ 运行时或不同的编译参数编译的第三方插件不兼容。C++ 中二进制兼容性的问题很复杂,最终会将您引向使用 COM,但您可以通过将实现隐藏在接口后面或使用PIMPL惯用法来解决其中一些挑战。
- 线程安全 - 有许多关于确保单例模式实现线程安全和一般线程安全的文章。您还可以选择各种线程和同步库。
- 所有权管理 - 取决于您偏好的资源管理策略,您可能希望工厂返回某种智能指针。类似于线程,您有多种选择,例如引用计数、RAII包装等等。
- 泛化 - 我们的抽象工厂相当具体。它负责将 RapidXML 节点转换为形状,仅此而已。明天您可能需要一个接收某种其他类型输入并行为略有不同的工厂。您可能会遇到很多重复的代码。但是,创建一个优雅的泛化可能具有挑战性。我强烈建议任何人感兴趣的阅读“Modern C++ Design”。
您可以直接从这里下载完整的源代码。