1.5 SOLID设计原则
单一职责原则(SRP)
开闭原则(OCP)
里氏替换原则(LSP)
接口隔离原则(ISP)
依赖倒转原则(DIP)
1.5.1 单一职责原则
日志类
struct Journal{
string title;
vector<string> entries;
explicit Journal(const string& title) : title(title) {}
};
// 添加记录
void Journal::add(const string& entry){
static int count = 1;
entries.push_back(boost::lexical_cast<string>(count++) + ":" + entry);
}
将“添加记录”功能作为Journal类的一部分是有意义的,更新记录是Journal类的职责
将日志保存在文件中使其持久化则是有问题的。如果将写入磁盘的功能添加到Journal类以及类似的类中,那么,关于持久化方法的任何改动(例如写入云端而非磁盘)都需要改动与之相关的每一个类。因此,最好将持久化功能单独作为一个类
struct PersistenceManager{
static void save(const Journal& j, const string& filename) {
ofstream ofs(filename);
for(auto& s:j.entries)
ofs << s << endl;
}
};
每个类只有一个职责,因此也只有一个修改该类的原因
1.5.2 开闭原则
假设数据库中有一系列产品,每个产品都有颜色和尺寸,定义如下:
enum class Color { Red, Green, Blue };
enum class Size { Small, Medium, Large };
struct Product {
string name;
Color color;
Size size;
};
现在,添加一个过滤器
struct ProductFilter {
using Items = vector<Product*>;
};
假设要根据颜色来过滤,于是定义如下成员函数
ProductFilter::Items ProductFilter::by_color(Items items, Color color){
Items result;
for (auto& i : items)
if (i->color == color)
result.push_back(i);
return result;
}
随后,增加了根据尺寸来过滤的要求
ProductFilter::Items ProductFilter::by_size(Items items, Size size) {
Items result;
for (auto& i : items)
if (i->size == size)
result.push_back(i);
return result;
}
现在,增加了同时根据颜色和尺寸过滤的需求,要再添加一个接口吗?如果以后有更多类似需求呢?
开闭原则要求软件实体对扩展开放,对修改关闭。也就是说,上述场景中的过滤器最好是可扩展的,而不是必须去修改它。
如何实现?首先,从概念上将过滤器划分为两个部分(单一职责原则):过滤器(一个以所有记录为输入,并返回其中某些记录的处理过程)和规范(应用于数据元素的谓词的定义)
首先对规范接口进行非常简单的定义
template <typename T>
struct Specification
{
virtual boo is_satisfied(T* item) = 0;
};
// 基于Specification<T>来定义过滤器Filter<T>
template <typename T>
struct Filter{
virtual vector<T*> filter(vector<T*> items, Specification<T>& spec) const = 0;
};
过滤器的实现
struct BetterFilter : Filter<Product>{
vector<Product*> filter(vector<Product*> items, Specification<Product>& spec) override{
vector<Product*> result;
for(auto& p : items)
if(spec.is_satisfied(p))
result.push_back(p);
return result;
}
};
接下来,要实现颜色过滤器,可以定义一个ColorSpecification
struct ColorSpecification : Specfication<Product>{
Color color;
explicit ColorSpecification(const Color color) : color(color) {}
bool is_satisfied(Product* item) override{
return item->color == color;
}
};
如果需要组合过滤条件,可以这样定义
template <typename T>
struct AndSpecification : AndSpectification<T>{
Spectification<T>& first;
Spectification<T>& second;
AndSpectification(Spectification<T>& first, Spectification<T>& second) : first(first), second(second) {}
bool is_satisfied(T* item) override{
return first.is_satisfied(item) && second.is_satisfied(item);
}
};
但是这种创建组合定义的做法还是低效了,难道要为了每两个条件的组合创建一个新的过滤器吗
可以重载运算符&&来改善
template <typename T>
struct Specification{
virtual boo is_satisfied(T* item) = 0;
AndSpectification<T> operator &&(Spectification& other){
return AndSpectification(*this, other);
}
}
template <typename T>
AndSpectification<T> operator&&(const Spectification<T>& first, const Spectification<T>& second) {
return {first, second};
};
可以继续添加功能,使得2个以上的条件可以&&
开闭原则的主旨是,我们不必返回到已经编写和测试的代码来修改它。
1.5.3 里氏替换原则
如果某个接口以基类Parent类型为参数,那么他应该同等地接受子类Child类型作为参数,并且程序不会产生任何异常
class Rectangle {
protected:
int width, height;
public:
Rectangle(const int width, const int height) : width{ width }, height{ height } {}
int get_width() const { return width; }
virtual void set_width(const int width) { this->width = width; }
int get_height() const { return height; }
virtual void set_height(const int height) { this->width = height; }
int area()const { return width * height; }
};
假设,现在有一个Square类,继承自Rectangle类,并覆写了两个setter方法
class Square :public Rectangle {
public:
Square(int size) :Rectangle(size, size) {}
void set_width(const int width) override { this->width = height = width; }
void set_height(const int height) override { this->width = width = height; }
};
这种做法是有问题的,遇到如下程序时会出错
void process(Rectangle& r) {
int w = r.get_width();
r.set_height(10);
cout << r.area() << endl;
}
这个程序在计算长方形的时候没有问题,它获取宽度,改变高度,但是如果传入一个Square对象,结果就不对了
这种Square的设计是有问题的,最好直接删掉
1.5.4 接口隔离原则
假设我们要定义一个多功能打印机,它可以完成打印、扫描、甚至传真,定义如下
struct MyFavouritePrinter{
void print(vector<Document*> docs) override;
void fax(vector<Document*> docs) override;
void scan(vector<Document*> docs) override;
};
现在,假设我们要定义一个抽象接口,类似于如下代码
struct IMachine{
virtaul void print(vector<Document*> docs) override = 0;
virtaul void fax(vector<Document*> docs) override = 0;
virtaul void scan(vector<Document*> docs) override = 0;
};
这样做存在问题,也许某个继承自IMachine的实现者并不需要扫描和传真功能,但基类却要求它实现这两个功能,当然,可以做一个空实现,但是没有必要这么做。**接口隔离原则的建议是将所有接口拆分开,让实现者根据自身需求挑选接口并实现。**这个虚拟基类应该这样设计
struct Iprinter{
virtaul void print(vector<Document*> docs) override = 0;
};
struct IScanner{
virtaul void scan(vector<Document*> docs) override = 0;
}
现在,具体的扫描仪或传真仪只需从上述抽象基类分别继承即可,如果需要多个功能,可以用多继承实现
struct IMachine: IPrinter, IScanner{
}
在具体的设备中实现这些接口时,则可以使用继承而来的已有接口
struct Machine : IMachine{
IPrinter printer;
IScanner scanner;
Machine(IPrinter& printer, IScanner& scanner) : printer(printer), scanner(scanner){}
void print(vector<Document*> docs) override {
printer.print(docs);
}
void scan(vector<Document*> docs) override {
scanner.scan(docs);
}
};
1.5.5 依赖倒转原则
高层模块不应该依赖底层模块,它们都应该依赖抽象接口
抽象接口不应该依赖细节,细节应该依赖抽象接口
例如,Reporting模块依赖ILogger接口
class Reporting{
ILogger& logger;
public:
Reporting(const ILogger& logger) : logger{logger} {}
void prepare_report(){
logger.log_info("Prepare the report");
//...
}
};
当Reporting依赖的接口增多时,或者接口也有自己的依赖时,为了处理这些依赖关系,可以使用依赖注入技术
假设有一辆小车,它有引擎和日志,可以说,小车同时依赖引擎和日志,定义如下
struct Engine{
float volume = 5;
int horse_power = 400;
friend ostream& operator<<(ostream& os, const Engine& obj){
return os << obj.volume << obj.horse_power;
}
};
struct ILogger{
virtual ~ILogger() {}
virtual void Log(const string& s) = 0;
};
struct ConsoleLogger : ILogger{
ConsoleLogger() {}
void Log(const string& s) override{
cout << s.c_str() << endl;
}
};
现在,即将定义的Car要依赖engine模块和logger模块,应当将这两个依赖组件作为Car的构造函数的参数
struct Car{
unique_ptr<Engine> engine;
shared_ptr<ILogger> logger;
Car(unique_ptr<Engine>& engine, const shared_ptr<ILogger>& logger) : engine{move(engine)}, logger{logger} {
logger->Log("making a car");
}
friend ostream& operator<<(ostream& os, const Car& obj){
return os << *obj.engine;
}
};
初始化Car时,并不会传入两个智能指针,相反,使用Boost::DI。首先,定义bind,将ILogger绑定到ConsoleLogger。这么做的含义是“任何时候,如果有对ILogger的需求,那么就给它提供一个ConsoleLogger”
auto injector = di::make_injector(di::bind<ILogger>.to<ConsoleLogger>());
现在配置好了依赖注入,可以使用它来创建Car
auto car = injector.create<shared_ptr<Car>>();
这种方法的优点在于,如果要修改正在使用的logger的类型,我们可以在某个单一位置(bind调用)进行更改,让ILogger出现的每个位置都可以使用我们提供的其他日志组件
创建型设计模式
第2章 构造器模式
2.1 预想方案
没有设计模式和OOP思想的时候,设计一个组件呈现网页是这样的
string words[] = {"hello", "world"};
ostringstream oss;
oss << "<ul>";
for(auto w : words)
oss << " <li>" << w << "</li>";
oss << "</ul";
printf(oss.str().c_str());
采用OOP的思想定义一个HtmlElement类,用于存储每一类标签的信息
struct HtmlElement{
string name, text;
vector<HtmlElement> elements;
HtmlElement() {}
HtmlElement(const string& name, const string& text) : name(name), text(text) {}
string str(int indent = 0) const {
//...
}
};
采用这种方法,可以使用更合理的方式来创建列表:
string words[] = {"hello", "world"};
HtmlElement list{"ul", ""};
for(auto w: words)
list.elements.emplace_back("li", w);
printf(list.str().c_str());
但是每个HtmlElement的构建过程不是很方便,可以通过构造器模式来改进它
2.2 简单构造器
构造器模式尝试将对象的分段构造过程封装到单独的类中
struct HtmlBulider{
HtmlElement root;
HtmlBulider(string root_name) {root.name = root_name;}
void add_child(string child_name, string child_text){
root.emplace_back(child_name, child_text);
}
string str(){return root.str();}
};
上面的HtmlBulider是专门用于构建Html元素的组件。add_child()方法用于向当前的元素中添加其他子元素,每个子元素为一个pair,使用方法如下
HtmlBuilder builder{"ul"};
builder.add_child("li", "hello");
builder.add_child("li", "world");
cout << builder.str() << endl;
2.3 流式构造器
现在,修改add_child()方法的定义
HtmlBuilder& add_child(string child_name, string child_text){
root.elements.emplace_back(child_name, child_text);
return *this;
}
通过将add_child()的返回值修改为对HtmlBuilder的引用,可以实现Builder的链式调用。这就是所谓的流式接口
HtmlBuilder builder{"ul"};
builder.add_child("li", "hello").add_child("li", "world");
cout << builder.str() << endl;
如果想要使用->运算符实现链式调用,可以返回一个指针而不是引用
HtmlBuilder* add_child(string child_name, string child_text){
root.elements.emplace_back(child_name, child_text);
return this;
}
HtmlBuilder builder{"ul"};
builder->add_child("li", "hello")->add_child("li", "world");
cout << builder.str() << endl;
2.4 向用户传达意图
怎么样使用户知道HtmlBuilder的使用方法呢?一种办法是,强制用户在构建对象时使用HtmlBulider
struct HtmlElement{
string name,text;
vector<HtmlElement> elements;
const size_t indent_size = 2;
static unique_ptr<HtmlBuilder> create(const string& root_name){
return make_unique<HtmlBulider> root_name;
}
protected:
HtmlElement() {}
HtmlElement(const string& name, const string& text) : name{name}, text{text} {}
};
这一段代码首先隐藏了HtmlElement的所有构造函数,所以无法在外部构造HtmlElement;然后,创建了一个工厂方法(参见第3章),从HtmlElement直接创建构造器,它是一个静态方法,可以这样使用
auto builder = HtmlElement::create("ul");
builder.add_child("li", "hello").add_child("li", "world");
cout << builder.str() << endl;
但是,我们的最终目的是创建一个HtmlElement,而不是它的构造器。所以,更好的做法是,通过定义调用运算符实现隐式转换,从而得到最终输出的HtmlElement
struct HtmlBuilder{
operator HtmlElement() const {return root;}
HtmlElement root;
// ...
}
增加了运算符之后,可以写出以下代码
HtmlElement e = HtmlElement::build("ul").add_child("li", "hello").add_child("li", "world");
cout << e.str() << endl;
遗憾的是,没有一种明确的方法可以告知用户要以上面的方式来使用这个API。除了上面定义的运算符外,还可以向HtmlBuilder添加相应的build函数
HtmlElement HtmlBuilder::build() const{
return root;
}
2.5 Groovy风格的构造器
偏离构造器主题
2.6 组合构造器
使用多个构造器来构建单个对象。现在,假设我们想记录一个人的某些信息
class Person{
string street_address, post_code, city;
string company_name, position;
int annual_income = 0;
Person() {}
};
Person有两方面的信息:地址和工作。如果要用单独的构造器来分别创建不同的信息,该如何定义API?为此,需要创建一个组合构造器
首先是PersonBuilderBase
class PersonBuilderBase{
protected:
Person& person;
explicit PersonBuilderBase(Person& person): person(person) {}
public:
operator Person(){return move(person);}
PersonAddressBuilder lives() const;
PersonJobBuilder works() const;
};
person是对即将创建的对象的引用,在这个类中并没有实际存储Person的成员。
以Person的引用作为参数的构造函数被声明为protected,因此只有其派生类可以使用它
Operator Person()是之前使用过的技术,它假设了Person提供了一个正确定义的移动构造函数
lives()和works()是两个返回构造器的函数:它们分别完成对地址信息和工作信息的构建
现在,基类中唯一缺少的就是实际要构建的对象,它实际上再即将定义的派生类PersonBuilder中,这个类也是用户真正希望使用的类
class PersonBuilder : public PersonBuilderBase{
Person p;
public:
PersonBuilder() :PersonBuilderBase{p} {}
};
PersonBuilder才是真正要构建Person类的地方。PersonBuilder不是用于继承的,它只是一个构建构造器初始化过程的工具
为什么要定义不同的public和protected构造函数?在于子构造器的实现中:
class PersonAddressBuilder : public PersonBuilderBase{
using self = PersonAddressBuilder;
public:
explicit PersonAddressBuilder(Person& person) : PersonAddressBuilder{person} {}
self& at(string street_address){
person.street_address = street_address;
return *this;
}
self& with_postcode(string post_code) {...}
self& in(string city) {...}
};
可以看到,PersonAddressBuilder提供了创建person地址的流式接口。注意,PersonAddressBuilder实际上继承自PersonBuilderBase(因此它也会有lives()和works()函数)并且将Person的引用传入PersonBuilder的构造函数。PersonAddressBuilder并没有继承自PersonBuilder,否则会创建很多Person实例。
PersonJobBuilder以相似的方式来实现。PersonAddressBuilder、PersonJobBuilder、PersonBuilder均声明为Person的友元类,因此它们可以访问Person的私有成员。接下来,可以写出如下代码
Person p = Person::create()
.lives().at("123 London Road")
.with_postcode("SW1 1GB")
.in("London")
.works.at("PragmaSoft")
.as_a("Consultant")
.earning(10e6);
使用create()函数得到了一个构造器,然后使用lives()函数得到了一个PersonAddressBuilder。一旦完成地址信息的初始化,仅通过调用works()就可以使用PersonJobBuilder了。
2.7 参数化构造器
如前所示,强制用户使用构造器而不是直接构造对象的唯一办法是使构造函数不可访问。然而,某些情况下,我们可能想要明确地强制用户从一开始就和构造器交互,甚至希望隐藏他们正在构建的对象
假设有一个用于发送电子邮件的API,内部描述如下
class Email{
public:
string from, to, subject, body;
};
现在,决定实现一个流式构造器,通过这个构造器,用户将在幕后构建Email
class EmailBuilder{
Email& email;
public:
explicit EmailBuilder(Email& email) : email(email){}
EmailBuilder& from(string from) {
email.from = from;
return *this;
}
// ...
}
为了强制用户只通过构造器发送邮件,可以实现一个MailService
class MailService{
class Email{...}
public:
class EmailBuilder {...};
void send_email(function<void(EmailBuilder&)> builder){
Email email;
EmailBuilder b{email};
builder(b);
send_mail_impl(email);
}
private:
void send_mail_impl(const Email& email){...}
}
用户使用的send_email()方法的入口并不是一组参数或预先打包好的对象,而是携带了一个函数。这个函数以EmailBuilder的引用为参数,然后通过EmailBuilder构建邮件的主体内容。一旦构建工作完成,我们就可以使用MailService的内部机制来生成一个完全初始化的Email。
可以看到,这里使用了一个技巧:EmailBuilder通过其构造函数的参数来获得Email的引用,而不是在其内部存储Email的引用。这么做的原因是,通过这种方式,EmailBuilder也就不必在它的任何一个API中公开暴露Email。
以下代码展示了用户如何使用它
MailService ms;
ms.send_email([&](auto& eb){
eb.from("foo@bar.com")
.to("bar@baz.com")
.subject("hello")
.body("Hello, how are you?");
});
参数化构造器强制用户通过我们提供的API来使用构造器。这种基于函数的技巧能够确保用户获得已初始化的构造器对象
2.8 构造器模式的继承性
流式构造器可以继承吗?可以,但过程不一定简单
构建一个简单对象
class Person{
public;
string name, position, date_of_birth;
friend ostream& operator<<(ostream& os, const Person& obj){
return os << obj.name << obj.position << obj.data_of_birth;
}
};
定义了一个基类PersonBuilder用于构建Person对象
class PersonBuilder{
protected:
Person person;
public:
[[nodiscard]] Person build() const{
return person;
}
}
然后是专门用于构建Person名称的类
class PersonInfoBuilder : public PersonBuilder{
public:
PersonInfoBuilder& called(const string& name){
Person.name = name;
return *this;
}
};
上述代码没有任何问题,但现在,假设要从PersonInfoBuilder派生另一个类,以构建Person的工作信息,也许会编写如下代码
class PersonJobBuilder: public PersonInfoBuilder{
public:
PersonJobBuilder& works_as(const string& position){
person.position = position;
return *this;
}
};
这段代码造成了问题,使得流式接口被破坏,并且整个创建过程都不再可用
PersonJobBuilder pb;
auto person = pb.called("Dmitri")
.works_as("Programmer")// 编译错误
.build();
为何编译无法通过,因为called()方法返回的*this是一个PersonInfoBuilder&类型,其中没有定义works_as()方法
正确的实现是这样的
template<typename TSelf>
class PersonInfoBuilder : public PersonBuilder{
public:
TSelf& called(const string& name){
Person.name = name;
return static_cast<TSelf&>(*this);
}
};
这是经典的CRTP(奇异递归模版模式)。引入了一个新的模板参数TSelf,期望这个参数继承自PersonInfoBuilder,但是却缺少concept或static_assert,遗憾的是,c++无法完成这种自我检查,因为想要完成某个类自我检查时,尚未对这个类进行完整的定义。
流式接口继承最大的问题在于,调用基类的流式接口时,能否将基类内部的this指针正确转换为正确的类型并作为该流式接口的返回值。解决这个问题唯一有效的办法是使用一个贯穿整个继承层次的模板参数TSelf。
为了理解这一点,再来看看PersonJobBuilder
template <typename TSelf>
class PersonJobBuilder : public PersonInfoBuilder<PersonJobBuilder<TSelf>>{
public;
TSelf& works_as(const string& position){
this->person.position = position;
return static_cast<TSelf&(*this);
}
};
PersonJobBuilder的基类不再是之前那个普通的PersonInfoBuilder,相反,基类的类型是PersonInfoBuilder<PersonJobBuilder>。因此,当继承自PersonInfoBuilder时,我们将PersonInfoBuilder的TSelf参数设置为PersonJobBuilder,以使基类的所有流式接口返回正确的类型,而不是基类本身的类型。
假设现在加入另一个属性data_of_birth和对应的PersonDataOfBirthBuilder,那么应该继承哪个类呢?答案并不是PersonInfoBuilder<PersonJobBuilder<PersonBirthDataBuilder>>,而是PersonJobBuilder<PersonBirthDataBuilder>
template <typename TSelf>
class PersonBirthDataBuilder : public PersonJobBuilder<PersonBirthDataBuilder<TSelf>>{
public:
TSelf& born_on(const string& data_of_birth){
this->person.data_of_birth = data_of_birth;
return static_cast<TSelf&>(*this);
}
};
最后,鉴于这些类总是带有模板参数,该如何构造这样的构造器呢?需要一个新的类型,而不仅是一个变量
class MyBuilder : public PersonBirthDateBuilder<MyBuilder> {};
为了使用上面定义的构造器,我们需要从递归地带有模板参数的类派生出不带模板参数的类,也就是说,现在我们可以利用MyBuilder继承链中的方法将所有内容放在一起
MyBuilder mb;
auto me = mb.called("Dmitri")
.works_as("Programmer")
.born_on("01/01/1980")
.build();
cout << me;
2.9 总结
构造器模式的目的是简化复杂对象或一系列对象的构建过程,从而单独定义构成该复杂对象的各个组件的构建方法。它有如下特点:
- 构造器模式可以通过流式接口调用链来实现复杂的构建过程。为了实现流式接口,构造器的函数需要返回this或*this
- 为了强制用户使用构造器的API,我们可以将目标对象的构造函数限制为不可访问,同时,定义一个create()接口返回构造器
- 通过定义适当的运算符,可以使构造器转换为对象本身
- 借助C++新特性中的统一初始化语法,可以实现Groovy风格的构造器。
- 单个构造器可以暴露多个子构造器接口。通过灵活地使用继承和流式接口,很容易将一个构造器变换为另一个构造器
再次重申,当对象的构建过程是非普通的时候,构造器模式是有意义的。对于那些通过数量有限且命名合理的构造函数参数来明确构造的简单对象而言,他们应该使用构造函数,而不必使用构造器模式
工程方法和抽象工厂模式
3.1 预想方案
考虑一个简单的由墙组成的建筑物结构。由以下几部分组成:底部起止二维坐标的点;海拔;高度
class Wall{
Point2D start, end;
int elevation, height;
public;
Wall(Point2D start, point2D end, int elevation, int height) : start(start), end(end), elevation(elevation), height(height) {}
};
为了让模型更接近实机,添加厚度和材质信息
enum class Material{
brick,
aerated_concrete,
drywall
};
class SolidWall : public Wall{
int width;
Material material;
public:
SolidWall(Point2D start, point2D end, int elevation, int height, int width, Material material) : Wall{start, end, elevation, height}, width{width}, material{material} {}
};
此时,这两个类都有可以被直接调用的public构造函数。然而,对于SolidWall这个类,再加上一些限制,例如,加气混凝土不能用于地下建筑,墙的最小厚度是120mm,如何实现
SolidWall::SolidWall(const Point2D start, const point2D end, const int elevation, const int height, const int width, const Material material) : Wall{start, end, elevation, height}, width{width}, material{material} {
if(elevation < 0 && material == Material::aerated_concrete)
throw invalid_argument("elevation");
if(width < 120 && material == Material::brick)
throw invalid_argument("width");
}
这种做法的主要问题在于,异常对于构造函数的限制,抛出异常时,构造函数会中断。
3.2 工厂方法
暂时移除掉相关的验证代码,并且将构造函数声明为protected。然后,添加一对静态方法以使用预定义尺寸和材料构建SolidWall对象
class SolidWall : public Wall{
int width;
Material material;
protected:
SolidWall(const Point2D start, const point2D end, const int elevation, const int height, const int width, const Material material);
public:
static SolidWall create_main(Point2D start, Point2D end, int elevation, int height){
return SolidWall{start, end, elevation, height, 375, Material::aerater_concrete};
}
static unique_ptr<SolidWall> create_partition(Point2D start, Point2D end, int elevation, int height){
return make_unique<SolidWall>(start, end, elevation, height, 120, Material::brick);
}
}
这两种静态方法都被称为工厂方法,它们强制用户创建特定类型而非任意类型的墙,可以这样使用
const auto main_wall = SolidWall::create_main({0,0}, {0,3000}, 2700, 3000);
cout << main_wall << "\n";
3.3 工厂
现在,可以为这两种工厂方法添加验证输入参数
static shared_ptr<SolidWall> create_main(Point2D start, Point2D end, int elevation, int height){
if(elevation < 0) return {};
return make_shared<SolodWall>(start, end, elevation, height, 375, Material::aerated_concrete);
}
上述代码返回的是一个shared_ptr,如果参数验证不满足则返回一个默认的初始值。这种方法使得工厂方法在某些参数不满足条件时会拒绝构建指定对象:
const auto also_main_wall = SolidWall::create_main({0,0}, {10000,0}, -2000, 3000);
if(!also_main_wall)
cout << "Main wall not created\n";
另外,在建筑物内部,如果新建的墙会与已经修建好的墙相交,则不能创建这一堵墙,如何实现这种约束?需要跟踪记录已创建好的每一堵墙,但是应在何处记录这个信息?为了解决这个问题,引入一个工厂,即专门负责创建特殊类型对象的单独的类,可以将WallFactory定义为
class WallFactory{
static vector<weak_ptr<Wall>> walls;
public:
static shared_ptr<SolidWall> create_main(Point2D start, Point2D end, int elevation, int height){/*如上*/}
static shared_ptr<SolidWall> create_partition(Point2D start, Point2D end, int elevation, int height){
const auto this_wall = new SolidWall{start, end, elevation, height, 120, Material::brick};
for(const auto wall: walls){
if(auto p = wall.lock()){
if(this_wall->intersects(*p)){
delete this_wall;
return {};
}
}
}
shared_ptr<SolidWall> ptr(this_wall);
walls.push_back(ptr);
return ptr;
}
};
这段代码将每一堵已经创建的墙保存在vector<weak_ptr>中。首先,我们以传统的方式(即使用new)来创建SolidWall,然后检查新创建的Solidwall是否与已存在的墙相交。如果相交,则删除它并返回一个包含默认值的对象的指针。否则,我们将这个对象的指针封装到shared_ptr,以weak_ptr的形式保存起来并返回。
需要注意,如果我们将SolidWall的构造函数定义为private或protected,那么SolidWall必须将类WallFactory声明为友元类,但这明显违背了开闭原则。
即使已经将WallFactory声明为友元类,我们仍旧不发使用make_shared。
现在,我们可以使用工厂而不是类Wall来创建对象
const auto partition = WallFactory::create_partition({2000,0},{2000,4000},0,2700);
cout << *partition << "\n";
3.4 工厂方法和多态
使用工厂方法的好处之一是它们可以返回多态类型。当然,这种做法将以按值传递返回对象的想法排除掉了(会导致对象切割),不过还是可以返回指针。
例如,定义一个枚举类Walltype用于指定需要基本的墙(Wall)还是SolidWall
enum class WallType{
basic,
main,
partition
};
可以定义一下多态工厂方法:
static shared_ptr<Wall> create_wall(WallType type, Point2D start, Point2D end, int elevation, int height){
switch(type){
case WallType::main:
return make_shared<SolidWall>(start, end, elevation, height, 375, Material::aerated_concrete);
case WallType::partition:
return make_shared<SolidWall>(start, end, elevation, height, 120, Material::brick);
case WallType::basic:
return shared_ptr<wall>{new Wall(start, end, elevation, height)};
}
return {};
}
为了简化代码,再次移除验证参数的相关代码。如上述代码所示,函数的返回类型是shared_ptr,但在有的场景下,函数也会返回shared_ptr。多态工厂方法的用法如下
const auto also_paritition = WallFactory::create_wall(wallType::partition, {0,0}, {5000, 0}, 0, 4200);
if(also_partition)
cout << *dynamic_pointer_cast<SolidWall>(also_partition) << "\n";
使用多态工厂方法时,需要格外注意:调用任何没有使用关键字virtual限定的方法都将只会得到基类中该方法所定义的行为。例如,如果Wall和SolidWall两个类都定义了ostream& operator<<,在不使用dynamic_pointer_cast的情况下,我们将看到只有基类Wall的输出
3.5 嵌套工厂
目前为止,从构造函数迁移到工厂,涉及以下步骤:
- 将构造函数声明为protected
- 将工厂声明为对象的friend类。如果定义的某些类具有一定的层次结构,那么需要在这个层次结构中的每一个元素重复上面的操作
- 在工厂方法内部创建对象,然后以shared_ptr的形式返回。注意,在工厂方法内部,无法调用make_shared,因为我们无法调用
上述所有步骤的核心问题都在于对象和创建该对象的工厂之间的纠缠。如果工厂在对象之后创建,并且有我们来控制工厂创建过程,那么包含友元类的声明显然违背了开闭原则。但如果工厂要创建一个它自己都无从知晓的对象,将工厂视为友元类显然更不可能。
可以考虑第三种选择,即创建嵌套的工厂,也就是,在对象内部定义工厂:
class Wall{
private:
class BasicWallFactory{
BasicWallFactory() = default;
public:
shared_ptr<Wall> create(const Point2D start, const point2D end, const int elevation, const int height){
Wall* wall = new Wall(start, end, elevation, height);
return shared_ptr<Wall>(wall);
}
};
public:
static BasicWallFactory factory;
}
关于内部类BasicWallFactory,有几点:它在private模块中,并且构造函数也被声明为private,因此,外部其他地方无法直接初始化BasicWallFactory;这个工厂方法不是静态的;类Wall将这个工厂方法对外暴露为一个静态成员
接下来可以这样使用工厂
auto basic = Wall::factory.create({0,0}, {5000,0}, 0, 3000);
cout<< *basic << "\n";
如果要更改此处的设计,那么也得完全修改相关联的地方
3.6 抽象工厂
抽象工厂是一种只在复杂系统重出现的模式。处于历史原因,我们需要讨论它。
考虑一个简单场景:一个咖啡馆,供应茶和咖啡 。这两种热饮是通过完全不同的设备制作的,我们可以将二者都模拟为工厂。实际上茶和咖啡既可以是热饮,也可以是冷饮,我们先讨论热饮。首先,我们定义什么是热饮:
struct HotDrink{
virtual void prepare(int volume) = 0;
};
函数prepare()用于准备指定容量的热饮。例如,对于茶,可以实现如下
struct Tea : Hotdrink{
void prepare(int volume) override{
cout << volume << endl;
}
};
对于咖啡,实现也是类似的。此时,我们可以编写一个make_drink()函数,该函数以饮品名称作为参数,然后返回对应的饮品
unique_ptr<HotDrink> make_drink(string type){
unique_ptr<HotDrink> drink;
if(type == "tea"){
drink = make_unique<Tea>();
drink->prepare(200);
}else{
drink = make_unique<Coffee>();
drink->prepare(50);
}
return drink;
}
不同的饮品由不同的设备制作。在本示例中,目前只关注热饮,并且通过恰如起名的类HotDrinkFactory来制作热饮
class HotDrinkFactory{
public:
virtual unique_ptr<HotDrink> make() const = 0;
};
这个类恰好是一个抽象工厂:它本身是有具体接口的工厂,但它是抽象的,这就意味着即使它可以作为函数的参数,我们也需要具体的代码来实现制作饮品。以咖啡为例
class CoffeeFactory : public HotDrinkFactory{
public:
unique_ptr<HotDrink> make() const override{
return make_unique<Coffee>();
}
};
TeaFactory的实现是类似的,现在假设要制作不同的饮料,比如热饮或冷饮,因此需要定义更高级别的接口
class DrinkFactory{
map<string, unique_ptr<HotDrinkFactory>> hot_factories;
public:
DrinkFactory(){
hot_factories["Coffee"] = make_unique<CoffeeFactory>();
hot_factories["Tea"] = make_unique<TeaFactory>();
}
unique_ptr<HotDrink> make_drink(const string& name){
auto drink = hot_factories[name]->make();
drink->prepare(200);
return drink;
}
};
类Drinkfactory定义了一个包含字符串和对应类型的工厂的map,实际的工厂类型是HotDrinkfactory
3.7 函数式工厂
当我们提及“工厂”术语时,通常指的是下面两个概念之一:
- 一个类,它可以创建对象
- 一个函数,调用它时,可以创建一个对象
第二个概念并不是工厂方法使用的典型场景,如果传入function<>类型的参数(或者传入普通的函数指针),该函数返回类型为T的变量,这也是一种工厂而不是工厂方法,如果将它同等看做成员函数,或许能更好地理解这一点
void construct(function<T()> f){
T t = f();
}
函数可以保存在变量中,这意味着我们可以将准备200ml的饮品的过程全部放在函数内部处理,而不必保存指向工厂的指针。可以通过将工厂替换为函数块实现
class DrinkWithVolumeFactory{
map<string, function<unique_ptr<HotDrink>()>> factories;
public:
DrinkWithVolumeFactory(){
factories["tes"] = []{
auto tea = make_unique<Tea>();
tea->prepare(200);
return tea;
}
}
};
当然,采用这种方法后,可以简化为直接调用保存的工厂,即
inline unique_ptr<HotDrink> DrinkWithVolumeFactory::make_drink(const string& name){
return factories[name]();
}
这样就可以像之前一样使用make_drink()。
3.8 对象追踪
工厂比调用构造函数更难使用。但是,工厂使我们可以追踪所有已经创建的对象。使用工厂的好处有
- 可以知道已经创建的特定类型的对象的数量
- 可以修改或者完全替换整个对象的创建过程
- 如果使用了智能指针,则可以通过观察对象的引用计数来获知对象在其他地方被引用的数量
服务定位器或控制反转容器可以采取这种对象追踪的策略。这样的容器可以以shared_ptr的形式构造对象,但在内部以weak_ptr的形式管理,这样既可以观察对象状态,又可以在运行时完全替换为新的对象
一旦引入这种对象构建方式,就可以迭代之前创建的MyClass类型的所有对象。由于这些对象都以weak_ptr形式进行管理,因此在weak_ptr所管理对象已被销毁时,需要清理这些weak_ptr。
这种方法允许“运行时编译”,即在应用程序运行时可以修改和重新编译部分源代码,并且可以在不中断程序和完全重新编译的前提下,用更新的实例替换特定对象的所有现有实例。本书不作演示。
3.9 总结
回顾一下本章的一些术语:
- 工厂方法:类的成员函数,用于创建对象。它通常可以替换构造函数
- 工厂:一个类,它知道如何创建对象。不过,如果我们传入给一个函数传入可创建对象的参数(如函数或者类似的“对象”),那么这个参数也被称为工厂
- 抽象工厂:抽象类可以被具体的类继承,由此产生了一个工厂类族,实际开发中很少见。
相比调用构造函数,使用工厂有几个关键优势
- 工厂可以拒绝构建对象,也就是说,工厂可以返回默认初始化的智能指针,可以返回optional或nullptr,而不用必须返回一个对象,可用于数据验证
- 工厂方法可以是多态的,因此工厂方法可以返回基类或其指针。使用其他方法(比如variant),工厂方法还可以支持返回不同的数据类型
- 与构造函数命名不同,工厂方法的命名不受约束并且可以更有意义
- 工厂可以实现缓存和其他存储优化,对于诸如池或单例模式之类的方法来说,这也是一种不错的选择
- 工厂可以将对象不同的关注点内容(比如验证代码)封装
工厂模式与构造器模式的差别在于,使用工厂模式,我们可以一次创建一个完整的对象;而使用构造器模式,则需要分布提供对象的部分信息才能逐步完成一个对象的创建
第4章 原型模式
4.1 对象构建
从已经有一个完整配置的对象直接拷贝。有如下示例
Contact john{"John Doe", Address{"123 East Dr", "London", 10}};
Contact jane{"John Doe", Address{"123 East Dr", "London", 11}};
john和jane在同一栋大楼的不同办公室工作,可能还有其他人也在这栋楼里工作。使用原型模式可以避免重复对该地址信息做初始化
4.2 普通拷贝
如果正在copy一个值和其所有成员都是通过值来存储的对象,那么copy毫无问题
class Address{
public:
string street, city;
int suite;
};
class Contact{
public:
strng name;
Address address;
};
Contact worker{"", Address{"123 East Dr", "London", 0}};
Contact john = worker;
john.name = "John Doe";
john.address.suite = 10;
在实际应用中,这种情况极其少见。在许多场景中,通常将内部的Address对象作为指针或引用,例如
class Contact{
public:
string name;
Address* addresss;
~Contact() {delete address;}
};
这种情况下,john和jane都只会共享同一个地址,这不是理想的结果
4.3 通过拷贝构造函数进行拷贝
避免拷贝指针的最简单的方法是确保对象的所有组成部分(如上面的示例中的Contact和Address)都完整定义了拷贝构造函数。例如
class Contact{
public:
string name;
Address* address;
};
那么,需要定义一个拷贝构造
Contact(const Contact& other) : name{other.name}{
address = new Address(
other.address->street,
other.address->city,
other.address->suite,
);
}
但这种方法并不通用,在上面的示例中当然没问题,但是如果Address的street成员是由街道名、门牌号和一些附加信息组成的,怎么办
一种办法是为Address定义拷贝构造函数
Address(const string& street, const string& city, const int suite):street{street}, city{city}, suite{suite} {}
现在,可以重写Contact的构造函数,在Contact的构造函数中可以重用拷贝函数
Contact(const Contact& other) : name{other.name} , address{new Address(*other.address)} {}
有些编译器在生成拷贝构造和移动构造的同时,也会生成拷贝赋值函数,在本示例中,拷贝赋值函数定义为
Contact& operator=(const Contact& other){
if(this == other)
return *this;
name = other.name;
address = other.address;
return *this;
}
现在可以构造原型,然后重用
Contact worker{"", new Address{"123 East Dr", "London", 0}};
Contact john{worker};
john.name = "John";
john.suite = 10;
这种方法是有效的,唯一问题是,需要为此付出额外工作。但是,如果忘记提供Address类拷贝赋值等函数的实现,程序仍会通过编译,并造成意外后果
4.4 “虚”构造函数
拷贝构造使用场景相当有限,并且存在的一个问题是,为了对变量做深度拷贝,我们需要知道变量具体是哪种类型。假设ExtendedAddress类继承自Address类
class ExtendedAddress : public Address{
public:
string country, postcode;
ExtendedAddress(const string& street, const string& city, const int suite, const string& country, const string& postcode): Address(street, city, suite), country{country}, postcode{postcode} {}
};
若我们要拷贝一个存在多态性质的变量
ExtendedAddress ea = ...;
Address& a = ea;
这种做法存在问题,因为我们并不知道a的最终派生类型是什么。由于最终派生类引发的问题,以及拷贝构造不能是虚函数,因此我们需要其他方法来拷贝
以Address对象为例,引入一个虚函数clone()
virtual Address clone(){return Address{street, city, suite};}
然而,这并不能解决继承场景下的问题。对于派生对象,我们想返回的是ExtendedAddress类型,但上述代码展示的接口将返回类型固定为Address。我们需要的指针形式的多态
virtual Address* clone(){return new Address{street, city, suite};}
现在,我们可以在派生类中做同样的事情,只不过要提供对应的返回类型
virtual ExtendedAddress* clone() override{
return new ExtendedAddress(street, city, suite, country, postcode);
}
现在,可以安全放心地调用clone()函数,而不必担心对象由于继承体系被切割了:
ExtendedAddress ea{"123 Weat Dr", "London", 123, "UK", "SW101EG"};
Address& a = ea;
auto cloned = a.clone();
现在,变量cloned的确是一个指向深度拷贝ExtendedAddress对象的指针了。当然,这个指针的类型是Address*,所以如果需要额外的成员,可以通过dynamic_cast进行转换或者调用某些虚函数。例如,使用cout << cloned只会打印基类的数据,因为柳树出淤泥算符并不是虚函数。
如果出于某些原因,想要使用拷贝构造,则clone()接口可以简化为
ExtendedAddress* clone() override{
return new ExtendedAddress(*this);
}
可以在之后把所有工作留到拷贝构造中完成。
使用clone()方法的不足之处在于,编译器并不会检查整个继承体系每个类中实现的clone()方法(其实也没有强制进行检查的方法)。如果其中一个clone()未实现,可能会引发意外结果
4.5 序列化
序列化指的是将对象或数据结构转换为一种可存储的方式,依赖的是一种名为“反射”的技术,但在C++中暂时不支持”反射“,因此,在C++中,需要自己实现它,可以使用Boost.Serialization的现成的库来解决序列化的问题,而不用费劲地处理位和思考序列化std::string的方法
class Address{
public:
string street.city;
int suite;
private:
friend class boost::serialization::access;
template<class Ar>
void serialize(Ar& ar, const unsigned int version){
ar& street;
ar& city;
ar& suite;
}
};
结果是,通过对Address类的所有成员使用&运算符,可以将Address类写入存储对象的任何位置。
现在,可以对Contact类做同样的操作
class Contact{
public:
string name;
Address* address = nullptr;
private:
friend class boost::serialization::access;
template<class Ar>
void serialize(Ar& ar, const unsigned int version){
ar& name;
ar& address;// 没有*
}
}
代码中serialize()函数的结构大致相同,注意到address没有作为ar& *address,而是将其序列化为非指针版本。Boost足够智能,仍然可以序列化address。
因此,如果希望以这种方式实现原型模式,则需要对对象图中可能出现的每种类型提供serialize()方法的实现
template <ayptname T>
T clone(T obj){
// 1、序列化对象
ostringstream oss;
boost::archive::text_oarchive oa(oss);
oa << obj;
string s = oss.str();
// 2、反序列化
istringstream iss(oss.str());
boost::archive::text_oarchive ia(iss);
T result;
ia >> result;
return result;
}
现在,可以轻松地基于一个命名为john的Contact对象拷贝并创建新的对象jane
Contact jane = clone(john);
jane.name = "Jane"
// ...
4.6 原型工厂
如果我们预定义了要拷贝的对象,那么将它们保存在哪里?全局对象?更明智的做法是使用某种专用的类来保存它
class EmployeeFactory{
static Contact main;
static Contact aux;
static unique_ptr<Contact> NewEmployee(string name, int suite, Contact& proto){
auto result = make_unique<Contact>(proto);
result->name = name;
result->address->suite = suite;
return result;
}
public:
static unique_ptr<Contact> NewMainOfficeEmployee(string name, int suite){
return NewEmployee(name, suite, main);
}
static unique_ptr<Contact> NewAuxOfficeEmployee(string name, int suite){
return NewEmployee(name, suite, aux);
}
}
现在,可以这样使用
auto john = EmployeeFactory::NewAuxOfficeEmployee("John Doe", 123);
auto jane = EmployeeFactory::NewMainOfficeEmployee("John Doe", 125);
为什么要使用工厂?如果我们从某个原型拷贝得到一个对象,但是忘记定义该对象的一些属性。此时,该对象的某些属性将为0或者空字符串。如果使用工厂,可以将所有非完全初始化的构造函数声明为private,并将EmployeeFactory声明为friend class。现在客户不在得到未完整构建的Contact对象了
4.7 总结
原型模式体现了对对象进行深度拷贝的概念,因此,不必么次都进行完全初始化,而是可以获取一个预定义的对象,拷贝它,稍微修改,然后独立于原始的对象使用它。有两种实现原型模式的方法:
- 编写正确拷贝原始对象的代码,也就是深拷贝。这项工作可以在拷贝构造/拷贝赋值运算符或者单独的clone()函数中完成
- 编写序列化/反序列化的代码,使用这项机制,在完成序列化之后马上进行反序列化,由此完成复制。该方法会引入额外开销。是否使用它取决于实际上的拷贝频率,与上一种方法相比,它的唯一优点是可以不受限制地使用序列化功能
不论使用哪种方法,有些工作必须完成。如果所有数据都按值储存,实际上直接operator=就够了。
单例模式
单例模式的理念非常简单,即应用程序中只能有一个特定组件的实例。
5.1 作为全局对象的单例模式
要使一个类只实例化一次,可以以朴素的方式进行,那就是只实例化它一次即可。这种做法的问题是,实例化可能是隐蔽的,难以觉察的。
一个显而易见的办法是,提供一个静态全局对象
static Database database{};
它的问题是,在不同的编译单元中,静态全局对象的初始化顺序是未定义的,可能会有意外影响。另外,难以找到静态全局对象。解决这个问题的办法是提供一个全局函数,让该函数对外暴露必要的对象
DataBase& get_database(){
static Database database;
return database;
}
这种实现方式容易出错,如果Database的析构函数中使用了某个其他单例对象,程序很可能会崩溃,因为静态变量或全局变量的销毁顺序是不确定的,正在被调用的对象实际上可能已经被析构了
5.2 单例模式的经典实现
之前的实现方式无法阻止创建额外对象。全局静态的Database并不能真正阻止在其他地方创建另一个实例
对于那些喜欢创建一个对象的多个实例的人来说,可以很容易地让它崩溃,在构造函数中添加一个静态计数器,然后在值增加时抛出异常
struct Database{
Database(){
static int instance_count {0};
if(++instance_count > 1)
throw exception("Cannot make >1 database!");
}
};
这种方式并不友好:尽管它通过抛出异常阻止了创建多个实例,但它无法传达我们不希望构造函数被多次调用的事实。
防止Database被显式构建的唯一方式仍旧是将其构造函数声明为private的,然后将之前提到的函数作为成员函数,并返回Database对象的唯一实例
static Database{
protected:
Database() {}
public:
static Database& get(){
static Database database;
return database;
}
Database(Database const&) = delete;
Database(Database&&) = delete;
Database& operator=(Database const&) = delete;
Database& operator=(Database&&) = delete;
};
可以使Database继承自Boost::noncopyable,它禁止拷贝构造和拷贝赋值运算符,但不禁止移动赋值和移动赋值运算符。
再次重申,如果Database依赖其他全局变量或静态变量,那么在其析构函数中使用它们是不安全的。因为这些对象的销毁顺序是不确定的,正在被调用的对象可能已经被销毁了。
可以将get()实现为堆分配,这样只有指针而非整个对象是静态的
static Database& get(){
static Database* database = new Database();
return *database;
}
这个实现依赖于“Database一直存在直到程序结束”的假设。使用指针而不是引用可以确保析构函数永远不会被调用,即使定义了析构函数。这段代码不会导致内存泄漏。
5.3 单例模式存在的问题
数据路单例模式设计得接口为
class Database{
public:
virtual int get_population(const string& name) = 0;
};
假设这个接口被一个名为SingletonDatabase的由Database派生的具体类采用,SingletonDatabase以同样的方式实现单例模式
class SingletonDatabase : public Database{
SingletonDatabase() {}
map<string, int> capitals;
public:
SingletonDatabase(SingletonDatabase const&) = delete;
void operator=(SingletonDatabase const&) = delete;
static SingletonDatabase& get(){
static SingletonDatabase db;
return db;
}
int get_population(const string& name) override{
return capitals[name];
}
};
SingletonDatabase的构造函数从文本文件中读取各个首都的名称和人口,并保存到一个map中。get_population()方法用于返回指定城市的人口数量。
在本例中,单例模式真正存在的问题在于它们能否在别的组件中使用,在前面的基础上,我们构建一个组件来计算几个不同城市的总人口
struct SingletonRecordFinder{
int total_population(vector<string> names){
int result = 0;
for(auto& name:names)
result += SingletonDatabase::get().get_population(name);
return result;
}
};
现在,SingletonRecordFinder完全依赖于SingletonDatabase,这给单元测试带来了困难
Test(RecordFinderTests, SingletontotalPopulationTest{
SingletonrecordFinder rf;
vector<string> names{"Seoul", "Mexico City"};
int tp = rf.total_population(names);
EXPECT_EQ(17500000 + 17400000, tp);
}
由于依赖,这只能做集成测试而不能做单元测试。
如何改善它,首先,不能再显式地依赖SingletonDatabase。由于我们需要实现数据库接口,因此可以创建一个新的ConfigurableRecordFinder以配置数据的来源
struct ConfigurableRecordFinder{
explicit ConfigurableRecordFinder(Database& db) : db(db) {}
int total_population(vector<string> namse){
int result = 0;
for(auto& name:names)
result += db.get_population(name);
return result;
}
Database& db;
}
现在,我们不再显式地使用SingletonDatabase,而是使用db引用,于是,我们创建一个专门用于测试记录查找器的虚拟数据库
class DummyDatabase : public Database{
map<string, int> capitals;
public:
DummyDatabase(){
capitals["alpha"] = 1;
capitals["beta"] = 2;
capitals["gamma"] = 3;
}
int get_population(const string& name) override{
return capitals[name];
}
};
借助DummyDatabase,我们可以重新编写单元测试
Test(RecordFinderTests, DummyTotalPopulationTest){
DummyDatabase db{};
ConfigurableRecordFinder rd{db};
EXCEPT_EQ(4, rf.total_population(vector<string>{"alpha", "gamma"}));
}
这个单元测试更健壮,因为即使实际数据库中的数据变化,也不必调整单元测试的值,因为虚拟数据保持不变。
5.3.1 每线程单例
我们已经提到过单例模式创建过程中的线程安全性,但是单例模式自身操作的线程安全性如何呢?可能应用程序中的所有线程之间不需要共享一个单例,而是每个线程都需要一个单例
每线程单例的构建过程与之前的单例模式一样,只是现在要为静态函数中的变量加上thread_local声明
class PerThreadSingleton{
PerThreadSingleton(){
id = this_thread::get_id();
}
public:
thread::id id;
staitc PerThreadSingleton& get(){
thread_local PerThreadSingleton instance;
return instance;
}
};
id如果不是必须的可以去掉。现在,为了验证每个线程确实有一个单例,可以运行如下代码
thread t1([](){
cout << "t1: " << PerThreadSingleton::get().id << "\n";
});
thread t2([](){
cout << "t2: " << PerThreadSingleton::get().id << "\n";
cout << "t2 again: " << PerThreadSingleton::get().id << "\n";
});
t1.join();
t2.join();
输出如下
txt
t2: 22712
t1: 22708
t2 again: 22712
线程局部单例解决了特殊的组件依赖问题。另一个好处是,不必担心线程安全问题,因此可以使用map而不是concurrent_hash_map。
5.3.2 环境上下文
5.3.3 单例模式与控制反转
如果在某个时刻决定不再将某个类作为单例,需要修改的代码就太多。一种解决方案是采用一种约定,在这种约定中,负责组件的函数并不直接控制组件的生命周期,而是外包给控制反转容器(IOC)
5.3.4 单态模式
单态模式是单例模式的一种变体,行为上类似于单例模式,但看起来像一个普通的类
class Printer{
static int id;
public:
int get_id() const {return id;}
void set_id(int value) {id = value;}
};
这种模式利用了同一个类的所有对象都共享同一个静态变量的特性。
单态模式是有效的,也有一些优点,它允许继承和多态,更容易控制生命周期。更大的优势是,它允许我们使用并修改当前系统中已经使用的对象。
缺点也很明显:它是一种侵入式方法(将普通对象转换为单态状态并不容易),并且静态成员的使用意味着他总是会占据内存。单态模式最大的缺点在于它做了国娱乐段的假设,即外界总是会通过getter()和setter()方法来访问单态类的成员
5.4 总结
单例模式的错误使用会破坏应用程序的可测试性和可重构性。如果必须使用,尽量避免直接使用它,将其指定为依赖项(例如,作为构造函数的参数),并保证所有依赖项都是从应用程序的某个唯一的位置(例如,控制反转容器)获取初始化的
结构型设计模式
第6章 适配器模式
6.1 预想方案
首先,定义两个简单对象
struct Point{
int x, y;
};
struct Line{
Point start, end;
};
考虑向量这个概念,它可能由一组线段对象定义。定义一对纯虚迭代器接口,而不是继承vector
struct VectorObject{
virtual vector<Line>::iterator begin() = 0;
virtual vector<Line>::iterator end() = 0;
};
现在,假如要定义Rectangle,只需要将描述矩形的4条边的线段存入vector
struct VectorRectangle : VectorObject{
VectorRectangle(int x, int y, int width, int height){
lists.emplace_back(Line{Point{x, y}, Point{x + width, y}});
lists.emplace_back(Line{Point{x + width, y}, Point{x + width, y + height}});
lists.emplace_back(Line{Point{x, y}, Point{x, y + height}});
lists.emplace_back(Line{Point{x, y + height}, Point{x + width, y + height}});
}
vector<Line>::iterator begin() override {
return lines.begin();
}
vector<Line>::iterator end() override{
return lines.end();
}
private:
vector<Line> lines;
};
现在,假设我们想在屏幕上画线段,甚至是画矩形。但是这是做不到的,因为用于绘制的唯一接口实际上是
void DrawPoints(CPaintDC& dc, vector<point>::iterator start, vector<point>::iterator end){
for(auto i = start; i != end; i++){
dc.SetPixel(i->x, i->y, 0);
}
}
上面的代码中,使用的是MFC中的CPaintDC类。
简言之,这个示例中遇到的问题是,我们需要提供像素坐标以渲染图像,但是我们只有一些向量对象
6.2 适配器
假如我们要绘制一系列矩形
vector<shared_ptr<VectorObject>> vectorObjects{
make_shared<VectorRectangle>(10, 10, 100, 100),
make_shared<VectorRectangle>(30, 30, 60, 60)
}
为了绘制这些对象,我们需要将每个矩形从一组线段转换为数量庞大的像素点。为此,需要单独定义一个适配器类,用于存储这些像素点,并且定义一组迭代器来访问这些点
struct LineToPointAdapter{
using Points = vector<Point>;
LineToPointAdapter(Line& line){}
virtual Points::iterator begin() {return points.begin();}
virtual Points::iterator end() {return points.end();}
private:
Points points;
};
将Line对象转换成像素点集的过程由构造函数完成,所以LineToPointAdapter是饿汉式的适配器
LineToPointAdapter(Line& line){
int left = min(line.start.x, line.end.x);
int right = max(line.start.x, line.end.x);
int top = min(line.start.y, line.end.y);
int bottom = max(line.start.y, line.end.y);
int dx = right - left;
int dy = line.end.y - line.start.y;
if(dx == 0){
for(int y = top; y <= bottom; y++)
points.emplace_back(Point{left, y});
}else if(dy == 0){
for(int x = left; x <= right; x++)
points.emplace_back(Point{x, top});
}
}
上述代码表示,只处理垂直或水平的线段,忽略其他类型的线段。构造一个由连续的点组成的集合来代表用像素点表示的线段。不论是水平还是垂直的线段,我们都构造一个由连续相邻的点组成的集合来代表用像素点表示的线段。我们避免了对角线线段以及与平滑表示这些线段相关的问题(例如:反走样)。
现在,以之前定义的矩形为例,我们传入两个矩形对象,使用这个适配器来渲染几何对象
for(const auto& obj : vectorObjects){
for(const auto& line : *obj){
LineToPointAdapter lpo{line};
DrawPoints(dc, lpo.begin(), lpo.end());
}
}
上述代码实现了这些工作:
- 传入一个包含shared_ptr对象的vector容器,然后遍历容器中的每一个对象
- 直接在解引用的对象上(*obj)迭代
- 为迭代访问到的每一个线段对象构造一个单独的LineToPointAdapter
- 最后,调用DrawPoints()函数,该函数将迭代访问由适配器生成的像素点集
6.3 临时适配器对象
上述代码中存在一个主要问题:每次刷新屏幕时,函数DrawPoints()都会被调用,这意味着适配器对象会不断地为同样的线段对象生成相同的像素点数据,怎么改善这个问题?一种办法是在程序的开始处定义一个像素点容器
vector<Point> points;
for(auto& o : vectorObjects){
for(auto& l : *o){
LineToPointAdapter lpo{l};
for(auto& p : lpo)
points.push_back(p);
}
}
然后,将DrawPoints()接口的实现简化为
DrawPoints(dc, points.begin(), points.end());
但是,假如在某个时候,原始的几何对象vectorObjects发生了变化。我们想要缓存未改动的数据,而仅仅只为变化了的对象重新生成像素点数据。
首先,为了避免重新生成数据,我们需要独特的识别线段的方法,这意味着我们需要独特的识别点的方法,这可以使用ReSharper的Generate|Hash函数
struct Point{
int x, y;
friend size_t hash_value(const Point& obj){
size_t seed = 0x725C686F;
boost::hash_combine(seed, obj.x);
boost::hash_combine(seed, obj.y);
return seed;
}
};
struct Line{
Point start, end;
friend size_t hash_value(const Line& obj){
size_t seed = 0x719E6B16;
boost::hash_combine(seed, obj.start);
boost::hash_combine(seed, obj.end);
return seed;
}
};
这里选择了Boost的hash实现。现在,我们可以构建一个新的LineToPointCachingAdapter,它可以缓存Point对象并在必要的时候重新生成它们。除了以下细微差别外,实现几乎相同。
首先,LineToPointCachingAdapter有一个缓存cache,它是一种从哈希值到点集的映射,可存储哈希值和对应的点集合
static map<size_t, Points> cache;
类型size_t正好是Boost的hash函数返回的类型。现在,当迭代访问生成的像素点集时,我们将以如下的方式返回被访问的对象
virtual Points::iterator begin() {return cache[line_hash].begin();}
virtual Points::iterator end() {return cache[line_hash].end();}
这个算法在生成像素点集之前,先检查这些像素点是否已经生成。如果已经生成,函数直接退出;如果没有生成,则算法生成像素点集,并保存到cache中
LineToPointCachingAdapter(Line& line){
static boost::hash<Line> hash;
line_hash = hash(line);
if(cache.find(line_hash) != cache.end())
return;
Points points;
// ...
cache[line_hash] = points;
}
有了hash函数和cache,可以显著减少转换次数
6.4 双向转换器
开发带有UI的应用程序时,常常碰到的一个问题是如何将UI的输入映射为适当的变量。例如,根据程序的设计,要求输入数字的文本框会将其内部的状态保存为字符串,而我们想要将输入的值记录为数字,然后验证该输入是否有效
通常,我们需要的是双向绑定,UI的输入会修改底层变量,但同时,如果底层的变量被修改,UI也将相应地更新
我们定一个独立的双向转换器,并将其作为基类,例如
template <typename TFrom, typename TTo>
class Converter{
public:
virtual TTo Convert(const TFrom& from) = 0;
virtual TFrom ConvertBack(const TTo& to) = 0;
};
这样,我们就可以在两种类型之间显示地定义具体的转换器,例如
class IntToStringConverter : Converter<int, string>{
public:
string Convert(const int& from) override{
return to_string(from);
}
int ConvertBack(const string& to) override{
int result;
try{
result = stoi(to);
}
catch(...){
return numrric_limits<int>::main();
}
}
};
接下来就可以使用了
IntToStringConverter converter;
cout << converter.Convert(123) << "\n";
cout << converter.ConvertBack("456") << "\n";
cout << converter.Convertback("xyz") << "\n";
6.5 总结
“适配器”是一个简单的概念:它允许我们将已有的接口调整为我们需要的另一个接口。适配器模式存在的真正问题是,在适配过程中,有时会生成临时数据以满足其他接口的要求。此时可以采用缓存策略,确保只在必要时生成新的数据。当缓存的数据变化时,需要清理缓存中过时的数据
第7章 桥接模式
7.1 Pimpl模式
如下
struct Person{
string name;
void greet();
Preson();
~Preson();
class PersonImpl;
PersonImpl* impl;
};
Person类将其具体实现隐藏在另一个类PersonImpl中。需要强调的是,PersonImpl类的实现不是在头文件中定义的,而是驻留在.cpp文件中
struct Person::PersonImpl{
void greet(Person* p);
};
类Person中对PersonImpl做了前向声明,并且保存了PersonImpl类型的指针。在Person的构造函数中初始化这个指针,在析构函数中销毁它。
Person::Person() : impl(new PersonImpl) {}
Person::~Person() {delete impl;}
Person::greet接口仅仅是把控制权转交给PersonImpl::greet()
void Person::greet(){
impl->greet(this);
}
void Person::PersonImpl::greet(Person* p){
printf("hello %s", p->name.c_str());
}
这就是Pimpl格式,它的优点有这几个:
- 隐藏了类的大部分实现。如果Person有许多private/protected成员,即使有限定符的存在,客户不能直接访问,但是我们也会提供一系列丰富的API,由此暴露了Person类内部的某些成员。如果采用Pimpl模式,只需要对外提供公共接口即可
- 修改隐藏的Impl类的数据成员不会使整个Person类重新编译
- 头文件中只需包含声明所需的头文件,而不必包含实现所需的头文件。例如,如果Person类中有一个vector<string> 类型的private成员,则必须在Person.h头文件中同时包含<vector>和<string>。使用Pimpl模式,这可以在.cpp文件中完成
Pimpl模式可以使系统重的头文件更加整洁,并且不必频繁改动。不过,副作用是影响编译速度。。Pimpl模式是桥接模式的一种很好的体现:它是一种不透明指针,起着桥梁的作用,将公共接口的成员与其隐藏在.cpp文件中的底层实现结合了起来。
7.2 桥接模式介绍
假设有两种对象:几何对象以及将几何对象绘制在屏幕上的渲染器对象。
如同适配器模式里一样,假设我们可以以向量和光栅形式进行渲染(这里不会编写实际的绘图代码),并且将几何对象的形状限制为圆形。
首先,基类Renderer定义如下
struct Renderer{
virtual void render_circle(float x, float y, float radius) = 0;
};
我们可以轻松地构建向量渲染和光栅渲染的具体实现,下面编写一些打印到控制台的代码来模拟实际渲染过程
struct VectorRenderer : Renderer{
void renderer_circle(float x, float y, float radius) override{
cout << "Rasterizing circle of radius" << radius << endl;
}
};
struct RasterRenderer : Renderer{
void renderer_circle(float x, float y, float radius) override{
cout << "Drawing a vector circle of radius" << radius << endl;
}
};
几何对象的基类Shape可以保存一个对渲染器的引用,我们在Shape中定义draw()和resize()两个成员函数用于支持渲染和调整尺寸的操作
struct Shape{
protected:
Renderer& renderer;
Shape(Renderer& renderer) : renderer{renderer} {}
public:
virtual void draw() = 0;
vortual void resize(float factor) = 0;
};
可以看到,Shape类含有一个Renderer类型的引用,这正是桥接模式中的“桥。接下来,我们创建Shape类的一个具体实现,并添加诸如圆心位置和半径等更多信息
struct Circle : Shape{
float x, y, radius;
Circle(Renderer& renderer, float x, float y, float radius):Shape{renderer} , x{x}, y{y}, radius{radius} {}
void draw() override{
renderer.render_circle(x, y, radius);
}
void resize(float factor) override{
radius *= factor;
}
};
draw()是连接Circle(包含位置和大小信息)和渲染过程的桥梁。这里的桥就是渲染器,例如
RasterRenderer rr;
Circle raster_circle{rr, 5, 5, 5};
raster_circle.draw();
raster_circle.resize(2);
raster_circle.draw();
这段代码中,RasterRenderer就是桥:我们神功一个RasterRenderer对象并将其引用传递给Circle。然后,对函数draw()的调用将会以此RasterRenderer引用为桥梁来对Circle进行渲染。如果需要调整圆的大小,则可以调用resize()函数,渲染过程仍旧可以正常进行,因为渲染器并不知道也不关心其所渲染的Circle对象
7.3 总结
桥接模式的概念很简单,它通常作为连接器或粘合剂,将两个“不相关的组件连接起来。抽象接口的使用允许组件之间在不了解具体实现的情况下彼此交互。
也就是说,桥接模式的参与这确实需要意识到彼此的存在。这与中介者模式形成了对比,中介者模式允许对象在毫不知晓对方的情况下进行通信。
第8章 组合模式
显然,对象通常由其他对象组成(换言之,其他对象聚合成一个对象)。组合通常是对象包含有其他对象,聚合指的是对象含有其他对象的指针,但在这里,组合和聚合的意思是对等的。
展示对象的组成成员的方式很少。通过实现begin()/end()成员函数可以展示类由对象组成,单着没有太大意义,因为在这些成员函数中可以做任何事情。也可以使用typedef来表明某个对象其实是一类特定类型的对象组成的容器。另一种可替换being()/end()成员函数的方法是使用协程。协程中的特殊函数的作用是允许调用者主动暂停执行,但协程的副作用是,他会暴露于生成“可恢复的”值序列的生成器。我们通常会讨论生成器函数,所以如果想定义生成器类,则必须对生成器函数的位置进行设计。一种方法是创建一个仿函数,即
class Values{
public:
generator<int> operator()(){
co_yield 1;
co_yield 2;
co_yield 3;
}
};
这样,我们就可以用普通的范围for循环来调用这个仿函数并得到返回的值
Values v;
for(auto i : v())
cout << i << ' ';// 1 2 3
但是,这种方式对展示对象组成的接口的可发现性没有什么帮助,即用户也许并不知道可以通过这种方式迭代访问对象的组成成员。开发C++API的程序员经常会忽略API的可发现性。我们可以尝试编写一个标记接口
template <typename T>
class Contains{
virtual generator<T> operator()() = 0;
};
另一种表明对象是容器类型的方法是继承某个容器类。
回到组合模式上来。本质上,组合模式为单个对象和容器对象提供了相同的接口。当然,定义一个接口并在两个对象中实现它,这很容易。
8.1 支持数组形式的属性
首先,展示如何在类的属性上使用组合模式。
假设有一款电子游戏,包含不同生物,每个生物有力量值,敏捷度等以数值表示的属性,可以这样定义这个类
class Creature{
int strength, agility, intelligence;
public:
int get_strength() const{
return strength;
}
void set_strength(int strength){
Creature::strength = strength;
}
// ...
};
至此完成初步定义。但如果此时想计算统计数据,则需要添加接口
class Creature{
int sum() const{
return strength + agility + intelligence;
}
double average() const{
return sum()/3.0;
}
int max()const {
return ::max(::max(strength, agility), intelligence);
}
};
但这是一种不好的实现,主要有以下几个原因:
- 计算sum时,很容易犯错,例如遗漏某个属性
- 计算average时,除数3.0对应于类中的成员数量,如果成员数增加或者减少,必须相应地修改这个数字
- 计算max时,需要嵌套max,成员数量越多,嵌套层数越深
采用支持数组形式的属性的方式如下。首先,为Creature类定义一个美剧成员,声明Creature的所有属性值;然后,创建一个数组,大小为总的属性数量
class Creature{
enum Abilities{str, agl, intl, count};
array<int, count> abilities;
};
在上面的枚举定义中,包含一个额外的成员count,其值为Creature包含的属性的数量。注意使用的是enum而不是enum class。枚举使得我们对这些属性成员的使用更容易。
现在,基于数组形式的属性,定义getter和setter
int get_strength() const { return abilities[str];}
void set_strength(int value) { abilities[str] = value;}
接下来,可以更改sum,average和max了
int sum() const{
return accumulate(abilities.begin(), abilities.end(), 0);
}
double average() const{
return sum() / (double)count;
}
int max() const{
return *max_element(abilities.begin(), abilities.end());
}
8.2 组合图形对象
考虑一个应用程序,例如PPT,可以在其中选择多个不同对象并拖动他们将其组合成一个对象,然而,只选择一个对象也是可以的。渲染也是如此,既可以渲染单个图形对象,也可以将多个图形形状组合在一起并将他们作为一组对象来绘制。
实现很简单,因为它只依赖于一个如下所示的接口
struct GraphicObject{
virtual void draw() = 0;
};
c从名称上看,GraphicObject始终是一个标量,也就是说,它始终表示单个项目。但是,几个矩形和圆组合在一起则代表一个组合图形对象。例如,就像定义Circle一样
struct Circlr : GraphicObject{
void draw() override{
cout << "Circle" <<endl;
}
};
类似的,我们可以定义由其他图形对象组成的GraphicObject。这种关系可以无限递归
struct Group : GraphicObject{
string name;
explicit Group(const string& name) : name(name) {}
void draw() override{
cout << "Group" << name.c_str() << "contains:" << endl;
for(auto& o : object)
o->draw();
}
vector<GraphicObject*> object;
};
不论是标量Circle还是Group对象都是可选染的,因为它们都实现了draw()函数。Group保留一个指向其他图形对象的指针变量,并使用该向量的元素来渲染自身
Group root("root");
Circle c1, c2;
root.objects.push_back(&c1);
Group subgroup("sub");
subgroup.objects.push_back(&c2);
root.objects.push_back(&subgroup);
root.draw();
上述代码的运行结果如下:
Group root contains:
- Circle
- Group sub contains:
- Circle
这是组合模式最简单的实现
8.3 神经网络
神经网络的核心元素是神经元。神经元可以根据输入产生一个输出值,我们可以将该值反馈到网络中的其他连接。由于我们只关注连接,因此可以这样建模
struct Neuron{
vector<Neuron* in, out;
unsigned int id;
Neuron(){
static int id = 1;
this->id = id++;
}
};
Neuron类中的成员id用于标识不同的Neuron对象。现在可以像下面那样将神经元连接起来
template<>
void connect_to<Neuron>(Neuron& other){
out.push_back(&other);
other.in.push_back(this);
}
现在,假设我们要创建神经网络层。一定数量的神经元组合在一起,就成了神经网络中的一层。以下代码不是一个好的实现
struct NeuronLayer : vector<Neuron>{
NeuronLayer(int count){
while(count-- > 0)
emplace_back(Neuron{});
}
};
表面上看不出来,但是它会遇到一个问题,这个问题是,我们如何将一个神经元连接到神经网络层上。广义上说,我们希望达到如下效果
Neuron n1, n2;
NeuronLayer layer1, layer2;
n1.connect_to(n2);
n1.connect_to(layer1);
layer1.connect_to(n1);
layer1.connect_to(layer2);
上述代码展示了4种场景,分别是:
- 一个神经元连接到另一个神经元
- 一个神经元连接到一个神经网络层
- 一个神经网络层连接以一个神经元
- 一个神经网络层连接另一个神经网络层
我们不可能对connect_to()进行4次重载。如果有3个不同的类,难道要创造9个函数吗?相反,我们要做的是在基类中实现多重继承
template <typename Self>
struct SomeNeuron{
template <typename T>
void connect_to(T& other){
for(Neuron& from : *static_cast<Self*>(this)){
for(Neuron& to : other){
from.out.push_back(&to);
to.in.push_back(&from);
}
}
}
};
可以看到,函数connect_to()是一个模板成员函数,它接受T,然后逐对地迭代*this和T&的神经元,将每一对神经元相互连接。但是需要注意,不能只迭代*this,因为这会提供SomeNeuron&类型的神经元对象,但我们想要的是准确类型的神经元对象
这就是我们要强制把SomeNeuron&设计成模板类,并且让模板参数Self代指继承类的原因。随后,在解引用之前,将this指针转换成Self*类型,然后再进行迭代。这意味着Neuron必须继承自SomeNeurons<Neuron>(CRTP)。
剩下的工作就是在Neuron和NeuronLayer中实现begin()和end(),以使for循环能够正常工作
由于NeuronLayer继承自vector,所以不必显式地实现begin()/end()。
Neuron* begin() override{return this;}
Neuron* end() override{return this + 1;}
现在,我们已经使单个标量对象表现得像一个可迭代的对象集合,先前的那种用法都是允许的了。
Neuron n1, n2;
NeuronLayer layer1, layer2;
n1.connect_to(n2);
n1.connect_to(layer1);
layer1.connect_to(n1);
layer1.connect_to(layer2);
如果要引入一个新容器NeuronRing,索要做的就是继承SomeNeuron<NeuronRing>,并实现being()/end(),然后新的类就可以立即连接到Neurous和NeuronLayers。
8.3.1 封装组合模式
我们可以通过设计一个基类来表明对象是标量
template <typename T>
class Scalar : public SomeNeurons<T>{
public:
T* begin(){return reinterpret_cast<T*>(this);}
T* end(){return reinterpret_cast<T*>(this) + 1;}
};
这样,我们从SomeNeurons继承了connect_to()方法,同时也为标量值实现了being()/end()方法,因此,我们将Neuron类定义为
class Neuron : public Scalar<Neuron>{
//...
};
然后就可以像之前一样使用Neuron
8.3.2 概念上的改进
此时,SomeNeuron类连接到包含Neuron的对象。我们可以通过明确要求两个连接的类型都必须是可迭代的来做一个小小的改进。为此,我们定义了一个concept
template <typename T>
concept Iterable = requires(T& t){
t.begin();
t.end();
} || requires(T& t){
begin(t);
end(t);
};
不够,这个概念是有限制的。编写下面的代码是很自然的
template <Iterable Self>
struct SomeNeurons{
template <Iterable T>
void connect_to(T& other){
//...
}
};
将connect_to()方法声明为Iterable是可行的。将类型参数Self声明为Iterable完全是另一回事。事实上,它不能正常工作。
考虑一下之前定义的Scalar类,它继承自SomeNeurons<T>,所以我们需要将T约束为可迭代的
template <Iterable T>
class Scalar : public SomeNeurons<T>{
//...
};
然而,这种方法无法定义Neuron类。我们之前将Neuron定义为
struct Neuron : Scalar<Neuron>
由于Scalar显式地声明其类型参数必须是可迭代的,我们需要使Neuron本身也是可迭代的,而它只能是因继承自Scalar而变为可迭代对象,但Neuron本身不是可迭代对象。请注意,继承顺序在这里并不重要。例如,如果停止从SomeNeurons继承Saclar,然后将Neuron定义为
struct Neuron : Scalar<Neuron>, SomeNeurons<Neuron>
它仍旧不能通过编译,即使后面一个基类完全满足之前的需求。我想基于概念的CRTP是不可能的。
8.3.3 概念和全局运算符
接下来,来看一个例子,在这个例子中,我们可以完全摆脱SomeNeurons基类。
在本例中,假设我们希望使用运算符而不是继承来连接神经元结构,“->“只能是成员函数。为灵活起见,引入一个不同的特性:运算符”–>”。它是”–“和”>”的融合运算符,这个技巧分两步实现:
- 定义返回特殊代理类的非成员运算符“–”
- 为该代理类指定“>”运算符成员,该运算符的作用等同于前面的connect_to()函数的功能
首先,将运算符“–”定义如下
template <Iterable T>
ConnectionProxy<T> operator--(T&& item, int){
return ConnectionProxy<T>{item};
}
然后,定义完整的代理类
template <Iterable T>
class ConnectionProxy{
T& item;
public:
explicit COnnextionProxy(T& item) : item(item) {}
template <Iterable U>
void operator>(U& other){
for(Neuron& from : item){
for(Neuron& to : other){
from.out.push_back(to);
to.in.push_back(from);
}
}
}
};
现在,可以连接两个神经元对象了,代码非常简洁
Neuron n1, n2;
n1-->n2;
但是,这种方法的可发现性是不存在的:寻找一个运算符已经很难,寻找运算符的组合更是不可能。
8.4 组合模式的规范
在介绍开闭原则的时候,曾经展示过规范模式的示例代码。该模式的关键方面是基类Filter和Specification,它们允许我们使用继承来构建符合开闭原则的可扩展的过滤器框架。该实现的一部分涉及组合规范——使用“与”或“或”运算符将多个规范组合在一起的规范
AndSpecification和OrSpecification都使用了两个操作数,但是这种限制完全是任意的,事实上,我们可以组合更多的元素。
此外,我们可以使用可重用的基类改进OOP模型,例如
template <typename T>
struct CompositeSpecification : Specifition<T>{
protected:
vector<unique_ptr<Specification<T>>> specs;
template <typename... Specs>
CompositeSpecification(Specs... specs){
this->sepcs.reserve(sizeof...(Specs));
(this->sepcs.push_back(make_unique<Specs>(move(specs))), ...);
}
};
上述代码将多个Specification以智能指针的信使存储在一个vector中,因此可以应对对象切片和多态向量的问题。我们不得不使用可变参数模板,因为initializer_list<Specification<T>>会引入切片。此外,由于向量初始化中的常量问题,我们不得不使用push_back()。
采用这种方法,我们可以重新实现AndSpecification
template <typename T>
struct AndSpecification : CompositeSpecfication<T>{
template <typename... Specs>
AndSpecification(Specs... specs) : CompositeSpecfication<T>{specs...}{}
bool is_satisfied(T* item) const override{
return all_of(this->specs.begin(), this->specs.end(), [=](const auto& s){
return s->is_satisfied(item);
});
}
};
这个类只是简单地重复了CompositeSpecfication的构造函数(为了代码简洁起见,这里省略了完美转发的提示)并提供了is_satisfied()的实现
以下是这个类的预期使用方式
auto spec = AndSpecification<Product>{green, large, cheap};
可以看到,组合器所做的工作只是逐个检查specs中的每个规范是否满足指定的要求。类似的,如果要实现OrCombinator,需要使用ans_of(),而不是all_of()。我们甚至可以根据更复杂的标准来实现某些规范。例如,可以设计一个组合规范,以验证给定的输入中,有至多/至少/指定数量的输入项满足要求
8.5 总结
组合模式允许我们为单个对象和对象集合提供相同的接口。这可以通过显式使用成员接口或鸭子类型来完成——例如,range-base for loop不要求继承任何内容,只需要提供合适的begin()/end()类型的成员函数即可正常运行
正是这些begin()/end()成员允许标量类型伪装成“集合”。值得注意的是,尽管connect_to()函数的嵌套for循环具有不同的迭代器类型,但它们能够将两个结构连接在一起:Neuron返回Neuron*,而NeuronLayer返回vector<Neuron>::iterator——这两者并不完全相同。这就是模板的效果。
最后,只有当你想要单个成员函数时,这些努力才是必要的。如果更倾向于调用全局函数,或者想重载connect_to(),那么不需要基类SomeNeurons。
第9章 装饰器模式
想要扩展某个类的功能,在不修改原始代码的前提下,怎么做?一种办法是继承,设计一个派生类,并添加需要的功能。但是,这种办法并不总是有效,原因有很多。例如,原来的类可能没有虚析构函数。但是继承不起作用的最关键原因是需要多个强化的功能,并且由于单一职责原则,我们希望将这些强化的功能单独分开。
装饰器模式允许我们在既不修改原始类型(违背了开闭原则)也不会产生大量派生类型的情况下强化既有类型的职责或功能。
9.1 预想方案
假设有一个Shape类,表示图形形状,我们需要赋予它颜色或透明度。可以创建两个继承者,即ColoredShape和TransparentShape,但是考虑到有人会想要一个ColoredTransparentShape。因此,我们生成了3个类来支持两种强化;如果需要3种强化功能,则需要7个不同的类。
但是,实际上我们想要不同的形状,他们会继承自什么基类?如果有3个基类和2种形状,类的数量将增加到14个。显然,这会难以管理。
struct Shape{
virtual string str() const = 0;
};
在这个抽象类中,str()是一个用于以文本方式显式特殊图形形状的虚函数。
现在,可以继承Shape
struct Circle : Shape{
float radius;
explicit Circle(const float radius) : radius{radius} {}
void resize(float factor) {radius *= factor;}
string str() const override{
ostringstream oss;
oss << "A circle of radius " << radius;
return oss.str();
}
};
我们已经知道,普通的继承关系本身不能为我们提供一种有效的强化Shape功能的方式,所以我们必须转向组合功能——这是装饰器模式强化对象的机制。实际上有两种不同的方法——以及其他几种模式——需要逐个讨论
动态组合允许在运行时组合某些东西,通常是通过按引用传递实现的,它的灵活性很强,因为组合可以在运行时相应用户的输入
静态组合意味着对象及其强化功能是在编译时使用模板组合而成的。这意味着在编译时需要知道对象确切的强化功能,因为之后无法对其进行修改。
9.2 动态装饰器
假设要为Shape类扩展关于颜色功能。我们使用组合功能而不是继承来实现ColoredShape,传入一个Shape对象的引用
struct ColoredShape : Shape{
Shape shape;
string color;
ColoredShape(Shape& shape, const string& color) : shape{shape}, color{color} {}
string str() const override{
ostringstream oss;
oss << shape.str() << " has the color " << color;
return oss.str();
}
};
可以看到,ColoredShape本身也是一个Shape,但同时也维护着一个它将装饰的Shape的引用,它也可以替换为指针。
除了颜色,还可以添加其他成员函数
void ColoredShape::make_dark(){
if(constexpr auto dark = "dark"; !color.starts_with(dark))
color.insert(0, dark);
}
以下是该装饰器的使用示例
Circle circle{0.5f};
ColoredShape redCircle{circle, "red"};
cout << redCircle.str();
redCircle.make_dark();
cout << redCircle.str();
如果我们现在想要基于Shape类添加另一个增加透明度的强化功能
struct TransparentShape : Shape{
Shape& shape;
uint8_t transparency;
TransparentShape(Shape& shape, const uint8_t transparency) : shape{shape}, transparency{transparency} {}
string str() const override {
ostringstream oss;
oss << shape.str() << " has " <<static_cast<float>(transparency) / 255.f*100.f << "% transparency";
return oss.str();
}
};
现在就有了一个强化功能,它采用0-255范围内的透明度值并以百分比的形式打印出来。我们现在可以单独使用这一强化功能
Square square{3};
TransparentShape demiSquare{square, 85};
cout << demiSquare.str();
但动态装饰器的强大之处在于,我们可以将ColoredShape和TransparentShape组成成一个同时具有颜色和透明度功能的Shape
Circle c{23};
ColoredShape cs{c, "green"};
TransparentShape myCircle{cs, 64};
cout << myCircle.str();
9.3 静态装饰器
上述场景中,Circle类中有一个resize()函数,但它并不是Shape接口的一部分。因此,在装饰器中不能调用它。
能否访问被装饰对象的所有属性成员和成员函数呢?能否构造这样的装饰器呢?
这是可以的,可以通过模板和继承实现。使用一种名为mixin继承的方式,其中,类继承自它的模板参数
template <typename T>
struct ColoredShape : T{
string color;
string str() const override{
ostringstream oss;
oss << T::str() << " has the color " << color;
return oss.str();
}
};
很重要的一点是如何确保模板类型参数T继承自Shape,有两种办法:
-
使用static_assert
template <typename T> struct ColoredShape2 : T{ static_assert(is_base_of_v<Shape, T>, "Template argum,ent must be a Shape"); }; -
使用概念。基于ColoredShape<T>和TransparentShape<T>的实现,我们可以组合一个同时具有颜色和透明度功能的Shape
ColoredShape<TransparentShape<Square>> square{"blue"}; square.size = 2; square.transparency = 0.5; cout << square.str(); square.resize(3);
但这样还不够完美,我们还没有充分利用构造函数,所以即使能够初始化最外层的类 ,也不能通过一行代码来完整构造具有特定大小、颜色和透明度的Shape对象。
为了更完美,我们可以为ColoredShape和TransparentShape转发构造函数。这些构造函数将接受两个参数:第一个是特定于当前模板类的参数,第二个是将转发给基类的通用参数包。例如
template <typename T>
struct TransparentShape : T{
uint8_t transparency;
template <typename...Args>
TransparentShape(const uint8_t transparency, Args...args) : T(std::forward<Args>(args)...), transparency{transparency} {}
// ...
};
上面可以看出,TransparentShape类的构造函数可以接受任意数量的参数,其中第一个参数用于初始化透明度值,其余参数转发给基类的构造函数使用。但是,这些参数的顺序必须颠倒过来。
构造函数参数的数量必须准确,如果参数的数量或类型不准确,程序将无法编译。这对调用构造函数的方式施加了某些限制,因为转发构造函数总是根据实际可用的内容尝试“填充”可用的构造函数。在嵌套构造函数具有重载的情况下,可能无法使用单行语法实例化所需的对象。
当然,千万不要将这些构造函数声明为explicit,否则当将多个装饰器组合起来时,C++的复制列表初始化(copy-list-initialization)规则会困扰你。
现在,可以运行下列代码了
ColoredShape<TransparentShape<Square>> sq{"red", 51, 5};
cout << sq.str();
可以看到,构造函数参数在继承链的构造函数中“分发”:值“red”进入ColoredShape,值51进入TransparentShape,值5进入Square。
9.4 函数装饰器
装饰器模式还可以应用于函数。例如,假设代码中有一个特定的函数,你希望记录该函数被调用的所有时间,这可以通过在调用函数前后放置一些代码来实现
cout << "Entering function XYZ\n";
// ...
cout << "Exiting function XYZ\n";
从关注点分离的角度来看这并不好,我们确实希望将日志功能存储在某个地方,以便复用并在必要时强化它
有不同的方法可以实现这一点。一种方法是简单地将整个工作单元作为一个函数提供给某些日志组件
struct Loger{
function<void()> func;
string name;
Logger(const function<void()>& func, const string& name) : func{func}, name{name} {}
void operator()() const{
cout << "Entering " << name << "\n";
func();
cout << "Entering " << name << "\n";
}
};
这样,就可以这样编写代码了
Logger([](){cout << "Hello\n";}, "HelloFunction")();
可以将函数作为模板参数而不是std::function传入。这导致与前面的结果略有不同
template <typename Func>
struct Logger2{
Func func;
string name;
Logger2(const Func& func, const string& name) : func{func}, name{name} {}
void operator()() const{
cout << "Entering " << name << "\n";
func();
cout << "Entering " << name << "\n";
}
};
这个实现的用途完全一样。我们可以创建一个工具函数来实际创建这样的记录器
template <typename Func>
auto make_logger2(Func func, const string& name){
return Logger2<Func>{func, name};
}
然后像下面这样使用它
auto call = make_logger2([](){cout << "Hello!" << endl;}, "HelloFunction");
call();
这样,我们就有能力在需要装饰某个函数时创建一个装饰器(其中包含装饰的函数)并调用它
现在,有一个新的需求:如果要调用函数add()的日志,应该
double add(double a, double b){
cout << a << "+" << b << "=" << (a + b) << endl;
return a + b;
}
需要add()函数的返回值吗?如果需要的话,它将从logger中返回(因为logger装饰了add()函数),再次改进一下logger
template <typename R, typename... Args>
struct Logger3<R(Args...)>{
Logger3(function<R(Args)> func, const string& name) : func{func}, name{name} {}
R operator() (Args ...args){
cout << "Entering " << name << "\n";
R result = func(args);
cout << "Entering " << name << "\n";
return result;
}
function<R(Args ...)> func;
string name;
};
在上述代码中,模板参数R表示返回值的类型,Args表示函数的参数类型。与之前不同的是,装饰器在必要时调用函数;唯一的区别是operator()返回了一个R,因此采用这种方法不会丢失返回值。
我们可以构造另一个工具函数make_:
template <typename typename... Args
auto make_logger3(R(*func)(Args...), const string& name){
return Logger3<R(Args...)>(function<R(Args...)>(func), name);
}
这里并没有使用std::function,而是将第一个参数定义为普通函数指针。我们现在可以使用此函数实例化日志调用并使用它
auto logged_add = make_logger3(add, "Add");
auto result = logger_add(2, 3);
当然,make_logger3可以被依赖注入取代。这种方法的好处是能够:
- 通过提供空对象而不是实际的记录器,动态地打开和关闭日志记录
- 禁用记录器正在记录的代码的实际调用(同样,通过替换其他记录器)
总之,对于开发人员而言,这是一个有用的工具函数。
9.5 总结
装饰器在遵循开闭原则的同时为类提供了额外的功能。它的关键点是可组合性:多个装饰器可以以任意顺序作用于对象。我们已经研究了以下类型的装饰器
- 动态装饰器可以存储对被装饰对象的引用并提供动态可组合性,但代价是无法访问底层对象自己的成员
- 静态装饰器使用mixin继承(从模板参数继承)在编译时组合装饰器。虽然失去了运行时的灵活性,但允许访问底层对象的成员。这些对象也可以通过构造函数转发进行完全初始化
- 函数装饰器可以封装代码块或特定函数,以允许组合行为
第10章 外观模式
10.1 幻方生成器
幻方是一个矩阵,它分为三个组件
- Generator(发生器):生成指定个数的随机数序列的组件
- Splitter(分离器):接受矩形矩阵,并输出一组表示矩阵中所有行、列和对角线的列表的组件
- Verifier(验证器):检查列表中每个行、列和对角线的元素的和是否相等
首先实现Generator
struct Generator{
virtual vector<int> generator(const int count) const{
vector<int> result(count);
generator(result.begin(), result.end(), [&](){return 1+rand()%9;});
return result;
}
};
Generator通过特定的算法,生成一个包含指定数量随机数的序列,并以vector的形式返回。
Splitter接受已生成的二维矩阵,并使用它生成表示矩阵的所有行、列和对角线的唯一元素。
struct Splitter{
vector<vector<int>> split(vector<vector<int>> array) const{
// ...
}
};
Verifier检查上述各行、列、对角线所有元素的和是否相等
struct Verifier{
bool verify(vector<vector<int>> array) const{
if(array.empty()) return false;
auto excepted = accumulate(array[0].begin(), array[0].end(), 0);
return all_of(array[0].begin(), array[0].end(), [=](auto& inner){
return accumulate(inner.begin(), inner.end(), 0) == excepted;
});
}
};
这三个类难以使用,如果要提供给客户,可以创建一个外观类,其本质是一个隐藏了内部所有实现细节并对外只提供一个简单接口的包装类
struct MagicSquareGenerator{
vector<vector<int>> generate(int size){
Generator g;
Splitter s;
Verifier v;
vector<vector<int>> square;
do{
square.clear();
for(int i=0; i<size; i++)
square.emplace_back(g.generate(size));
}while(!v.verify(s.split(square)));
return square;
}
};
现在,客户可以这样使用它
MagicSquareGenerator gen;
auto square = gen.generate(3);
细微调整
可以添加一些附加功能,允许高级用户自定义和扩展外观类的行为。例如,我们可能希望幻方对象允许用户提供自定义数字生成器。为了实现这一点,我们修改MagicSquareGenerator,将每个子系统作为模板参数
template <typename G = Generator, typename S = Splitter, typename V = Verifier>
struct MagicSquareGenerator{
vector<vector<int>> generate(int size){
G g;
S s;
V v;
// ...
}
};
如果愿意,我们可以进一步添加限制,要求参数G、S和V从相应的类继承。
现在,可以创建一个UniqueGenerator,以确保生成的集合中的所有数字都是唯一的
struct UniqueGenerator : Generator{
vector<int> generate(const int count) const override{
vector<int> result;
do{
result = Generator::generate(count);
}while(set<int>(result.begin(), result.end()).size() != result.size());
return result;
}
};
然后,我们将新的Generator送入外观类,从而得到一个新的幻方,注意,我们只提供第一个模板参数,其余两个使用默认值
MagicSquareGenerator<UniqueGenerator> gen;
auto square = gen.generate(3);
这个例子表明,可以将不同子系统之间复杂的交互隐藏在外观类后面,还可以灵活控制外观类内部子系统的可配置性,以便用户可以在需要时定制外观类的内部操作
10.2 构建贸易终端
第11章 享元模式
“享元”是一个临时组件,有时也称为token或者cookie,扮演“智能引用”的克瑟、享元模式通常用于具有大量非常相似的场景,并且希望存储所有这些值的内存开销最小。
11.1 用户名问题
想象一下,一个大型多人在线游戏,一把枪射出的一模一样的子弹,每个子弹都有自己的模型贴图。如此,就需要耗费大量内存存储相同的模型贴图。正确的做法是,只存储一次这份模型贴图,然后存储指向具有该名字的每个用户的指针。这样才能节省空间。
下面的例子中,以姓名为例,存储姓和名的索引
typedef uint16_t = key;
struct User{
User(const string& first_name, const string& last_name) : first_name{add(first_name)}, last_name{add(last_name)} {}
protected:
key first_name, last_name;
static bimap<key, string> names;
static key seed;
static key add(const string& s){...}
};
可以看到,User的构造函数使用其私有的add()函数来初始化first_name和last_name。这个函数在必要时将键值对(key通过种子seed生成)插入name结构中。这里使用的是boost::bimap(双向映射),因为它可以更容易地搜索到重复项——如果某个姓或名已经存在于bimap中了,只需要返回它的索引即可。
接下来实现add()
static key User::add(const string& s){
auto it = names.right.find(s);
if(it == names.right.end()){
names.insert({++seed}, s);
return seed;
}
return it->second;
}
现在,如果想对外暴露姓和名(这两个成员受protected访问限制,类型为key,不是很有用),我们可以提供适当的getter和setter
const string& get_first_name() const{
return names.left.find(first_name)->second;
}
const string& get_last_name() const{
return names.left.fdind(last_name)->second;
}
如果想定义User的流输出运算符,则可以编写如下代码
friend ostream& operator<<(ostream& os, const User& obj){
return os << boj.get_first_name << obj.get_last_name;
}
在存在大量重复的用户名的情况下,节省的空间是巨大的,尤其是为key选择占据字节数较少的数据类型时。
11.2 Boost.Flyweight
在之前的示例中,尽管可以复用Boost库中的代码,还是手动实现了一个享元。boost::flyweight的作用恰如起名:构建一个节省空间的享元
采用boost::flyweight,User的实现变得相当简单
struct User2{
flyweight<string> first_name, last_name;
User2(const string& first_name, const string& last_name): first_name(first_name), last_name(last_name) {}
};
11.3 字符串的范围
如果我们调用string::substring(),函数会返回一个全新构造的string对象吗?不一定。有些语言返回一个全新对象,有些语言能显式地以起止范围的形式返回子串,这也是享元模式的一个实现,不但可以节省内存空间,还允许我们通过该范围操纵实际的底层对象。
C++中与字符串的范围相关的特性是string_view,并且对于数组类型而言还有其他变体——尽量避免拷贝!在C++中,string类型出现很久之后才有string_view特性,允许string类型的对象隐式地转换为string_view,即
string s = "hello world!";
string_view sv = string_view(s).substr(0, 5);
我们将构建自己的非常简单的基于字符串范围的接口。假设在我们定义的类中已存储了一些文本格式的字符串,我们想要获取其中某一段范围的文本,并将其修改为大写字母的形式。虽然可以直接在底层的文本数据上修改,但我们希望底层的原始文本数据不变,而只有在使用流输出运算符的时候才将选定范围的文本改写为大写的形式
11.3.1 幼稚解法
解决这个问题的一种非常愚蠢的方法是:定义一个大小与纯文本字符串长度相同的bool数组,数组中每个元素标识文本串种的字符是否是大写
class FormattedText{
string plainText;
bool *caps;
public:
explicit FormattedText(const string& plainText) : plainText(plainText){
caps = new bool[plainText.length()];
}
~Formattedtext(){
delete[] caps;
}
};
我们可以定义一个工具函数,用于将指定范围的字符修改为大写形式
void capitalize(int start, int end){
for(int i = start; i <= endl; i++)
cap[i] = true;
}
现在,我们可以定义流输出运算符,利用bool数组辅助输出操作
friend ostream& operator<<(ostraeam& os, const FormattedText& obj){
string s;
for(int i = 0; i < obj.plainText.length(); i++){
char c = obj.plainText[i];
s += (obj.caps[i] ? toupper(c) : c);
}
return os <, s;
}
它可以正常运行
FormattedText ft("This is a brave new world");
ft.capitalize(10, 15);
cout << ft;
不过,为每个字符单独定义一个bool值是愚蠢的做法,其实只使用开始标记和结束标记就可以了。
我们再次尝试使用享元模式
11.3.2 享元实现
接下来,我们使用享元模式实现BetterFormattedText。首先,定义外部类和嵌套的TextRange类,TextRange恰好是我们的享元类:
class BetterFormattedText{
public:
struct TextRange;
int start, end;
bool capitalize{false};
bool covers(int position) const{
return position >= start && position <= end;
}
private:
string plain_text;
vector<TextRange> formatting;
};
可以看到,TextRange保存了它所指代的字符串的起止位置,并且还保存了格式化信息——是否想将这段文本改为大写,同样还可以有其他格式信息(如粗体、斜体等)。它只有一个成员函数covers(),主要帮助我们确定是否需要将此格式应用于给定位置的字符。
BetterFormattedText保存了vector<TextRange>,并且可以根据需要构建新的TextRange:
TextRange& get_range(int start, int end){
formatting.emplace_back(textRange{start, end});
return *formatting.rbegin();
}
这段代码做了三件事:
- 构建了一个新的TextRange对象
- 将textRange移动到vector中
- 函数返回了vector中最后一个TextRange对象的引用
在上面的实现中,我们并没有真正检查重复的范围——这也符合基于享元模式节省空间的精神。
现在,我们为BetterFormattedText实现流输出运算符operator<<
friend ostream& operator<<(ostream& os, const BetterFormattedText& obj){
string s;
for(size_t i = 0; i < obj.plain_text.length(); i++){
auto c = obj.plain_text[i];
for(const auto& rng : obj.formatting){
if(rng.covers(i) && rng.capitalize)
c = toupper(c);
s += c;
}
}
return os << s;
}
现在,我们所要做的就是遍历每个字符,并检查当前字符是否在TextRange指定的范围内。如果在,则将具体操作应用于指定范围内的所有字符,在本例中,操作即将字符转换为大写形式。
BetterFormattedText bft("This is a brave new world");
bft.get_range(10, 15).capitalize = true;
cout << bft;
11.4 总结
享元模式本质上是一种节约内存空间的技术。它的具体体现是多种多样的:有时会将享元类作为API token返回,以对该享元进行修改;有时候,享元是隐式的,隐藏在幕后——就像User示例一样,客户并不知道程序中实际使用的享元。
第12章 代理模式
代理模式与装饰器模式类似,但代理模式的目标是在提供某些内部强化功能的同时准确(或尽可能地)保留正在使用的API。
代理模式不是一种同质的模式,因为人们构建的不同类型的代理相当多,并且服务于不同的目的。、
12.1 智能指针
智能指针是代理模式最简单最直接的展示。智能指针是一个包装类,其中封装了原始指针,同时维护着一个引用计数,并重载了部分运算符。但总体来说,智能指针提供了原始指针所具有的接口
struct BackAccount{
void deposit(int amount) {...}
BankAccount* ba = new BankAccount;
ba->deposit(123);
auto ba2 = make_shared<BankAccount>();
ba2->deposit(123);
};
所有原始指针出现的位置,都可以使用智能指针
12.2 属性代理
本质上,属性代理是一个可以根据使用语义伪装成普通成员的类。我们可以这样定义它
template <typename T>
struct Property{
T value;
Property(const T initial_value){
*this = initial_value;
}
operator T(){
return value;
}
T operator= (T new_value){
return value = new_value;
}
};
前面的实现通常需要自定义的位置添加了注释,这些注释的位置大致对应于getter/setter的位置。例如,一种可能的自定义实现是在setter中添加额外的通知(notify)代码,以便实现可观察的属性(根据观察者模式)。
本质上,类Property<T>是底层T类型的替代品,不管这个类型是什么。它仅仅是允许与T相互转换,让二者都在幕后使用value成员。现在,我们可以将普通类型替换为以下类型
struct Creature{
Property<int> strength{10};
Property<int> agility{5};
};
在属性成员上的典型操作也适用于属性代理类型:
Creature creature;
creature.agility = 20;
auto x = creature.strength;
属性代理的一个可能扩展是引入伪强类型,可以使用Property<T, int Tag>,以便使用不同类型定义具有不同作用的值。例如,如果我们希望在相似的类型上支持某种算法,以便可以将两个强度值相加,但强度值和敏捷度不能相加,那么这种方法非常有用。
12.3 虚拟代理
如果试图对nullptr解引用,会导致段错误。但是,在某些情况下,我们只希望在访问对象时再构造该对象,而不希望过早地为它分配内存(单例模式的懒汉式),因此在实际使用它之前将其保持为nullptr或类似未初始化的状态。
这种方法称为惰性实例化或惰性加载。如果确切地知道哪些地方需要这种延迟行为,则可以提前计划并为它们制定特别的规定。但如果不知道,则可以构建一个代理,让该代理接受现有对象并使其成为惰性对象。我们称之为虚拟代理,因为底层对象可能根本不存在,所以我们不是在访问具体的对象,而是在访问虚拟的对象
struct Image{
virtual void draw() = 0;
};
类Bitmap实现了Image接口,它的饿汉模式(与惰性模式相反)的实现将在构建时从文件加载图像,即使该图像实际上并不需要任何东西,例如
struct Bitmap : Image{
Bitmap(const string& filename){
cout << "Loading image from" << filename << endl;
}
void draw() override{
cout << "Drawing image " << filename << endl;
}
};
构建Bitmap的行为将触发加载图像的行为
Bitmap img{"pokemon.png"};
这并不是我们想要的,我们希望在调用draw()时才加载图像。现在,我们回到Bitmap,并将它变为惰性模式,但要假设它是固定不变的且不可修改(或者说是不可继承的)。
在这种情况下,我们可以构建一个虚拟代理,让该代理聚合原始Bitmap,提供相同的接口,并复用原始Bitmap的功能
struct LazyBitmap : Image{
LazyBitmap(const string& filename) : filename(filename){}
~LazyBitmap() {delete bmp;}
void draw() override{
if(!bmp)
bmp = new Bitmap(filename);
bmp->draw();
}
private:
Bitmap *bmp(nullptr);
string filename;
};
这个Bitmap的构造函数要轻量得多,它只存储一个文件名,文件不会立即加载
所有工作都在draw()中,我们在draw()中检查bmp指针,以确认底层(饿汉式)Bitmap对象是否已经构建。如果没有,则构造它,然后调用它的draw()函数来绘制图像。
现在,假设我们有某个使用Image类型的API
void draw_image(Image& img){
cout << "About to draw the image" << endl;
img.draw();
cout << "Done drawing the image" << endl;
}
我们可以传入LazyBitmap的实例化对象而不是Bitmap的实例化对象(多亏了多态机制!)来使用这个API,以惰性加载的方式加载图像,并通过LazyBitmap的接口来渲染图像
LazyBitmap img{"pokemon.png"};
draw_image(img);
如上所述,虚拟代理允许我们进行延迟加载
12.4 通信代理
假设我们使用Bar类型的对象调用了成员函数foo(),通常我们假设Bar与正在运行的代码在同一台机器上分配空间,同时也希望Bar::foo()在同一个进程中执行。
现在,假设我们决定将Bar移动到网络上的另一台机器上,但是我们仍然希望之前的代码能够正常工作。这样的话,我们需要一个通信代理
struct Pingable{
virtual wstring ping(const wstring& message) = 0;
};
如果我们在进程内构建ping-pong服务,则可以按如下方式实现pong
struct Pong : Pingable{
wstring ping(const wstring& message) override{
return message +L" pong";
}
};
总的来说,每次发起pong服务,它会在消息的末尾加上单词“pong,然后返回消息。请注意,这里没有使用ostringstream&,而是在每次调用中生成新字符串,这个API很容易被复用为一个Web服务。
现在,我们可以尝试一下这种方式
void tryit(Pingable& pp){
wcout << pp.ping(L"ping") << "\n";
}
Pong pp;
for(int i = 0; i < 3; i++){
tryit(pp);
}
结果是打印了3次“ping pong”,如预期的一样。
现在,假设我们决定将Pingable服务重新定位到很远的Web服务器上,也许其平台式ASP.NET
[Route("api/[controller]")]
public class PingpongController : Controller{
[HttpGet("{msg}")]
public class Get(string msg){
return msg + " pong";
}
}
通过这个设置,我们将构建一个名为RemotePong的通信代理,它将取代pong。
// 待续
12.5 值代理
值代理是某个值的代理。值代理通常封装原语类型,并根据其用途提供增强的功能。
我们考虑需要将一些值传递到一个函数中的示例。该函数既可以接受具体的固定值,也可以在运行时从预定义数据集合中选择随机值。
一种方法是修改这个函数并引入几个重载函数,不过我们要修改函数的参数原型。接下来,我们引入一个辅助类Value<T>
template <typename T>
struct Value{
virtual operator T() const = 0;
};
这个类只有一个纯虚函数,该函数负责执行隐式类型转换。只要编译器认为这种转换有用,就会将类型转换为T类型
基于这个辅助类,我们引入一个代表常量值的类Const<T>
template <typename T>
struct Const : Value<T>{
Const T v;
Const() : v{} {}
Const(T v) :v{v} {}
operator T() const override{
return v;
}
};
这个类充当类型T的包装类,并在需要时返回类型为T的值。另外,它的构造函数不是显式的,这意味着可以这样使用它
const Const<int> life{42};
cout << life/2 << "/n";
类似的,我们可以继承Value<T>,引入一个新的类,用于从一组不同的值中以相同的概率随机选择一个值
template <typename T>
struct OneOf : Value<T>P{
vector<T> values;
OneOf() : values{{T{}}} {}
OntOf(initializer_list<T> values) :values{values} {}
operator T() const override{
return values[rand() % values.size()];
}
};
我们可以使用一组值初始化一个容器,并在需要时随机生成一个值
OneOf<int> stuff{1,3,5};
cout << stuff << "\n";
现在,我们可以在应用程序中使用这些类型。例如,假如我们正在为应用程序UI测试一个新的主题。可以定义一个函数
void draw_ui(const Value<bool>& use_dark_theme){
if(use_dark_theme)
cout << "Using dark theme\n";
else
cout << "Using normal theme\n";
}
当对应用程序做A/B测试时,可以使用如下的方式来调用这个函数
OneOf<bool> dark{true, false};
draw_ui(dark);
一旦确认用户更喜欢该UI主题,只需要将这个变量替换为Const即可
COnst<bool> dark{true};
draw_ui(dark);
请注意,由于没有从bool到const Value&类型的隐式转换,所以目前我们不能直接调用draw_ui(true);
另一种方法是直接实现一个“正常”的函数
void draw_ui(bool use_dark_theme){
if(use_dark_theme)
cout << "Using dark theme\n";
else
cout << "Using normal theme\n";
}
然后,在调用方指定入口参数即可
OneOf<bool> dark{true, false};
draw_ui(dark);
// or
draw_ui(true);
两种方法的区别明显。
在传递Value的引用的情况下,我们的操作需要遵循对象的层次结构;但在函数中,可以使用隐式类型转换多次生成值——每次调用时这些值可能会不同
另外,在函数调用方面使用Value意味着我们可以用诸如true的字面值替换它,而不会失去通用性。这种方法还遵循最小惊奇原则,因为任何将Value<T>视为参数类型的客户都不得不花费宝贵的时间来搜索这种类型的层次结构并学习如何使用它。
12.6 总结
本章展示了代理模式的部分案例。与装饰器模式不同,代理模式不会尝试通过添加新成员来扩展对象的功能——它所做的只是强化现有成员的潜在行为。代理主要作为一种替代品。
- 属性代理是底层成员的替身,可以在分配或访问期间替换成员并执行其他操作
- 虚拟代理为底层对象提供虚拟访问的接口,并且实现了诸如惰性加载的功能。也许你感觉到似乎在和一个实际的对象打交道,但实际上底层的对象有可能尚未创建,它们会在真正需要的时候才加载或创建
- 通信代理允许在改变了对象的物理位置(例如,移动到了云上)的情况下使用同样的API
- 值代理可以替换单个标量值,并为其赋予额外的功能
除此之外,还有很多其他代理,我们自己构建的代理很可能不属于预先存在的类别,不过,它们会在我们的应用程序中执行具体而便捷的操作
第20章 观察者模式
20.1 属性观察器
给定一个Person类,它含有一个变量age
struct Person{
int age;
Person(int age) : age(age){}
};
如何知道一个人的年龄变化了?可以尝试轮询:每100ms读取一次age,并将新值与前一个值比较。这种方法可行,但繁琐且不可扩展
我们希望每次age更新时,我们都能得到通知。唯一的方法是设计一个setter,即
struct Person{
int get_age() const {return age;}
void set_age(const int value) {age = value;}
private:
int age;
};
set_age()函数可以通知那些关心年龄变化的对象,但是,如何通知呢
20.2 Observer<T>
一种方法是定义一个基类,任何关心Person变化的对象都需要继承它
struct PersonListener{
virtual void person_changed(Person&p, const string& property_name) = 0;
};
然而,它不能达到预期效果,因为属性更改可能发生在Person以外的类上,那样就得生成额外的类。以下是更通用的结构
template <typename T>
struct Observer{
virtual field_changed(T* source, const string& field_name) = 0;
};
第一个参数是对属性发生变化的T类型对象的引用,第二个参数是该属性的名称。通过字符串传递属性名称,的确有损于代码的可重构性。
这样,我们可以观察Person类的变化
struct ConsolePersonObserver : Observer<Person>{
void field_changed(Person& source, const string& field_name) override{
if(field_name == "age"){
cout << "Person's age has changed to " << source.get_age() << ".\n";
}
}
};
可以同时观察多个类的属性变化,添加Creature类,可以同时观察两者
struct ConsolePersonObserver : Observer<Person>, Observer<Creature>{
void field_changed(Person& source, ...) {...}
void field_changed(Creature& source, ...) {...}
};
另一种方法是使用std::any。
20.3 Observable<T>
既然Person类成为了一个被观察的类,那么它将承担一些新的职责
- 维护一个列表,其中保存了所有关注Person变化的观察者
- 允许观察者通过subscribe()/unsubscribe()接口订阅或取消对Person变化的订阅
- 当Person发生变化时,通过notify()接口通知所有观察者
所有这些功能都可以移动到一个单独的基类中,以避免对每个潜在的可观察类进行重复性的代码
template <typename T>
struct Observable{
void notify(T& source, const string& name) {...}
void subscribe(Observer<T>* f){observers.push_back(f);}
void unsubscribe(Observer<T>* f){...}
private:
vector<Observer<T>*> observers;
};
subscribe()接口将新的观察者添加到私有的observers列表中。observers列表不会暴露给外界,甚至不会暴露给其派生类。
接下来,实现notify()接口
void notify(T& source, const string& name){
for(auto obs : observers)
obs->field_changed(source, name);
}
然而,仅仅继承自Observerable<T>不够,类还需要在其属性发生变化时调用notify()
例如,set_age()函数。它现在有3个职责
- 检查名称是否已更改。如果age是20,而我们为其分配了20,那么执行任何分配操作或通知操作都没有意义
- 为这个属性分配一个合适的值
- 使用正确的参数调用notify()接口
因此,set_age()接口的新版本如下
struct Person : Observable<Person>{
void set_age(const int age){
if(this->age == age)
return;
this->age = age;
notify(*this, "age");
}
private:
int age;
};
20.4 连接观察者和被观察者
现在,准备使用之前设计的接收Person属性变化通知的基础数据结构
struct ConsolePersonObserver : Observer<Person>{
void field_changed(Person& source, const string& field_name) override{
cout << "Person's " << field_name << " has changed to " << source.get_age() << ".\n";
}
};
下面是它的用法
Person p{20};
ConsolePersonObserver cpo;
p.subscribe(&cpo);
p.set_age(21);
p.set_age(22);
20.5 依赖问题
例如,在某些国家,16岁及以上的人具有投票选举权。假设当一个人的投票权发生变化时,我们希望得到通知,首先,假设Person类中有如下setter()方法
bool get_can_vote() const {return age >= 16;}
它没有底层的属性成员和setter(可以引入一个can_vote属性,但它显然多余),但我们认为有义务添加notify()接口。怎么做?可以试着找出使得can_vote变化的原因,就是set_age()。因此,如果我们想要得到投票状态变化的通知,这些需要在set_age()中完成。
void set_age(int value) const{
if(age == value) return;
auto old_can_vote = can_vote();
age = value;
notify(*this, "age");
if(old_can_vote != can_vote())
notify(*this, "can_vote");
}
这个函数中发生的事情太多了。不仅要检查age是否改变,还要检查can_vote是否改变,并发出通知!can_vote依赖两个属性,age和citizenship,这意味着两个属性的setter都必须处理can_vote的通知。如果age也会以这种方式影响其他10种属性呢?这是不可行的,会导致不可维护的脆弱代码,因为变量之间的关系需要手动跟踪
在这个场景中,can_vote是age的一个依赖属性。依赖属性的困难本质上类似于Excel等工具,鉴于不同单元格之间存在大量依赖关系,当其中一个单元格变化时,如何确定要重新计算哪些单元格
属性依赖关系可以用某种映射<string, vector<string>>来表示,它将保留一个受属性影响的属性列表。但是,它必须手动定义,保持它与实际代码的同步也很麻烦
20.6 取消订阅与线程安全
取消订阅如何实现?
void unsubscribe(Observer<T>* onserver){
// remove将待删除元素移动到vector末尾,erase实现真正的删除
observers.erase(remove(observers.begin(), observers.end(), observer), observers.end());
}
尽管erase-remove的使用在技术上是正确的,但这种正确性仅仅在单线程的场景中才成立。vector不是线程安全的,因此,同时调用subscribe()和unsubscribe()可能会导致一些意外结果,因为两个接口都会改变vector
通过加锁,可以解决这个问题
template <typename T>
struct Observable{
void notify(T& source, const string& name){
scoped_lock<mutex> lock{mtx};
// ...
}
void subscribe(Observer<T>* f){
scoped_lock<mutex> lock{mtx};
// ...
}
void unsubscribe(Observer<T>* o){
scoped_lock<mutex> lock{mtx};
// ...
}
private:
vector<Observer<T>*> observers;
mutex mtx;
};
另一个非常可行的替代方法是使用PPL/TPL中的concurrent_vector。这无法保证顺序,但可以避免自己管理锁
20.7 可重入性
最后一个实现通过在3个关键接口中加锁来保证线程安全。想象一下这种场景:TrafficAdministration组件会一直监视一个人,知道他长大可以开车为止。当他们17岁时,该组件将取消订阅
struct TrafficAdministration : Observer<Person>{
void TrafficAdministration::field_change(Person& source, const string& field_name) override{
if(field_name == "age"){
if(source.get_age() < 17)
cout << "Not enough to drive!\n"
else
cout << "Enough to drive.\n"
source.unsubscribe(this);
}
}
};
当age变为17时,整个调用链将会是notify() --> field_changed() --> unsubscribe()
这是存在问题的,因为在unsubscribe()接口中,我们会尝试去获得一个已经获得的锁,这就是可重入性问题。有不同的方法可以解决这个问题:
-
一种方法是直接禁止这种情况
-
另一种方法是放弃从集合中删除元素的想法。相反,我们可以选择
void unsubscribe(Observer<T>* o){ auto it = find(observers.begin(), observers.end(), o); if(it != observers.end()) *it = nullptr; }随后,当调用notify()时,只需进行额外检查
void notify(T& source, const string& name){ for(auto obs : observers) if(obs) obs->field_changed(source, name); }当然,这只能解决notify()和subscribe()之间的竞争。例如,如果同时执行subscribe()和unsubscribe(),这仍然是对集合的并发修改,而且仍然可能失败。所以,可能至少需要在那里维护一把锁。
还有一种可能是在notify()中复制整个集合。我们仍就需要这把锁,只是不必再notify()中使用它了,即void notify(T& source, const string& name){ vector<Observer<T>*> observers_copy; { lock_guard<mutex_t> lock{mtx}; observers_copy = observers; } for(auto obs : observers_copy) if(obs) obs->field_change(source, name); }在这个实现中,的确使用了锁,但是当我们调用field_changed()时,锁已经释放了,因为这把实在局部作用域中拷贝vector时使用的。不必担心效率问题,因为复制vector的指针并不会占用太多内存。
最后,将mutex替换为recursive_mutex总是可以的。但是,它有性能问题,更多的是,在大多数情况下,如果代码设计得很好,就可以使用普通的非递归的变量20.8 Boost.Sinagls2中的观察者模式
很多库都提供了观察者模式的实现,例如Boost.Signals2库。这个库本质上提供了一种称为信号的类型,它表示C++术语中的“信号”(事件)。可以通过提供函数或lambda表达式来订阅该信号。也可以取消订阅,当我们想得到通知时,可以触发该信号。
我们可以使用Boost.Signals2来定义Observable<T>template <typename T> struct Observable{ signal<void(T&, const string&)> property_changed; };它的调用方式如下:
struct Person : Observable<Person>{ // ... void set_age(const int age){ if(this->age == age) return; this->age = age; property_changed(*this, "age"); } };这个API实际上是在调用signal,除非我们添加一些成员函数,使得API用起来更加方便
Person p{123}; auto conn = p.property_changed.connect([](Person&, const string& prop_name){ cout << prop_name << " has been changed" << endl; }); p.set_age(20); conn.disconnect();connect()函数会返回一个connection对象,如果我们不再需要从该信号处得到通知,该connection对象也可以用于取消订阅。
20.9 视图
属性观察者面临一个巨大而明显的问题:这种方法具有侵入性,显然违背了关注点分离的原则。关于被观察对象的变化的通知是一个单独的问题,因此将其直接添加到对象中可能不是最好的方法,尤其是考虑到它只是许多问题之一,一旦类成员定义完成,这些问题可能会在之后的阶段变得更加明显。
因此,如果我们决定从使用Observable<T>改为使用一些完全不同的结构。如果我们在之前定义的数据结构和代码中广泛使用了Observable,那么必须仔细检查每个地方,修改每一个属性以使用新的结构,还必须修改这些类。这是一件乏味且容易出错的事情,应该避免它。
所以,如果希望在发生变化的对象之外处理关于更改的通知,应该在哪里添加他们?应该采用装饰器模式
一种方法是在被观察对象的前面放置另一个对象,让该对象处理更改通知和其他事情。这就是我们通常所说的视图——例如,它可以是绑定到UI的对象
要使用视图,可以使用普通属性(甚至是public访问区域),使对象保持简单且没有任何额外行为struct Person{ string name; };事实上,让数据对象尽可能简单是值得的,在Kotlin等语言中,这就是所谓的数据类。现在要做的是在对象的顶部构建一个视图。该视图还可以包含其他问题,包括属性观察者。
struct PersonView : Observable<Person>{ explicit PersonView(const Person& person) : person(person) {} string& get_name(){return person.name;} void set_name(const string& value){ if(value == person.name) return; person.name = value; property_changed(person, "name"); } protected: Person& person; };我们创建的这个视图实际上是一种装饰器。它使用getter()/setter()包装底层对象,并用于触发事件。如果想让其更加复杂,可以继续添加功能。
现在,随着视图的构建,可以将它插入应用程序的其他部分。例如,如果应用程序有一个带有可编辑文本成员的用户界面,那么该成员可以与视图中name的getter和setter交互。20.10 总结
-
回顾一下主要设计思路
-
确定希望向观察者传达什么信息
-
希望观察者是整个类,还是只是一些虚函数?
-
如何处理取消订阅这个接口
- 如果不打算支持这个功能,就不存在可重入性问题中的移除问题了
- 如果要支持这个功能,可以先做标记,随后再移除?
- 如果不喜欢基于原始的指针做分发,可以考虑用weak_ptr
-
Observer<T>的函数可能被多个不同线程调用吗?如果可能,则需要维护订阅列表
- 在所有相关的函数中添加scoped_lock()
- 使用一些线程安全的容器,比如TBB/PPL concurrent_vector。这可能会损失按序相关的特性,但保证了线程安全
-
允许同一个观察者多次订阅吗?如果允许,那么不能使用set
本章呈现的部分代码属于过度思考和过度设计的例子,远远超出了大多数人想要实现的目标
但是,目前还没有一个理想的观察者模式的实现来满足所有的需求,无论选择哪种实现方式,都有一些妥协。
第22章 策略模式
每天与标准库打交道的时候,已经使用过策略模式了,例如,指定特定的排序算法时,就是在指定排序策略,即为整体算法提供部分的定义
vector<int> values{3,1,5,2,4}; sort(values.begin(), values.end(),less<>{}); for(int x:values) cout << x<< ' ';用函数式编程的术语来说,sort()是一个高阶函数,也就是说,它是一个接受其他函数的函数。C++提供了两种实现它的方法
- 函数以模板参数的形式接受一个函数。这对客户并不友好,因为代码提示不会提供关于“函数参数的签名应该是什么”的信息
- 函数以适当的函数指针形式接受一个函数,如std::function或类似的东西。这更友好,因为我们可以知道函数参数应该采用什么形式
至于函数参数的实际构造方式,在类似sort()这样的算法中,该策略既可以以可调用对象(例如,仿函数)的引用的形式提供,也可以以以lambda表达式的形式提供
vector<int> values{3,1,5,2,4}; sort(values.begin(), values.end(), [=](int a, int b){return a > b;}) for(int x:values) cout << x << '';虽然sort()中使用的策略对象是临时的(它只在调用期间有效),但我们也可以将策略保存在变量中,然后在必要时重用它。将策略定义为类由很多好处,包括
- 基于类的策略可以保存状态
- 策略可以有多种方法接口,这些接口可以描述该策略的组成部分
- 策略之间可以相互继承,以达到复用的目的
- 可以通过接口而不是函数标签来描述策略之间的依赖关系
- 在IoC容器中可以选择配置默认的策略
换句话说,将策略定义为类十分有用,尤其是当策略很复杂、可配置或者由多个部分组成时
22.1 动态策略
假如要将由多个字符串组成的数组或向量以列表的形式输出:just、like、this。如果考虑不同的输出格式,则需要获取每个元素,并将其与一些额外的标记一起输出。但对于HTML或LaTeX等语言,列表还需要开始标记和结束标记。
我们可以为输出列表指定一个策略:输出开始标记/元素,输出列表中的每一个元素,输出结束标记/元素。这是另一种包含动态(运行时可替换)和静态(模板合成、固定的)方式的一种设计模式。
enum class OutputFormat{ markdown, html };下面的基类的定义展示了我们所要制定的策略的基本框架
struct ListStrategy{ virtual void start(ostringstream& oss) {} virtual void add_list_item(stringstream& oss, const string& item) {} virtual void end(ostring& oss) {} };基类不是抽象类,它实际上是一种空对象。这样做的目的是,继承者只需重写必要的方法,而其他方法只需要提供无操作函数的实现。、
现在,我们来看文本处理的组件。这个组件包含一个名为append_list()的成员函数,用于处理指定的列表struct TextProcessor{ void append_list(const vector<string> items){ list_strategy->start(oss); for(auto& item:items) list_strategy->add_list_item(oss); list_strategy->end(oss); } private: ostringstream oss; unique_ptr<ListStrategy> list_strategy; };我们定义了一个名为oss的存放所有输出的缓冲区,还定义了一个用于输出列表的策略以及append_list(),它指定了使用给定策略输出列表的一组步骤。
这里使用的组合是两种可能的选项之一,可以用于算法框架的具体实现。我们还可以将add_list_item()等函数添加为虚拟成员,以便派生类重写:这就是模板方法模式所做的。
回到主题,我们现在可以为列表实施不同的策略,例如Html-ListStrategystruct HtmlListStrategy : ListStrategy{ void start(stringstream& oss) override{ oss << "<ul>\n"; } void end(ostringstream& oss) override{ oss << "</ul>\n"; } void add_list_item(ostringstream& oss, const string& item) override{ oss << " <li>" << item << "</li>\n"; } };通过重写虚函数,我们指定了如何处理列表元素。我们将以同样的方式实现MarkdownListStrategy,但是由于MarkDown不需要开始标记和结束标记,所以我们只需重写add_list_item()函数
struct MarkdownListStrategy : ListStrategy{ void add_list_item(ostringstream& oss, const string& item) override{ oss << " * " << item; } };现在,我们可以使用TextProcessor,给它输入不同的策略,从而得到不同的结果,例如:
TextProcessor tp{OutputFormat::markdown}; tp.append_list({"foo", "bar", "baz"}); cout << tp.str() << endl;我们可以在运行时切换策略——这正是我们将这种实现方法称为动态策略的原因。这是在set_output_format()函数中完成的,该函数的实现非常简单
void set_output_format(const OutputFormat format){ switch(format){ case OutputFormat::markdown: list_strategy = make_uniquer<MarkdownListStrategy>(); break; case OutputFormat::html: list_strategy = make_unique<HtmlListStrategy>(); break; } }现在,从一种策略切换到另一种策略变得十分简单,我们可以立即看到结果
tp.clear(); tp.set_output_format(OutputFormat::Html); tp.append_list({"foo", "bar", "baz"}); cout << tp.str() << endl;22.2 静态策略
借助模板的威力,我们可以将策略放到“类型中。我们只需要对TextStrategy类做很小的修改
template <typename LS> struct textProcessor{ void append_list(const vector<string> items){ list_strategy.start(oss); for(auto& item:items) list_strategy.add_list_item(oss, item); list_strategy.end(oss); } private: ostringstream oss; LS list_strategy; };我们所做的修改只是添加了LS模板参数,将成员list_strategy的类型改为模板参数类型,而不再使用之前的指针类型。append_list()函数的结果与之前相同
TextProcessor<MarkdownListStrategy> tpm; tpm.append_list({"foo", "bar", "baz"}); cout << tpm.str() << endl; TextProcessor<HtmlListStrategy> tpm; tpm.append_list({"foo", "bar", "baz"}); cout << tpm.str() << endl;上面代码的运行结果与动态策略的输出结果是相同的。请注意,我们必须要提供两个textProcessor实例,每个实例处理一种策略。
22.3 总结
策略模式允许我们定义通用的算法框架,然后以组件的形式提供框架内部流程的具体实现。高木事有几种不同的实现方式
- 函数式策略,即将策略以仿函数或lambda表达式的形式进行传递,这种策略是一个临时对象,我们通常不打算保留它
- 动态策略维护了指向策略的指针或引用。切换到另一个不同的策略只需要修改指针或引用即可
- 静态策略要求在编译时就敲定具体的策略——之后没有机会再修改策略了
我们应该使用动态策略还是静态策略呢?动态策略允许在对象创建完成以后进行重新配置。总体来说,取决于实际情况
最后,我们可以约束类型所采用的策略集合:与使用通用的ListStrategy参数不同,我们可以使用std::variant类型,它只允许传入指定的策略类型 -
4955

被折叠的 条评论
为什么被折叠?



