☰
C++设计模式之建造者模式
2026/11/3 4:08:37 网站建设 项目流程

前言


当一个对象的构造需要很多参数,其中一部分可选、一部分有默认值,还有若干参数之间存在约束时,构造函数会迅速膨胀。常见的结果是一串“长参数列表构造函数”,调用处写满位置参数和true/false,读代码的人必须跳到声明处才知道每个值代表什么。建造者模式(Builder Pattern)就是把“对象的逐步组装”从“一次性的构造函数调用”里拆出来,让每一步都有名字,并在最终产出前做统一校验。


常见的误解有两个。第一,把建造者模式等同于“写一个带链式调用的 setter 类”,忽略了它对构造过程和产品不变式的约束;第二,以为它只能用于创建复杂对象,于是在只有两个字段的结构体上也套一层 Builder,平白增加代码量。C++ 没有 Python 那种关键字参数,建造者确实能提升可读性,但它不是所有构造问题的默认答案。


本文先讲 GoF 的经典结构,再讲现代 C++ 更常用的嵌套链式 Builder,最后给出一个带校验的完整例子。示例以 C++17 为准,使用std::unique_ptr和移动语义;不涉及第三方库。


一、要解决的问题:参数膨胀与不变式


假设一个Computer对象有 CPU、内存、硬盘、显卡四个字段,其中 CPU 必填,其余有默认值。最直接的写法是:


// 适用于 C++17
class Computer
{
public:
Computer(std::string cpu, int ram, int disk, bool gpu);
// ...
};

调用处会变成Computer("8-core", 32, 1024, true)。参数一多,问题随之而来:



  • 位置含义不直观:32是内存还是硬盘?只能靠记忆或查声明。

  • 默认值难处理:想只改显卡,必须把前面所有参数再写一遍。

  • 校验分散:每个调用点都可能忘记检查“内存必须为正”。

  • 扩展困难:新增一个字段,所有调用点都要改。


建造者模式把这些问题集中到一处:每个设置步骤是一个有名字的方法,默认值由 Builder 持有,最终build()统一检查约束并构造产品。


经典 GoF 结构包含四个角色:




角色职责



Product(产品)被构造的复杂对象,通常把构造函数设为私有

Builder(抽象建造者)声明一步步构建的接口,以及取得结果的方法

ConcreteBuilder(具体建造者)实现各步骤,维护中间状态

Director(导演)按固定顺序调用建造步骤,不关心具体产品



Director 适合“构建流程固定、产品族可替换”的场景,例如按同一套步骤生成 HTML 报告和 Markdown 报告。如果构建顺序由调用方自由决定,Director 反而多余,直接用链式 Builder 更自然。


二、现代 C++ 的链式 Builder


C++ 里更常见的写法是流式接口(fluent interface):每个设置方法返回 Builder 的引用,从而可以连续调用;build()返回产品值。它省掉了 Director,把顺序控制交给调用方,适合参数可选、组合灵活的对象。


设计时有几个关键点:



  1. 产品构造函数私有,只对 Builder 开放。这样外部无法绕过校验直接造出非法对象。

  2. Builder 保存中间状态,默认值写在成员初始化器里,避免每个设置方法都重复。

  3. build()做集中校验,发现非法组合就抛出明确异常,或返回std::optional/错误码。

  4. build()按值返回产品。返回引用或指针指向 Builder 内部对象,会在 Builder 销毁后悬垂。

  5. 设置方法按值接收字符串再std::move,避免不必要的拷贝,也避免保存调用方临时对象的引用。


嵌套类可以访问外围类的私有成员,但外围类不会自动访问嵌套类的私有成员;因此需要在产品类里写friend class Builder;(这里的Builder指嵌套类),让 Builder 能调用私有构造函数。


三、完整示例:带校验的 Computer Builder


// 适用于 C++17
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>

class Computer
{
public:
class Builder; // 前置声明嵌套建造者

void describe() const
{
std::cout << "CPU: " << cpu_
<< ", RAM: " << ram_gb_ << " GB"
<< ", Disk: " << disk_gb_ << " GB"
<< ", GPU: " << (gpu_ ? "yes" : "no") << '\n';
}

private:
Computer(std::string cpu, int ram_gb, int disk_gb, bool gpu)
: cpu_(std::move(cpu)),
ram_gb_(ram_gb),
disk_gb_(disk_gb),
gpu_(gpu)
{
}

friend class Builder; // 只允许 Builder 调用私有构造

std::string cpu_;
int ram_gb_;
int disk_gb_;
bool gpu_;
};

class Computer::Builder
{
public:
Builder& cpu(std::string value)
{
cpu_ = std::move(value);
return *this;
}

Builder& ram(int gb)
{
ram_gb_ = gb;
return *this;
}

Builder& disk(int gb)
{
disk_gb_ = gb;
return *this;
}

Builder& gpu(bool enabled)
{
gpu_ = enabled;
return *this;
}

Computer build() const
{
if (cpu_.empty())
throw std::invalid_argument("cpu is required");
if (ram_gb_ <= 0)
throw std::invalid_argument("ram must be positive");
if (disk_gb_ <= 0)
throw std::invalid_argument("disk must be positive");

return Computer(cpu_, ram_gb_, disk_gb_, gpu_);
}

private:
std::string cpu_; // 必填,默认空
int ram_gb_ = 8; // 可选,默认 8 GB
int disk_gb_ = 256;
bool gpu_ = false;
};

int main()
{
Computer office = Computer::Builder{}
.cpu("4-core")
.ram(16)
.build();

Computer gaming = Computer::Builder{}
.cpu("8-core")
.ram(32)
.disk(1024)
.gpu(true)
.build();

office.describe();
gaming.describe();
return 0;
}

这段代码里,Computer::Builder{}创建临时对象,链式调用返回引用,最后build()返回Computer值。build()是const成员函数,因为它只读取中间状态、不修改 Builder;返回时把成员拷贝进产品,产品再std::move到自己的成员里。对于字符串,这一步最多发生一次拷贝;如果传入的是临时std::string,cpu(std::string value)按值接收后std::move,可以省掉一次分配。


friend class Builder;写在Computer内,指的是嵌套的Computer::Builder。没有这个友元声明,Builder 无法调用Computer的私有构造函数,编译会失败。反过来说,产品构造函数私有是这套模式的价值所在:只要所有创建路径都经过build(),就不可能存在“内存为负”的Computer对象。


GoF 风格的 Director 可以用一个函数表示:


// 适用于 C++17
Computer make_development_machine()
{
return Computer::Builder{}
.cpu("4-core")
.ram(16)
.disk(512)
.build();
}

当“开发机”“游戏机”“服务器”有固定配方时,把这些函数集中起来,比让每个调用点自己链式拼装更一致。Director 不需要是类,一个返回产品的函数就足够;这也是现代 C++ 相比 GoF 原书更轻量的地方。


四、什么时候不该用建造者


建造者不是越多越好。以下情况直接构造更合适:



  • 字段少且都必填,例如二维点Point{x, y}。聚合初始化已经足够清晰。

  • 字段都是公开的数据载体,没有不变式需要维护。加一层 Builder 只是把S{1, 2}变成S::Builder{}.a(1).b(2).build()。

  • 需要的是“根据参数选择不同子类”,那更像工厂方法或抽象工厂,而不是建造者。

  • C++20 起可以用指定初始化器Config{.host = "x", .port = 80}表达部分字段,聚合体场景下可读性已经很好;但指定初始化器不能做校验,也不能隐藏字段。




方案适合场景代价



普通构造函数参数少、必填、无复杂校验参数多时位置含义不清

聚合初始化纯数据、无不变式无法校验、字段全公开

建造者模式参数多、可选组合多、需要校验多一个类,构造过程分两步

工厂方法按条件创建不同子类不解决参数膨胀

指定初始化器(C++20)聚合体的少量可选字段不能封装校验



常见坑点



  1. ❌build()返回Computer&或指向 Builder 内部成员的指针。


✅ 按值返回产品;产品是独立对象,生命周期由调用方管理。



  1. ❌ Builder 的链式方法返回Builder而非Builder&,每次调用都复制一份中间状态。


✅ 返回*this的引用;需要支持派生 Builder 时,返回基类引用仍会切片,应改用 CRTP 或避免继承。



  1. ❌ 产品构造函数保持 public,调用方绕过 Builder 直接创建非法对象。


✅ 构造函数私有,friend class Builder;只开放给建造者;所有不变式在build()中检查。



  1. ❌build()不做校验,把“内存为负”“CPU 为空”留给使用方发现。


✅ 在build()集中检查必填项和取值范围,抛出明确异常或返回错误结果。



  1. ❌ 复用同一个 Builder 创建多个产品,前一对象的可选字段残留到后一对象。


✅ 每次构建用新的 Builder;或提供reset()把状态恢复默认值;或让build()消耗 Builder。



  1. ❌ 设置方法按const std::string&接收并保存引用,调用方的临时字符串销毁后悬垂。


✅ 按值接收再std::move到成员,或明确要求调用方保证生命周期。



  1. ❌ 在 Director 里保存 Builder 的裸指针,所有权和生命周期不清楚。


✅ Director 接收Builder&,或持有std::unique_ptr<Builder>并明确所有权。



  1. ❌ 给只有两个必填字段的结构体套 Builder,代码量翻倍但可读性没有提升。


✅ 简单聚合体直接初始化;只有出现可选参数组合或校验需求时再引入 Builder。


总结




要点做法



产品创建构造函数私有,友元开放给 Builder

链式调用设置方法返回Builder&

默认值写在 Builder 成员初始化器中

校验集中在build(),失败给出明确错误

返回按值返回产品,避免悬垂

流程复用用返回产品的函数充当轻量 Director



建造者模式真正解决的问题是“把分散在调用点的构造知识收拢到一处”:哪些参数必填、默认值是什么、哪些组合非法。链式语法只是让调用处更好读,不是模式的核心。判断要不要用它,看对象是否存在需要维护的不变式、是否存在大量可选组合;如果答案是否定的,普通的聚合初始化反而更诚实。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询