
摘要本文围绕“对象从出生到销毁”这条主线讲清构造函数、初始化列表、析构函数、拷贝构造的调用时机并深挖浅拷贝双重释放的事故现场。核心结论带指针成员的类必须自定义深拷贝或禁用拷贝new/delete 与 malloc/free 的本质区别是是否调用构造/析构。示例在 gC11 模式下验证。一、问题背景C 语言里malloc一块内存、填几个字段就算“创建对象”到了 C类里可能有const成员、引用成员、需要申请堆内存的指针成员编译器不认识你的自定义逻辑没法替你自动初始化。于是 C 提供了构造函数与析构函数对象实例化时自动调用构造生命周期结束时自动调用析构。围绕这套机制笔试面试题高度集中空类sizeof为什么是 1初始化列表和函数体里赋值有什么区别析构函数能手动调用吗调用几次浅拷贝到底怎么把程序搞崩的二、构造函数对象的“出生手续”思路函数名与类名相同、无返回值连 void 都不能写、支持重载。用户不写任何构造时编译器给一个默认无参构造一旦写了任何构造函数默认无参构造就不再提供想保留要显式写出来。classCar{string brand;floatweight;inthorsepower;public:Car():brand(红旗),weight(1.5f),horsepower(200){}// 无参Car(string b,floatw,inth):brand(b),weight(w),horsepower(h){}// 带参重载};初始化列表不只是风格问题Computer::Computer(string n,intp,doublec):name(n),price(p),cpu(c){// 函数体可为空进入函数体前成员已初始化完毕}对 string 等自定义类型成员函数体内赋值 先默认构造再赋值两次操作初始化列表 直接构造一次操作效率差异明显。对内置类型int、float 等两者差异不大。const成员和引用成员只能用初始化列表没有第二条路。列表书写顺序不影响执行顺序实际初始化顺序永远按成员在类内的声明顺序书写顺序与之不一致时如: b(a_), a(1)b 依赖 a 就会用到未初始化值是经典笔试题陷阱。三、空类 sizeof 之谜classEmpty{};coutsizeof(Empty);// 1实测输出C 规定每个对象必须有唯一地址编译器能分配的最小单元是 1 字节所以空类对象塞 1 字节的“占位符”。非虚成员函数和静态成员变量都不占对象内存——函数代码在代码区静态成员在静态存储区。带虚函数的类则另当别论虚表指针 4/8 字节后面多态文章会讲。四、析构函数与“手动调用”实验思路~类名()、无参、不能重载对象销毁时自动调用用于释放资源。容易忽略的坑析构函数可以被手动调用Carcc(demo,1.0f,100);cc.~Car();// 手动调用对象生命周期提前结束cc.info();// 未定义行为对象已死不能再使用// 作用域结束时编译器再次自动调用析构 → 重复析构显式调用析构函数会提前结束对象生命周期此后再次使用该对象是未定义行为。而对象离开作用域或delete时还会再次自动调用析构造成重复析构。实践结论除了 placement new 配合的极端场景永远不要手动调析构。五、拷贝构造调用时机函数原型固定Student(const Student other)——参数必须是本类常引用写成传值会无穷递归拷贝实参又要调拷贝构造。Students1(张三,20);Student s2s1;// 时机1用已有对象初始化新对象Students3(s1);// 时机2直接构造voidfunc(Student s);func(s1);// 时机3值传递实参Studentfunc2();Student rfunc2();// 时机4返回对象无名对象临时对象补充Car(BMW, 2.0f, 300);创建即用即销毁C17 起Car c Car(...)的 prvalue 返回强制省略拷贝直接构造到目标不再多一次拷贝。对于时机4返回对象时编译器优先进行RVO/NRVO返回值优化。C17 起对于返回 prvalue纯右值的情况拷贝省略是强制性的对于返回具名局部变量的 NRVOC17 仍为可选优化不同编译器可能表现不同。因此拷贝构造通常不会触发。六、new/delete vs malloc/free笔试题标准答案对比项malloc / freenew / delete性质C 标准库函数C 运算符关键字调用构造/析构不调用自动调用new 调构造delete 调析构返回类型void*需强转直接返回目标类型指针类型检查/大小计算手动算字节数编译器按类型自动确定失败行为返回 NULL默认抛std::bad_alloc释放数组free(p)必须delete[] p写成delete p是未定义行为int*pnewint(10);// 分配并初始化Car*cpnewCar(Tesla,2.1f,500);// 自动调构造deletecp;// 自动调析构实测输出带参构造→析构各一次int*arrnewint[10];delete[]arr;// 注意方括号七、深拷贝 vs 浅拷贝指针成员引发的“血案”思路不写拷贝构造时编译器给默认的逐成员复制浅拷贝。成员里有裸指针时两个对象的指针指向同一块堆内存析构时被delete两次 → double free程序崩溃或者一方修改另一方“见鬼”地跟着变。事故现场 修复代码#includecstring#includeiostreamusingnamespacestd;classMyString{char*data;public:MyString(constchar*s){datanewchar[strlen(s)1];strcpy(data,s);}~MyString(){delete[]data;}constchar*c_str()const{returndata;}// 【浅拷贝默认的问题】删掉下面这个函数// MyString b a; 后函数退出时 a、b 析构同一块内存 → 崩溃/双重释放// 方案深拷贝——重新开内存、搬内容MyString(constMyStringother){datanewchar[strlen(other.data)1];strcpy(data,other.data);cout深拷贝: dataendl;}// 同时禁用拷贝赋值避免赋值时浅拷贝MyStringoperator(constMyStringother)delete;};intmain(){MyStringa(hello);MyString ba;// 触发深拷贝构造couta.c_str() / b.c_str()endl;// hello / hello两块独立内存return0;// a、b 各自析构各自的内存安全}效果与代价深拷贝多分配一块内存空间代价换来所有权清晰——谁申请谁释放。不想深拷贝的替代方案把拷贝构造 delete;C11从语法上禁止拷贝适用于管理独占资源的类文件句柄、连接。同样也应禁用拷贝赋值运算符。八、std::string为什么别再拿 char* 当字符串string s2Hello;strings4(World);couts2s4;// 直接拼接 HelloWorlds2.append( C);// 追加s2.length();// 长度s2.clear();// 清空string 内部封装了动态内存管理自带深拷贝语义、、全部重载好彻底告别strcpy越界和忘记free的经典事故。九、对比分析三种“初始化成员”的方式怎么选方式执行时机适用场景代价初始化列表对象进入构造函数体之前const/引用成员必用非POD成员性能更优需注意声明顺序陷阱函数体内赋值默认构造完成之后逻辑简单、需要计算/校验后再定值对自定义类型多一次默认构造赋值 delete拷贝编译期禁止独占资源类防误拷贝对象不可复制传参用引用/指针底层原因初始化列表对应“用参数直接调用成员的构造函数”函数体赋值是“先调成员的默认构造再调operator”。对 string 这种堆上持有字符缓冲的成员前者一次分配后者先分配再析构再分配浪费明显。对内置类型则没有这种开销。十、结论对象生命周期 构造含拷贝构造→ 使用 → 析构编译器全自动插入new/delete 只是把“自动”从栈扩展到堆。带指针成员的类三条路深拷贝、 delete禁拷、换智能指针下一篇 STL/模板后会见到 RAII 思想。new[] 配 delete[]delete 单个配不带括号的——配对错误是未定义行为常见表现为崩溃或堆损坏务必严格遵守。十一、实践建议亲手做一次“事故复现”删掉深拷贝函数、打开调试器跑MyString b a;观察崩溃栈停在free/delete——比背十遍概念有用。写一个“个人理财管理系统”小项目练手课堂大作业思路账户类用 string 动态数组记录流水必须自定义拷贝构造处理指针成员每天迭代一个新知识点。笔试题自测对象数组Test arr[10];调几次构造答如果类有无参构造调 10 次默认构造如果类只有带参构造则编译不过需要用初始化列表逐个提供参数例如Test arr[3] {Test(1), Test(2), Test(3)};可能涉及拷贝构造但通常被优化。参考malloc/free 与 new/delete 区别 - CSDN 博客new/new[]/delete/delete[] 与 malloc/free 对比 - 知乎专栏C 深拷贝和浅拷贝 - CSDN 博客