C++ 设计模式学习笔记(三):把创建对象的变化放到合适的位置
调用者已经使用抽象接口,为什么换一个实现仍然要修改它?课程用文件分割器给出一个很直接的答案:变量的类型可以是接口,创建对象时却仍然写着具体类名。使用阶段的多态,不能自动消除创建阶段的依赖。
本文覆盖课程 P8 工厂方法、P9 抽象工厂、P10 原型和 P11 构建器。内容依据各讲完整音轨的本地转录及课件抽样核对,按创建决策逐步变复杂的过程重组;属于 AI 辅助视频笔记 vibe watching。文中的完整 C++20 示例为整理时原创,所有权与构造期行为的说明属于现代 C++ 补充。
来源档案
讲者:李建忠;课程:《C++设计模式》,本次据带有 GeekBand 标识的 26 讲 B 站转载版本整理。本文对应:P8《工厂方法》、P9《抽象工厂》、P10《原型模式》、P11《构建器》。
转载账号为“川中一郎”,合集标题《C++设计模式》,标识 BV15pxQzSE8j;上传者与原讲者分别署名。讲者账号发布的《C++设计模式》试听可核对课程署名;本次整理的转载合集用于定位上述分 P。链接失效时,可用“李建忠 C++设计模式 GeekBand”及讲次标题检索。
工厂方法:接口左边改了,右边的具体类还在
P8 的起点是一个界面程序:用户点击按钮,MainForm 收集文件路径等参数,创建分割器,然后调用 Split。如果始终只有一种分割方法,直接创建具体对象很自然。
需求增加后,二进制、文本、图片、视频文件分别需要不同实现。第一步是提取 ISplitter 接口,让各类分割器实现同一个操作。调用代码似乎已经面向抽象了,但下面这个示意片段仍然知道具体类型:
// 示意:使用接口,并不意味着创建时没有具体依赖。
ISplitter* splitter = new BinarySplitter;
splitter->Split();
把变量改成接口指针,只改变了后续如何调用;右边的 BinarySplitter 仍然参与编译。把对象放到栈上也不会改变这个事实。问题在于类型选择的位置,并不由是否使用 new 决定。
课堂接着尝试提取一个 Create 函数。如果按钮仍然选择某个固定的具体工厂,而工厂里仍然写死一种产品,这一层包装未必建立了需要的变化边界。真正的转折是让创建操作也成为可替换的契约:SplitterFactory 声明虚创建方法,具体工厂分别创建具体分割器,MainForm 接受外部传入的工厂。
此时一次按钮操作可以分成稳定的三步:收集参数、请求工厂创建对象、通过产品接口执行分割。格式选择由外部装配决定。更换视频分割器时,稳定的界面流程不需要再出现一个新的具体类名。
这里有两个很容易遗漏的细节。首先,界面可能在多次点击中创建多个独立任务,所以注入工厂与注入一个已经创建好的分割器,并不总是等价。其次,如果 MainForm 又在自己的构造函数里创建 BinarySplitterFactory,具体选择只是换了名字回到原处。注入的意义在于让调用者接受决策结果。
工厂没有消灭具体依赖
应用启动时,总要有地方选定一个具体工厂。课程强调的是把这类依赖集中起来,让它停留在容易修改的装配位置。一个没有任何具体对象的程序,也就没有实际可执行的行为。
因此,不宜把这段推导理解成“普通工厂函数没有价值”。实际 C++ 程序可以通过编译单元隐藏实现,也可以注入函数对象;简单场景甚至一个返回 std::unique_ptr<ISplitter> 的创建函数就足够。工厂方法展示的是通过可覆写的创建接口延迟决定产品,继承层次是这一实现的手段。
所谓“多态创建”,也是这个意义上的说法:运行时选择的是工厂的虚函数,C++ 的 new 关键字本身并没有变成虚操作。不同具体类的底层构造参数可以不同,但工厂向使用者公开的调用契约应当稳定;差异配置可以提前保存在具体工厂中。
抽象工厂:三个对象分别正确,放在一起却可能不匹配
P9 将问题从一个产品推进到一组需要协作的产品。数据库访问需要连接、命令和结果读取对象。起初代码直接依赖 SQL Server 的实现,后来又需要支持 Oracle、MySQL 等供应商。
按照上一讲的办法,可以分别提取 IDBConnection、IDBCommand、IDataReader,再为每种产品提供工厂。单独看,每个工厂都返回正确的接口类型;组合起来却可能出现 SQL Server 连接搭配 Oracle 命令的情况。命令必须使用兼容的连接,结果读取器也与执行命令的驱动关联,接口类型符合要求不代表协作关系就正确。
抽象工厂将相关创建职责收拢到一个产品族接口中。使用方选择一份数据库家族工厂,由它提供相互配套的产品。课堂更偏好用“家族工厂”帮助理解这个意图:这里的“抽象”,并不是说工厂类里只要有虚函数,就属于抽象工厂模式。
一个简化的职责关系可以写成:
| 使用方的需求 | 工厂负责的选择 |
|---|---|
| 取得数据库连接 | 选择某个驱动的连接实现 |
| 创建能使用该连接的命令 | 保持命令与连接属于兼容家族 |
| 读取执行结果 | 保持结果对象与执行驱动的语义一致 |
课堂代码在讲解中也有调整:真实 API 经常由命令执行后返回读取器,并非客户总要独立调用一个 CreateReader。理解产品之间的关系,比照着类图凑齐每个创建方法更重要。
产品族扩展与产品种类扩展,是两个方向
当连接、命令等产品接口稳定时,增加一个新的数据库家族,通常可以新增一套工厂和产品实现。调用方仍使用原来的创建与访问协议。
如果要增加一种全新的产品,例如事务对象,并要求所有工厂都提供它,那么工厂接口及既有实现就可能一起变化。抽象工厂隔离的是产品族的选择,并没有承诺产品种类也可以无限变化而不影响接口。
同样,不相关的对象不必因为“都需要创建”就塞进一个巨大工厂。家族边界应来自协作约束。一个错误实现的工厂仍然可能返回混搭产品,模式类图无法替代实现正确性;可以通过封装、类型约束或驱动身份校验进一步保证兼容。
工厂方法与抽象工厂因此既有关联,也有区别:前者关注让具体创建决定沿可扩展接口延迟,后者关注一组相关产品的创建一致性。抽象工厂可以用多个虚工厂方法实现,但两者并不能只靠方法数量机械区分。
原型:最方便的起点,可能是一个已经配置好的对象
P10 回到分割器。工厂可以从参数创建对象,但有时目标对象已经经历复杂配置,甚至停留在一个适合继续工作的中间状态。重新列出全部构造参数、重新完成初始化,可能比复制这个状态更复杂。
原型模式让产品接口提供克隆操作。调用者持有一个作为模板的 ISplitter,需要执行任务时向它请求副本。具体分割器知道怎样复制自己,界面不需要知道它的具体类型。
课堂示例把之前分开的产品接口与创建入口合到一起:ISplitter 增加 clone,具体实现通过自己的拷贝构造函数创建副本。按钮点击后,应保存克隆返回的对象,再执行分割。原型本身作为后续创建的依据保留,当前任务对副本进行修改。
这与工厂方法的共同点,是让使用者不直接写出具体产品类型;不同点是创建所依据的信息。工厂通常根据创建契约重新构造,原型则以一个已有对象的状态作为起点。简单对象未必值得增加克隆接口;“已经有一份所需状态”才是原型更有说服力的场景。
克隆真正需要保证的语义
new Concrete(*this) 调用了拷贝构造函数,但这不意味着对象图已经自动深拷贝。如果成员是拥有资源的裸指针,默认复制会复制地址,两个对象可能共同修改同一块数据,甚至重复释放。
正确问题是:副本后续修改哪些状态时,必须与原型隔离?可变任务配置通常需要独立,体积很大的不可变字典则可以按明确契约共享。深复制并不等于无差别地复制程序里能到达的一切,文件句柄、网络连接等资源也往往没有合理的普通复制语义。
下面是完整的原创 C++20 示例。它用可复制的值成员保存分割配置,克隆接口返回独占所有权。为突出复制语义,分割器只处理内存字符串,不承担文件 I/O。
#include <algorithm>
#include <cassert>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
class Splitter {
public:
virtual ~Splitter() = default;
virtual std::unique_ptr<Splitter> clone() const = 0;
virtual void set_width(std::size_t width) = 0;
virtual std::vector<std::string> split(std::string_view text) const = 0;
};
class TextSplitter final : public Splitter {
std::size_t width_;
std::vector<std::string> notes_;
public:
TextSplitter(std::size_t width, std::vector<std::string> notes)
: width_(width), notes_(std::move(notes)) {
if (width == 0) throw std::invalid_argument("zero width");
}
std::unique_ptr<Splitter> clone() const override {
return std::make_unique<TextSplitter>(*this);
}
void set_width(std::size_t width) override {
if (width == 0) throw std::invalid_argument("zero width");
width_ = width;
}
std::vector<std::string> split(std::string_view text) const override {
std::vector<std::string> result;
while (!text.empty()) {
const auto count = std::min(width_, text.size());
result.emplace_back(text.substr(0, count));
text.remove_prefix(count);
}
return result;
}
};
int main() {
TextSplitter prototype(3, {"configured template"});
auto first = prototype.clone();
auto second = prototype.clone();
first->set_width(2);
assert((first->split("abcdef") ==
std::vector<std::string>{"ab", "cd", "ef"}));
assert((second->split("abcdef") ==
std::vector<std::string>{"abc", "def"}));
assert(prototype.split("abcdef") == second->split("abcdef"));
}
两个副本来自同一原型,但调整第一个副本不会改变第二个或原型。std::vector 和 std::string 的值语义也使配置数据具有独立副本。示例按字节切分,实际处理 UTF-8 文本时还要定义是否允许切断字符边界;这是分割器的业务契约,克隆模式不会替我们解决。
构建器:产品是什么,与它怎样一步步建出来
P11 的房屋案例把创建复杂度放到另一个维度:房屋需要地基、墙、窗户、屋顶等步骤,不同房屋使用不同材料或实现,但总体建造顺序可能稳定。
最初可以在 House::init 中安排流程,再通过虚函数调用各个具体建造步骤。石头房、草房等派生类覆盖步骤,复用稳定顺序。这个结构已经能够表达“稳定流程调用可变步骤”,与模板方法有明显联系。课程并没有要求一开始就建立完整的 Builder、Director、Product 三套类。
接着出现一个 C++ 特有的诱惑:既然对象需要初始化,能否直接在 House 的构造函数中执行流程,并期待派生类的步骤被调用?这里必须区分语法允许与派发行为。构造函数可以调用虚函数,但基类构造期间不会派发到尚未完成构造的派生层实现。不能依靠这种调用完成所期待的派生构造流程。C++ 标准草案中的构造与析构规则明确规定了这一边界。
当 House 同时承担成品的状态与业务操作、创建步骤和流程组织时,职责可能越来越重。这时再拆分就有实际理由:
House表示最终产品,保存建成后的结构和行为。HouseBuilder负责具体建造过程,持有正在构造的产品,并提供取得结果的入口。- 如果稳定的步骤安排还值得单独复用,
HouseDirector负责调用顺序,将各步骤委托给 Builder。
不同 Builder 可以在相同流程下构造不同表示;Director 不需要知道每一步用了什么材料。产品与构造过程分离后,两者也更容易分别变化。
分步创建不能牺牲对象有效性
现代 C++ 实现还需要明确尚未完成的产品由谁拥有。通常可以让 Builder 用 std::unique_ptr 保存它,步骤失败时自动清理,全部成功后再转移给调用者。客户端不应在屋顶尚未建好时取得一个声称已经有效的完整房屋。
是否允许重复调用步骤、步骤能否跳过、取得结果后能否继续使用 Builder,也都是需要规定的协议。链式返回 *this 可以让调用形式更方便,但写出一串链式 setter,并不自动意味着已经解决了复杂构造的问题。
课程反复回到同一个前提:如果连整体建造顺序都经常变化,那么当前假设的稳定点就需要重新检查。继续提接口并不能无限抵御变化,接口本身也必须有某种稳定契约。
四种创建方式应当按问题选择
| 模式 | 想隔离的创建问题 | 需要保留的前提 |
|---|---|---|
| 工厂方法 | 使用流程不决定具体产品类型 | 创建与产品接口相对稳定 |
| 抽象工厂 | 一组产品需要成套匹配 | 产品种类及家族关系明确 |
| 原型 | 从已有配置或状态产生新对象 | 复制语义定义清楚 |
| 构建器 | 复杂产品需要分步构造 | 步骤协议及流程边界清楚 |
它们也可以组合:一个家族工厂可以选择合适的 Builder,工厂方法的实现可以克隆某个原型。组合是否合理,取决于实际创建约束;不应为了在同一张图里容纳更多模式而增加间接层。
阅读这几讲时,最有用的检查是沿着调用链寻找具体决定:类型由谁选,配置从哪里来,产品之间怎样配套,半成品何时变成有效成品,失败后谁释放资源。回答这些问题,才能看出创建逻辑是否真正被放到了合适的位置。
来源
资料处理范围:各讲音轨全文转录与阅读,结合课程代码和课件抽样画面核对;转录中的英文类名、术语及演示过程的临时代码以画面和上下文校正。本文保留推导与案例,未提供课程逐字稿。