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

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

它解决的问题是:

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

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

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

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

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

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

  • 一个诊断请求报文
  • 一个整车配置对象
  • 一个自动化测试工况
  • 一个 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。

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

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

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

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

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

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

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

相关推荐
霸道流氓气质9 小时前
业务链路追踪日志设计模式 — 从原理到实践
设计模式
nnsix1 天前
设计模式 - 建造者模式 笔记
笔记·设计模式·建造者模式
cui17875681 天前
矩阵拼团 + 复购拼团:新零售最稳的复购模式,规则简单
大数据·人工智能·设计模式·零售
百珏1 天前
[灰度发布]:全链路透传组件:APM、自研方案与 Java Agent 的实现取舍
后端·设计模式·架构
likerhood1 天前
设计模式 · 享元模式(Flyweight Pattern)java
java·设计模式·享元模式
AI大法师1 天前
从 Adobe 焕新看品牌系统升级:Logo、主色、字体与产品体验如何重新对齐
大数据·人工智能·adobe·设计模式
贵慜_Derek1 天前
《从零实现 Agent 系统》连载 03|控制循环:感知—决策—行动—反思
人工智能·设计模式·架构
nnsix1 天前
设计模式 - 原型模式 笔记
笔记·设计模式·原型模式
nnsix1 天前
设计模式 - 适配器模式 笔记
笔记·设计模式·适配器模式