【设计模式精讲】7.建造者模式(Builder)
【摘要】:构造函数十个参数、其中七个可选,为了少写几个重载,你造出了一排「重叠构造函数」,调用处全是猜不透的
("x", 0, false, nullptr, true)。本文从这个经典坏味道讲起,给出建造者模式:把复杂对象的 构造过程 从 表示 中分离,同一套步骤代码能攒出不同形态的产品。C++ 实现以 Refactoring Guru 的汽车/手册双生成器为例,覆盖传统写法与链式(Fluent)Builder、主管(Director)角色;进阶展示现代 C++ 的链式引用、std::optional命名参数模拟与聚合初始化的取舍。文末对照 Boost.URL 流式接口、标准库filesystem::path与 POCOStatement的真实形态。
【关键词】:建造者、Builder、链式调用、Fluent、Director、重叠构造函数
1. 十个参数的构造函数,你敢传第几个
HTTP 请求对象的构造现场:
cpp
// 说明性片段(省略 Request 定义)
// ❌ 重叠构造函数(telescoping constructor)
Request r1("GET", "/api"); // 其他全默认
Request r2("POST", "/api", "json",
/*timeout*/ 3000,
/*retries*/ true,
/*proxy*/ nullptr,
/*keepAlive*/ true); // 谁记得住第 6 个是啥
为了少写长参数表,你重载出三四个「短版」构造函数,彼此调用、层层补默认值------RG 称之为 重叠构造函数(telescoping constructor) :望远镜一样一节套一节。坏处很快显现:布尔参数传反了编译器不吭声;新增一个可选项,所有重载重新排一遍;代码评审时没人能验证第五个 true 是不是真的该是 true。更早的信号其实出现在类设计时:构造函数承担的「配置维度」超过了三个,人脑已经装不下。这种「步骤多、可选多、但每次只用其中几步」的构造场景,就是建造者模式的主场。
2. 模式意图与定义
- 一句话定义 (GoF 原文意图):Separate the construction of a complex object from its representation so that the same construction process can create different representations ------将复杂对象的构造与它的表示分离,使同样的构造过程可以创建不同的表示。RG 的表述更直白:建造者能 分步骤 创建复杂对象,并允许使用相同的创建代码生成不同类型和形式的对象。
- 解决的问题:一次性构造装不下的复杂度(参数多、可选多、步骤有顺序依赖),交给一个专职的「分步装配器」;不同建造者对同一组步骤做不同实现,就能产出完全不同的产品。
RG 的汽车例子最能体现「同一过程、不同表示」:setSeats/setEngine/setGPS 是同一组步骤,CarBuilder 执行它们造出一辆配置好的 Car,CarManualBuilder 执行同样步骤却攒出一本 使用手册 ------手册的每一章描述对应部件。两个产品连共同接口都没有,这正是建造者与其他创建型模式的重要区别:产品不必共享接口 。GoF 的原始动机同构:RTF 阅读器解析文档时向 TextConverter 发同一串转换请求,ASCII 转换器、TeX 转换器各自产出不同格式的结果。
3. UML 图 + 结构说明
#mermaid-svg-gpMhOI3kGmHVuI0z{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gpMhOI3kGmHVuI0z .error-icon{fill:#552222;}#mermaid-svg-gpMhOI3kGmHVuI0z .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gpMhOI3kGmHVuI0z .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gpMhOI3kGmHVuI0z .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gpMhOI3kGmHVuI0z .marker.cross{stroke:#333333;}#mermaid-svg-gpMhOI3kGmHVuI0z svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gpMhOI3kGmHVuI0z p{margin:0;}#mermaid-svg-gpMhOI3kGmHVuI0z g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-gpMhOI3kGmHVuI0z g.classGroup text .title{font-weight:bolder;}#mermaid-svg-gpMhOI3kGmHVuI0z .cluster-label text{fill:#333;}#mermaid-svg-gpMhOI3kGmHVuI0z .cluster-label span{color:#333;}#mermaid-svg-gpMhOI3kGmHVuI0z .cluster-label span p{background-color:transparent;}#mermaid-svg-gpMhOI3kGmHVuI0z .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gpMhOI3kGmHVuI0z .cluster text{fill:#333;}#mermaid-svg-gpMhOI3kGmHVuI0z .cluster span{color:#333;}#mermaid-svg-gpMhOI3kGmHVuI0z .nodeLabel,#mermaid-svg-gpMhOI3kGmHVuI0z .edgeLabel{color:#131300;}#mermaid-svg-gpMhOI3kGmHVuI0z .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-gpMhOI3kGmHVuI0z .label text{fill:#131300;}#mermaid-svg-gpMhOI3kGmHVuI0z .labelBkg{background:#ECECFF;}#mermaid-svg-gpMhOI3kGmHVuI0z .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-gpMhOI3kGmHVuI0z .classTitle{font-weight:bolder;}#mermaid-svg-gpMhOI3kGmHVuI0z .node rect,#mermaid-svg-gpMhOI3kGmHVuI0z .node circle,#mermaid-svg-gpMhOI3kGmHVuI0z .node ellipse,#mermaid-svg-gpMhOI3kGmHVuI0z .node polygon,#mermaid-svg-gpMhOI3kGmHVuI0z .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gpMhOI3kGmHVuI0z .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z g.clickable{cursor:pointer;}#mermaid-svg-gpMhOI3kGmHVuI0z g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-gpMhOI3kGmHVuI0z g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-gpMhOI3kGmHVuI0z .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-gpMhOI3kGmHVuI0z .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-gpMhOI3kGmHVuI0z .dashed-line{stroke-dasharray:3;}#mermaid-svg-gpMhOI3kGmHVuI0z .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-gpMhOI3kGmHVuI0z #compositionStart,#mermaid-svg-gpMhOI3kGmHVuI0z .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #compositionEnd,#mermaid-svg-gpMhOI3kGmHVuI0z .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #dependencyStart,#mermaid-svg-gpMhOI3kGmHVuI0z .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #dependencyStart,#mermaid-svg-gpMhOI3kGmHVuI0z .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #extensionStart,#mermaid-svg-gpMhOI3kGmHVuI0z .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #extensionEnd,#mermaid-svg-gpMhOI3kGmHVuI0z .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #aggregationStart,#mermaid-svg-gpMhOI3kGmHVuI0z .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #aggregationEnd,#mermaid-svg-gpMhOI3kGmHVuI0z .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #lollipopStart,#mermaid-svg-gpMhOI3kGmHVuI0z .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z #lollipopEnd,#mermaid-svg-gpMhOI3kGmHVuI0z .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-gpMhOI3kGmHVuI0z .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-gpMhOI3kGmHVuI0z .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gpMhOI3kGmHVuI0z .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gpMhOI3kGmHVuI0z .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gpMhOI3kGmHVuI0z :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 按序调步骤
<<interface>>
Builder
+reset() : void
+setSeats(n : int) : void
+setEngine(e : Engine) : void
+setGPS() : void
CarBuilder
-car : Car
+getProduct() : Car
CarManualBuilder
-manual : Manual
+getProduct() : Manual
Director
+constructSportsCar(b : Builder) : void
+constructSUV(b : Builder) : void
产品无需共同接口
RG 拆出五个参与者(比 GoF 的表述多了 Director 的定位说明):
- 建造者(Builder)接口 :声明通用的产品构造步骤(
setSeats等); - 具体建造者(Concrete Builders) :提供步骤的不同实现,各自持有并最终交出产品------注意「取结果」方法只能在具体建造者上,因为不同建造者的产品类型可以毫无关系(静态类型语言没法在公共接口里声明它,RG 特意强调了这一点);
- 产品(Products) :最终对象,不同建造者的产品 不要求 同层次、同接口;
- 主管(Director) :定义步骤的调用顺序,封装「最受欢迎的几种配置」;RG 明确说主管 不是必需的------客户端也可以直接逐步调建造者,主管只是复用例行流程的好地方;
- 客户端(Client):创建建造者(与主管),从建造者(而非主管)处取结果。
4. 传统 C++ 写法(C++11 之前)
按 GoF 时代的形态实现 RG 的汽车示例:接口声明步骤,两个具体建造者一个造车、一个造手册,主管封装「跑车配置」:
cpp
// C++98/03 写法
#include <iostream>
#include <string>
#include <vector>
// ---- 产品:无共同接口,各自独立 ----
class Engine {
public:
explicit Engine(const std::string& t)
: m_t(t) {}
std::string type() const { return m_t; }
private:
std::string m_t;
};
class Car {
public:
void setSeats(int n) { m_seats = n; }
void setEngine(Engine* e) { m_engine = e; }
void setGps(bool b) { m_gps = b; }
int seats() const { return m_seats; }
const Engine* engine() const {
return m_engine;
}
private:
int m_seats = 0;
Engine* m_engine = 0;
bool m_gps = false;
};
class Manual {
public:
void add(const std::string& s) {
m_lines.push_back(s);
}
void print() const {
for (size_t i = 0;
i < m_lines.size(); ++i)
std::cout << m_lines[i] << "\n";
}
private:
std::vector<std::string> m_lines;
};
建造者与主管:
cpp
// C++98/03 写法(续,产品定义同上,略)
// ---- 建造者接口:只有步骤,不承诺产品 ----
class Builder {
public:
virtual ~Builder() {}
virtual void reset() = 0;
virtual void setSeats(int n) = 0;
virtual void setEngine(Engine* e) = 0;
virtual void setGps(bool b) = 0;
protected:
Builder() {}
Builder(const Builder&);
Builder& operator=(const Builder&);
};
// ---- 造车的建造者 ----
class CarBuilder : public Builder {
public:
CarBuilder() { reset(); }
virtual ~CarBuilder() { delete m_car; }
virtual void reset() {
delete m_car;
m_car = new Car();
}
virtual void setSeats(int n) {
m_car->setSeats(n);
}
virtual void setEngine(Engine* e) {
m_car->setEngine(e);
}
virtual void setGps(bool b) {
m_car->setGps(b);
}
// 取结果在具体建造者上
Car* getProduct() {
Car* p = m_car;
m_car = 0; // 交出所有权
return p;
}
private:
Car* m_car;
};
// ---- 写手册的建造者:同样的步骤 ----
class CarManualBuilder : public Builder {
public:
CarManualBuilder() { reset(); }
virtual void reset() {
delete m_manual;
m_manual = new Manual();
}
virtual void setSeats(int n) {
m_manual->add(
"座椅:" + std::to_string(n) + " 座");
}
virtual void setEngine(Engine* e) {
m_manual->add("引擎:" + e->type());
}
virtual void setGps(bool b) {
if (b) m_manual->add("含 GPS 导航");
}
Manual* getProduct() {
Manual* p = m_manual;
m_manual = 0;
return p;
}
private:
Manual* m_manual;
};
// ---- 主管:固化步骤顺序,复用配置 ----
class Director {
public:
void constructSportsCar(Builder& b) {
b.reset();
b.setSeats(2);
b.setEngine(new Engine("Sport"));
b.setGps(true);
}
};
用法:Director d; CarBuilder cb; d.constructSportsCar(cb); Car* car = cb.getProduct();------把 cb 换成 CarManualBuilder,同一句 constructSportsCar 就「造」出这本跑车的手册,主管的流程代码一个字没改 。注意传统写法里所有权全靠约定:getProduct 后调用者负责 delete,建造者析构兜底清理未取走的产品------这正是下一节要消灭的东西。
5. 现代 C++ 进阶写法
升级一:链式(Fluent)Builder + 值语义 。多数场景里「不同表示」并不需要,要的只是 分步命名配置 ------此时退化为单建造者 + 每步返回 *this 的链式调用,建造者按值持有产品,取结果时移动出去:
cpp
// 现代 C++:链式 Builder
#include <memory>
#include <string>
#include <utility>
struct Request {
std::string method = "GET";
std::string path;
std::string body;
int timeoutMs = 3000;
bool keepAlive = false;
};
class RequestBuilder {
public:
RequestBuilder&& method(
std::string m) && {
m_r.method = std::move(m);
return std::move(*this);
}
RequestBuilder&& path(
std::string p) && {
m_r.path = std::move(p);
return std::move(*this);
}
RequestBuilder&& body(
std::string b) && {
m_r.body = std::move(b);
return std::move(*this);
}
RequestBuilder&& timeoutMs(int t) && {
m_r.timeoutMs = t;
return std::move(*this);
}
RequestBuilder&& keepAlive() && {
m_r.keepAlive = true;
return std::move(*this);
}
Request build() && {
return std::move(m_r);
}
private:
Request m_r;
};
// 用法:
// Request r = RequestBuilder{}
// .method("POST").path("/login")
// .body("{\"u\":1}").keepAlive()
// .build();
两个细节值得学:布尔参数改成 keepAlive() 无参命名动作 ,彻底消灭「第 5 个 true 是啥」;步骤函数带 && 限定(C++11 的 引用限定符,进阶标注:C++11 起),链式中途保存到变量再续链会被编译器拒绝,防止半成品构建器被误复用。
升级二:命名参数模拟 。没有建造者时,std::optional 成员 + 聚合初始化也能达到「只写关心的字段」:
cpp
// C++17:可选成员 + 聚合初始化
#include <optional>
#include <string>
struct RequestOptions {
std::optional<int> timeoutMs;
std::optional<std::string> proxy;
};
// 调用处:
// RequestOptions opts{.timeoutMs = 5000};
(C++20 指定初始化器写法如上;C++11/14 可用双成员 {值, 已设置} 或分开的 setter 模拟。)它比建造者轻得多,但无步骤顺序、无校验时机,适合纯数据配置;需要「最后统一校验再产出」的复杂对象,仍是建造者更稳。一句话记法:optional 管默认值,Builder 管过程与校验,两者常配合使用------Builder 的步骤内部照样可以吞 optional 字段。
升级三:Director 的去留 。现代代码里 Director 常被两层替代:预设工厂函数(makeSportsCar() 内部逐步调建造者)或建造者上的预设方法(withSportPreset())。RG 本就明说主管非必需------别为了模式的完整而引入主管,流程只在两处以上复用时才值得抽。
最后谈一个常被忽略的设计点:校验时机 。建造者把「半成品」暴露在中间状态,那么「路径参数必须有 method」「有 body 必须 POST」这类约束在哪里查?两种流派:逐步校验 ------每步 setter 检查依赖,发现早但代码啰嗦、且步骤顺序被迫固定;收口校验 ------build() 里统一检查并抛异常(或返回 std::expected/Status,C++23 前用 optional+错误码模拟),步骤保持自由、规则集中一处。多数场景推荐收口校验:它让 build() 成为唯一的「从不合法到合法」的关口,与不变式建立(establish invariant)的时机天然对齐;这也解释了为什么取结果方法必须在具体建造者上------校验和交付是同一个动作的两面。顺带一提,两种流派混用最危险:逐步校验挡住的路径换到收口校验后悄悄失守,规则归属必须二选一并写进注释。
6. 优缺点与适用场景
- ✅ 优点(GoF + RG):分步构造 ,步骤可以暂缓、可以递归(组合树的建造天然适合);同一套步骤代码产出不同表示 (汽车 vs 手册);单一职责------复杂构造代码从产品业务逻辑中剥离;客户端拿到手的一定是构造完成、校验通过的对象,没有半成品窗口期。
- ❌ 缺点:RG 说得干脆------要新增多个类,整体复杂度上升;建造者与产品的字段演化要同步(产品加字段,步骤、预设、手册全要跟);链式调用调试栈不如构造函数直观,步骤写错顺序(有依赖时)仍靠运行时校验兜底。
- 🎯 适用场景:参数 ≥ 5 且大多可选(HTTP 请求、SQL 构造、配置对象);构造有 顺序或依赖 (先 setBody 才能 setLength);同一流程要多形态输出(对象 + 文档 + 序列化);不可变对象想要「分步攒、一次成」。参数少而稳定时,普通构造函数或
optional聚合即可。
〔辨析〕建造者 vs 工厂方法(第 5 篇)/ 抽象工厂(第 6 篇):RG 的对比最准------抽象工厂 立刻 返回成品、关注「造一族」;建造者 分步 执行、关注「攒一个」,且产品可无共同接口。粗记:问「要几个」找工厂,问「几步装」找建造者。与原型(第 8 篇):建造者从零攒,原型从已有对象复制;RG 补充的演化关系也值得记:项目初期常用工厂方法,随后按痛点演进为抽象工厂、原型或建造者。
7. 开源项目中的身影
Boost.URL:流式接口即建造者思想的直系应用 。Boost.URL 的 url 类通过基类 url_base 提供一整套流式修改方法,分步攒出一个 URL,各 setter 返回引用支持链式:
cpp
// 说明性片段(依赖 Boost.URL,示意)
#include <boost/url.hpp>
boost::urls::url u;
u.set_scheme("https")
.set_host("example.com")
.set_port(8443)
.set_path("/api/v1");
点评:它没有叫 UrlBuilder 的类,url 自己就是「分步构造中的半成品」------这提示我们建造者的边界由 语义 决定:凡是「多个步骤、最终成型」的接口,都是这个模式。与第 5 节的自造 Builder 相比,它少了「最后统一校验」的时机(URL 规范化即时进行),属于轻量形态。
标准库 std::filesystem::path :可能被你天天用而没意识到------path("/usr") /= "local" /= "bin" 用 operator/= 分步拼出路径,跨平台分隔符由实现统一处理。它展示了建造者最朴素的价值:把「多次分步 + 每步规范化」包进类型,调用方从字符串拼接和平台差异中解放。
POCO:Poco::Data::Statement 的逗号流 。POCO 的 SQL 语句对象用重载的 operator, 把数据绑定步骤「流」进语句,攒齐后 execute() 一次成型:
cpp
// 说明性片段(依赖 POCO.Data,示意)
using namespace Poco::Data;
Statement sel(session);
sel << "SELECT name FROM person"
" WHERE age > ?",
use(minAge), now; // 分步绑定,now 收尾执行
点评:<< 喂 SQL、use() 绑参数、now 触发执行------步骤、收尾动作一应俱全,是「建造者 + 收尾哨兵」的变体;代价是重载运算符的可读性见仁见智。GoF 当年点名的应用是 RTF 转换器与 Synthizer 前端;今天从 URL 到 SQL,「分步攒、一次成」的接口形态已经无处不在------建造者早已溶进现代 API 设计的默认选项。
本篇小结
当构造函数的参数表开始需要注释才能读懂,把「构造」从「表示」中拆出来:建造者用一组命名步骤替你消灭重叠构造函数与魔法布尔,用「取结果在具体建造者」支持完全不同的产品形态,用可选的 Director 固化复用流程;现代 C++ 再以链式 *this、&& 限定、optional 聚合把成本压到最低。别过度:三五个稳定参数用不上它。选型口诀:立刻要成品找工厂,成族要配套找抽象工厂,分步攒大件找建造者,想照抄现成对象找下一讲的原型。
本文模式定义、汽车/手册示例与优缺点清单参考了 Refactoring Guru《设计模式》中文版「生成器」一章,意图译文、RTF 动机与后果清单参考了 GoF《Design Patterns》第 3 章 Builder 一节。