如果你一路跟着这份C核心编程笔记走到第二篇说明你已经从“用C语言的思维写C”来到了“用面向对象的方式组织代码”的门口。这一篇我没有按语法手册的写法罗列知识点而是把重点放在对象的生命周期、拷贝行为、资源管理这三条主线上——构造函数在什么时机被调用、初始化列表的顺序为什么能坑人、浅拷贝和深拷贝到底差在哪里、static成员和const成员函数各自负责什么、运算符重载怎么重才算“像内置类型”。整理完这一份笔记后我最大的感受是C里到处是“默认行为”——默认构造、默认拷贝、默认析构这些默认在绝大部分时候很好用可一旦对象内部持有堆内存或者外部资源默认就会变成事故现场。这份笔记适合两类人。一类是刚学完基础语法、正在啃类和对象这部分内容的初学者另一类是写小项目时经常被莫名其妙的崩溃和内存泄漏折腾到凌晨的开发者。把这篇里的场景对着自己的代码过一遍很多问题能直接定位。1. 进入核心编程篇2之前先把“对象”在心里重新定义1.1 对象不只是“结构体函数”很多人在学完基础语法之后定义类的时候还是结构体的思路成员变量堆一堆成员函数堆一堆能编译通过就算完事。但面向对象的核心并不是“把变量和函数放一个结构体里”而是让对象成为一份资源的载体。所谓资源不只是new出来的堆内存也可以是文件句柄、网络连接、数据库会话、互斥锁甚至是某个正在运行的线程。用C语言写代码时这些资源全靠手动管理忘记释放是常事。到了C这些资源的获取和释放绝大多数都落在构造函数和析构函数身上。所以我在第二篇笔记的一开始先重新定义了对象的三要素状态成员变量保存了哪些数据这些数据能不能保持一致行为成员函数怎么操作状态怎么把内部细节隐藏起来生命周期对象什么时候创建、什么时候销毁、销毁之前要归还哪些资源。初学的时候容易只盯着前两条等到程序跑起来出现内存泄漏或悬空指针才意识到第三条才是最难把握的。1.2 对象放在内存哪个区决定谁来负责清理上一篇笔记里复盘过内存四区栈区、堆区、全局区、代码区。这个四区模型放在对象上直接决定析构函数的触发时机。栈区对象在离开作用域时自动析构堆区对象在delete时由delete语句触发析构全局区对象在main函数执行前构造在程序退出后析构。这里最经典的问题是把局部对象的地址返回给外部。局部对象出了函数就析构外部拿到的是一个悬空指针用一次崩一次而且崩得毫无规律。真正想在函数之外继续使用对象就得在堆上创建并让调用方负责释放。另一个容易踩的坑是把栈上对象的地址塞进全局指针。程序流程一复杂全局指针指向的栈对象早就析构了后面再用就是未定义行为。理解了“对象在哪个区谁负责清理”再看构造函数、析构函数、拷贝控制思路会顺很多。建议写类之前先问一句“这个类需要手动析构吗”如果答案是不确定先别急着写业务逻辑。2. 构造函数与析构函数对象的“出生登记”和“注销清单”2.1 构造函数有四种写法调用场景要分清C的构造函数不只有“和类同名”这一个特征按参数和性质可以分成四类默认构造、有参构造、拷贝构造、移动构造C11之后。每一种对应不同的创建场景。class Student { public: Student(); // 默认构造 Student(const char* name, int age); // 有参构造 Student(const Student other); // 拷贝构造 Student(Student other); // 移动构造 ~Student(); // 析构函数 private: char* name_; int age_; };它们分别在哪被调用我用一张表整理过构造类型典型触发场景默认构造定义对象但没有传参数有参构造定义对象时传入了对应参数拷贝构造用一个已存在的对象初始化新对象移动构造按值返回临时对象、把资源转移给新对象这里最容易混淆的是“按值返回”和“用一个对象初始化另一个对象”。很多人以为返回值靠复制其实现代C在C17之后已经做了强制复制省略很多临时对象的拷贝根本不会发生。但是如果类的设计里有裸指针指向堆内存不管拷贝还是移动都必须手动处理这一步跳不过去。2.2 初始化列表哪些成员必须在这里完成初始化初始化列表的语法是在构造函数体之前用冒号接上一串“成员(参数)”的写法。它不只是语法糖有几个成员是必须在初始化列表里初始化的const成员变量引用类型成员变量没有默认构造函数的成员对象。原因在于构造函数体执行的时候成员对象已经构造完了。如果成员对象没有默认构造函数编译器在进入函数体之前根本不知道怎么构造它直接报错。你只能在初始化列表里告诉编译器“用这个参数去构造这个成员。”class Registration { public: Registration(const string id) : id_(id), server_(8080) { // 构造函数体内只能做赋值但 id_ 和 server_ 必须在初始化列表完成 } private: const string id_; // const 成员 ServerConfig server_; // 没有默认构造函数的成员对象 };写到这里顺便提醒一句能用初始化列表就用初始化列表不要在构造函数体内给成员赋值。前者是“直接构造”后者是“先默认构造再赋值”多了两次操作。对于简单int差别不大但对于string、vector这类带堆内存的成员两种写法效率差一个量级。2.3 初始化列表成员顺序的坑按声明顺序不是按列表顺序这是很多人在笔试和实际代码里都翻过车的点。初始化列表的书写顺序并不决定成员的初始化顺序成员的初始化顺序只由它们在类中的声明顺序决定。class Demo { public: Demo(int v) : b_(v), a_(b_) {} int a_; // 先初始化 int b_; // 后初始化 };表面上看初始化列表先写了b_再写a_似乎会把b_赋值为v再用b_去初始化a_。实际上执行顺序是先初始化a_而此时b_还没有初始化a_拿到的是一个随机值。等a_初始化完成后b_才被设为v。所以在初始化列表里永远不要依赖“排在后面的成员”去初始化“排在前面的成员”。如果你遇到一个对象出来之后数值莫名其妙不对先检查成员声明顺序这个坑靠调试器很难看出来。2.4 析构函数什么时候触发析构函数跟构造函数相反构造函数负责把资源准备好析构函数负责把资源还回去。触发时机有四类栈上局部对象离开作用域delete作用于堆对象程序退出时全局/静态区对象被销毁容器销毁时容器内的元素对象逐个析构。继承体系下还有个顺序问题先析构派生类再析构基类构造时正好反过来先基类、再成员、最后派生类构造函数体。这个顺序保证了基类的资源一定在派生类资源之后释放不会出现“基类资源已经没了派生类还在用”的情况。3. 拷贝控制浅拷贝、深拷贝以及编译器替我们做的“默认动作”3.1 编译器默认的拷贝构造到底做了什么如果你没有声明拷贝构造函数编译器会隐式生成一个它做的事情非常“朴素”把这个对象的每个成员变量逐字节拷贝到另一个对象里。对于int、double、指针这种内置类型这个默认行为简单高效。问题恰恰出在指针上。默认拷贝构造拷贝的是指针本身而不是指针指向的数据。链表、字符串、动态数组这类类一旦被“拷贝”两个对象的指针成员会指向同一块堆内存。等两个对象都析构时同一块内存会被delete两次——轻则运行时报警重则直接破坏堆内存结构后续跑什么都可能崩。这就是经典的浅拷贝。它最大的问题不是“效率低”而是所有权混乱两个对象都认为自己拥有这块内存析构时互不相让。3.2 什么时候必须自己写拷贝构造判断标准很简单类是否直接管理独占性资源。只要成员里有裸指针指向堆内存或者类持有文件句柄、socket、锁这类独占资源就必须自己设计拷贝行为。举一个最常见的例子手写的字符串类class StringDemo { public: StringDemo(const char* str) { size_ strlen(str); data_ new char[size_ 1]; strcpy(data_, str); } ~StringDemo() { delete[] data_; } private: char* data_; size_t size_; };这段代码单看没什么问题但只要出现一次拷贝StringDemo a(hello); StringDemo b(a); // 浅拷贝b的data_和a的data_指向同一块内存a析构时delete一次b析构时再delete一次。两次delete同一块堆内存程序大概率直接崩。正确的做法是自定义拷贝构造为b单独分配一块独立内存把字符串内容复制过去StringDemo(const StringDemo other) { size_ other.size_; data_ new char[size_ 1]; strcpy(data_, other.data_); }这就是深拷贝。拷贝出来的对象和原对象完全独立各自析构互不干扰。3.3 拷贝构造在参数传递、返回值和容器扩容里都有触发点拷贝不是只在“显式复制一个对象”时触发很多日常代码里藏着拷贝触发点按值传参函数形参是对象时实参被拷贝进函数体函数结束形参再析构按值返回返回局部对象时旧标准会触发拷贝C17之后很多场景被强制省略但移动构造仍然重要容器插入vector.push_back(obj)时会把obj拷贝或移动到容器内部容器扩容vector容量不够时会把旧元素全部搬运到新内存搬运过程逐个触发拷贝或移动。用vector存对象时如果你没有写移动构造扩容过程就会把每个元素都深拷贝一遍旧内存再全部释放。对象多了之后性能肉眼可见地下降。这也是为什么建议只要类需要深拷贝就顺手把移动构造也写上。3.4 移动构造把早就该补的补上移动构造做的事情和拷贝构造正好相反不复制资源而是把源对象的资源“偷”过来然后把源对象置空。StringDemo(StringDemo other) noexcept { data_ other.data_; // 直接把指针拿过来 size_ other.size_; other.data_ nullptr; // 源对象不再持有资源 other.size_ 0; }加noexcept的原因很微妙vector在扩容时如果移动构造不承诺不抛异常标准库为了保证异常安全会退化成“拷贝搬运”而不是“移动搬运”。一旦退化性能优势全丢。移动构造还有一个非常朴素的出发点我们不希望把一个临时对象的资源“复制”一份再销毁临时对象而是直接让新对象接管临时对象的资源一步到位。直觉上这就是“搬家”和“抄家”的区别。4. static成员、const成员与友元类的“全局属性”和“权限外挂”4.1 static成员变量类内声明、类外定义static成员变量不属于某个对象而是属于整个类。它被所有对象共享本质上就是一个有访问控制作用域限制的全局变量。static成员变量有一个初学者必踩的坑在类内声明但必须到类外定义。C17之前类内的static成员变量只是声明没有分配存储。如果你不写类外定义链接时就会报未定义引用。class Counter { public: static int total; // 类内声明 }; int Counter::total 0; // 类外定义必须写这些static成员变量最常见的用途是实例计数、全局状态、配置项、对象池统计等。构造时total析构时total--随时知道当前有多少个存活对象。这个模式非常适合排查内存泄漏总数一直上涨说明对象只构造没析构多半是漏了delete。4.2 static成员函数的边界static成员函数有两个特点不依赖对象因此没有this指针只能访问static成员变量和其他static成员函数。反过来说static成员函数不能访问非static成员变量因为“非static成员变量属于具体对象”而static成员函数并没有绑定某个对象。这个边界经常让人困惑尤其当你想着“在某个工具类里写一个static函数去统计所有对象总和”时如果该函数访问非static成员编译器会直接拒绝。4.3 const成员函数约束“只读”的约定在成员函数声明末尾加const表示“这个函数承诺不修改对象状态”。class Account { public: double Balance() const { return balance_; } private: double balance_; };这个const有两个实际作用一是约束自己防止在只读函数里误改成员变量二是满足调用方一个const对象只能调用const成员函数。如果你有一个const Account然后试图调用一个非const成员函数编译器直接报错。这个机制逼着你在设计阶段就想清楚哪些操作是只读的哪些操作会改状态。mutable是唯一例外。被mutable标记的成员变量即使在const成员函数里也可以修改。通常用在缓存数据、内部计数器、线程同步变量这些“为了维持对象逻辑状态而必须修改”的成员上。注意别滥用mutable相当于打开了const的缺口能不用就不用。4.4 友元给“外部函数”开权限的代价友元函数和友元类本质上是让某个不属于类内部的函数或类能够访问当前类的私有成员。声明方式很简单class Point { friend ostream operator(ostream os, const Point p); private: int x_; int y_; };之后这个友元函数就可以直接读p.x_和p.y_。友元常见于运算符重载尤其是输出运算符因为标准库的流对象在左边成员函数版本写不出来只能靠非成员函数而它又要访问私有成员友元几乎是标准解法。友元最大的代价是破坏封装。私有成员的“私有”价值在于类的内部实现怎么改都不影响外部。一旦友元函数读了私有成员类内部一调整友元函数可能跟着坏。所以友元要克制使用只给真正需要紧密协作的外部函数开权限。5. 运算符重载让自定义类型用起来像内置类型5.1 什么运算符值得重载什么运算符别碰运算符重载的目的是让自定义类型用起来和内置类型一样自然。判断标准就一句这个运算符是否有直觉上明确、一致的含义值得重载的赋值运算符比较运算符、!、输出/输入运算符、下标运算符[]容器类自增/自减、--迭代器、计数器加法/减法等算术运算符如果类型本身有数学含义。不建议碰的逻辑与、逻辑或||。C规定它们有短路求值语义重载后会丢失短路行为很容易写出反直觉的代码逗号运算符、取地址运算符。除非你在写特殊的代理类否则重载它们只会让代码变得难懂没有任何明确语义的运算符。比如给一个日期类重载*解释成“往后推N个月”或者“计算两个日期的间隔”读者不看文档根本猜不到。5.2 赋值运算符的四个动作赋值运算符和拷贝构造函数很容易混但二者的时机不一样拷贝构造是“创建一个新对象时用已有对象初始化”赋值是“对象已经存在把另一个对象的内容盖上去”。一个正确的赋值运算符至少要完成四件事StringDemo operator(const StringDemo other) { if (this other) { return *this; // 1. 自赋值检测 } delete[] data_; // 2. 释放旧资源 data_ new char[other.size_ 1]; // 3. 分配新资源并拷贝 strcpy(data_, other.data_); size_ other.size_; return *this; // 4. 返回引用支持连续赋值 }自赋值检测为什么必要如果不检测a a的时候第二步就会把a自己的数据删掉第三步拿已经释放的other.data_做拷贝结果是未定义行为。返回类型为什么是StringDemo而不是void因为内置类型的赋值是连续的a b c会先让b等于c再把结果赋给a。如果你的赋值运算符返回void这种写法直接编译失败自定义类型就跟内置类型不一致了。还有一种更不容易出错的写法叫copy-and-swap先拷贝一份临时对象再用临时对象和当前对象交换成员。由于swap操作通常不抛异常异常安全性能有保证。我向来建议如果你不是非常确定手写顺序不出错优先用copy-and-swap。5.3 输出运算符写成友元的原因输出运算符的左操作数必须是ostream对象右操作数才是你的类对象。如果把operator写成成员函数左操作数就成了类对象调用方式会变成p cout完全反直觉。所以必须做成非成员函数。而它访问对象私有成员时通常只能靠友元ostream operator(ostream os, const Point p) { os ( p.x_ , p.y_ ); return os; }返回ostream同样是为了支持链式输出cout p q;。这个引用指向的就是传入的流对象不会发生拷贝。5.4 重载的反直觉陷阱不要制造“惊喜”运算符重载最大的风险不是语法写错而是让代码读起来很吓人。比如给一个“学生类”重载两个学生相加返回什么年龄之和学分之和每个人读代码都有自己的猜测歧义本身就是坏设计。我的建议是遵循最小重载原则。只重载那些语义极明确的运算符并且重载之后的行为要和内置类型保持一致。比如就必须只判断“相等”而不做“排序”就必须给出严格的弱序关系别让比较逻辑里带上“顺便修改内部状态”的副作用。运算符重载一旦让读者感到意外代码的可维护性就崩了。6. 堆对象、RAII与移动语义把资源管理变成习惯6.1 堆对象的两类事故用new创建的堆对象必须delete否则内存泄漏但delete过了头又会变成二次释放。这句话说起来简单做起来很难。第一类事故是忘记delete对象在函数里new出来函数结束没人管堆内存一直占着。表现就是程序越跑越慢内存占用只增不减。第二类事故是提前delete某个函数把堆对象delete了但另一个地方还保存着这个对象的指针后面再访问就是访问已释放内存运气好出随机结果运气差直接崩溃。这两类事故的根源是同一个资源所有权不明确。没有人能回答“这块堆内存到底归谁管、什么时候释放”于是所有地方都以为不归自己管或者所有地方都以为自己有权清理。6.2 RAII是治这两类事故的“标准解”RAII的全称是Resource Acquisition Is Initialization翻译过来叫“资源获取即初始化”。核心套路是在构造函数里获取资源在析构函数里释放资源。资源跟着对象走对象生命周期结束资源必然被释放。我们前面写的StringDemo已经是RAII的雏形构造时分配堆内存析构时释放堆内存。栈对象离开作用域析构函数必然执行堆内存必然释放。这就是为什么只要对象在栈上程序员几乎不需要手动干预内存。这个思路可以推广到任何独占资源文件、锁、网络连接。把资源封装进对象对象一析构资源自动归还。哪怕程序中间抛了异常栈式对象也会在栈回退时逐个析构资源不会泄漏。6.3 智能指针带来的所有权直觉RAII思想最广为人知的产品就是智能指针。unique_ptr表达“独占所有权”一个堆对象只能被一个unique_ptr拥有shared_ptr表达“共享所有权”通过引用计数决定何时释放。我写笔记时给自己立了一个规矩现代C代码里裸new应该几乎消失。创建堆对象优先用make_unique或make_shared让智能指针负责生命周期。这样前面说的“忘记delete”和“提前delete”就从代码结构上消失了。auto stu make_uniqueStudent(Alice, 20); // 不需要手动 deletestudent 离开作用域自动释放ownership一旦明确另一个好处是代码接口变清楚。函数参数如果接受的是unique_ptr调用方就知道这个函数要转移所有权如果接受的是普通引用都知道只是临时借用。这类约定比任何注释都可靠。6.4 移动语义“转移”远比“复制”轻前面写了移动构造这里再补一句直觉移动不是“复制得更快”而是“把资源的所有权让渡出去”。源对象被掏空之后它仍然是一个合法对象只是不再持有资源可以安全析构。std::move的作用只是把左值强制转换成右值引用让编译器选择移动构造函数而不是拷贝构造函数。它是“类型转换”不会移动任何东西。真正移动动作发生在移动构造函数或移动赋值函数里。移动语义的大规模收益在vector扩容和函数按值返回时会集中体现。一个类如果既支持深拷贝又支持移动vector扩容就从“逐元素复制再释放旧内存”变成“逐元素转移指针开销极小”。如果你现在维护的类里有堆资源又还没有写移动构造建议尽快补上标准库容器会直接用行动感谢你。整理完这一份核心编程篇2我最想给自己敲三句黑板第一写类先想生命周期析构缺位等于资源无人收场第二拷贝控制不是“语法题”而是资源所有权设计题浅拷贝出问题只是时间问题第三RAII和移动语义不是高深技巧它们是现代C的基本生活习惯。如果你也正在啃C核心编程这部分建议不要只记结论而是把这里的代码片段自己敲一遍在调试器里看一遍对象的构造顺序和析构顺序。看十遍不如崩一次崩完你就理解我为什么在这篇笔记里反复强调“默认行为不可妄信”。下一篇笔记我会继续整理继承与多态里的虚函数机制那里还有另一片充满“想当然”的雷区。