浅谈c++构造函数问题,初始化和赋值问题
前言C 对象的成员有两种「获得初值」的方式在构造函数的成员初始化列表里初始化或者在构造函数体里赋值。两者在语法上只差一个冒号在语义上却是「构造」与「先构造再覆盖」的区别。这个区别在成员是const、引用或没有默认构造函数的类类型时会直接决定代码能不能编译。常见的误解有三个。第一以为写在初始化列表里的顺序就是实际初始化顺序——实际顺序由成员在类中的声明顺序决定与列表书写顺序无关。第二以为在函数体里赋值「效果一样」——对内置类型可能看不出差别对类类型会多一次默认构造加一次赋值对const成员和引用成员则根本不允许。第三把Person p();当成「调用默认构造函数创建对象」——它其实是一个函数声明这就是著名的「最令人烦恼的解析」most vexing parse。本文按「列表 vs 函数体 → 必须用列表的成员 → 初始化顺序 → 委托构造与类内初始值 → 其他构造相关陷阱」展开代码以 C17 为准。一、初始化列表与函数体赋值先看两种写法的直接对比// 适用于 C17#include stringclass A {public:A(const std::string name, int age) {name_ name; // 函数体赋值age_ age;}private:std::string name_;int age_;};class B {public:B(std::string name, int age): name_(std::move(name)), age_(age) {} // 初始化列表private:std::string name_;int age_;};A的执行过程是先调用std::string的默认构造函数把name_构造成空串再在函数体里调用operator把实参赋给它。也就是说每个std::string成员都经历了一次「默认构造 赋值」。B则直接调用拷贝或移动构造函数完成初始化没有中间的空串状态。对int这类内置类型两种写法在优化开启后通常看不出差别但对类类型差别是实打实的函数调用。更重要的是正确性下面的成员根本不允许在函数体里「赋值」。成员种类能否只在函数体赋值原因const成员否常量必须在初始化时确定值之后不可修改引用成员否引用必须在初始化时绑定之后不可重新绑定无默认构造函数的类类型否函数体赋值前需要先默认构造编译失败有默认构造的类类型可以但多一次构造先默认构造再赋值内置类型可以未初始化时值不确定赋值后才有确定值二、必须使用初始化列表的成员// 适用于 C17#include stringclass Connection {public:Connection(const std::string host, int counter): host_(host), // const 成员必须初始化counter_(counter) // 引用成员必须绑定{}private:const std::string host_;int counter_;};如果尝试在函数体里写host_ host;编译器会报「只读成员不能赋值」写counter_ counter;则会被理解为「把counter的值赋给引用所指的对象」而不是让引用绑定到counter——后者在语法上就无法表达。无默认构造函数的类类型同理// 适用于 C17class NoDefault {public:explicit NoDefault(int v) : value_(v) {}private:int value_;};class Holder {public:Holder() : item_(0) {} // 必须初始化否则编译失败private:NoDefault item_;};如果Holder的构造函数不写初始化列表编译器会尝试默认构造item_而NoDefault没有默认构造函数直接报错。三、初始化顺序由声明顺序决定这是最容易埋下隐蔽 bug 的一条规则成员按它们在类中声明的顺序初始化与初始化列表的书写顺序无关。// 适用于 C17class Order {public:Order() : b_(1), a_(b_ 1) {} // 列表里 b_ 在前但实际先初始化 a_private:int a_;int b_;};实际执行顺序是a_先初始化此时b_还没有初始化读取它的值是未定义行为undefined behavior, UB。这段代码可能「看起来正常」也可能在不同优化级别下给出不同结果。正确的做法是让列表顺序与声明顺序一致// 适用于 C17class Order {public:Order() : a_(1), b_(a_ 1) {} // 顺序一致b_ 读到已初始化的 a_private:int a_;int b_;};开启-WreorderGCC/Clang或/W4MSVC一类警告可以在列表顺序与声明顺序不一致时得到提示。把警告当错误处理能挡住大部分这类问题。还有一个相关细节基类总是在派生类成员之前初始化虚基类又在普通基类之前。构造函数的初始化列表里即使写了基类顺序也由继承关系决定同样不受书写顺序影响。四、委托构造与类内初始值C11 起支持委托构造函数delegating constructor一个构造函数可以把初始化工作交给同类中的另一个构造函数。// 适用于 C17class Widget {public:Widget() : Widget(0, 0) {} // 委托给下面的构造函数Widget(int w, int h) : width_(w), height_(h) {}private:int width_;int height_;};委托之后不能再在初始化列表里初始化其他成员——因为目标构造函数已经完成了全部成员的初始化。函数体里仍然可以做其他逻辑。类内初始值default member initializer是另一种减少重复的手段// 适用于 C17class Config {public:Config() default;explicit Config(int retries) : retries_(retries) {}private:int retries_ 3; // 未在列表中指定时使用bool verbose_ false;std::string name_ default;};类内初始值会在构造函数没有显式初始化该成员时生效。它和初始化列表不是互斥的列表里写了就以列表为准。把常用的默认值写在声明处可以让构造函数只关心「与默认不同的部分」。五、explicit 与最令人烦恼的解析单参数构造函数默认允许隐式转换// 适用于 C17class Meter {public:Meter(double v) : value_(v) {}double value() const { return value_; }private:double value_;};void print(Meter m);// print(3.5); // 隐式把 double 转成 Meter如果这种转换不是设计意图应当加explicit// 适用于 C17class Meter {public:explicit Meter(double v) : value_(v) {}double value() const { return value_; }private:double value_;};加上之后print(3.5)编译失败必须写print(Meter(3.5))。C17 起explicit还可以用于转换运算符和某些带花括号的初始化场景规则更细但「单参数构造函数默认加 explicit」是一个稳妥的起点。「最令人烦恼的解析」则是另一个方向的坑// 适用于 C17class Person {public:Person() default;};// Person p(); // 这是函数声明返回 Person名为 p无参数Person p; // 默认初始化调用默认构造函数Person q{}; // C11 起推荐的写法明确是对象Person p();在语法上完全符合函数声明编译器没有理由把它当成变量定义。用花括号初始化可以消除歧义也顺带避免了窄化转换。六、完整示例// 适用于 C17#include iostream#include string#include utilityclass Student {public:Student() default;explicit Student(std::string name): Student(std::move(name), 0) {} // 委托构造Student(std::string name, int age): name_(std::move(name)), age_(age), id_(nextId()) {}const std::string name() const { return name_; }int age() const { return age_; }int id() const { return id_; }private:static int nextId() {static int counter 0;return counter;}std::string name_ unknown; // 类内初始值int age_ 0;const int id_ nextId(); // const 成员类内初始值或初始化列表二选一};int main() {Student a;Student b(alice);Student c(bob, 20);std::cout a.name() a.age() a.id() \n;std::cout b.name() b.age() b.id() \n;std::cout c.name() c.age() c.id() \n;return 0;}按标准语义三个对象的id依次是 1、2、3a使用默认构造函数id_由类内初始值调用nextId()得到 1b经由委托转到双参数构造函数c直接调用双参数构造函数二者都在初始化列表里把id_初始化为 2 和 3。name_和age_在默认构造时使用类内初始值因此a的名字是unknown、年龄是 0。这里id_是const成员它必须在「类内初始值」或「构造函数的初始化列表」中被初始化两处都不写编译器会报错。这也是const成员与普通成员最大的不同它没有「先默认构造、稍后再赋值」这条路。另外nextId()里的static int counter是函数内静态变量C11 起其初始化是线程安全的magic statics但自增本身不是原子操作多线程同时构造对象时需要额外同步。常见坑点1. 在函数体里给 const 成员赋值❌const int id_;然后在构造函数体里id_ 1;✅Student(...) : id_(1) {}在初始化列表里完成。2. 引用成员试图「重新绑定」❌int ref_;在函数体里ref_ x;这改的是被引用对象的值。 ✅Holder(int x) : ref_(x) {}初始化时绑定。3. 初始化列表顺序与声明顺序不一致❌Order() : b_(1), a_(b_ 1) {}a_先初始化读到未初始化的b_。 ✅ 让列表顺序与声明顺序一致并开启-Wreorder。4. 类类型成员在函数体里赋值❌name_ n;先默认构造再赋值多一次调用。 ✅: name_(n)直接构造需要转移所有权时用std::move。5. 成员初始化列表里忘记基类❌ 派生类构造函数不写基类基类被默认构造可能缺少必要参数。 ✅Derived(...) : Base(args...), member_(...) {}显式初始化基类。6. 认为explicit只影响拷贝初始化❌ 以为加了explicit之后Meter m(3.5);也不能用。 ✅explicit只阻止隐式转换直接初始化仍然允许。7. 写Person p();以为定义了对象❌ 编译器把它当成函数声明。 ✅Person p;或Person p{};。8. 在初始化列表里用std::move移动同一个对象两次❌A(std::string s) : a_(std::move(s)), b_(std::move(s)) {}第二次拿到的是空的。 ✅ 每个对象只移动一次需要两份就拷贝一份。总结场景推荐写法原因普通成员初始化列表少一次默认构造const成员初始化列表或类内初始值之后不可赋值引用成员初始化列表必须初始化时绑定无默认构造的成员初始化列表否则编译失败默认值类内初始值减少构造函数重复多个构造函数共享逻辑委托构造避免复制初始化列表单参数构造函数加explicit阻止意外隐式转换声明对象T obj;或T obj{};避免被解析成函数声明初始化列表不是「语法糖」而是成员真正被构造的地方函数体里的赋值是「构造之后再覆盖」。把这句话记住const成员、引用成员、初始化顺序这三个高频问题就都有了解释。写构造函数时优先把所有成员都放进初始化列表并保持列表顺序与声明顺序一致。