建造者模式:复杂对象如何一步步构建

上一篇讲了抽象工厂模式。

它解决的问题是:

一整套相关对象,应该由同一套工厂统一创建。

比如不同车型平台下,制动控制器、转向控制器、电池管理器、通信对象要成套创建,不能随便混用。

但真实工程里,还有另一类创建问题。

这类问题不是"创建哪一个对象",也不是"创建哪一族对象",而是:

一个对象本身太复杂,不能简单地靠一个构造函数一次性创建出来。

比如车企软件里很常见的对象:

  • 一个诊断请求报文
  • 一个整车配置对象
  • 一个自动化测试工况
  • 一个 OTA 升级任务
  • 一个仿真场景配置
  • 一个数据采集任务参数

这些对象往往不是只有两三个字段。

它们可能包含:

  • 必填参数
  • 可选参数
  • 默认参数
  • 依赖参数
  • 校验规则
  • 构建顺序
  • 不同构建模板
  • 构建完成后的不可变状态

这时,如果还用一个巨大的构造函数,代码很快就会变得难读、难改、难校验。

建造者模式,就是为了解决这个问题。


一、先从一个车企场景说起

假设我们现在要构建一个诊断请求对象。

一个诊断请求可能包含:

  • 服务 ID
  • 数据标识 DID
  • 目标 ECU
  • 请求 payload
  • 是否需要安全访问
  • 安全等级
  • 超时时间
  • 重试次数
  • 是否记录 trace
  • 是否允许功能寻址
  • 响应校验规则

如果直接写成构造函数,可能会变成这样:

cpp 复制代码
DiagnosticRequest request(
    0x22,
    0xF190,
    "VCU",
    payload,
    true,
    0x03,
    500,
    3,
    true,
    false,
    ResponseCheckMode::Strict
);

这段代码能跑。

但问题很明显。

第一,参数太多,调用方很难看懂每个参数是什么意思。

第二,几个 true/false 连在一起时,几乎没有可读性。

第三,参数顺序一旦写错,编译器不一定能发现。

第四,某些参数之间有约束,比如只有需要安全访问时,安全等级才有意义。

第五,如果未来新增一个参数,所有构造点可能都要跟着改。

所以,这里的问题不是"对象不能创建"。

真正的问题是:

复杂对象的构建过程没有被清晰表达出来。


二、为什么巨大构造函数会出问题?

很多人一开始会觉得:

构造函数不就是用来初始化对象的吗?

参数多一点也没关系吧?

简单对象当然没问题。

比如:

cpp 复制代码
Point p(10, 20);

这种写法很清楚。

但如果对象变复杂,构造函数参数越来越多,就会出现典型问题。


1. 参数含义不清楚

比如:

cpp 复制代码
TestScenario scenario(80, 120, 30, true, false, 3);

这几个数字和布尔值分别是什么?

是车速?

是 SOC?

是温度?

是持续时间?

是重试次数?

是安全等级?

调用方必须去看构造函数定义才能知道。

这就说明对象创建代码本身没有表达业务语义。


2. 可选参数会让构造函数膨胀

很多复杂对象都有默认值。

比如测试工况:

  • 默认环境温度 25℃
  • 默认路面附着系数 0.8
  • 默认驾驶模式 Normal
  • 默认不注入故障
  • 默认不启用极端工况

如果把所有参数都塞进构造函数,就会出现大量调用方并不关心的参数。

cpp 复制代码
VehicleTestCase testCase(
    60,
    80,
    25,
    0.8,
    DriveMode::Normal,
    false,
    FaultType::None,
    0,
    0,
    true
);

很多参数只是为了凑构造函数,不是当前业务真正关心的东西。


3. 参数之间可能有约束

复杂对象往往不是字段堆叠。

它们内部有规则。

比如诊断请求:

  • serviceId 必须合法
  • did 只有在读数据服务下才需要
  • 安全等级只有在开启安全访问时才有效
  • 超时时间不能小于 0
  • 重试次数不能无限大
  • 功能寻址下某些服务不允许发送

如果只是构造函数直接赋值,这些规则很容易散落到外部。

最后就会变成:

cpp 复制代码
if (needSecurity) {
    request.SetSecurityLevel(level);
}

if (timeout > 0) {
    request.SetTimeout(timeout);
}

if (serviceId == 0x22) {
    request.SetDid(did);
}

看起来是在创建对象,实际上调用方已经开始承担对象内部规则了。

这会破坏封装。


4. 对象可能出现"半成品状态"

如果用一堆 setter 创建对象,可能会写成这样:

cpp 复制代码
DiagnosticRequest request;

request.SetServiceId(0x22);
request.SetTargetEcu("VCU");
request.SetTimeoutMs(500);
request.SetRetryCount(3);
request.SetDid(0xF190);

这比巨大构造函数好读一些。

但它也有问题。

在所有 setter 调完之前,request 其实是一个半成品。

如果中间被拿去使用,就可能出错。

比如:

cpp 复制代码
DiagnosticRequest request;

request.SetServiceId(0x22);
request.SetTargetEcu("VCU");

sender.Send(request);  // DID 还没设置

所以,复杂对象创建要解决的不只是"写起来方便",还要解决:

构建过程中可以逐步配置,但构建完成后的对象必须是有效的。

这就是建造者模式的价值。


三、建造者模式到底是什么?

建造者模式可以这样理解:

把一个复杂对象的构建过程从对象本身拆出来,让调用方可以一步步配置,最后一次性生成一个完整对象。

再说得直白一点:

对象不是靠一个巨大构造函数硬塞出来,而是通过一个 Builder 按步骤构建出来。

它关注的不是"创建哪个类"。

它关注的是:

一个复杂对象应该如何一步步组装。

所以,建造者模式也是创建型模式。

但它和工厂方法、抽象工厂的关注点不一样。

工厂方法关注:

创建哪一种产品。

抽象工厂关注:

创建哪一族产品。

建造者模式关注:

一个复杂产品内部怎么构建。


四、建造者模式解决的核心问题

建造者模式最核心的价值有三个。


1. 让复杂对象创建过程更清晰

原来是这样:

cpp 复制代码
DiagnosticRequest request(
    0x22,
    0xF190,
    "VCU",
    payload,
    true,
    0x03,
    500,
    3,
    true,
    false,
    ResponseCheckMode::Strict
);

使用 Builder 后,可以变成:

cpp 复制代码
auto request = DiagnosticRequestBuilder()
    .SetServiceId(0x22)
    .SetDid(0xF190)
    .SetTargetEcu("VCU")
    .SetPayload(payload)
    .RequireSecurity(0x03)
    .SetTimeoutMs(500)
    .SetRetryCount(3)
    .EnableTrace()
    .UsePhysicalAddressing()
    .Build();

这段代码更像是在描述业务:

我要构建一个读 DID 的诊断请求。

目标是 VCU。

需要安全访问。

超时 500ms。

重试 3 次。

开启 trace。

创建过程本身就有可读性。


2. 把校验逻辑集中起来

复杂对象的合法性不应该散落在调用方。

Builder 可以在 Build() 时统一校验:

  • 必填字段是否存在
  • 参数范围是否合法
  • 参数组合是否冲突
  • 默认值是否补齐
  • 构建结果是否满足不变量

这样调用方不需要到处写校验逻辑。


3. 避免对象暴露半成品状态

Builder 可以是可变的。

但最终生成出来的产品对象可以是不可变的。

也就是说:

text 复制代码
Builder 阶段:允许一步步配置
Product 阶段:必须完整、合法、稳定

这对工程代码很重要。

因为真正被业务流程使用的对象,最好不要处于半初始化状态。


五、建造者模式的核心角色

建造者模式通常有几个角色。

1. Product:最终产品

也就是最终要创建出来的复杂对象。

比如:

text 复制代码
DiagnosticRequest
VehicleTestScenario
VehicleConfig
OtaUpdateTask

它应该是构建完成后的稳定对象。


2. Builder:建造者接口或建造者类

负责一步步设置构建参数。

比如:

text 复制代码
SetServiceId()
SetDid()
SetTargetEcu()
RequireSecurity()
SetTimeoutMs()
Build()

它把"怎么构建"这件事从 Product 里拆出来。


3. ConcreteBuilder:具体建造者

如果同一种产品有不同构建方式,可以有多个具体 Builder。

比如:

text 复制代码
DiagnosticRequestBuilder
VehicleScenarioBuilder
SimulationScenarioBuilder

不过在很多 C++ 工程里,未必一定要单独抽象出 Builder 接口。

如果只有一种构建方式,一个具体 Builder 就够了。


4. Director:指导者,可选

Director 负责封装一套固定构建流程。

比如:

text 复制代码
BuildReadVinRequest()
BuildReadDtcRequest()
BuildClearDtcRequest()

它不是必须的。

如果构建流程比较简单,调用方直接用 Builder 就可以。

如果某些构建流程经常复用,就可以加 Director。


六、建造者模式的结构

可以用一个简化结构理解:

text 复制代码
调用方
  ↓
Builder
  ↓ 一步步设置参数
Product

如果有 Director,则变成:

text 复制代码
调用方
  ↓
Director
  ↓ 使用
Builder
  ↓ 创建
Product

用 UML 简化表示:
creates
uses
DiagnosticRequest
-serviceId
-targetEcu
-payload
-timeoutMs
DiagnosticRequestBuilder
+SetServiceId(id)
+SetDid(did)
+SetTargetEcu(ecu)
+SetPayload(payload)
+RequireSecurity(level)
+SetTimeoutMs(timeout)
+Build()
DiagnosticRequestDirector
+BuildReadVinRequest()
+BuildReadDtcRequest()

这张图里最重要的不是类名,而是:

Product 不直接暴露复杂构建过程,Builder 负责把对象一步步构建完整。


七、一个 C++ 示例:构建诊断请求对象

下面用一个简化的诊断请求来说明。

先定义最终产品。

cpp 复制代码
#include <cstdint>
#include <iostream>
#include <optional>
#include <string>
#include <vector>

class DiagnosticRequest {
public:
    uint8_t GetServiceId() const {
        return m_serviceId;
    }

    uint16_t GetDid() const {
        return m_did;
    }

    const std::string& GetTargetEcu() const {
        return m_targetEcu;
    }

    int GetTimeoutMs() const {
        return m_timeoutMs;
    }

    int GetRetryCount() const {
        return m_retryCount;
    }

    bool NeedSecurity() const {
        return m_needSecurity;
    }

    uint8_t GetSecurityLevel() const {
        return m_securityLevel;
    }

    bool TraceEnabled() const {
        return m_traceEnabled;
    }

private:
    friend class DiagnosticRequestBuilder;

    uint8_t m_serviceId = 0;
    uint16_t m_did = 0;
    std::string m_targetEcu;
    std::vector<uint8_t> m_payload;

    bool m_needSecurity = false;
    uint8_t m_securityLevel = 0;

    int m_timeoutMs = 500;
    int m_retryCount = 0;
    bool m_traceEnabled = false;
};

这里 DiagnosticRequest 本身不提供一堆 setter。

它更像一个构建完成后的稳定对象。

然后定义 Builder。

cpp 复制代码
class DiagnosticRequestBuilder {
public:
    DiagnosticRequestBuilder& SetServiceId(uint8_t serviceId) {
        m_request.m_serviceId = serviceId;
        m_hasServiceId = true;
        return *this;
    }

    DiagnosticRequestBuilder& SetDid(uint16_t did) {
        m_request.m_did = did;
        m_hasDid = true;
        return *this;
    }

    DiagnosticRequestBuilder& SetTargetEcu(const std::string& ecu) {
        m_request.m_targetEcu = ecu;
        return *this;
    }

    DiagnosticRequestBuilder& SetPayload(const std::vector<uint8_t>& payload) {
        m_request.m_payload = payload;
        return *this;
    }

    DiagnosticRequestBuilder& RequireSecurity(uint8_t level) {
        m_request.m_needSecurity = true;
        m_request.m_securityLevel = level;
        return *this;
    }

    DiagnosticRequestBuilder& SetTimeoutMs(int timeoutMs) {
        m_request.m_timeoutMs = timeoutMs;
        return *this;
    }

    DiagnosticRequestBuilder& SetRetryCount(int retryCount) {
        m_request.m_retryCount = retryCount;
        return *this;
    }

    DiagnosticRequestBuilder& EnableTrace() {
        m_request.m_traceEnabled = true;
        return *this;
    }

    std::optional<DiagnosticRequest> Build() {
        if (!Validate()) {
            return std::nullopt;
        }

        return m_request;
    }

private:
    bool Validate() const {
        if (!m_hasServiceId) {
            return false;
        }

        if (m_request.m_targetEcu.empty()) {
            return false;
        }

        if (m_request.m_timeoutMs <= 0) {
            return false;
        }

        if (m_request.m_retryCount < 0 || m_request.m_retryCount > 5) {
            return false;
        }

        if (m_request.m_needSecurity && m_request.m_securityLevel == 0) {
            return false;
        }

        // 0x22 表示 ReadDataByIdentifier,必须有 DID
        if (m_request.m_serviceId == 0x22 && !m_hasDid) {
            return false;
        }

        return true;
    }

private:
    DiagnosticRequest m_request;
    bool m_hasServiceId = false;
    bool m_hasDid = false;
};

使用时可以这样写:

cpp 复制代码
int main() {
    auto request = DiagnosticRequestBuilder()
        .SetServiceId(0x22)
        .SetDid(0xF190)
        .SetTargetEcu("VCU")
        .RequireSecurity(0x03)
        .SetTimeoutMs(500)
        .SetRetryCount(3)
        .EnableTrace()
        .Build();

    if (!request.has_value()) {
        std::cout << "build diagnostic request failed\n";
        return 1;
    }

    std::cout << "service id: "
              << static_cast<int>(request->GetServiceId())
              << "\n";

    return 0;
}

这样写的好处是:

  • 参数含义清楚
  • 构建过程可读
  • 校验逻辑集中
  • 不容易得到非法对象
  • 默认值可以在 Builder 或 Product 内部统一管理

这就是建造者模式的基本价值。


八、如果构建流程经常复用,可以引入 Director

有些诊断请求非常常见。

比如:

  • 读取 VIN
  • 读取 DTC
  • 清除 DTC
  • 读取软件版本号
  • 进入扩展会话
  • 执行安全访问

如果每个地方都手写 Builder 链式调用,也会重复。

这时可以引入一个 Director。

cpp 复制代码
class DiagnosticRequestDirector {
public:
    static std::optional<DiagnosticRequest> BuildReadVinRequest(
        const std::string& targetEcu) {
        return DiagnosticRequestBuilder()
            .SetServiceId(0x22)
            .SetDid(0xF190)
            .SetTargetEcu(targetEcu)
            .SetTimeoutMs(500)
            .SetRetryCount(3)
            .EnableTrace()
            .Build();
    }

    static std::optional<DiagnosticRequest> BuildReadDtcRequest(
        const std::string& targetEcu) {
        return DiagnosticRequestBuilder()
            .SetServiceId(0x19)
            .SetTargetEcu(targetEcu)
            .SetTimeoutMs(1000)
            .SetRetryCount(2)
            .EnableTrace()
            .Build();
    }
};

调用方就可以写成:

cpp 复制代码
auto request = DiagnosticRequestDirector::BuildReadVinRequest("VCU");

这里 Director 的作用不是"让模式更完整"。

它的真正价值是:

把常用构建配方沉淀下来。

所以 Director 不是必须的。

只有当构建流程有复用价值时,它才有意义。


九、这个例子到底好在哪里?

1. 创建代码更像业务描述

对比一下。

巨大构造函数:

cpp 复制代码
DiagnosticRequest request(0x22, 0xF190, "VCU", payload, true, 3, 500);

Builder 写法:

cpp 复制代码
auto request = DiagnosticRequestBuilder()
    .SetServiceId(0x22)
    .SetDid(0xF190)
    .SetTargetEcu("VCU")
    .RequireSecurity(0x03)
    .SetTimeoutMs(500)
    .Build();

后者更清楚。

因为它把参数含义直接写在方法名里。

这不是语法糖。

这是在降低误用概率。


2. 校验集中在构建阶段

复杂对象最怕的一件事是:

字段虽然都赋值了,但组合起来是不合法的。

Builder 可以在 Build() 阶段统一处理。

比如:

  • 读 DID 请求必须有 DID
  • 安全访问必须有安全等级
  • 超时时间必须大于 0
  • 重试次数必须有上限
  • 某些服务不能用功能寻址

这些规则如果散落到调用方,后期一定会乱。

集中在 Builder 里,至少可以保证所有对象都走同一套构建校验。


3. 产品对象更稳定

Builder 可以一步步改。

但最终 Product 不一定要暴露 setter。

这就避免了业务流程随意修改内部字段。

比如诊断请求构建完成后,服务 ID、目标 ECU、超时时间通常不应该在发送过程中被随便改掉。

这种设计更符合前面讲类定义时强调的:

类应该维护自己的不变量。

Builder 负责构建过程。

Product 负责稳定状态。

两者职责分开,系统会更清楚。


4. 默认值更容易统一管理

很多复杂对象都有默认值。

比如:

  • 默认超时时间 500ms
  • 默认重试次数 0
  • 默认不开启 trace
  • 默认物理寻址
  • 默认严格响应校验

如果没有 Builder,这些默认值可能散落在多个构造点。

Builder 可以统一管理默认值。

调用方只需要设置自己关心的部分。


十、车企项目里,哪些地方适合建造者模式?

1. 构建复杂诊断报文

诊断请求往往包含服务、DID、payload、寻址方式、安全等级、超时、重试、响应校验等配置。

这些字段不是简单堆叠,而是有规则。

比如:

  • 0x22 服务需要 DID
  • 0x2E 写数据需要 payload
  • 0x27 安全访问需要 seed/key 流程
  • 功能寻址下某些服务受限

这类对象很适合用 Builder。


2. 构建自动化测试工况

自动化测试工况也很典型。

比如一个测试场景可能包含:

  • 初始车速
  • SOC
  • 档位
  • 驾驶模式
  • 环境温度
  • 路面附着系数
  • 故障注入
  • 持续时间
  • 期望结果
  • 数据采集信号

如果直接写构造函数,参数会非常多。

用 Builder 可以写成:

cpp 复制代码
auto scenario = VehicleScenarioBuilder()
    .SetInitialSpeedKph(60)
    .SetSocPercent(80)
    .SetDriveMode(DriveMode::Eco)
    .SetRoadFriction(0.7)
    .InjectFault(FaultType::WheelSpeedSensorLost)
    .ExpectWarning("ABS_FAULT")
    .Build();

这段代码本身就像测试描述。

对测试工程来说,可读性非常重要。

因为测试用例经常要被别人 review、维护、复制、调整。


3. 构建整车配置对象

整车配置对象通常也很复杂。

比如:

  • 车型平台
  • 动力形式
  • 电池容量
  • 电机类型
  • 制动系统类型
  • 转向系统类型
  • ADAS 功能开关
  • 地区法规配置
  • 供应商差异配置
  • 标定参数版本

如果所有配置都通过一个构造函数传入,调用方会非常痛苦。

Builder 可以把配置过程按业务语义拆开:

cpp 复制代码
auto config = VehicleConfigBuilder()
    .SetPlatform("APlatform")
    .SetPowertrain(PowertrainType::BEV)
    .SetBatteryCapacityKwh(75)
    .EnableFeature("ACC")
    .EnableFeature("AEB")
    .SetMarketRegion("EU")
    .SetCalibrationVersion("2026.05")
    .Build();

这种写法更容易读,也更容易加校验。


4. 构建仿真场景

仿真场景往往有大量可选参数。

比如:

  • 道路类型
  • 交通参与者
  • 天气
  • 光照
  • 传感器噪声
  • GPS 漂移
  • 故障注入
  • 回放数据源
  • 触发条件

这些参数组合非常多。

Builder 可以让仿真场景以更自然的方式描述出来。


5. 构建 OTA 升级任务

OTA 升级任务也可能适合。

比如:

  • 目标 ECU
  • 包版本
  • 下载地址
  • 校验方式
  • 升级窗口
  • 失败回滚策略
  • 重试策略
  • 用户提示策略
  • 安全校验策略

这些字段之间有强约束。

用 Builder 可以把这些约束集中到构建阶段,避免业务流程拿到不完整任务。


十一、建造者模式和工厂方法有什么区别?

这是很容易混淆的地方。

可以先用一句话区分:

工厂方法关注"创建哪一种对象"。

建造者模式关注"一个复杂对象怎么构建完整"。

比如工厂方法解决的是:

text 复制代码
我要创建 CAN Transport,还是 DoIP Transport?

建造者解决的是:

text 复制代码
我要如何一步步构建一个完整的 DiagnosticRequest?

再对比一下:

对比项 工厂方法 建造者模式
关注点 对象类型选择 对象构建过程
典型问题 创建哪种具体实现 参数多、步骤多、校验多
返回结果 某个产品对象 一个完整复杂对象
扩展点 新增产品类型 新增构建步骤或构建配方
典型场景 CAN / DoIP / Simulation 对象切换 复杂报文、测试场景、配置对象

所以不要看到"创建对象"就都叫工厂。

如果重点是选择类型,更像工厂。

如果重点是组装过程,更像建造者。


十二、建造者模式和抽象工厂有什么区别?

抽象工厂关注的是产品族。

比如:

text 复制代码
A 平台一整套对象
B 平台一整套对象
仿真环境一整套对象
真实环境一整套对象

建造者关注的是单个复杂对象。

比如:

text 复制代码
一个完整诊断请求
一个完整测试工况
一个完整整车配置

可以这样理解:

抽象工厂解决"成套对象从哪里来"。

建造者解决"复杂对象怎么拼起来"。

两者也可以一起用。

比如一个 SimulationEnvironmentFactory 创建一组对象,其中某个复杂的 SimulationScenario 可以由 SimulationScenarioBuilder 构建。

它们不是互斥关系。

只是解决的问题不同。


十三、建造者模式和 setter 有什么区别?

很多人会问:

Builder 不就是一堆 setter 吗?

看起来像,但重点不一样。

普通 setter 是在修改对象本身。

Builder 是在构建一个对象。

普通 setter 写法可能是:

cpp 复制代码
DiagnosticRequest request;
request.SetServiceId(0x22);
request.SetDid(0xF190);
request.SetTargetEcu("VCU");

这种写法的问题是:

  • request 可能中途处于非法状态
  • 外部可以随时继续修改
  • 校验逻辑容易散
  • 产品对象不稳定

Builder 写法是:

cpp 复制代码
auto request = DiagnosticRequestBuilder()
    .SetServiceId(0x22)
    .SetDid(0xF190)
    .SetTargetEcu("VCU")
    .Build();

这里真正被业务使用的是 Build() 之后的对象。

也就是说:

text 复制代码
setter 修改的是产品对象
builder 配置的是构建过程

如果 Builder 只是把 setter 换个名字,没有校验、没有默认值、没有构建边界,那它就只是形式上的 Builder。


十四、建造者模式最容易被滥用在哪里?

1. 简单对象也强行 Builder

比如一个对象只有两个字段:

cpp 复制代码
class Point {
public:
    Point(int x, int y);
};

这时如果写成:

cpp 复制代码
auto point = PointBuilder()
    .SetX(10)
    .SetY(20)
    .Build();

就没有必要。

建造者模式适合复杂对象。

如果对象本身很简单,普通构造函数更清楚。


2. Builder 只包装 setter,没有任何价值

有些代码看起来用了 Builder:

cpp 复制代码
auto config = ConfigBuilder()
    .SetA(a)
    .SetB(b)
    .SetC(c)
    .Build();

Build() 只是简单返回对象,没有校验,没有默认值,没有构建规则。

这时 Builder 的价值很弱。

它只是把代码写长了。

所以要记住:

Builder 的价值不在链式调用,而在管理复杂构建过程。


3. Builder 变成万能配置中心

还有一种反模式是 Builder 什么都管。

比如:

cpp 复制代码
class VehicleSystemBuilder {
public:
    SetBrakeConfig();
    SetBatteryConfig();
    SetLogConfig();
    SetDatabaseConfig();
    SetHmiConfig();
    SetCloudConfig();
    SetUserConfig();
    SetSecurityConfig();
};

如果这些东西没有清晰边界,Builder 就会变成新的万能 Manager。

这和前面单例、工厂里讲过的问题一样。

模式本身不能拯救职责混乱。

如果职责边界不清,换成 Builder 也只是换一种混乱方式。


4. Build 之后对象还能被随便改

如果 Builder 构建完对象后,对象仍然暴露大量 setter:

cpp 复制代码
auto request = builder.Build();

request.SetServiceId(0x31);
request.SetTimeoutMs(-1);

那 Builder 的校验价值就被削弱了。

因为你在构建阶段保证了合法性,但构建后又允许别人破坏合法性。

所以复杂对象更推荐:

  • Builder 阶段可变
  • Product 阶段尽量稳定
  • 必要修改通过明确业务方法完成,而不是随便 setter

5. 构建失败没有清晰处理

Builder 可能构建失败。

比如:

  • 必填字段缺失
  • 参数范围错误
  • 参数组合冲突
  • 依赖资源不可用

如果 Build() 失败时只是返回一个默认对象,风险很大。

更好的方式是明确表达失败:

  • 返回 std::optional
  • 返回 std::expected
  • 返回错误码和错误信息
  • 抛异常,但要统一项目风格

不要悄悄构建一个看似可用、实际错误的对象。


十五、工程中更推荐的用法

1. 先确认复杂度是否真实存在

使用 Builder 前,先问:

这个对象真的复杂吗?

判断方式可以很简单:

  • 参数是否很多
  • 可选字段是否很多
  • 默认值是否很多
  • 参数之间是否有约束
  • 构建流程是否有顺序
  • 构建逻辑是否在多个地方重复

如果都没有,就不要急着上 Builder。


2. 必填参数要明确

Builder 最怕的一件事是:

看起来所有字段都可以不填。

比如:

cpp 复制代码
auto request = DiagnosticRequestBuilder().Build();

这通常是不合理的。

必填参数要么通过构造 Builder 时传入:

cpp 复制代码
DiagnosticRequestBuilder builder(0x22, "VCU");

要么在 Build() 时严格校验。

不要让调用方轻易构建出一个空对象。


3. 默认值要集中管理

Builder 很适合管理默认值。

比如:

text 复制代码
timeout = 500ms
retryCount = 0
traceEnabled = false
addressingMode = Physical

这些默认值应该集中在 Builder 或 Product 内部。

不要让调用方到处重复写默认值。


4. Build 阶段要做完整校验

Builder 不只是拼字段。

真正重要的是 Build()

它应该负责:

  • 补齐默认值
  • 检查必填项
  • 校验参数范围
  • 校验参数组合
  • 返回构建成功或失败

如果没有这一步,Builder 的工程价值会大打折扣。


5. Product 尽量保持稳定

复杂对象一旦构建完成,就应该尽量保持稳定。

尤其是:

  • 诊断请求
  • 测试工况
  • OTA 任务
  • 整车配置
  • 仿真场景

这些对象如果在运行过程中被随意改,很容易造成排查困难。

所以可以考虑:

  • 字段私有
  • 只提供只读接口
  • 通过构造完成初始化
  • 少暴露 setter
  • 修改时重新构建新对象

6. 常用构建流程可以沉淀成 Director 或静态方法

如果某些构建流程经常重复,就不要让调用方到处写。

比如:

cpp 复制代码
BuildReadVinRequest()
BuildDefaultCityScenario()
BuildHighTemperatureChargingCase()
BuildMockVehicleConfig()

这些可以沉淀成 Director、工厂函数,或者项目里统一的构建工具。

关键不是类名叫什么。

关键是:

复用构建配方,而不是复制一堆构建步骤。


十六、建造者模式的优缺点

优点

建造者模式的优点很明确:

  • 复杂对象创建过程更清晰
  • 避免巨大构造函数
  • 减少参数顺序错误
  • 可读性更好
  • 默认值更容易管理
  • 构建校验更集中
  • 可以避免产品对象半初始化
  • 有利于构建不可变对象
  • 常用构建流程可以复用

它适合处理"对象本身构建复杂"的问题。


缺点

它的缺点也很明显:

  • 会多一个 Builder 类
  • 简单对象使用会显得啰嗦
  • 如果没有校验和默认值管理,容易变成形式主义
  • Builder 设计不好也会膨胀
  • 构建失败处理需要统一
  • 链式调用过长时也可能影响可读性

所以,建造者模式不是为了让创建代码看起来更"优雅"。

它是为了让复杂对象的构建过程更可控。


十七、使用建造者模式前,先问这 6 个问题

1. 这个对象的参数是否很多?

如果只有两三个参数,构造函数通常更简单。

2. 是否有大量可选参数?

如果很多参数不是每次都要设置,Builder 会更清晰。

3. 参数之间是否有约束?

如果字段之间存在依赖关系,Builder 可以集中校验。

4. 是否需要统一默认值?

如果默认值散落在各处,Builder 可以收拢。

5. 是否存在常用构建流程?

如果很多地方都在构建类似对象,可以沉淀成 Director 或构建模板。

6. Build 之后对象是否应该稳定?

如果对象构建完成后不应该被随便修改,Builder 很适合。


十八、总结

建造者模式解决的核心问题是:

复杂对象不应该靠巨大构造函数或一堆随意 setter 拼出来。

它不是为了让代码看起来更花。

它真正想解决的是:

  • 参数太多导致可读性差
  • 可选参数和默认值难管理
  • 构建规则散落在调用方
  • 对象容易处于半成品状态
  • 参数组合是否合法无法集中保证
  • 常用构建流程无法复用

一句话概括:

建造者模式的重点不是"创建对象",而是"把复杂对象的构建过程表达清楚"。

在车企软件里,它很适合这些场景:

  • 构建复杂诊断报文
  • 构建自动化测试工况
  • 构建整车配置对象
  • 构建仿真场景
  • 构建 OTA 升级任务
  • 构建数据采集任务参数

但它也不适合所有场景。

如果对象很简单、参数很少、没有复杂校验,就不要为了"看起来像设计模式"而强行 Builder。

设计模式真正有价值的地方,不是让代码显得高级,而是让复杂性有地方安放。

如果上一篇抽象工厂模式提醒我们:

不要把"一整套相关对象"拆成一堆互不约束的创建逻辑。

那么这一篇建造者模式提醒我们:

不要把"复杂对象"硬塞进一个巨大构造函数里。

如果这篇对你有帮助,欢迎点赞、转发、关注。

我们下一篇继续拆设计模式。

相关推荐
触底反弹39 分钟前
🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚!
人工智能·设计模式·面试
杨充21 小时前
10.可测试性实战设计
设计模式·开源·代码规范
杨充21 小时前
9.重构十二式的实战
设计模式·开源·代码规范
杨充21 小时前
6.设计原则的全景图
设计模式·开源·全栈
杨充21 小时前
2.面向对象的特性
设计模式
杨充21 小时前
7.SOLID原则案例汇
设计模式·开源·全栈
杨充21 小时前
8.反模式与坏味道
设计模式·开源·代码规范
杨充21 小时前
3.接口vs抽象类比较
设计模式
咖啡八杯1 天前
文法、BNF与AST
java·设计模式·解释器模式·ast·文法