公司动态
C++构造函数深入解析:this指针、拷贝构造与初始化列表实战
1. 为什么要把构造函数单独拿出来聊面试过不少候选人也带过不少新人关于构造函数这块最常见的情况是“背得很熟一问就懵”。“构造函数嘛就是初始化对象的”“没有写构造函数编译器会自动生成一个默认的”“拷贝构造、移动构造、赋值运算符这些我都看过”——但真让他们解释为什么类需要构造函数、什么时候必须自己写、this 在带参构造里到底扮演什么角色往往就露馅了。“构造函数”这四个字表面上是 C 的一种语法实际上是整个面向对象体系里“对象从无到有”这一关键环节的载体。它决定了你的对象在出生时处于什么状态哪些成员必须先准备好哪些资源必须先拿到手。构造函数没写好后面所有成员函数跑得再欢也都是在一栋地基歪了的楼里装修。这篇博文我不想按教科书的写法去复述定义。我打算从“构造函数到底解决了什么真实问题”出发把构造函数的演进逻辑、this 指针在其中的作用、拷贝构造函数和重载容易踩的坑以及我自己在项目里总结出的实操经验一次性讲清楚。适合刚入门 C 想建立正确认知的同学也适合已经写了一段时间但总觉得有些概念隔着一层纱的开发者。2. 先搞清楚一个更底层的问题对象凭什么能“初始化”2.1 从 C 语言的结构体初始化说起如果不带任何 C 滤镜先回到 C 语言时代看一个最朴素的场景。你定义一个结构体struct Point { int x; int y; };然后你写struct Point p;此时p.x和p.y的值是什么没人知道。可能是上一次某个函数用过的残留值可能是栈上的垃圾数据完全取决于运气。所以 C 程序员被迫养成了一个习惯定义完变量之后立刻手动给每个字段赋值。或者干脆用memset把整块内存清成零。但这里有个致命问题——如果你忘了手动初始化程序不会编译报错也不会运行报错它只是静默地带着一个垃圾值继续跑直到某一天某次计算突然异常然后你排查三天三夜最后发现是一个没有初始化的字段在作祟。C 引入构造函数本质上就是要把“初始化”这件事从“习惯”变成“语言强制规则”。你只要创建一个对象不管通过哪种方式构造函数都会被执行。这就相当于把“记得系安全带”变成“不系安全带车就发动不了”。2.2 构造函数真正保护的是一套“类不变量”比“每个字段有值”更重要的是构造函数在维护一套“类不变量”。什么叫不变量就是类的实例在整个生命周期内必须始终成立的性质。举个简单的例子你写一个BankAccount类里面有个成员double balance;。你希望余额永远不能为负——这就是这个类的一个不变量。如果初始化发生在构造函数之外每个成员函数都可以随意改变balance那么“余额不为负”这条约束就形同虚设。但如果你在构造函数里就完成了校验并且只在构造函数里设置初始值后续所有修改都必须通过Deposit()或Withdraw()这类函数那这个不变量就得到了结构的保证。构造函数就是那个“把三观正过来”的入口它保证对象一出生就处于一个合法、自洽的状态而不是从一个可能完整、可能残缺的中间态开始。这也解释了为什么“构造函数体为空”不一定是坏事。如果成员变量本身就是具有默认构造函数的类型或者通过成员初始化列表已经完成初始化那么构造函数体什么都不做反而是合理的。八股文里说的“构造函数负责初始化”准确的说法应该是“构造函数负责把对象带入一个满足类不变量的状态”而不是机械地要求构造函数体里必须写一堆赋值语句。3. 一个绕不开的东西构造函数和 this 指针3.1 带参构造里为什么要强调“拥有 this”热搜词里有这样一句话“在带参数列表的类型中声明的构造函数必须拥有‘this’”。很多人看到这句话的第一反应是“这不是废话吗所有成员函数不都有 this 吗”——从语言规则层面讲确实每个非静态成员函数包括构造函数都有一个隐式的 this 参数。但从概念层面理解“必须具备 this”意义远不止“编译器传入了参数”那么简单。关键在于构造函数要初始化的对象在使用者的代码里可能还没有一个“名字”。比如你写Student stu(Alice, 18);stu这个名字是调用点给的。但在这个对象构造的过程中stu这个标识符还不存在构造函数内部的this指针是唯一能指向这块正在构造中的内存的途径。所有成员变量的初始化本质上是“通过 this 找到这块内存然后往里面填入数据”。换个更贴近直觉的方式理解你把构造函数想象成一台正在装配汽车的流水线。this就是这条流水线上那张随车工单上面写着“这辆正在组装的车是给哪位客户的”。在车还没出厂对象还没构造完之前你唯一能标识它身份的就是这张工单。没有 this工人构造函数连该往哪个底盘上装发动机都不知道。3.2 参数名和成员名撞车时this 就是救场的那根绳子实际开发中最常见的场景是参数名和成员变量名同名。比如class Student { public: Student(std::string name, int age) { // 这里的 name 到底是参数还是成员变量 name name; age age; } private: std::string name; int age; };在 C 的遮蔽规则里name在构造函数内部优先指向参数所以name name;就是参数给自己赋值屁用没有。成员变量还是原封未动。这个问题怎么解两个方案。方案一是给成员变量加后缀或前缀比如name_、m_name。这在很多项目的代码规范里都有要求也是我比较推荐的做法因为一劳永逸地避免了遮蔽问题。方案二就是通过 this 指针显式区分Student(std::string name, int age) { this-name name; this-age age; }this-name明确告诉编译器“我要赋值的是当前对象的成员变量 name右边的 name 是参数”。这行代码的可读性和正确性都很好而且在没有成员命名规范的团队里几乎是唯一救命的写法。我见过不少团队关于“到底该用下划线后缀还是 this 指针”争论不休。我的看法是如果你的项目已经统一了成员命名规范按规范走如果没有规范或者你在写一个别人可能接手的小项目请在构造函数里一律用this-member。因为它把“参数传给成员”这个意图写得特别直白任何看代码的人都不会误解。判断一个构造函数是否靠谱看他怎么处理参数和成员的同名关系基本就够了。4. 拷贝构造函数和重载别把概念讲成“八股文”4.1 拷贝构造到底在拷贝什么拷贝构造函数的核心是“用一个已有的对象去构造一个新的对象”。它的典型声明形式是T(const T other)。为什么参数必须是引用这个问题我面试必问能讲清楚的候选人不多。如果是按值传参T(T other)那么在调用拷贝构造函数时为了把实参传给形参编译器又需要拷贝一次对象于是它又去调用拷贝构造函数然后为了这次调用的参数传递又要拷贝一次……无限递归下去直接编译失败。所以标准规定拷贝构造函数的第一个参数必须是引用最常用的是 const 引用。这不仅仅是风格偏好是一个硬性语法约束。然后在“哪些场景会触发拷贝构造”上有些细节非常容易忽略std::vectorStudent list; list.push_back(stu); // 触发拷贝构造 Student another stu; // 这是拷贝构造不是赋值 Student other2; other2 stu; // 这是赋值运算符不是拷贝构造很多人在Student another stu;这里以为是“赋值”其实不是。这条语句在语义上是“用 stu 初始化 another”属于 copy-initialization调用的是拷贝构造函数。other2 stu;才是赋值运算。还有更隐蔽的情况C11 之前函数按值返回对象时可能产生临时对象并触发拷贝构造。虽然现代编译器普遍做 RVO返回值优化甚至 C17 之后在很多场景下直接保证了 elision省略拷贝但如果你在多线程、跨模块边界或某些老编译器环境下开发不要默认所有临时对象都能被优化掉否则最终会收获一份运行效率极低的代码——每传递一次对象就深拷贝一整份数据。4.2 构造函数重载 vs 拷贝构造别混为一谈构造函数重载是指同一个类中定义多个构造函数参数列表不同。比如class Student { public: Student() {} // 无参构造 explicit Student(int id) {} // 一个 int 参数 Student(std::string name, int age) // 两个参数 };它们之间的区别仅仅在于参数列表的差异。编译器根据你在构造对象时提供的实参选择一个匹配的构造函数进行调用。拷贝构造函数特殊在哪里它的“特殊”不在重载规则上而在于它有几个其他重载构造没有的待遇它是编译器在“你没有显式定义时”可能自动生成的几个函数之一。它的调用可以隐式地发生在很多场景按值传参、返回对象、初始化列表等里。如果类里包含不可拷贝的成员比如std::unique_ptr编译器自动生成的拷贝构造会被删除导致你Student b a;直接编译报错。这引出一个非常实际的问题如果类中包含裸指针、文件句柄、网络连接等资源一定要自己实现拷贝构造函数做深拷贝并配合析构函数释放资源否则会自动生成一个浅拷贝版本的拷贝构造——两个对象指向同一块堆内存析构时同一块内存被释放两次程序崩溃这个经典坑我后面详述。4.3 为什么我建议你“默认不写拷贝构造”现在很多 C 项目的最佳实践是除非你明确知道这个类需要支持拷贝语义否则不要依赖编译器生成的拷贝构造。更激进一点的做法是直接禁止拷贝class NonCopyable { public: NonCopyable(const NonCopyable) delete; NonCopyable operator(const NonCopyable) delete; };为什么这么“狠”因为编译器自动生成的拷贝构造是逐个拷贝成员变量。如果一个类里有资源管理型成员浅拷贝的后果就是资源被重复释放。这比不写构造函数还要致命——不写构造函数无非是值不确定浅拷贝可是实打实地埋雷。现代 C 的推荐思路是“把资源管理放到专门类型里让业务类尽量使用 RAII 成员”。如果你用std::vector而不是裸指针用std::string而不是char*那么编译器自动生成的拷贝构造往往是正确的——它进行的是基于类型的拷贝而std::vector的拷贝构造会自己完成深拷贝。自动生成的拷贝构造本身的“浅拷贝”问题本质上是因为类内部的裸资源管理放错了位置。这句话能想明白你对构造函数的理解已经超过大多数只会背八股文的面试者了。5. 实操如何在真实项目里写一个靠谱的构造函数5.1 成员的初始化顺序一个隐藏很深的规则写构造函数有一个非常容易踩的规则成员变量的初始化顺序不是按初始化列表里的书写顺序而是按它们在类中声明的顺序。class Test { public: Test(int v) : b(v), a(b) {} // 看起来是先初始化 b 再初始化 a int a; int b; };在上面这段代码中类中声明的顺序是a先于b所以即使初始化列表把b写在前面实际执行顺序仍然是先初始化a再初始化b。于是a(b)拿到的b是一个未初始化的垃圾值。这绝对不是一个面试冷知识我现实里见过线上 bug 就是有人把成员顺序和初始化列表顺序弄反导致a拿到一个疑似正确、偶尔错误的值排查了两天才定位到。这类问题编译器不会报任何警告在 release 模式下尤其隐蔽。所以我的习惯是让初始化列表的顺序与成员声明的顺序保持一致。这不是强制规则但能避免一大类心智负担。5.2 优先使用初始列表而不是构造函数体赋值在构造函数体里写name n; age a;也能达到“赋值”的效果但它和初始化列表有本质区别。对于内置类型两者性能差不多。但对于有自定义构造函数的类型比如std::string情况就完全不同了Student(std::string name, int age) { this-name name; // 先默认构造然后拷贝赋值 this-age age; }这个写法实际上对name做了两步操作先用std::string的默认构造函数创建了一个空字符串然后调用拷贝赋值运算符把name的值赋进去。而如果你写Student(std::string name, int age) : name(name), age(age) {}则name只经历一次拷贝构造如果参数是右值还能走移动构造直接就位。用一个生活类比来说初始化列表相当于你搬进新房子之前就把家具定制好再搬进去构造函数体赋值相当于先搬进毛坯房再往里面一件件买家具。前者一步到位后者空跑一趟。性能差异在 string、vector 这类重量级容器上非常明显如果你的构造函数有十个八个成员差距就是实打实的。5.3 一个综合的构造函数示例我把上述要点整合到一个例子里供你直接参考class DatabaseConnection { public: // 普通带参构造参数名加前缀避免遮蔽初始化列表与声明顺序一致 DatabaseConnection(const std::string host, int port) : host_(host), port_(port), connected_(false) {} // 拷贝构造虽然这里用默认即可但显式写出来便于二次加日志 DatabaseConnection(const DatabaseConnection other) : host_(other.host_), port_(other.port_), connected_(other.connected_) {} // 禁止简单赋值防止误操作复制一个已经连接的对象 DatabaseConnection operator(const DatabaseConnection) delete; private: std::string host_; // 成员声明顺序host_、port_、connected_ int port_; bool connected_; };这个类的业务逻辑不复杂但构造函数层面的设计足够清晰所有成员通过初始化列表直接初始化拷贝构造按成员逐个拷贝赋值运算符明确删除从语法层面避免“两个对象共享连接状态”这种诡异场景。在实际项目里这种一眼能看穿构造逻辑的代码比一堆重载然后各写各的维护成本低得多。6. 常见问题与排查技巧实录6.1 一张速查表帮你解决高频报错报错或现象根本原因典型场景与处理方式“use of deleted function”类继承或包含不可拷贝成员拷贝构造/赋值被 delete有unique_ptr成员时发生。按需实现移动构造或把类改为不可拷贝“no matching function for call to X::X()”类没有无参构造但代码中用了X obj;明确传参或提供全参数默认值编译通过但成员值随机构造函数体赋值未生效或初始化列表顺序与声明顺序不一致使用初始化列表且保持与成员声明顺序一致同一内存被释放两次浅拷贝拷贝构造和析构函数都在释放同一指针实现深拷贝改用 RAII 容器或禁止拷贝构造参数需要 const 引用却用了值传参拷贝成本高且可能无意中多次调用拷贝构造对于非内置类型优先使用const T6.2 实际开发里我踩过的三个典型坑第一个坑是“默认构造 手动 init”。早年写某个网络模块时我图省事让类有一个默认构造函数然后靠外部调用Init()方法完成初始化。一开始没什么问题直到后期有人在一个对象数组里漏调了Init()几千个连接同时带着默认值上线那个线上事故是我职业生涯里印象最深的教训之一。之后我在新代码里都坚持“构造函数完成全部必需初始化”缺实参就直接不让编译通过断绝“半初始化对象”的可能性。第二个坑是“重载构造函数里互相调用”。我见过有人试图从两个构造函数之间互相复用逻辑Student() : Student(, 0) {} // 委托构造C11 以后“委托构造”是合法的但前提是语法正确。我见过最离谱的写法是在构造函数体里调用另一个构造函数这根本不起作用——它会创建一个临时对象然后立刻销毁当前对象的成员依然没初始化。委托构造请写在初始化列表里而不是函数体里。第三个坑是“成员初始化的顺序陷阱”。我在 5.1 节已经详细写了这里补一个真实教训当时我想当然地认为“初始化列表里谁在前面谁就先初始化”结果a带着b的垃圾值跑了好几个版本直到有人更换了成员声明顺序bug 才暴露。这之后我给自己立了一条铁律凡是初始化列表顺序和成员声明顺序不一致的代码一律不许合入主干。6.3 如何顺藤摸瓜排查“构造函数不执行”的错觉有段时间有同事向我反馈“我明明写了构造函数但调试代码的时候发现它没执行。”我让他把代码发来一看——他在栈上声明对象的时候写的是Student stu();。这行代码不会创建一个Student对象而是一个函数声明一个名叫stu、不接受参数、返回Student的函数。这就是著名的 “Most Vexing Parse”。所以排查时如果发现疑似“构造函数没被调用”第一件事就是检查你是不是多写了一对括号。另外如果你在构造函数里打日志没输出也先别急着怀疑编译器检查一下是不是变量被声明成了引用Student ref stu;不会创建新对象当然不会调用新构造函数。这种问题在重构旧代码时经常出现因为引用变量和普通变量在声明语句上几乎长得一样。7. 关于构造函数后续还可以怎么深入这篇我把构造函数的基础概念、this 的作用、拷贝构造与重载、初始化列表、常见坑都梳理了一遍。如果你已经把这些内容消化了接下来可以往两个方向延伸。一个方向是移动构造和移动赋值。C11 的移动语义解决了临时对象拷贝开销过大的问题它的声明形式、与拷贝构造的重载关系、类里同时存在两者时的匹配规则都是非常有嚼头的内容。另一个方向是构造函数的异常安全。如果构造函数体中途抛异常对象是否算构造成功析构函数会不会被调用成员变量的资源如何保证不泄漏这些在生产环境的代码里才是最致命的问题。构造函数看似是一个语法点实际串起的是初始化、资源管理、类型设计、性能优化一整条线。真正搞懂了它你在 C 这条路上会轻松很多。