上一篇聊了工厂模式的整体框架,这篇开始切入一个很容易被忽视、但真正决定工厂代码可读性和扩展性的底层话题——重载。很多朋友在实现工厂类时,会写好几个create、make或者build开头的方法,但往往只是"能编译通过"而已,并没有真正吃透重载机制在工厂场景中的工作方式,结果就是代码里满是隐式转换、二义性报错,或者灾难性的"重载了但没完全重载"。
这篇文章就把重载这件事掰开揉碎,从编译器的视角、实际工厂场景的视角以及代码演进的角度来审视。注意,这是个系列的第一篇,所以我会把基础机制和工厂中的典型应用场景讲透,模板与泛型工厂的部分留到下一篇。
1. 重载在 factory 机制里的角色与价值
1.1 为什么工厂代码需要重载
工厂模式的核心目标是把"对象创建逻辑"集中管理,但现实中的对象创建往往不是一刀切的。比如一个LoggerFactory,它要支持创建文件日志、控制台日志、网络日志;再比如一个ResourceFactory,它要根据配置字符串、配置对象、甚至字节流来构建不同的资源实例。
如果没有重载,你只能给每个创建方法起不同的名字:
createFileLoggercreateConsoleLoggercreateNetworkLogger
或者只能接收一个"万能参数"(比如std::string),然后在函数内部用一堆if-else去解析。这两种方案的问题都很明显:第一种把工厂类变成了"方法名动物园",每加一种产品就得加一个方法,而且调用方必须记住精确的方法名;第二种则破坏了类型安全,把所有参数简化成字符串,编译器完全帮不上忙。
重载给工厂模式的解法是:用一个统一的方法名,通过参数列表的差异,让编译器替你完成"路由"。这在工程上带来的好处非常直接:
- 调用方只需要记住"用某个方法创建某类对象"这一个概念;
- 参数类型本身就是一种文档,
create(int)和create(const Config&)的语义一目了然; - 新增产品时,通常只需增加一个新的重载版本,不用改动已有调用点,符合开闭原则。
我维护过一个内部推荐系统的基础库,里面的模型工厂就是典型的重载场景:ModelFactory::create根据入参是dense_features、sparse_features还是raw_bytes,走了完全不同的构造链路。没有重载的时候,这个类里有将近二十个create_from_xxx方法,每次调用前都要查一下 API 文档;重构为重载版本之后,接口一下子清爽了,而且因为参数类型不兼容,调用方想传错也传不进去。
1.2 重载与多态的区别:选型前先想清楚
这是我在代码评审里经常要跟人掰扯的问题:重载和覆盖(虚函数)到底有什么区别?它们在工厂里各自扮演什么角色?
一句话总结:重载是编译期的静态多态,覆盖是运行期的动态多态。
| 对比维度 | 重载(Overload) | 覆盖(Override) |
|---|---|---|
| 决策时机 | 编译期,编译器根据实参静态类型决定 | 运行期,根据对象动态类型通过虚表决定 |
| 函数签名 | 必须不同(参数个数或类型) | 必须相同 |
| 所在作用域 | 必须同一作用域(类内或全局) | 基类与派生类之间 |
| 工厂中的应用方式 | 按入参类型分派创建逻辑 | 工厂产物自身的行为扩展 |
很多新手在写工厂时会犯一个经典的错误:试图用重载解决"运行时类型相关的创建",也就是根据对象的runtime类型来决定创建策略。这事重载干不了,因为重载决议发生在编译期。比如你有一个Base指针,它实际指向的是Derived,你把Base*传给某个重载函数,编译器只会按Base*这个静态类型去匹配,而不会"看穿"它实际指向谁。如果产品类型本身存在继承体系,并且需要"把产品当作基类使用",那应该在产品类里用虚函数,或者配合typeid和注册表,而不是硬套重载。
因此,在工厂设计的第一步,就应该想清楚:你的分派维度是"参数的静态类型/个数",还是"对象的动态类型/行为"。前者用重载,后者用继承和虚函数。两者方向完全不同,选错了后面就是无穷无尽的"行为不符合预期"。
1.3 一个贴近实战的场景:多种入参,同一创建语义
拿我自己做过的配置加载器来说。系统里有三种配置来源:
- 本地文件路径(
std::filesystem::path) - 运行时传入的 JSON 字符串(
std::string) - 已经解析好的键值对容器(
std::map<std::string, std::string>)
我用了同一个ConfigLoader::load重载方法,分别接收这三种类型,内部再各自走文件读取、字符串解析和容器直构的链路。调用方代码只关心"我要加载配置",而不关心"我今天拿到的是文件还是字符串",代码读起来就像是在描述业务意图,而不是在描述数据结构。
从这个例子里可以提炼出重载在工厂机制中的核心价值:把"创建/构建"这个动词统一,把"怎么创建"的差异交给参数类型去表达。这种设计在复杂度可控、分支明确、扩展方向可预见的场景下,比模板特化和注册表方案更直观、更容易被团队理解和维护。
2. 重载机制的底层原理:编译器究竟怎么选
前面讲了不少"为什么",现在进入"怎么选"的层面。如果你不理解编译器选择重载版本的原则,那你在代码里碰到的所有"咦,它怎么走到这个分支里去了"的迷惑行为都无法解释。
2.1 名字修饰与函数签名:重载能存在的根本原因
C 语言里不允许同名函数,原因很简单:在链接层面,函数名直接映射到符号表中的符号名,同名就是同符号,无法区分。C++ 引入了名字修饰(Name Mangling)机制,编译器会把函数名和参数类型编码成一个独一无二的字符序列作为底层符号。
举个例子,两个函数:
int create(int); std::unique_ptr<Resource> create(const ResourceConfig&);在底层,它们会被修饰成类似_Z6createi和_Z6createRK14ResourceConfig这样的符号,而不再是千篇一律的create。链接器看到的是两个完全不同的符号,自然就可以共存。
理解这一点有什么实际意义?至少有三条:
- 签名由函数名和参数列表决定,返回值不在签名内。这是大多数人在重载上翻的第一个跟头,你没法只靠返回类型不同来实现重载,编译器根本分不清调用者想要哪个返回值。
- 名字修饰在不同编译器之间不通用。所以跨动态库边界的导出符号才会要求用
extern "C",这个以后聊到跨语言互操作时再展开。 - 模板也是依赖名字修饰来实现多态实例化的。换句话说,模板和重载在底层机制上有亲缘关系。
2.2 重载决议的完整流程:三步走
编译器选择一个重载版本,不是随机的,也不是看哪个顺眼,而是严格走标准指定的流程。为了方便记忆,我把它概括成三步:
第一步:查找候选函数(Candidate Functions)
在调用点的作用域内,编译器会做一个名称查找(Name Lookup),把所有同名函数的声明捞出来。注意这个细节:查找是受"可见性"约束的。如果你在派生类里写了一个同名函数,基类里所有的同名重载都会被"隐藏"(hiding),哪怕参数列表跟派生类的完全不重叠。这是 C++ 里著名的大坑"隐藏规则":
struct Base { void parse(int); }; struct Derived : Base { void parse(double); }; Derived d; d.parse(42); // 意外!调用的是 Derived::parse(double),而不是 Base::parse(int)想绕过这个坑,要么在派生类里用using Base::parse;把基类重载导入作用域,要么就别在继承体系里搞同名不同参的函数。
第二步:确定可行函数(Viable Functions)
在候选集中,编译器会剔除那些"无法用这些实参调用"的函数。剔除条件有两个:一是参数个数对得上,二是每个实参都能隐式转换到对应形参类型。同时,如果函数有默认参数,实参个数可以比形参少。
第三步:选择最佳匹配(Best Match)
剩下的可行函数里,编译器逐个比较参数的"匹配分数"。原则是:谁的转换代价最小,谁就赢。
不同匹配方式的优劣排序大致是:
- 精确匹配(标准转换或直接同一类型)
- 提升匹配(如
int到long) - 标准转换(如
int到double、派生类指针到基类指针) - 用户自定义转换(通过构造函数或转换运算符)
如果两个候选函数的匹配分数一样好,而且编译器无法从"更优的转换规则"上做出区分,就会报二义性错误。
这里有一个非常值得注意的细节:顶层const(即指针本身的const)和引用类型在重载决议中的处理方式很微妙。比如:
void process(int*); void process(const int*);这两个重载是可以同时存在的,实参是int*时选第一个,实参是const int*时选第二个。但如果你写的是int* const,也就是"指针本身不可变",那它跟int*在重载决议中是同一个"身份",因为顶层 const 不作为区分依据。
2.3 隐式转换在工厂重载中引发的"路径选择"
工厂重载的实参往往不是恰好匹配某一种形参,而是能隐式转换成多种候选函数的参数类型。这种东西在实盘里最容易出事。
我举个实际踩过的坑。假设工厂里有三个重载:
Resource create(const std::string& name); Resource create(const char* name); Resource create(int id);调用create("db_connection")时,编译器会选择const char*这个精确匹配版本,这是符合直觉的。但如果你加了一个新重载:
Resource create(bool enable);然后写create(0),编译器会选bool版本还是int版本?答案是:int版本是精确匹配,bool版本需要int到bool的标准转换,所以选int。但如果写的是create(true),那bool版本是精确匹配,create(1)则明显走int版本。
真正"阴间"的是这一种情况:
void func(unsigned int); void func(unsigned long);然后调用func(0)。int字面量0同时可以转换到unsigned int和unsigned long,转换级别相同(都是整数转换),于是二义性。编译器不会替你做决定,只能报错。工厂函数如果搞了这种模糊重载,调用方想传个0都会炸,这种 API 设计本身就是失败的。
我的教训是:在工厂的重载设计里,尽量避免"数值类型层次模糊"的重载集合。比如同一个工厂方法,既有int版本又有long版本,既有double版本又有float版本,这就是在给自己埋雷。宁可设计成显式的标签类型,也不要依赖调用方小心翼翼地控制字面量类型。
3. 核心细节:拷贝构造与重载决议的纠缠
3.1 拷贝构造函数也是"构造重载"
很多朋友对"拷贝构造函数和重载"这个热搜词感到迷惑:拷贝构造不就是ClassName(const ClassName&)吗?它跟重载有什么关系?
关系非常大。拷贝构造函数本质上是构造函数家族里的一个特殊重载版本。当我们谈论"构造"时,其实编译器在重载决议里会同时考虑:
- 默认构造函数
ClassName() - 拷贝构造函数
ClassName(const ClassName&) - 移动构造函数
ClassName(ClassName&&) - 任意个数的自定义构造函数
ClassName(int, int)、ClassName(const std::string&)等
你写ClassName a(std::move(b))能走移动构造,写ClassName c = a能走拷贝构造,这些行为的背后全是重载决议在起作用。换句话说,你写的每一行构造代码,编译器都在心里做了一次重载选择。
3.2 拷贝构造里的 const 引用与非 const 引用
这里有一个看起来不起眼、实战中却能坑死人的细节。考虑:
class Widget { public: Widget(const Widget& other); // 版本 A Widget(Widget& other); // 版本 B };这两个拷贝构造函数可以同时存在。根据重载决议规则,实参如果是非 const 左值,精确匹配Widget&版本 B;如果是 const 左值,则只能匹配版本 A;如果是右值,则会优先匹配移动构造(如果没有移动构造,则匹配 const 引用版本 A)。
这个规则背后有个设计意图:版本 B 可以把other视为可修改的对象,通常用于实现"转移资源但不清空源对象"的语义。但在实际工程中,同时提供两个版本的拷贝构造函数极其罕见,大多数情况下只写const Widget&版本就够了,当你不确定时,不要画蛇添足。
真正的坑在于非 const 引用参数会导致拷贝构造无法接受临时对象。因为非 const 左值引用不能绑定到右值。假设:
void takeWidget(Widget w); Widget makeWidget(); takeWidget(makeWidget()); // 如果拷贝构造函数是 Widget(Widget&),这里直接编译错误原因是makeWidget()返回临时对象,它是右值,无法绑定到Widget&。如果拷贝构造函数只有Widget(Widget&)这一个版本,那传临时对象就编译不过。这种问题在过去代码 review 中非常常见。
3.3 拷贝构造与工厂返回值的微妙关系
在工厂场景中,拷贝构造函数常常是"背锅侠"。不少朋友写工厂时发现:
Resource create() { Resource r; ... return r; // 理论上应该移动或复制 }然后程序就要求必须有拷贝构造函数,否则编译不过。这是因为 C++17 之前的版本中,从函数返回局部变量可能经历拷贝(虽然绝大多数编译器会做 RVO/NRVO 优化)。到了 C++17,纯右值强制复制消除(guaranteed copy elision)基本解决了直接返回临时对象的拷贝问题,但返回具名局部变量时,还是会需要移动或拷贝构造可用于备用路径。
在工厂类设计中,处理这个问题有几条经验:
- 工厂方法的返回类型尽量用值返回,配合现代 C++ 的强制复制消除,开销是可控的;
- 如果你的产品类型是纯资源句柄、不允许拷贝,那就把移动构造函数显式定义出来;
- 不要让工厂方法返回裸指针再要求调用方管理生命周期,
std::unique_ptr作为返回值时不会有任何拷贝负担; - 如果你的类里有自定义析构函数,编译器会默认把移动构造和拷贝构造一起"禁用",这种场景下工厂返回该类型对象时,编译错误提示往往让新手一头雾水。
4. 实操:用重载机制写一个可扩展的工厂
理论说得再多,不如直接写代码。这一节我们用真实场景来演示:一个网络请求配置工厂,通过重载创建不同类型的请求配置对象,并展示如何规避重载决议里的隐藏陷阱。
4.1 场景设定与需求分析
假设我们要设计一个RequestConfigFactory,它能创建三类配置:
- 基于 URL 字符串的请求配置
- 基于已有端口的请求配置
- 基于 JSON 配置块的请求配置
常规的RequestConfig可以这样定义:
struct RequestConfig { std::string url; int port{0}; std::string protocol; // 标记类型的空结构,用于消除重载二义性 struct FromJsonTag {}; };这里我把FromJsonTag预留在类里,是为了后面解决"JSON 配置块可能是字符串,也可能是流"的二义性问题。
4.2 第一种方案:直接参数类型重载
class RequestConfigFactory { public: RequestConfig create(const std::string& url) { return buildFromUrl(url); } RequestConfig create(const char* url) { // 转发到 string 版本 return create(std::string(url)); } RequestConfig create(int port) { return buildFromPort(port); } RequestConfig create(const std::map<std::string, std::string>& headers) { return buildFromHeaders(headers); } private: RequestConfig buildFromUrl(const std::string& url) { RequestConfig cfg; cfg.url = url; if (url.rfind("https://", 0) == 0) { cfg.protocol = "https"; cfg.port = 443; } else { cfg.protocol = "http"; cfg.port = 80; } return cfg; } RequestConfig buildFromPort(int port) { RequestConfig cfg; cfg.port = port; return cfg; } RequestConfig buildFromHeaders(const std::map<std::string, std::string>& headers) { RequestConfig cfg; cfg.protocol = headers.count("protocol") ? headers.at("protocol") : "http"; cfg.port = headers.count("port") ? std::stoi(headers.at("port")) : 80; return cfg; } };这个版本已经能解决大部分需求了,而且调用方体验很好:
RequestConfigFactory factory; auto c1 = factory.create("https://api.example.com"); auto c2 = factory.create(8080); auto c3 = factory.create({{"protocol", "https"}, {"port", "8443"}});注意这里我故意重载了const char*的版本。如果不加这个版本,create("https://api.example.com")会发生const char[23]到std::string的隐式转换,也能编译通过,但那是一次自定义转换。有了const char*精确匹配版本之后,重载决议会优先选它,避免了构造临时std::string的开销。如果以后想改成std::string_view,只需要把这个版本改成create(std::string_view)并且转发即可,对外接口不变。
4.3 第二种方案:引入标签类型,消灭隐式二义性
上面的代码有个隐患:如果继续扩展,比如增加create(const std::string& jsonString)来创建基于 JSON 的配置,那么:
RequestConfig create(const std::string& url); // 已有 RequestConfig create(const std::string& jsonStr); // 新增,签名完全一样!这就是重载冲突,编译器直接报错。C++ 不允许两个函数参数表完全相同、只是参数名字不同。所以,如果我们需要"解析 JSON 字符串创建请求配置",就不能再用参数类型来区分了。
解法是引入标签类型(Tag Type):
class RequestConfigFactory { public: struct FromUrl {}; struct FromJson {}; struct FromPort {}; RequestConfig create(const std::string& url, FromUrl) { ... } RequestConfig create(const std::string& json, FromJson) { ... } RequestConfig create(int port, FromPort) { ... } };这种设计让调用变成:
auto c1 = factory.create("https://api.example.com", RequestConfigFactory::FromUrl{}); auto c2 = factory.create(R"({"url":"https://api.example.com"})", RequestConfigFactory::FromJson{});标签类型把"同一类型参数的不同语义"显式地表达出来。这是我在大型工程中最推荐的做法,因为:
- 它不会在未知情况下发生隐式转换,类型安全;
- 调用处可读性非常高,
FromJson{}一看就知道这个字符串要被当作 JSON 解析; - 后续想加第五种、第六种创建方式,加一个标签类型和对应的重载即可,不影响旧调用。
4.4 用 std::string_view 还是不用的挣扎
有些朋友喜欢用std::string_view做参数类型,觉得这样可以避免拷贝。在工厂场景里需要谨慎。std::string_view作为函数参数本身没有问题,但它有一个很大的安全隐患:它不拥有底层字符串数据。如果工厂方法内部把string_view存到了RequestConfig里,而调用方传入的是临时字符串:
auto cfg = factory.create(std::string("temp") + ".com"); // 临时字符串,语句结束就销毁 // cfg 里保存的 string_view 悬空了!这就变成了典型的悬垂引用 bug,而且不容易在代码评审中发现。所以我的建议是:
- 如果工厂方法内部只是立即解析、不做长期保存,可以用
std::string_view作为参数,提高灵活度; - 如果解析结果或者原始字符串要存入返回对象,就必须用
std::string或std::shared_ptr<const std::string>,让生命周期被管理起来; - 在重载集合里同时出现
std::string和std::string_view时,要小心调用create("literal")时到底选谁:字面量能精确匹配std::string_view,也能通过用户转换匹配std::string,最终会选string_view。这大概率不是你想要的。
4.5 从"入参驱动"到"构建器驱动"的重载设计
当重载参数越来越多时,你会发现一个尴尬的现实:每个重载版本都是独立的代码块,但它们往往共享大量逻辑。
RequestConfig create(const std::string& url) { ... } RequestConfig create(int port) { ... } RequestConfig create(const std::map<std::string, std::string>& headers) { ... }每个函数里都有对RequestConfig的字段赋值,重复度很高。这时候可以考虑引入一个内部Builder:
class RequestConfigBuilder { public: RequestConfigBuilder& withUrl(const std::string& url); RequestConfigBuilder& withPort(int port); RequestConfigBuilder& withProtocol(const std::string& protocol); RequestConfig build(); }; class RequestConfigFactory { public: RequestConfig create(const std::string& url) { return RequestConfigBuilder().withUrl(url).build(); } RequestConfig create(int port) { return RequestConfigBuilder().withPort(port).build(); } RequestConfig create(const std::map<std::string, std::string>& headers) { RequestConfigBuilder builder; if (headers.count("protocol")) builder.withProtocol(headers.at("protocol")); if (headers.count("port")) builder.withPort(std::stoi(headers.at("port"))); return builder.build(); } };这个设计把"字段赋值"的细节收敛到了Builder里,工厂的重载方法变成了薄薄的适配层。我个人非常喜欢这种写法,因为它同时享受了重载带来的调用直观性和 Builder 带来的字段构建灵活性,后续想扩展校验逻辑、默认值逻辑,都只需改 Builder 一处。
5. 实战中绕不开的坑:排查经验与避坑清单
5.1 常见编译错误速查表
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
call of overloaded 'create(int)' is ambiguous | 重载集合里有两个版本的转换代价相同 | 新增精确匹配版本,或用标签类型区分 |
no matching function for call to 'create(...)' | 参数类型无法隐式转换到任何已有重载 | 检查成员函数是否为const、形参是否为 const 引用,必要时加显式转换 |
function definition does not declare parameters | 有的朋友试图靠返回值区分重载 | 必须修改参数列表 |
invalid conversion from 'const char*' to 'bool' | create(true)和create("xxx")同时存在时,某些字面量会触发意外转换 | 避免把bool作为工厂的关键重载参数,尤其在和老代码并存的场景 |
overloaded 'create' cannot be declared | 多个版本参数表完全相同 | 用标签类型、多个参数组合或改名为不同语义的辅助函数 |
5.2 一个真实的二义性教训
有一段时间我负责的模块里有个媒体加载工厂,接口长这样:
Media create(const std::string& url); Media create(const std::string& mimeType, const std::string& content);大概用了半年,一直没问题。直到某次迭代,有人加了一个:
Media create(const std::string& rawData, bool fromCache);新同事的本意是通过第二个参数区分"是否从缓存加载"。看起来没问题,然而调用处写的是:
factory.create(data, true);这个调用本身能匹配(std::string, bool),没问题。但问题是代码库里有大量老代码是这么写的:
factory.create(url);这也不冲突。真正的灾难发生在某次有人写了:
factory.create("cache://data_key", true);由于"cache://data_key"是const char*,它既能隐式转换为std::string,而true精确匹配bool——但另一个重载是create(const std::string&, const std::string&),字面量"true"并不是字符串啊,所以不会选它。这个还好,没炸。
真正炸的是几个月后,另一个模块的调用:
factory.create(some_string, another_string);它的本意是匹配create(mimeType, content),因为两个都是std::string。但巧的是another_string定义成了一个"包装类",恰好这个包装类有operator bool(),于是编译器发现两个版本都可行:
create(const std::string&, const std::string&):需要两个用户定义转换(包装类到字符串没有直接路径,但包装类内部可以被string构造,实际用到了构造路径)create(const std::string&, bool):第二参数只需一次用户定义转换operator bool()
重载决议比较的是"每组参数的转换序列",第二版本比第一版本更优,于是编译器毫不犹豫地选了create(rawData, bool)版本!最终结果是:一个本意是"媒体类型+内容"的调用,悄悄变成了"原始数据+是否走缓存",媒体加载直接失败。
排查这类问题非常痛苦,因为编译器没有任何警告。从那以后,我在代码规范里加了一条:工厂方法重载集合中,禁止使用 bool 作为主要参数,因为它会制造太多隐式转换路径。如果确实有布尔语义,请使用显式的枚举或标签类型。
5.3 默认参数与重载的"伪和谐"
默认参数和重载的组合非常危险:
void create(const std::string& url, bool validate = true); void create(const std::string& url);这两个函数签名里,第一个可以只传一个参数,第二个也可以只传一个参数。当调用create("url")时,编译器该选谁?
答案是:它会选非默认参数的精确匹配版本,即void create(const std::string& url)。但这并不意味着安全,因为一旦你调用的是create("url", true),你就只能匹配第一个。于是,调用方会陷入一种困惑:明明函数只有一个参数时行为是 X,两个参数时行为却是 Y。这种体验对调用方极不友好。
我的态度是明确的:工厂方法不要同时提供"默认参数版本"和"无默认参数版本"。如果嫌调用方每次都要传true麻烦,那就直接提供两个明确的重载版本,而且用标签类型取代布尔参数:
struct Validate {}; void create(const std::string& url, Validate); void create(const std::string& url);这样调用create(url)和create(url, Validate{}),语义清清楚楚,重载决议也不会出岔子。
5.4 重载与继承体系的隐藏陷阱
前面提到的隐藏规则(hiding rule)在工厂里最常见的变体是:你有一个BaseFactory,它定义了createFromString和createFromInt,派生类DerivedFactory想扩展一个createFromString但签名不同。结果,你发现在派生类里调用createFromInt居然报编译错误,因为派生类的同名函数把基类版本全隐藏了。
解决方案是三选一:
- 在派生类里用
using引入基类重载; - 给每个方法起不同的名字,避开隐藏规则;
- 彻底改用
std::function和注册表模式,完全绕开"重载"机制。
其中第三点在大型工程里也经常出现,尤其是产品类型特别多、每个类型对应不同工厂实现时。重载虽然强大,但它毕竟是编译期的静态机制,无法在运行时根据配置动态追加新的创建策略。如果业务需要运行时注册新工厂方法,那就得用注册表模式。
5.5 代码检查清单
最后分享一份自己在提交代码前对照检查的清单,权当总结:
- 重载集合里是否存在"只靠返回值区分"的非法设计?
- 是否存在
bool参数导致隐式转换歧义的隐患? - 是否有
const char*、std::string、std::string_view混用导致的悬垂或隐式转换? - 派生类修改重载函数时,是否意识到了基类版本被隐藏?
- 函数参数里的
const用的是顶层还是底层,是否匹配预期? - 默认参数有没有跟重载组合成"看似和谐实则混乱"的接口?
- 新增重载是否破坏了既有调用点的重载决议结果?
这个清单的每一条都在我自己的工程实践中流出过血,尤其第 7 条,排查成本极高。写完之后再读一遍这段,我自己最有感触的还是标签类型这个工具——它在解决二义性上几乎是无敌的,但没有用到它之前,你根本想象不到 C++ 的重载决议会把你带到哪个阴沟里。
如果这篇反馈不错,下一篇打算深入工厂机制里的模板重载与类型推导,也就是template factory和if constexpr在创建流程中的应用,顺带讲讲为什么有时候模板推导会跟重载决议打架,以及怎么用std::enable_if和概念来"焊接"它们。