C++11类新功能实战:移动语义、默认特殊成员与重构指南
1. 老代码重构时最先撞上的是特殊成员函数的暗礁前阵子接手了一个遗留了好几年的模拟交易引擎里面有个持仓结构体几十个字段构造函数、拷贝赋值、析构函数全部手写。代码能跑但一开编译警告就是一片红。最让人头疼的是每加一个字段就得同步改五六个地方构造函数初始化列表、拷贝构造、赋值运算符、移动构造虽然当时没有、序列化函数。漏改一个字段就是线上偶发数据不一致。这种事情相信每个人都经历过。C11给类带来了很多新能力但多数人的认知停留在移动语义智能指针这些词上真正落到类设计上其实是一套组合拳。这套组合拳打好了类的可维护性、性能、安全性会同时上一个台阶。我这次重构就是围绕C11的类新功能把整个结构体重写了过程里踩了不少坑也总结了一些比较实用的经验这篇文章想完整地记录下来。如果你也正在老项目里挣扎或者想系统地理解C11类的新功能怎么落地这篇应该能给你一些参考。以下内容不涉及编译器厂商特有的黑魔法全部基于标准C11我在项目里用的是常见的Linux编译环境但代码都是可移植的。2.default和delete先给特殊成员函数定规矩2.1 编译器帮你写的函数到底啥时候触发C类里有六种特殊成员函数默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值。C11之前你不写它们编译器在某些场合会自己生成你写了一个其他可能就不生成了。这个某些场合的规则非常绕很多老代码的隐性Bug就藏在这里。举个例子。原来那个结构体里我手写了拷贝构造和拷贝赋值但是没写析构函数。按C98的规则有拷贝构造和拷贝赋值但不写析构编译器依然会隐式生成析构函数看起来没问题。可如果哪天我给这个类加了一个std::unique_ptr成员一切就变了unique_ptr不能拷贝编译器生成拷贝构造时要拷贝它直接报错还是得改代码。加字段要改换成员要改这种类根本上就是没设计好。C11给了两个钥匙default和delete。把不需要自己写逻辑的特殊成员函数显式标成default让编译器按标准生成把不允许发生的操作标成delete从语法层面禁掉而不是靠不实现来偷偷禁用。2.2 三/五法则在C11里的正确姿势旧规则叫三法则如果你需要自定义析构、拷贝构造、拷贝赋值里的任何一个那三个大概率都需要自己写。C11引入移动语义之后变成了五法则。但我的建议是能default就别自己写让编译器生成的代码通常更安全、性能也更好。比如重构后的持仓结构体我这样处理class Position { public: Position() default; ~Position() default; Position(const Position) default; Position(Position) default; Position operator(const Position) default; Position operator(Position) default; // 字段访问代码省略 private: int64_t security_id_; int64_t account_id_; uint32_t volume_; double avg_price_; ... };一开始我觉得这样写很啰嗦后来发现这是最清晰的表达阅读代码的人一眼就能看出所有特殊成员函数都由编译器生成不需要去数哪个字段没在拷贝函数里被复制。delete更实用。比如那个交易引擎里的订单类订单产生了就不该被复制只能移动。我把拷贝路径全删了class Order { public: Order(int64_t id) : id_(id) {} Order(const Order) delete; Order operator(const Order) delete; Order(Order) default; Order operator(Order) default; private: int64_t id_; std::string client_tag_; };这里有个关键点把拷贝构造设成delete后编译器不会再隐式生成而移动构造仍然可以default。注意delete不仅仅是不能调用那么简单它还会参与重载决议。有时候不小心写了个接受int的构造函数然后给函数传0结果编译器选中的构造函数完全出乎意料用delete可以精准封堵这种隐式转换路径class Config { public: explicit Config(int64_t size) : size_(size) {} Config(char) delete; // 防止把char隐式转成int64_t避免误用 private: int64_t size_; };2.3 我判断要不要语法的一个检查清单经过这次实战我总结出一个清单每次写类都会过一遍这个类的生命周期里拷贝有意义吗没有就delete。移动比拷贝能带来实际性能收益吗比如内部有大块std::vector、std::string或智能指针那就写移动操作并default。需要自定义析构吗如果不需要务必把析构也声明成default并放在公开区。这在后面会和final配合出一些很有意思的效果。一旦声明了delete一定要想清楚哪些代码会因此编译失败是别人误用还是自己应该换个方式设计。default和delete不是C11最炫酷的特性但它们是现代C类设计的底座。把这块梳理顺了后面的移动语义、构造委托这些才有稳定的落脚点。3. 构造函数重构委托构造和继承构造省掉的不只是代码量3.1 委托构造一个构造函数调用另一个构造函数旧的持仓类里构造函数写了三份无参、全字段参数、从缓存快照恢复。三份代码各自初始化所有字段加一个字段改三处漏一处就是数据不齐。C11允许构造函数调用同类的另一个构造函数这叫委托构造。语法上就是在初始化列表里调用别的构造函数。class Position { public: Position() : Position(-1, -1, 0, 0.0) {} Position(int64_t sec, int64_t acct, uint32_t vol, double price) : security_id_(sec), account_id_(acct), volume_(vol), avg_price_(price) {} Position(const Snapshot snap) : Position(snap.security_id, snap.account_id, snap.volume, snap.avg_price) {} private: ... };注意几个坑委托不能循环。A委托BB又委托A编译直接报infinite chain of delegating constructors。被委托的构造函数必须是当前对象的某个已定义构造函数不能委托给基类的构造函数那是继承构造的活。一旦某个构造函数使用委托初始化列表它自己就不能再初始化任何成员了。成员的初始化全部由被委托的构造函数完成。这样反而是好事强制把初始化逻辑收敛到一个地方。常见错误是把委托写在函数体内比如在构造函数体里调用另一个构造函数结果是创建了一个临时对象然后被丢弃当前对象没有被正确初始化。这种坑在老项目里还真不少。委托只能写在初始化列表的冒号后面不能写在花括号里。3.2 继承构造让派生类少写一堆转发代码基类构造函数有几个重载派生类只是加了些新成员旧写法是要把基类每个构造函数都转发一遍class TradeMessage { public: TradeMessage(int64_t id, int32_t type) : id_(id), type_(type) {} protected: int64_t id_; int32_t type_; }; class FilledMessage : public TradeMessage { public: // C98里必须这样转发 FilledMessage(int64_t id, int32_t type) : TradeMessage(id, type) {} // ... };如果基类有五个构造函数派生类就得写五个转发。C11的继承构造允许直接引用基类构造函数class FilledMessage : public TradeMessage { public: using TradeMessage::TradeMessage; // 继承基类构造函数 // 这里可以用基类构造的方式构造派生类 };注意这个using会继承基类所有构造函数但有几个限制。首先基类的默认构造、拷贝构造、移动构造不会被继承编译器只会继承非特殊成员的构造函数。其次如果派生类自己有同签名的构造会覆盖继承来的那个。最后继承构造只能用于初始化不会阻止派生类自己定义新构造。我在重构中用继承构造的典型场景是给一个派生类增加一个时间戳是否有效的布尔字段但不想重写五个构造函数class ReportedMessage : public TradeMessage { public: using TradeMessage::TradeMessage; ReportedMessage(int64_t id, int32_t type, bool valid) : TradeMessage(id, type), valid_(valid) {} private: bool valid_ false; };这里valid_ false用的是C11的类内成员初始化后面第6节细说。3.3 委托和继承同时用时基类谁先初始化我刚开始把继承构造和委托构造混在一起时犯过一个错误以为派生类委托给派生类其他构造会先调用一个转发版本然后转发版本再去调用基类构造。实际初始化顺序是先初始化虚基类然后直接基类再按声明顺序初始化成员最后执行构造函数体。所以派生类继承的基类构造函数在执行时如果又委托给本类的其他构造要确保那个被委托的构造也有正确的基类初始化。真实项目里我的建议是继承构造只用于完全不想在派生类里增加新初始化逻辑的类。一旦派生类需要初始化自己的成员哪怕是使用类内初始值最好还是在列表里显式调用基类构造不要偷懒用继承构造。因为继承构造的语义是用基类的构造方式构造派生类它只会初始化基类部分和类内初始值不会初始化你在构造列表里写的其他东西构造函数列表里再传参不算。4. 移动语义进入类右值引用、移动构造和noexcept的正确打开方式4.1 为什么移动构造长得丑但你必须写C11之前函数返回一个大型对象比如一个持有std::vectordouble和std::string的类返回时会经历拷贝构造甚至可能两次拷贝性能损耗明显。C11的移动构造可以从被返回的临时对象里偷资源把那块动态数组的所有权直接转交给新对象。典型写法class RiskSnapshot { public: RiskSnapshot() default; RiskSnapshot(const RiskSnapshot other) : name_(other.name_), items_(other.items_) {} RiskSnapshot(RiskSnapshot other) noexcept : name_(std::move(other.name_)), items_(std::move(other.items_)) { other.pnl_ 0.0; // 源对象置零保证析构安全 other.is_valid_ false; } RiskSnapshot operator(RiskSnapshot other) noexcept { if (this ! other) { name_ std::move(other.name_); items_ std::move(other.items_); pnl_ other.pnl_; is_valid_ other.is_valid_; other.pnl_ 0.0; other.is_valid_ false; } return *this; } // 拷贝赋值同理...... private: std::string name_; std::vectordouble items_; double pnl_; bool is_valid_; };移动构造本身并不新它只是在资源所有权层面的转移。C标准说被移动的对象处于有效但未指定的状态。所以移动之后源对象必须可以被析构也可以被赋值。上面把pnl_置零、is_valid_置false是为了让调试的时候状态更确定。4.2noexcept是移动操作的生命线在移动构造函数上方我写了noexcept。这不是可有可无的点缀。std::vector扩容时如果元素类型的移动构造是noexcept的那么容器扩容会放心地用移动如果不是noexcept为了异常安全它只能退化成拷贝。你在std::vectorRiskSnapshot里存几个万条数据没有noexcept的移动构造扩容成本就是全部拷贝一遍。有了noexcept就是一个个指针的交换。noexcept写错没有任何编译警告但运行时性能差距很大。还有一个更隐蔽的影响标准模板库在算法里会把移动加noexcept当成强异常安全的保障比如std::vector的reserve、shrink_to_fit如果移动构造不是noexcept它们不会回退到拷贝而是可能直接抛出异常这在老代码里很难排查。我的原则是默认情况下移动构造和移动赋值应标成noexcept。只有当移动操作可能调用可能抛异常的用户代码比如移动一个自定义类时它内部用了会抛异常的操作时才去掉。实际中基本都是noexcept。4.3 三法则变五个函数的实践顺序一个普通的值类型类在现代C里建议按这个顺序声明五个特殊函数析构函数需要自定义就写不需要就用default。移动构造和移动赋值能写就写用noexcept内部依次移动每个成员。拷贝构造和拷贝赋值如果类里包含自制的资源管理就认真写否则default。这种顺序不是随意的。先确定能不能移动这决定了类的性能特性再确定能不能拷贝这决定了类的语义。如果让编译器自动生成移动操作条件和规则非常复杂只要你自定义了析构、拷贝构造、拷贝赋值、移动赋值中的任何一个编译器就不会自动生成移动构造。所以很多时候你以为编译器的移动操作在帮你干活实际上根本没有走的全是拷贝路径。用上面的顺序写一遍能避免这种我以为有移动语义的错觉。5.override、final和constexpr给类加上编译期的安检闸门5.1override不是语法糖是夜班巡检员C11新增的override关键字用在虚函数声明后面告诉编译器这个函数必须覆盖基类的某个虚函数。如果基类里没有匹配的虚函数编译直接报错。看起来只是检查但作用极大。老代码里我曾经改过一个基类签名// 老的基类 virtual void onTrade(const Trade t); // 重构成 virtual void onTrade(const Trade t, int64_t ts);结果所有派生类里的onTrade(const Trade)全都静默地变成了新函数编译器一声不吭因为他们确实是独立的虚函数只是名字碰巧一样。等到运行时才发现事件全没收到。这件事让我之后所有虚函数都写overrideclass PatternStrategy : public Strategy { public: void onTrade(const Trade t) override; // 如果基类没有合适签名这里直接编译期报错 };另一个细节final关键字可以放在类上也可以放在虚函数上。类写final表示这个类不能再被继承虚函数写final表示派生类不可再覆盖它。对安全关键代码final是很好的意图表达。class StatsPrinter final : public BasePrinter { public: void print(const Stats s) override final; };注意final的位置类名后面函数声明最后。5.2constexpr构造函数让自定义类也能成为编译期常量C11的constexpr构造函数是类的新能力它允许你定义一个函数这个构造函数可以在编译期求值。配合全局常量、静态数组长度这类场景非常有用。比如一个表示坐标的类struct Point { constexpr Point(double x, double y) : x_(x), y_(y) {} double x_; double y_; }; constexpr Point ORIGIN(0.0, 0.0);规则不少我挑几个容易踩的坑说说constexpr构造函数的函数体必须是空的成员必须通过初始化列表初始化。所有成员必须在编译期可求值不能有动态内存分配。C11里一个很大的限制constexpr构造函数只能初始化字面值类型的成员像std::string不行。所以如果想用常量表达式构造含复杂类型的对象C11做不到到C14、20逐步放宽。我实际项目里最实用的场景是把一些配置表直接放成编译期常量比如静态的订单类型映射表struct OrderTypeInfo { constexpr OrderTypeInfo(int32_t code, const char* name) : code_(code), name_(name) {} int32_t code_; const char* name_; }; constexpr OrderTypeInfo kInfo[] { {1, NEW}, {2, CANCEL}, {3, REJECT} };这样既有类型安全又能用switch扫描。编译期就把表内容确定不会在运行时因为初始化顺序出问题。5.3 虚函数与constexpr能否共存constexpr函数不能是虚函数。因为虚函数需要运行时动态分派编译期无法确定具体调用目标。所以我习惯把constexpr留给纯数据型的小类虚函数留给需要多态的框架层两者不要混在一起。如果你看到一个类又想让多态又想用编译期求值基本说明设计有问题要么拆结构要么改用模板。6. 类内成员初始化一种必然以及std::initializer_list的配合6.1 给成员变量默认值但别跟构造函数列表打架C11支持在类定义中直接给成员一个初始值class Account { public: Account() default; explicit Account(int64_t id) : id_(id), balance_(0.0) {} private: int64_t id_ -1; // 类内初始化 double balance_ 0.0; // 类内初始化 std::string name_; // 默认构造 };好处很明显忘了在构造函数列表里初始化某个成员时至少还有类内默认值撑腰。老代码那种构造函数里漏了某个成员导致随机值的经典事故基本绝迹了。但有两个规则要记住如果构造函数列表里也初始化了某个成员类内初始值会被忽略谁的优先级高构造函数的初始化列表优先。类内初始化不能跟初始化列表里同一个成员重复设置会引起歧义甚至编译错误。我踩过一个具体的坑某个类里写int32_t level_ 0;然后构造函数列表里又写了level_(GetLevel())。初看好像没问题编译也通过但某个调用路径下GetLevel()函数的执行时间远晚于类内初始化的时间导致日志里输出的level_有时还是0有时是正确值排查了很久才知道是二者混用导致的行为差异。建议要么所有成员都用构造列表初始化要么都用类内初始值不要一半一半。除非你非常清楚每个成员的实际初始化时机。我在重构时静态常量、标志位、简单数值类型直接用类内初始值需要从外部参数初始化的成员统一放构造列表。6.2std::initializer_list花括号初始化不再是语法糖C11给类增加了一个很特别的构造函数形态接收std::initializer_listTclass MetricsBag { public: MetricsBag(std::initializer_listdouble vals) { for (double v : vals) { items_.push_back(v); } } private: std::vectordouble items_; }; MetricsBag bag {1.0, 2.5, 3.0};写库的时候这个特性非常顺手。原来你得写MetricsBag bag; bag.push(...)三行现在一行。不过有两点坑std::initializer_list构造函数与普通构造函数的重载决议规则很奇怪。当你使用花括号语法时编译器优先选择initializer_list构造函数即使普通构造函数能更匹配。比如std::vectorint v{10, 20}会得到一个有两个元素的vector而不是包含10个20的vector。这个梗老生常谈了但每次还是会有人踩。initializer_list对象本身只能被遍历不能修改单个元素。它是被动的只读列表。如果你需要修改能力还是用别的方式。实际工程里我倾向于只给集合形态的类使用initializer_list构造。对于业务模型类尽量避免给它们加这种构造因为会误导调用者用花括号传一堆裸值可读性差。6.3 类内初始化和std::initializer_list组合的经验给一个复杂类同时用类内初始化和花括号构造有个更细的坑如果你在构造列表里使用花括号可能不会调用你认为的那个构造函数。最典型class Config { public: Config(std::initializer_liststd::string names) { /* ... */ } Config(int max_count) { /* ... */ } private: std::vectorstd::string names_; }; Config c1{5}; // 调用 initializer_list 传一个 int编译失败 Config c2(5); // 正常调用 int 构造所以花括号列表的语义会让Config c1{5}尝试构造一个initializer_liststring发现int无法转成string可能报错或产生意想不到匹配。这提醒我们给一个类提供initializer_list构造时最好让这类也保留明显的普通构造使用圆括号调用减少歧义。7. 移动语义进阶别让默认的隐式移动骗了你7.1 有用户声明的析构函数会让移动构造静默消失前面提到只要你自己声明了析构函数编译器就不会生成移动构造。但还有更隐蔽的析构函数用default也是用户声明同样会抑制移动操作生成标准里说的是用户声明析构函数就会抑制哪怕它是default。所以我在第2节建议把析构函数写default其实在C11里也会让移动构造不被生成。这是一个非常容易被忽略的点。具体表现是什么你定义了个类class OldApiWrapper { public: ~OldApiWrapper() default; // 用户声明了析构 };然后你在std::vector里放一堆这个类调用push_back(std::move(wrapper))。编译器不会报错但实际调用的是移动不它根本找不到移动构造于是去调用了拷贝构造。如果你的拷贝构造没有声明编译器会隐式生成一个新的拷贝构造因为析构没有抑制拷贝。一切静悄悄但性能回到老样子。为什么标准这样设计因为早期版本里用户声明析构意味着类可能在执行一些清理逻辑这种情况下编译器保守一点不帮你生成移动。到C11都保持了这个保守策略。所以你要么接受这个现实要么明确写default的移动构造。经验是每个值类型类只要声明了析构请同时把所有特殊成员函数都声明一遍。否则你永远不确定编译器到底生成了哪些。7.2 成员是const或引用时移动操作与你无关如果你的类里有const成员或引用成员那么拷贝赋值和移动赋值都几乎无法正常实现因为重新赋值等于要改const对象。编译器会直接删除这些特殊成员。这种情况下要么去掉const设计要么用std::unique_ptr持有这些不可变对象通过指针的转移来间接实现改变指向。比如一个持有常量ID的实体类class Entity { public: Entity(int64_t id) : id_(id), data_(new std::string()) {} private: const int64_t id_; // 不可变ID std::unique_ptrstd::string data_; };这里Entity无法拷贝也无法移动赋值因为id_是const。如果要传递Entity只能通过指针或引用。有一次我在单元测试里想把它存进std::vectorEntity然后排序直接编译不过。这才意识到const成员会让类彻底失去赋值能力。设计时要想清楚你到底想不想要可赋值性想要就别用const成员改用只读接口int64_t id() const。7.3 完美转发类的构造函数里要不要用模板右值引用有移动语义就有完美转发。C11的std::forward常见于工厂函数。给类写一个万能引用的构造函数要注意它几乎会拦下所有其他构造调用包括拷贝构造。这是最典型的隐式截胡陷阱。templatetypename T class Box { public: explicit Box(T t) : value_(std::forwardT(t)) {} private: T value_; };这个构造函数表面上是接受任何类型但它会参与重载决议如果传入的是左值Box b2(b1);因为T在左值时被推导为T重载决议可能选中它而不是拷贝构造。所以万能引用构造通常要么配合std::enable_if限制要么只在工厂函数里用不要直接写在普通类里。我的经验是类的普通构造函数不要用万能引用。需要转发时写一个自由函数make_box内部用std::forward转发给一个只接受右值的私有构造或者干脆用emplace_back这类容器接口来利用移动。8. 实测重构案例把旧结构体升级为现代C类的完整对照8.1 重构前手写五件的重复劳动旧的持仓类简化后大概长这样struct OldPosition { int64_t sec_id; int64_t acct_id; uint32_t vol; double price; std::vectorint64_t related; OldPosition() : sec_id(-1), acct_id(-1), vol(0), price(0.0), related() {} OldPosition(int64_t s, int64_t a, uint32_t v, double p) : sec_id(s), acct_id(a), vol(v), price(p), related() {} OldPosition(const OldPosition o) : sec_id(o.sec_id), acct_id(o.acct_id), vol(o.vol), price(o.price), related(o.related) {} OldPosition operator(const OldPosition o) { if (this ! o) { sec_id o.sec_id; acct_id o.acct_id; vol o.vol; price o.price; related o.related; } return *this; } ~OldPosition() default; };这个类有个很大的问题拷贝操作写得很仔细但完全没有移动语义的体现在容器里反复拷贝时related这个vector会被复制一次次。加上以后要加字段必须再改五个位置。8.2 重构后现代C11类的清爽样貌class NewPosition { public: NewPosition() : NewPosition(-1, -1, 0, 0.0) {} NewPosition(int64_t s, int64_t a, uint32_t v, double p) : sec_id(s), acct_id(a), vol(v), price(p), related() {} NewPosition(const NewPosition) default; NewPosition(NewPosition) noexcept default; NewPosition operator(const NewPosition) default; NewPosition operator(NewPosition) noexcept default; ~NewPosition() default; // 构建一个带关联订单的持仓 NewPosition attachRelated(std::initializer_listint64_t ids) { related.assign(ids.begin(), ids.end()); return *this; } private: int64_t sec_id -1; int64_t acct_id -1; uint32_t vol 0; double price 0.0; std::vectorint64_t related; };注意几个设计细节委托构造把无参构造转发到全参构造保证所有成员的初始化路径只有一条。特殊成员函数全部显式default移动操作加noexcept。类内初始值为默认状态兜底。attachRelated采用initializer_list调用方可以写p.attachRelated({1001, 1002, 1003})。8.3 性能前后的直观对比我在单元测试里构造了10万个NewPosition不断push_back进std::vector并中途reserve一个预估容量来触发扩容。结果方案耗时(相对)旧结构体拷贝路径35毫秒新结构体默认移动9毫秒新结构体noexcept移动提前reserve7毫秒这个数据不具备普遍性但差距非常明显。尤其当related里存了几百个元素时拷贝与移动的差距是指数级的。全程没有写一行手移动代码完全依靠default和noexcept。9. 最后几个类的新功能组合拳的实战建议结合这次重构我最后想分享几个在实际项目里反复验证过的心得。第一不要单独使用某一个新特性组合使用效果才最好。比如类内初始值配合委托构造可以消灭构造函数重复代码delete配合移动语义可以让不可拷贝对象的意图更明显override配合final让整个继承体系的意图可编译期验证。第二代码评审时专门检查每个类的特殊成员函数声明。如果看到一个类写了多个构造函数但没声明任何拷贝/移动操作多半存在隐患。尤其是涉及资源管理、容器存储的类我会要求补全默认/删除/移动声明。第三请务必在大量使用std::vectorT的容器类上测试移动性能。写noexcept移动构造是这类性能优化的前置条件没有它vector的扩容会退化成拷贝有了它你不需要手动调优就能获得大部分收益。第四继承构造和委托构造容易在构造函数数量较多时让人困惑。我的建议是每类最多保留两个逻辑入口一个最完整的参数列表构造一个默认构造。其余的入口都委托给这两个避免出现所有字段组合的构造爆炸。第五constexpr构造函数在C11里限制很多但值得用在小型值对象上。如果你发现自己还在用宏定义常量表试着改成constexpr结构体数组类型安全性和可读性都能提升不少。C11的类新功能不是几条孤立的语法是一套关于谁生成、谁删除、谁移动、谁能编译期求值的设计语言。把旧代码按照这些规则重写一遍你会感觉那些隐形的错误很多在编译期就被拦住了。剩下的才轮到运行时去解决。