精读《C++20设计模式》——创造型设计模式:构建器系列
精读《C++20设计模式》——创造型设计模式:构建器系列
前言
《C++20设计模式》的开始,我们的作者就抛出了一个问题:如何保证最大自由度的(笔者这里认为的最大自由度是——最强兼容性的)让使用者安全的构造一个对象的问题。很好,构建复杂对象向来是一个令人繁琐的事情。笔者再写ToDoLists的时候,就遇到了这个问题。我们也在这里,好好的谈一谈为什么“构建复杂对象向来是一个令人繁琐的事情”。
class Task {
public:enum class Priority {Immediate,High,Medium,Low};std::string taskTitle; // task titlestd::string taskDescription; // task descriptionstd::string details; // detailsPriority priority; // task priorityCTime ddl; // task ddl
};
笔者最开始的时候,就是粗暴的塞上Task(...)
带上一个长串的构造函数直接就写上了。这样好吗?这是一种解决方式,但是他好扩展嘛?不好!
- 后续,我们的Task如果还想进一步复杂,比如说外引一些Link的时候,我们还要添加一些新的成员。我们难道是不是还要在构造函数中添加新的成员呢?那这个时候引用了我们成千上万的构造函数的代码是不是都要改呢?
- 我们的构造函数非常的耗时,或者是复杂的,甚至可能在我们无法控制的流程中扔出异常,我们如何外交处理?
- 如果我们提交的子成员如果构造不成功,我们如何处理承接的东西呢?(严格而言不算构造的一个环节,但是笔者认为,构造的对象只有被外部有机会访问到了,这才是构造的最后一环)
等等诸如此类的问题。我们发现,问题的一切在于,我们将提交对象组合的材料过程,构建对象本身的流程和让对象有效存在耦合在了一起
Task* aTask = new Task("A Task", "This is a Demo Task", "Demo Tasks are placed for a detailed test", Task::Priority::High, {2025, 9, 24, 20, 38, 11});
你看,我们在这里一下子做了三个事情:
- 使用构造函数,提交了我们需要的参数,不管是不是必要的(第一个难点,构造函数的调用入口)
- 执行构造流程的构造函数执行(第二个难点,执行构造函数本身,我们没有机会修正构造流程)
- 返回我们的对象并真实的让aTask指向一个合法存在的Task对象本身!(第三个难点)
原来我们将这三个步骤耦合在一起了!那么,我想我们可以猜到构造器模式试图解决什么问题了——那就是让这三步尽可能转移到其他的地方,让我们的客户程序员使用的时候,有机会编写合适的代码让对对象构造可以正确且优雅的被处理和承接。
简单构造器和流式构造器
非常好!我想程序经验丰富的朋友已经开始聒噪了:嘿!你的做法首先就有问题: “提交了我们需要的参数,不管是不是必要的”这一步本身就是一个巨大的失误。所有的非匹配的参数就应该外置出去!我们提供一个标准的getter/setter来设置这些非必要的参数。
正确!笔者认为,就是要这样做。我甚至斗胆说,构造器就是将这个过程得到更加的规范化和自然化。我们假定一个事情。我们的Task就是要存在一个最基本的任务描述,一个ddl和一个优先级。这样我们才把一个任务描述清楚了,剩下的就是一些非必要的内容。我们马上就可以对对象进行一个简单的分类了。
#include <chrono>
#include <format>
#include <print>
#include <string>
class Task {
public:struct CTime {int year;int month;int day;int hour;int minute;int second;// Constructor that matches the format: {year, month, day, hour, minute, second}CTime(int y, int m, int d, int h, int min, int s): year(y), month(m), day(d), hour(h), minute(min), second(s) { }// A simple method to dump the time for verificationvirtual std::string dumpTimeString() const;// current timestatic CTime now();// frient operatorfriend bool operator>(const CTime& t1, const CTime& t2);};enum class Priority {Immediate,High,Medium,Low};private: // Making member variables private is a best practice for encapsulation// ---------- Must Valid before the Exsitance of TaskPriority priority;CTime ddl;std::string taskDescription;// ---------- Must Valid before the Exsitance of Task End// ---------- Can be configured later by setters and accessed by getterstd::string taskTitle;std::string details;// ---------- Can be configured later by setters and accessed by getter Endpublic:// Constructor to initialize the "Must Valid" membersTask(Priority p, CTime d, const std::string& desc): priority(p), ddl(d), taskDescription(desc) {// do some cases of precheckif (CTime::now() > d) {throw std::invalid_argument("Invalid Time Settings");}if (desc.empty()) {throw std::invalid_argument("Invalid Task Description");}// ...// Maybe we need to log into the databases...// Ok, we have finished our sessions}// Getters for all members// omitted for too easy...// Setters for members that can be configured latervoid setTaskTitle(const std::string& title) {taskTitle = title;}void setDetails(const std::string& d) {details = d;}
};
我们稍作一点整理,就能看到我们一般尝试的范式了,使用getter/setter来化简,这就是我们的预想方案。也是笔者和我看到一些demo级别的代码所作的初始化方案。但是我们发现,我们将大量的设置构建还是混合在了我们的工作类本身了。而且我们注意到,我们的构造函数也被非常的复杂化,夹杂了大量的异常抛出,不使用异常,我们也需要额外添加一个别扭的成员:bool isValid{false}
,我们甚至还要额外的维护状态!太糟糕了!
那我们怎么办呢?总是要处理这个问题的。我们思考一下,都做到这一步了,为什么我们不尝试使用一个类,委托我们的构造任务,让这个类完成我们的构建,将Task的所有检查,赋值成员的转化工作全部放置到这个类来完成呢?很好,那就叫Task的构建器TaskBuilder吧!
如果我们只是简单的将TaskBuilder设置成Task的友元类,并且在配置结束后,并且在配置结束后,使用build接口返回Task,这就是简单构造器。
class TaskBuilder {
public:void setPriority(Task::Priority p) { m_priority = p; }void setDdl(const Task::CTime& d) { m_ddl = d; }void setDescription(std::string desc) { m_description = std::move(desc); }void setTitle(std::string t) { m_title = std::move(t); }void setDetails(std::string d) { m_details = std::move(d); }std::optional<Task> build() const {if (!m_priority || !m_ddl || !m_description) {return std::nullopt;}Task task;task.m_priority = *m_priority;task.m_ddl = *m_ddl;task.m_description= *m_description;if (m_title) task.m_title = *m_title;if (m_details) task.m_details = *m_details;return { task };}private:std::optional<Task::Priority> m_priority;std::optional<Task::CTime> m_ddl;std::optional<std::string> m_description;std::optional<std::string> m_title;std::optional<std::string> m_details;
};
Notes: option是一个很好用的C++工具类,我们可以像指针一样检查这个成员是否是有效的!现在我们终于不用使用flags的方式来告诉我们的成员是否有效,而是直接将检查内化到我们的std::optional去了。
使用上,我们可以很轻松的写下代码:
int main() {TaskBuilder builder;builder.setPriority(Task::Priority::High);builder.setDdl(Task::CTime::now());builder.setDescription("Prepare blog post");builder.setTitle("Simple Builder");builder.setDetails("Non-fluent style");Task task = builder.build();task.doWork();
}
但是你没有发现这样写代码很累人嘛?使用过Kotlin的朋友知道run/apply/with是很方便的东西,C++能不能做到呢?能!,但是我们需要这样做一点小trick。让我们的构造器可以流动起来!
class TaskBuilder {
public:TaskBuilder() = default;TaskBuilder& withPriority(Task::Priority p) {m_priority = p;return *this;}TaskBuilder& withDdl(const Task::CTime& d) {m_ddl = d;return *this;}TaskBuilder& withDescription(std::string desc) {m_description = std::move(desc);return *this;}TaskBuilder& withTitle(std::string title) {m_title = std::move(title);return *this;}TaskBuilder& withDetails(std::string details) {m_details = std::move(details);return *this;}Task build() const {// 这里笔者偷懒了,也是顺便说明——我们在TaskBuilder中随意根据// 团队的代码规范选择我们构造失败的处理cases,而终于不用改动Task的代码了!if (!m_priority || !m_ddl || !m_description) {throw std::runtime_error("Cannot build Task: Missing required properties.");}Task task { *m_priority, *m_ddl, *m_description };if (m_title) {task.setTaskTitle(*m_title);}if (m_details) {task.setDetails(*m_details);}return task; // RVO}private:// Mustsstd::optional<Task::Priority> m_priority;std::optional<Task::CTime> m_ddl;std::optional<std::string> m_description;// Optionsstd::optional<std::string> m_title;std::optional<std::string> m_details;
};
我们马上就可以自然的写出这些代码了:
int main() {try{Task task = TaskBuilder{}.withPriority(Task::Priority::High).withDdl(Task::CTime::now()).withDescription("Finish Builder blog").withTitle("Blog Writing").withDetails("Explain simple builder").build(); // Build将内容move出来,RVO立大功 }catch(...){ // 偷懒,我不想写具体的exception捕获类型了// process the exception}task.assignedAsFinish(); // 如果我们提供了完成接口,你发现从这里开始我们就在正常的使用他!
}
或者我们可以编写更加完整的例子!
#include "./Task.h"
#include <iostream>int main() {try {// Using the builder to create a new Task object.auto myTask = TaskBuilder().withPriority(Task::Priority::High).withDdl({ 2025, 9, 25, 10, 0, 0 }).withDescription("Complete the final project report.").withTitle("Project Report").withDetails("Check all data points and finalize the conclusion.").build();// Accessing the properties of the built task.std::cout << "Task built successfully!" << std::endl;// Notes: impl the dump_formated_task by yourself, or check the source code// for details :)std::cout << "Task Description: " << myTask->dump_formated_task() << std::endl;// Trying to build a task without all required properties to show error handling.auto invalidTask = TaskBuilder().withDescription("This will fail.").build();} catch (const std::runtime_error& e) {std::cerr << "Error: " << e.what() << std::endl;}return 0;
}
**等等!**你可能感到太快了,怎么就突然到达这里了?啊哈,那我们慢慢说。
首先,咱们是怎么像行云流水一般,构造我们的对象的呢?
// Using the builder to create a new Task object.
auto myTask = TaskBuilder().withPriority(Task::Priority::High).withDdl({ 2025, 9, 25, 10, 0, 0 }).withDescription("Complete the final project report.").withTitle("Project Report").withDetails("Check all data points and finalize the conclusion.").build();
说的就是这里!答案是,您可以看到设置的with*系列的构造函数,我们一直在返回我们构造器的引用本身。,那我们可以这样行云流水般的使用嘛?当然可以!
// Using the builder to create a new Task object.
auto taskBuilder = TaskBuilder().withPriority(Task::Priority::High).withDdl({ 2025, 9, 25, 10, 0, 0 }).withDescription("Complete the final project report.");
// Ok We can Hang up the build
// like await the databasesauto title = DataBases::queryTitleByTimeRandom({ 2025, 9, 25, 10, 0, 0 });
auto task = taskBuilder.withTitle(title).withDetails("Check all data points and finalize the conclusion.").build();
看到了没?我们甚至实现了延迟的构造,可以将构造器到处传递到不同的子系统,而不需要传递潜在的非完全构造准备的对象。这样,我们就保证了流通在代码中的 Task 对象总是对象有效的!除非我们在逻辑上认为他们是无效的(比如说任务超时了,取消了等等)
哦对了,值得一提的是,这本书没有提到的是阶段式的构造器,是在笔者上面的例子中派生出来的。
class TaskBuilder {// private ctor & fieldsstruct Stage1 {TaskBuilder builder;auto withPriority(Task::Priority p) { builder.m_priority = p; return Stage2{std::move(builder)}; }};struct Stage2 {TaskBuilder builder;auto withDdl(Task::CTime d) { builder.m_ddl = d; return Stage3{std::move(builder)}; }};struct Stage3 {TaskBuilder builder;auto withDescription(std::string desc) { builder.m_taskDescription = std::move(desc); return FinalStage{std::move(builder)}; }};struct FinalStage {TaskBuilder builder;FinalStage& withTitle(std::string t) { builder.m_taskTitle = std::move(t); return *this; }Task build() { return builder.buildImpl(); }}; public:static Stage1 start() { return Stage1{}; } };
这个构造器是要求严格时序构造的构造器,笔者没有遇到过,但是感兴趣的朋友可以自己试试使用这种方式来保证构造对象的构造时序是严格的。
何时适用流式构造器(适用场景)
- 对象构造参数很多(尤其大量可选参数)
- 需要集中验证或复杂初始化逻辑
- 想把可读性作为优先目标(API 像句子一样)
- 目标对象的构造不方便通过单一构造函数或 aggregate 初始化表达
缺点(权衡)
- 实现相对复杂(比简单构造函数多代码)
- 如果只需要少量参数,可能显得啰嗦
- 默认实现通常只能在运行时发现缺失字段(可用 staged builder 改进)
- 非线程安全、需要注意可变状态的复用
组合式构造器
在上面,我们就好好聊了一下经典的流式构造器和提到了一种严格时序的构造器。那么,我们是否可以在职责上划分出来不同的构造器,或者说,进一步更加严肃的,将我们的构造器在职责上产生划分,也就是从Builder中派生基类。得到一系列的不同的新的Builder,下一次我们想要添加新的成员,我们就真的能在外部添加新的Builder而不是强迫的修改Builder本身的行为
我们的想法是这样的。我们在基类的构造器中盛放所有的构造成员,然后派生的类中承担更加具体的构造任务。比如说我们可以开放访问权限给我们的基类,它实际上承担最终一次构造的任务
public:friend struct Builder; // Open Special Access to the Builder(Base)static struct Builder builder(){return Builder{};}
你会发现,只要你喜欢,你完全可以隐藏掉Builder(就是把builder返回Task,这个时候Builder就可以失踪了),然后彻底的将Task的构造函数放到private区域。
struct Builder {std::optional<Task::Priority> m_priority;std::optional<Task::CTime> m_ddl;std::optional<std::string> m_description;std::optional<std::string> m_title;std::optional<std::string> m_details;// What we need is to ADD Casts instead of modifying codes// let all derived constructors to actually construct the membersstruct BuilderMain mainBuilder();struct BuilderOptional optional();Task build() const {if (!m_priority || !m_ddl || !m_description) {throw std::runtime_error("Task build error: missing required fields");}Task t { *m_priority, *m_ddl, *m_description };if (m_title)t.taskTitle = *m_title;if (m_details)t.taskDescription = *m_details;return t;}
};// Main And Must
struct BuilderMain {Builder& b;BuilderMain(Builder& base): b(base) { }BuilderMain& withPriority(Task::Priority p) {b.m_priority = p;return *this;}BuilderMain& withDdl(const Task::CTime& d) {b.m_ddl = d;return *this;}BuilderMain& withDescription(std::string desc) {b.m_description = std::move(desc);return *this;}Builder& doneMain() {return b;}
};// Optional
struct BuilderOptional {Builder& b;BuilderOptional(Builder& base): b(base) { }BuilderOptional& withTitle(std::string t) {b.m_title = std::move(t);return *this;}BuilderOptional& withDetails(std::string d) {b.m_details = std::move(d);return *this;}Builder& doneOptional() {return b;}
};// Cast Interfaces
inline BuilderMain Builder::mainBuilder() {return BuilderMain { *this };
}
inline BuilderOptional Builder::optional() {return BuilderOptional { *this };
}inline Builder Task::builder() {return Builder {};
}
看到了嘛?我们现在就能这样使用我们的构造器了:
// main.cc
#include "./Task.h"
#include <iostream>int main() {try {auto myTask = Task::builder().mainBuilder().withPriority(Task::Priority::High).withDdl({ 2025, 9, 25, 10, 0, 0 }).withDescription("Complete the final project report.").doneMain().optional().withTitle("Project Report").withDetails("Check all data points and finalize the conclusion.").doneOptional().build();std::cout << "Task built successfully!" << std::endl;std::cout << "Task Description: " << myTask.dump_formated_task() << std::endl;// 尝试构造一个缺少必填属性的 Task,看看异常是否被抛出auto invalidTask = Task::builder().mainBuilder()// 不设 priority 或 ddl.withDescription("This will fail.").doneMain().build();} catch (const std::runtime_error& e) {std::cerr << "Error: " << e.what() << std::endl;}return 0;
}
我们现在终于可以保证一个非常健全的,真正满足开闭原则的构造器模式了!
总结一下
很好,我们看了很多的内容,现在我们回过头来,来一个小小的总结:
我们在试图解决什么问题?
当一个类(如 Task
)的成员较多、部分是 必填属性、部分是 可选属性 时,用一个长构造函数会导致:
- 参数太多、可读性差;
- 扩展性差(每新增一个字段就得修改构造函数签名、调用处);
- 构造逻辑(合法性校验、异常处理、内部状态一致性)被混杂在构造函数内部,使业务逻辑与构造逻辑耦合。
- 如果不抛异常,又要引入
isValid
、状态标志等冗余成员,使类变得脏乱。
我们怎么解决这个问题的?
我们将“参数收集 / 校验 / 构造逻辑”从 Task
内部剥离出来,用一个专门的构造器(Builder)来处理,从而让 Task
只专注自己的业务语义。
我们提出的解决方案到底有什么差别
风格 | 方法签名 / 用法 | 特点 / 优点 | 缺点 / 风险 |
---|---|---|---|
简单构造器(非流式 / 强制 setter) | builder.setXxx(...) 不返回 builder | 实现简单、直观,强迫使用者显式调用每个 setter | 调用代码繁琐;容易忘调用某个 setter;链式调用不可用 |
流式构造器(Fluent / 方法链式) | builder.withXxx(...).withYyy(...).build() | 可读性好,调用像“句子”;可以灵活顺序;适合可选字段很多 | 默认只能在运行时校验必填字段;滥用 builder 对象时可能出错;实现相对复杂 |
阶段式构造器(Staged / Typed-Builder) | 不同阶段返回不同类型,强制时序规则 | 在编译期限制调用顺序、保证必填项必须设置 | 实现较复杂;类型设计复杂;对用户稍有学习成本 |
组合构造器 / 派生子 Builder | 基础 Builder + 多个子 Builder(负责不同属性分类)组合使用 | 职责分明、易扩展;新增属性时可以扩展子 Builder | 设计复杂性较高;可能有类型转换接口混用;用户 API 学习略高 |