【C++ 面试真题】聊聊 C++ 的构造与析构
【C++ 面试真题】聊聊 C++ 的构造与析构
构造和析构是 C++ 面向对象篇的"开场必问"。背得出"构造初始化、析构清理"只是及格,真考你的是"多层继承下构造析构的执行顺序、基类析构为什么必须 virtual、构造函数里能不能调虚函数"——一道题能从语法问到内存布局。本文把构造析构的通用机制一次讲透——它是整个面向对象的地基。
一、先说结论:构造/析构 = 对象的"生与死"
❓ 构造函数和析构函数到底是干嘛的?
✅ 一句话:构造函数让对象"活过来"(成员拿到合法初值、申请资源),析构函数让对象"干净地走"(释放资源、归还系统)。
两者都是特殊成员函数——名字固定、没有返回值、你不调,编译器替你调:
structFile{File(constchar*path);// 构造:打开文件~File();// 析构:关闭文件private:FILE*fp_;};{Filef("a.txt");// 构造自动调用// ... 用 f}// 离开作用域,析构自动调用💡 这就是RAII的根基:把资源绑定到对象生命周期,构造获取、析构释放,再也不用手动 close/free。智能指针、内存管理用的都是这个思想。
二、构造函数"家族"有哪些?
❓ 构造函数分几种?
✅ 主要四种,本篇聚焦前两种的通用机制,拷贝和移动各有专门考法,先不展开:
| 种类 | 签名特征 |
|---|---|
| 默认构造 | 无参T() |
| 有参构造 | T(args) |
| 拷贝构造 | T(const T&) |
| 移动构造 | T(T&&) |
structWidget{Widget();// 默认构造Widget(intn);// 有参构造Widget(constWidget&);// 拷贝构造Widget(Widget&&);// 移动构造};⚠️注意:一旦你写了任何一个构造函数,编译器就不再为你合成默认构造了。想保留默认构造,得显式写
Widget() = default;。
三、构造与析构的执行顺序(高频考点)
❓ 多层继承、成员对象都有构造析构时,到底按什么顺序执行?
✅ 记住一条铁律:
- 构造:基类先 → 成员对象次 → 自己的函数体最后;
- 析构:完全相反,函数体先 → 成员次 → 基类最后。
structMember{Member(){cout<<"Member 构造\n";}~Member(){cout<<"Member 析构\n";}};structBase{Base(){cout<<"Base 构造\n";}~Base(){cout<<"Base 析构\n";}};structMid:Base{// 中间层Mid(){cout<<"Mid 构造\n";}~Mid(){cout<<"Mid 析构\n";}};structDer:Mid{Member m_;// 成员对象Der(){cout<<"Der 构造\n";}~Der(){cout<<"Der 析构\n";}};{Der d;}// 输出顺序:// Base 构造// Mid 构造// Member 构造// Der 构造// Der 析构// Member 析构// Mid 析构// Base 析构💡记忆窍门:构造像"穿衣"(内衣→外套,从里到外),析构像"脱衣"(外套→内衣,从外到里)。派生类永远裹在基类外面,所以构造最后到位、析构最先脱掉。
四、为什么基类析构要 virtual?(重头戏)
❓ 为什么总有人说"基类析构函数必须声明为 virtual"?
✅ 因为通过基类指针 delete 派生类对象时,只有基类析构是 virtual,才会触发派生类析构——否则只调基类析构,派生部分直接泄漏。
看这个经典翻车现场:
structBase{~Base(){}// ❌ 非 virtual};structDer:Base{int*p_;Der():p_(newint[100]){}~Der(){delete[]p_;}// 不会执行!};Base*b=newDer;deleteb;// ❌ 只调 Base::~Base()// Der::~Der() 没被调用// 100 个 int 泄漏加上virtual立刻修好:
structBase{virtual~Base(){}// ✅ 虚析构};// 现在 delete b 会先调 Der::~Der()// 再调 Base::~Base(),顺序正确⚠️判断准则:只要这个类打算被多态使用(即有人会用基类指针/引用指向派生对象),它的析构就必须 virtual。反过来,如果一个类不打算被继承(或只用具体类型操作),析构非 virtual 即可,省掉虚表开销。
💡加分点:C++ 核心准则(C.35)的建议更干脆——一个要有虚函数的类,析构基本上就该是 virtual。因为"有虚函数"本身就是"打算被多态使用"的信号。这条几乎可以当公式记。
五、构造/析构里能调虚函数吗?
❓ 在构造函数或析构函数里调用虚函数,会发生什么?
✅能编译通过,但行为和你想的"多态"不一样——构造/析构期间,虚调用会退化成静态调用(只调当前层自己的版本,不会进入派生类)。
structBase{Base(){init();}// ⚠ 调虚函数virtualvoidinit(){cout<<"Base::init";}};structDer:Base{voidinit()override{cout<<"Der::init";}};Der d;// 你以为打印 Der::init// 其实打印 Base::init !为什么?构造Der时,先执行Base的构造体——此时Der还没"成形",它的成员还没初始化。如果这时候真的去调Der::init,访问到的是一堆垃圾。所以语言规定:基类构造期间,对象的实际类型就是基类,虚函数只能落到基类版本。析构同理(拆除顺序里,派生部分已经没了)。
🎯最佳实践:别在构造/析构里调虚函数,那是 bug 的温床。如果非要在构造期做"派生类相关的初始化",让派生类自己构造函数里调一次普通虚函数即可——那时对象已完整。
虚函数到底是怎么"知道"该调哪个版本的?答案是虚函数表(vtable)——这是虚函数那道题的核心,本文先点到为止。
六、= default和= delete(C++11)
❓= default和= delete是干嘛的?
✅ 两个 C++11 引入的"显式控制特殊成员函数"的语法:
structWidget{Widget()=default;// 显式要默认构造Widget(constWidget&)=delete;// 禁止拷贝};= default:让编译器重新合成那个被你写别的构造函数"压制"掉的默认实现;= delete:明确禁用某个函数,任何人调用都编译报错。
Widget w1;// ✅Widgetw2(w1);// ❌ 编译报错:已删除Widget w3=w1;// ❌ 同上💡典型场景:不可拷贝的类(如
std::mutex、std::unique_ptr)就是把拷贝构造/拷贝赋值= delete。比 C++98 时代"声明成 private 且不实现"干净得多。
七、成员初始化列表:为什么要用?
❓ 为什么推荐用初始化列表,而不是在构造体里赋值?
✅ 因为初始化列表是"直接构造成员",而在构造体里x = 1是"先默认构造、再赋值"——对成员多了一次默认构造开销,而且有些类型(const、引用、无默认构造的类)只能初始化、不能赋值。
structBad{intn;string s;Bad(intx,string str){n=x;// 先默认构造再赋值s=str;// 多一次临时对象}};structGood{intn;string s;Good(intx,string str):n(x),s(str){}// ✅ 直接构造};⚠️初始化顺序陷阱:成员按声明顺序初始化,跟列表里写的顺序无关。如果成员间有依赖,搞错声明顺序会用到没初始化的成员:
structTrap{inta_,b_;// 想让 b = a + 1Trap(intv):b_(v+1),a_(v){}// ⚠ 危险!// 实际顺序:a_ 先(=v),b_ 后(=v+1)// 这里碰巧对,但若写成 b_(a_+1)// a_ 还是垃圾值!};🎯建议:成员声明顺序和初始化列表顺序保持一致,开
-Wreorder警告(GCC/Clang 默认开)帮你抓这种坑。
八、面试高频追问
❓ Q1:构造函数能是虚函数吗?
✅不能。虚函数靠虚表工作,而虚表指针是在构造期间才被填进去的——构造函数执行时对象还没"活",没有虚表可用。语言层面直接禁止 virtual 构造函数。
❓ Q2:析构函数能是纯虚函数吗?
✅能(常用来定义抽象类),但必须给纯虚析构提供一份定义,否则链接报错——派生类析构时会调用基类析构,找不到实现就炸。
structBase{virtual~Base()=0;// 纯虚析构};Base::~Base(){}// ✅ 必须有定义❓ Q3:编译器什么时候会合成默认构造函数?
✅ 仅当你需要它、又没自己写时(比如定义了对象却没给初值)。如果类里有成员本身没有默认构造,合成会失败。别把"会合成"当默认保证——需要就显式写。
❓ Q4:为什么析构函数执行后对象就"不能用了"?
✅ 析构一旦执行,对象的成员已被销毁、资源已归还,再访问就是未定义行为(use after free)。这也是为什么不要返回局部对象的引用/指针——对象早就没了。
❓ Q5:构造抛异常会怎样?
✅已构造的成员和基类子对象会被逆序析构,但对象自己的析构不会执行(它根本没构造完成)。所以"在构造里申请多个资源"很危险,推荐用智能指针成员托管——每个成员自己 RAII,异常时自动释放,这也是内存管理的核心思想。
九、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 构造顺序 | 基类 → 成员 → 函数体 |
| 析构顺序 | 函数体 → 成员 → 基类(相反) |
| 多态基类析构 | 必须 virtual,否则派生部分泄漏 |
| 构造期调虚函数 | 退化为静态调用,别这么干 |
= default | 重新要编译器合成 |
= delete | 明确禁用某函数 |
| 初始化列表 | 直接构造,省开销;顺序按声明 |
| 纯虚析构 | 可以,但必须给定义 |
一句话回顾
构造是"基类→成员→自己",析构恰好反过来;只要类会被多态使用,基类析构就得 virtual——这两条记牢,构造析构这一关基本就过了。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续聊面向对象篇的拷贝构造与拷贝赋值——浅拷贝为什么会 double free、三五法则到底在说什么,敬请关注 👋