C++移动语义下Customer类构造方案的可行性咨询
问题背景
我近期观看了Nicolai Josuttis的演讲《The Nightmare of Move Semantics for Trivial Classes》,演讲核心演示了如何实现“完美Customer类”,让构造函数在接收最多3个入参时尽可能减少malloc调用次数。演讲中给出的几类实现方案和对应开销如下:
// 共产生11次malloc (4次构造+7次拷贝+1次移动) Customer(std::string f = "", std::string l = "", int i = 0) : first(f), last(l), id(i) {} // 共产生5次malloc (4次构造+1次拷贝+5次移动) Customer(std::string f = "", std::string l = "", int i = 0) : first(std::move(f)), last(std::move(l)), id(i) {} // 手动编写所有重载组合并小心规避歧义,示例如下 // 共产生5次malloc (4次构造+1次拷贝+1次移动) Customer(const std::string&, const std::string&, int i = 0); // 共产生5次malloc (4次构造+1次拷贝+1次移动) template<typename S1, typename S2 = std::string, typename = std::enable_if<std::is_convertible_v<std::string>>> Customer(S1&& f, S2&& l = "", int i = 0): first(std::forward<S1>(f)), last(std::forward<S1>(l)), id(i) {}
针对这个场景,我设计了一种演讲中未提及、也未在其他公开资料中检索到的实现范式,我认为该方案在性能和易用性上表现优异:通过私有继承持有所有成员变量的结构体,构造函数使用可变参数模板完成参数转发初始化。我想确认几个问题:
- 该实现方式是否存在缺陷?
- 是否会引发性能问题或使用异常?
- 是否存在其他潜在陷阱?
- 该方案是否属于已被业界提出的已知范式?
- 如果是已知范式,为何未在本次演讲中被提及?
我的实现代码如下:
#include <string> #include <utility> struct CustomerData { std::string first; std::string last; int id; }; class Customer: private CustomerData { public: // 构造函数模板 template<typename... Args> Customer(Args... args): CustomerData{std::move(args)...} { } };
修订记录:
- 将原POD类型修改为普通struct(包含std::string的类型不属于POD,感谢Daniel Langr指出);
- 补充方案背景说明,方便未观看完整视频的读者理解;
- 将原代码中的std::forward替换为std::move,感谢Jarod42指出问题;
- 应apple apple要求补充了演讲中给出的参考实现代码。
方案分析
性能表现
纯看传3个参数构造的场景,这个写法的性能和演讲中给出的最优方案一致——参数按值传入后直接通过移动构造聚合体内的std::string成员,不会产生多余的字符串拷贝,malloc次数和最优方案持平,没有额外的性能开销。
存在的缺陷与潜在陷阱
这个写法看似简洁,实际隐含了几个非常容易踩的坑:
- 劫持默认拷贝/移动构造:可变参数构造模板没有做任何模板参数限制,当传入
const Customer&或者Customer&&类型参数时,编译器会优先匹配这个泛型构造模板,而不是编译器默认生成的拷贝/移动构造函数,导致拷贝、移动逻辑完全失效。比如下面的代码会直接编译失败:
要修复这个问题必须给模板加上SFINAE限制,排除参数为Customer自身类型的场景,额外增加了代码复杂度。Customer c1("Alice", "Smith", 1); Customer c2(c1); // 不会调用拷贝构造,转而匹配可变参数模板,尝试将Customer对象转为CustomerData的聚合初始化参数,直接报错 - 参数顺序强绑定,错误提示极差:必须严格按照
first、last、id的顺序传参,一旦参数顺序写反,只要类型可以隐式转换就会直接生成逻辑错误的代码,不会有任何语义层面的提示;如果类型不匹配,编译器会抛出非常冗长难读的模板实例化错误,排查成本很高。 - 不支持缺省参数:演讲中的参考实现都支持0-3个参数任意传入,缺省参数自动用默认值填充,而这个写法必须严格传入3个参数才能完成初始化,传0、1、2个参数都会直接编译失败,和原需求的接口兼容性差。如果要支持缺省参数,需要额外写多个构造函数重载,反而失去了这个写法“简洁”的优势。
- 值传参缺少优化弹性:当前写法的参数是值传递,对于
std::string这类支持移动的类型开销和按值传参再move的方案一致,但如果后续成员中加入不可移动、拷贝开销极高的类型,值传递会产生额外的一次移动/拷贝开销,不如转发引用的写法灵活。
关于范式归属
这个写法不算全新的范式,本质是C++17放宽聚合初始化规则(允许基类参与聚合初始化)之后,衍生出的一种简化聚合类型构造的写法,在开源代码中偶尔能见到,但它的适用场景非常窄:只适合完全不需要参数校验、不需要支持缺省参数、不需要自定义拷贝/移动逻辑的纯值类型,且必须给构造模板加上足够的限制排除歧义场景,否则根本没法正常使用。
Josuttis在演讲中没有提到这个方案非常合理:他整场演讲的核心就是吐槽“看似简单的 trivial 类,为了适配移动语义很容易写出隐含各种坑的代码”,而这个方案刚好踩中了他吐槽的所有点——看起来代码量小,实则隐含了大量语义陷阱,要把所有坑都填平需要加的限制、补的重载,代码量比手写重载或者用受限转发引用的方案还大,完全达不到“简单、无隐坑”的要求。
内容的提问来源于stack exchange,提问作者Johnie Walker

