|
| 1 | +### 建造者模式 |
| 2 | + |
| 3 | +建造者模式(`Builder`)涉及到复杂对象的创建,即不能在单行构造函数调用中构建的对象。这些类型的对象本身可能由其他对象组成,可能涉及不太明显的逻辑,需要一个专门用于对象构造的单独组件。 |
| 4 | + |
| 5 | +我认为值得事先注意的是,虽然我说建造者适用于复杂的对象的创建,但我们将看一个相当简单的示例。这样做纯粹是为了空间优化,这样领域逻辑的复杂性就不会影响读者欣赏模式实现的能力。 |
| 6 | + |
| 7 | +#### 场景 |
| 8 | + |
| 9 | +让我们想象一下,我们正在构建一个呈现`web`页面的组件。首先,我们将输出一个简单的无序列表,其中有两个`item`,其中包含单词`hello`和`world`。一个非常简单的实现如下所示: |
| 10 | + |
| 11 | +```c++ |
| 12 | +string words[] = { "hello", "world" }; |
| 13 | +ostringstream oss; |
| 14 | +oss << "<ul>"; |
| 15 | +for (auto w : words) |
| 16 | +oss << " <li>" << w << "</li>"; |
| 17 | +oss << "</ul>"; |
| 18 | +printf(oss.str().c_str()) |
| 19 | +``` |
| 20 | +
|
| 21 | +这实际上给了我们想要的东西,但是这种方法不是很灵活。如何将项目符号列表改为编号列表?在创建了列表之后,我们如何添加另一个`item`?显然,在我们这个死板的计划中,这是不可能的。 |
| 22 | +
|
| 23 | +
|
| 24 | +因此,我们可以通过`OOP`的方法定义一个`HtmlElement`类来存储关于每个`tag`的信息: |
| 25 | +
|
| 26 | +
|
| 27 | +```c++ |
| 28 | +struct HtmlElement |
| 29 | +{ |
| 30 | + string name; |
| 31 | + string text; |
| 32 | + vector<HtmlElement> elements; |
| 33 | + HtmlElement() {} |
| 34 | + HtmlElement(const string& name, const string& text) |
| 35 | + : name(name) |
| 36 | + , text(text) |
| 37 | + { } |
| 38 | +
|
| 39 | + string str(int indent = 0) const |
| 40 | + { |
| 41 | + // pretty-print the contents |
| 42 | + } |
| 43 | + }; |
| 44 | +``` |
| 45 | + |
| 46 | +有了这种方法,我们现在可以以一种更合理的方式创建我们的列表: |
| 47 | + |
| 48 | +```c++ |
| 49 | +string words[] = { "hello", "world" }; |
| 50 | +HtmlElement list{"ul", ""}; |
| 51 | +for (auto w : words) |
| 52 | + list.elements.emplace_back{HtmlElement{"li", w}}; |
| 53 | +printf(list.str().c_str()); |
| 54 | +``` |
| 55 | +
|
| 56 | +这做得很好,并为我们提供了一个更可控的、`OOP`驱动的条目列表表示。但是构建每个`HtmlElement`的过程不是很方便,我们可以通过实现建造者模式来改进它。 |
| 57 | +
|
| 58 | +#### 简单建造者 |
| 59 | +
|
| 60 | +建造者模式只是试图将对象的分段构造放到一个单独的类中。我们的第一次尝试可能会产生这样的结果: |
| 61 | +
|
| 62 | +```c++ |
| 63 | +struct HtmlBuilder |
| 64 | +{ |
| 65 | + HtmlElement root; |
| 66 | +
|
| 67 | + HtmlBuilder(string root_name) { root.name = root_name; } |
| 68 | +
|
| 69 | + void add_child(string child_name, string child_text) |
| 70 | + { |
| 71 | + HtmlElement e{ child_name, child_text }; |
| 72 | + root.elements.emplace_back(e); |
| 73 | + } |
| 74 | +
|
| 75 | + string str() { return root.str(); } |
| 76 | +}; |
| 77 | +``` |
| 78 | + |
| 79 | +这是一个用于构建`HTML`元素的专用组件。`add_child()`方法是用来向当前元素添加额外的子元素的方法,每个子元素都是一个名称-文本对。它可以如下使用: |
| 80 | + |
| 81 | +```c++ |
| 82 | +HtmlBuilder builder{ "ul" }; |
| 83 | +builder.add_child("li", "hello"); |
| 84 | +builder.add_child("li", "world"); |
| 85 | +cout << builder.str() << endl; |
| 86 | +``` |
| 87 | +
|
| 88 | +你会注意到,此时`add_child()`函数是返回空值的。我们可以使用返回值做许多事情,但返回值最常见的用途之一是帮助我们构建流畅的接口。 |
| 89 | +
|
| 90 | +
|
| 91 | +#### 流畅的建造者 |
| 92 | +
|
| 93 | +让我们将`add_child()`的定义改为如下: |
| 94 | +
|
| 95 | +```c++ |
| 96 | +HtmlBuilder& add_child(string child_name, string child_text) |
| 97 | +{ |
| 98 | + HtmlElement e{ child_name, child_text }; |
| 99 | + root.elements.emplace_back(e); |
| 100 | + return *this; |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +通过返回对建造者本身的引用,现在可以在建造者进行链式调用。这就是所谓的流畅接口(`fluent interface`): |
| 105 | + |
| 106 | +```c++ |
| 107 | +HtmlBuilder builder{ "ul" }; |
| 108 | +builder.add_child("li", "hello").add_child("li", "world"); |
| 109 | +cout << builder.str() << endl; |
| 110 | +``` |
| 111 | +
|
| 112 | +引用或指针的选择完全取决于你。如果你想用`->`操作符,可以像这样定义`add_child()` |
| 113 | +
|
| 114 | +```c++ |
| 115 | +HtmlBuilder* add_child(string child_name, string child_text) |
| 116 | +{ |
| 117 | + HtmlElement e{ child_name, child_text }; |
| 118 | + root.elements.emplace_back(e); |
| 119 | + return this; |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +像这样使用: |
| 124 | + |
| 125 | +```c++ |
| 126 | +HtmlBuilder builder = new HtmlBuilder{ "ul" }; |
| 127 | +builder->add_child("li", "hello")->add_child("li", "world"); |
| 128 | +cout << builder->str() << endl; |
| 129 | +``` |
| 130 | +
|
| 131 | +#### 交流意图 |
| 132 | +
|
| 133 | +我们为`HTML`元素实现了一个专用的建造者,但是我们类的用户如何知道如何使用它呢?一种想法是,只要他们构造对象,就强制他们使用建造者。你需要这样做: |
| 134 | +
|
| 135 | +```c++ |
| 136 | +struct HtmlElement |
| 137 | +{ |
| 138 | + string name; |
| 139 | + string text; |
| 140 | + vector<HtmlElement> elements; |
| 141 | + const size_t indent_size = 2; |
| 142 | + static unique_ptr<HtmlBuilder> build(const string& |
| 143 | + root_name) |
| 144 | + { |
| 145 | + return make_unique<HtmlBuilder>(root_name); |
| 146 | + } |
| 147 | +
|
| 148 | + protected: // hide all constructors |
| 149 | + HtmlElement() {} |
| 150 | + HtmlElement(const string& name, const string& text) |
| 151 | + : name{name} |
| 152 | + , text{text} |
| 153 | + {} |
| 154 | +}; |
| 155 | +``` |
| 156 | + |
| 157 | +我们的做法是双管齐下。首先,我们隐藏了所有的构造函数,因此它们不再可用。但是,我们已经创建了一个工厂方法(这是我们将在后面讨论的设计模式),用于直接从`HtmlElement`创建一个建造者。它也是一个静态方法。下面是如何使用它: |
| 158 | + |
| 159 | +```c++ |
| 160 | +auto builder = HtmlElement::build("ul"); |
| 161 | +(*builder).add_child("li", "hello").add_child("li", "world"); |
| 162 | +cout << builder.str() << endl; |
| 163 | +``` |
| 164 | + |
| 165 | +但是不要忘记,我们的最终目标是构建一个`HtmlElement`,而不仅仅是它的建造者!因此,锦上添花的可能是建造者上的 `operator HtmlElement`的实现,以产生最终值: |
| 166 | + |
| 167 | +```c++ |
| 168 | +struct HtmlBuilder |
| 169 | +{ |
| 170 | + operator HtmlElement() const { return root; } |
| 171 | + HtmlElement root; |
| 172 | + // other operations omitted |
| 173 | +}; |
| 174 | +``` |
| 175 | + |
| 176 | +前面的一个变体是返回std::move(root),但是否这样做实际上取决于你自己。不管怎样,运算符的添加允许我们写下以下内容: |
| 177 | + |
| 178 | +```c++ |
| 179 | +HtmlElement e = *(HtmlElement::build("ul")) |
| 180 | + .add_child("li", "hello") |
| 181 | + .add_child("li", "world"); |
| 182 | +cout << e.str() << endl; |
| 183 | +``` |
| 184 | + |
| 185 | +遗憾的是,没有办法明确地告诉其他用户以这种方式使用API。对构造函数的限制加上静态`build()`函数的存在,希望用户能够使用构造函数,但是,除了操作符之外,还可以向`HtmlBuilder`本身添加一个相应的`build()`函数: |
| 186 | + |
| 187 | +```c++ |
| 188 | +HtmlElement HtmlBuilder::build() const |
| 189 | +{ |
| 190 | + return root; // again, std::move possible here |
| 191 | +} |
| 192 | +``` |
| 193 | +#### Groovy风格的建造者 |
| 194 | + |
| 195 | +这个例子稍微偏离了专用建造者,因为实际上没有看到任何建造者。它只是一种对象构造的替代方法。 |
| 196 | + |
| 197 | +诸如`Groovy、Kotlin`等编程语言都试图通过支持使构建过程更好的语法结构来展示它们在构建`DSL`方面有多么出色。但是为什么c++应该有所不同呢?多亏了初始化列表,我们可以使用普通的类有效地构建一个兼容`HTML`的`DSL` |
| 198 | + |
| 199 | +首先,我们将定义一个`HTML`标签: |
| 200 | + |
| 201 | +```c++ |
| 202 | +struct Tag |
| 203 | +{ |
| 204 | + std::string name; |
| 205 | + std::string text; |
| 206 | + std::vector<Tag> children; |
| 207 | + std::vector<std::pair<std::string, std::string>> attributes; |
| 208 | + friend std::ostream& operator<<(std::ostream& os, const Tag& tag) |
| 209 | + { |
| 210 | + // implementation omitted |
| 211 | + } |
| 212 | + }; |
| 213 | +``` |
| 214 | +
|
| 215 | +到目前为止,我们已经有了一个可以存储其名称、文本、子标签(内部标签),甚至`HTML`属性的标签。我们也有一些打印代码,但在这里显示太无聊了。 |
| 216 | +
|
| 217 | +现在我们可以给它提供两个在保护字段的构造函数(因为我们不希望任何人直接实例化它)。我们之前的实验告诉我们,我们至少有两种情况: |
| 218 | +
|
| 219 | +- 一个由名称和文本初始化的标签(例如,一个列表项) |
| 220 | +- 一个由名称和一组子元素初始化的标签 |
| 221 | +
|
| 222 | +第二种情况更有趣;我们将使用一个`std::vector`类型的形参 |
| 223 | +
|
| 224 | +```c++ |
| 225 | +struct Tag |
| 226 | +{ |
| 227 | + ... |
| 228 | + protected: |
| 229 | + Tag(const std::string& name, const std::string& text) |
| 230 | + : name{name} |
| 231 | + , text{text} |
| 232 | + {} |
| 233 | + Tag(const std::string& name, const std::vector<Tag>&children) |
| 234 | + : name{name} |
| 235 | + , children{children} |
| 236 | + {} |
| 237 | +}; |
| 238 | +``` |
| 239 | + |
| 240 | +现在,我们可以继承这个标签类,但仅限于有效的`HTML`标签(因此限制了我们的DSL)。让我们定义两个标签:一个用于段落,另一个用于图像 |
| 241 | + |
| 242 | +```c++ |
| 243 | +struct P : Tag |
| 244 | +{ |
| 245 | + explicit P(const std::string& text) |
| 246 | + : Tag{"p", text} |
| 247 | + {} |
| 248 | + |
| 249 | + P(std::initializer_list<Tag> children) |
| 250 | + :Tag("p", children) |
| 251 | + {} |
| 252 | +}; |
| 253 | +struct IMG : Tag |
| 254 | +{ |
| 255 | + explicit IMG(const std::string& url) |
| 256 | + : Tag{"img", ""} |
| 257 | + { |
| 258 | + attributes.emplace_back({"src", url}); |
| 259 | + } |
| 260 | +}; |
| 261 | +``` |
| 262 | +
|
| 263 | +前面的构造函数进一步约束了我们的API。根据前面的构造函数,段落只能包含文本或一组子元素。另一方面,图像不能包含其他标记,但必须具有一个名为img的属性,该属性具有提供的地址。 |
| 264 | +
|
| 265 | +现在,由于统一初始化和派生的所有构造函数,我们可以写以下内容: |
| 266 | +
|
| 267 | +```c++ |
| 268 | +std::cout << |
| 269 | + P{ |
| 270 | + IMG {"http://pokemon.com/pikachu.png"} |
| 271 | + } |
| 272 | + << std::endl; |
| 273 | +``` |
| 274 | + |
| 275 | +这不是很棒吗?我们已经为段落和图像构建了一个小型`DSL`,这个模型可以很容易地扩展以支持其他标签。并且没有看到`add_child()`调用。 |
| 276 | + |
| 277 | + |
| 278 | + |
| 279 | +#### 组合建造者 |
| 280 | + |
| 281 | +我们将通过一个使用多个构建器构建单个对象的例子来结束对构建器的讨论。假设我们决定记录关于一个人的一些信息: |
| 282 | + |
| 283 | +```c++ |
| 284 | +class Person |
| 285 | +{ |
| 286 | + // address |
| 287 | + std::string street_address, post_code, city; |
| 288 | + |
| 289 | + // employment |
| 290 | + std::string company_name, position; |
| 291 | + int annual_income = 0; |
| 292 | + Person() {} |
| 293 | +}; |
| 294 | +``` |
| 295 | + |
| 296 | +`Person`的成员变量中包含:地址信息和就业信息。如果我们想为这两类信息提供单独的构建器,我们如何提供最方便的API呢?为此,我们将构建一个复合构建器。这个构造不是简单的,所以请注意,即使我们想为就业和地址信息创建不同的构建器,我们也会生成不少于四个不同的类。 |
| 297 | + |
| 298 | +// TODO |
| 299 | + |
| 300 | +#### 总结 |
| 301 | + |
| 302 | +建造者模式的目标是定义一个完全用于分段构造复杂对象或一组对象的组件。我们已经观察到建造者的以下关键特征: |
| 303 | + |
| 304 | +- 构建器可以拥有流畅的接口,可用于使用单个调用链进行复杂的构造。为了支持这个,构造函数应该返回`this`或`*this`。 |
| 305 | +- 为了强制`API`的用户使用一个构造器,我们可以使目标对象的构造器不可访问,然后定义一个静态的`create()`函数来返回这个构造器。 |
| 306 | +- 通过定义适当的操作符,可以将构造器强制转换为对象本身。 |
| 307 | +- 多亏了统一的初始化器语法,`groovy`风格的建造者在c++中是可能的。这种方法非常普遍,并且允许创建不同的领域特定语言(DSLs)。 |
| 308 | +- 单个建造者接口可以公开多个子建造者。通过巧妙地使用继承和流畅接口,可以轻松地从一个建造者跳转到另一个。 |
| 309 | + |
| 310 | +重申一下我已经提到过的内容,当对象的构造是一个重要的过程时,使用建造者模式是有意义的。由有限数量的合理命名的构造函数参数明确构造的简单对象可能应该使用构造函数(或依赖注入),而不需要这样的建造者。 |
0 commit comments