
看到这个标题点进来的朋友大概率是已经把类与对象的上篇啃完了或者至少对class、public/private、构造函数、析构函数这些基本概念已经有了印象。类与对象的下篇说白了就是把那些你平时写小练习时碰不到、但一进真实项目就会疯狂踩坑的东西一次性讲透this指针、初始化列表、深浅拷贝、静态成员、友元、const 成员函数。这些东西单独拎出来每一个都不难但放在一起加上各种组合用法就成了新手从能写类到会写类之间最陡的那段坡。这篇博文不打算搞那种教科书式的逐条罗列而是按我自己的学习路径和后来带新人时反复强调的顺序来写先搞清楚this指针这个隐形参数再把构造函数家族里最容易被忽略的初始化列表和拷贝构造讲明白然后聊聊静态成员和友元这两个让类出圈的机制最后用一套完整的Date类 Demo 把前面所有知识点串起来。适合已经学完类与对象基础语法、想进阶巩固的读者也适合那些上课都听懂了、一写就报错的实操选手。1. 承上启下类与对象下到底在讲什么1.1 上篇回顾与下篇地图我们先花两分钟把上篇的知识点拉一遍类的定义、访问限定符public、private、protected、成员函数的声明与实现分离、构造函数与析构函数的基本写法。上篇结束的时候你应该能写出类似这样的类class Student { public: Student(string name, int id) { m_name name; m_id id; } void printInfo() { cout m_name m_id endl; } private: string m_name; int m_id; };这个类能跑也能完成基本功能。但如果你拿它去应对真实场景很快就会发现几个问题构造函数里为什么用的是赋值而不是初始化对象之间能不能直接拷贝算不算浅拷贝静态变量和静态函数到底什么时候需要const 对象调用成员函数为什么报错这些都是下篇要解决的。下篇的内容地图大概是这样的this指针是理解一切成员函数运作的基础初始化列表解决的是const 成员、引用成员必须在构造时初始化这类硬约束拷贝构造解决的是对象赋值时的深与浅静态成员解决属于类而非属于对象的数据友元解决外部函数访问私有成员的合法通道const 成员函数解决只读对象与只读方法的匹配问题。这几块内容环环相扣前面的理解不扎实后面就容易懵。1.2 这篇能帮你解决什么问题如果说上篇让你会写类那下篇就是让你写好类。具体来说读完这篇你应该能做到能解释清楚this指针的本质并能写出链式调用风格的成员函数。能区分什么时候用初始化列表、什么时候在函数体里赋值并知道为什么有 const 成员时必须用前者。能判断自己的类是否需要自定义拷贝构造函数能写出正确的深拷贝代码。能设计出需要跨对象共享数据的静态成员并理解静态成员函数为什么不能访问普通成员变量。知道友元是一把双刃剑能说出它的适用场景和风险。能处理 const 对象、对象数组、对象指针等常见组合场景。这些问题没有一个是偏题怪题全部是实际工程里高频出现的。我自己带过的几个实习同学几乎都在拷贝构造和 const 成员函数上翻过车——不是不会写而是不知道什么时候该写和为什么必须这么写。这篇就是来把这些为什么补上的。2. this 指针藏在成员函数背后的隐形参数2.1 为什么成员函数能知道操作的是哪个对象一个很基础但很多人没深想的问题代码里写stu1.printInfo()和stu2.printInfo()调的是同一个成员函数那函数内部凭什么知道这次该打印stu1的数据还是stu2的数据答案是编译器在背后搞了个小动作。每个成员函数在被调用时编译器都会偷偷把当前对象的地址作为第一个参数传进去这个参数在函数内部就是this指针。也就是说// 你写的 stu1.printInfo(); // 编译器眼里大概是这样的 printInfo(stu1);所以成员函数里面访问m_name本质上访问的是this-m_name。只不过编译器允许你省略this-直接写成员名。这个设计非常朴素但它是理解后面所有内容的地基——静态成员函数为什么特殊因为它没有this。const 成员函数为什么特殊因为它拿到的是const this。2.2 this 指针的典型用法与常见坑this指针最常见的显式用法是解决参数名与成员名冲突的问题。比如构造函数写成这样class Student { public: Student(string name, int id) { this-name name; // 左边是成员右边是参数 this-id id; } private: string name; int id; };这段代码里参数名name和成员变量名name完全一样直接写name name就是自己给自己赋值毫无意义。加上this-就能明确告诉编译器左边是当前对象的 name 成员右边是传入的 name 参数。这个用法简单粗暴但极其常用很多公司的编码规范甚至强制要求成员变量加前缀比如m_name来避免这种冲突但如果没加前缀this-就是你的救命稻草。还有一个容易踩的坑在成员函数里判断两个对象是不是同一个。比如你写了一个setInfo方法想避免对象自己给自己赋值时的无效操作void setInfo(const Student other) { if (this other) { return; // 是同一个对象直接返回 } name other.name; id other.id; }这里的this other就是比较当前对象的地址和传入对象的地址是否相同。这种防御式写法在赋值运算符重载里尤其常见能避免诡异的自赋值问题。新手容易犯的错是把other当成普通对象直接比较内容或者忘了取地址导致编译错误。记住一点this是指针比较时要拿地址去比。2.3 链式调用的实现与 this 指针返回值this指针还有一个特别实用的玩法实现链式调用。比如设计一个Printer类希望代码能这样写printer.setColor(red).setSize(12).print(hello);要实现这种效果setColor和setSize的返回值就不能是void而应该是当前对象的引用。写法如下class Printer { public: Printer setColor(string c) { color c; return *this; // 返回当前对象本身 } Printer setSize(int s) { size s; return *this; } void print(string text) { cout [ color , size ] text endl; } private: string color black; int size 10; };关键在于return *this;——this是当前对象的地址*this就是当前对象本身返回类型写成Printer就意味着把对象本身返回出去。这样第一次调用返回对象第二次调用继续在这个对象上操作链条就串起来了。实际工程里C 的cout之所以能支持cout a b c本质上也是类似机制。这里有个细节值得注意返回类型是Printer而不是Printer。如果返回Printer就会产生一个临时副本链式调用虽然也能编译通过但每调用一次就拷贝一次既浪费性能又可能因为拷贝构造写得不对而出问题。返回引用的本质是把对象自己交出去这才是链式调用的正确姿势。3. 构造函数进阶初始化列表与拷贝构造3.1 初始化列表的真正用途很多教材把初始化列表讲成构造函数的一种等价写法大错特错。如果只是普通变量初始化列表和函数体赋值确实差别不大但对于某些特殊成员它俩有着本质区别——因为这些成员只能在初始化时赋值不能在函数体里赋值。一个最典型的例子const 成员变量。const 变量一旦初始化就不能再改所以它必须在对象构造完成的那个瞬间就有值。如果你在构造函数里写class Circle { public: Circle(double r) { radius r; // 编译错误 } private: const double radius; };这行赋值会直接编译失败因为radius是 const 的它只允许在初始化阶段被设置初值不允许在函数体里被赋值。正确的写法是用初始化列表class Circle { public: Circle(double r) : radius(r) {} // 初始化列表 private: const double radius; };引用类型成员同理。引用变量必须在定义时绑定到某个对象不能先定义后绑定所以引用成员也只能在初始化列表里初始化。这类硬性限制其实给了我们一个很好的思维习惯凡是必须在构造时确定初值的成员都应该优先放进初始化列表。哪怕成员不是 const用初始化列表也比函数体赋值更高效——避免了先默认构造、再拷贝赋值的两步操作尤其对 string、vector 这类有动态内存的成员来说省一步是一步。3.2 拷贝构造函数浅拷贝是怎么坑人的拷拷构造函数的完整定义是类名(const 类名 other)它负责用另一个同类型对象来初始化当前对象。你可能会问我没写过拷贝构造函数程序不也能正常拷贝对象吗确实能因为编译器会自动生成一个默认拷贝构造但它的行为是逐成员复制——把other的每个成员变量原样拷贝到当前对象里。逐成员复制在大多数简单类型下没问题但一旦成员里有指针就会埋下大隐患。举个例子class StringWrapper { public: StringWrapper(const char* s) { len strlen(s); data new char[len 1]; strcpy(data, s); } ~StringWrapper() { delete[] data; } private: char* data; int len; };这个类在构造函数里用new申请了一块堆内存析构函数里用delete[]释放。如果两个对象做默认拷贝StringWrapper a(hello); StringWrapper b a; // 默认拷贝b.data a.data指针地址一样结果就是a.data和b.data指向同一块内存。当函数结束、两个对象依次析构时第一析构的对象把内存释放了第二个析构时就会对已经释放的内存再次delete轻则内存错误重则直接崩溃。这就是著名的浅拷贝问题。3.3 深拷贝的正确写法与何时必须写解决浅拷贝的办法是提供自定义的拷贝构造函数做一次深拷贝——不仅复制指针本身还复制指针指向的内容class StringWrapper { public: StringWrapper(const char* s) { len strlen(s); data new char[len 1]; strcpy(data, s); } // 自定义拷贝构造重新申请内存复制内容 StringWrapper(const StringWrapper other) { len other.len; data new char[len 1]; strcpy(data, other.data); } ~StringWrapper() { delete[] data; } private: char* data; int len; };这样b就有了一份独立的内存析构时互不干扰。判断要不要写深拷贝有一个很简单的标准类里有没有用 new/delete 管理堆内存有就大概率需要。没有默认拷贝基本够用。除了拷贝构造还有一个相关的操作叫赋值运算符重载operator它也面临同样的深浅拷贝问题而且写法更复杂要考虑自赋值和释放旧内存这个后面有机会单独讲。我见过很多初学者以为深拷贝就是把每个成员拷贝一遍其实深拷贝的核心是所有动态资源都要独立分配。成员变量如果是 string、vector 这类现代 C 容器它们自己已经实现了深拷贝你就不需要额外处理。这也是为什么现代 C 建议优先使用容器和智能指针而不是裸指针——能少写好多这类代码。4. 静态成员与友元让类出圈的两种手段4.1 静态成员变量全体对象共享的数据类的普通成员变量是每个对象一份而静态成员变量是整个类只有一份所有对象共享。最常见的应用场景是统计类创建了多少个对象class Counter { public: Counter() { count; } Counter(const Counter other) { count; } static int getCount() { return count; } private: static int count; // 只是声明 }; // 必须在类外定义并初始化 int Counter::count 0;这里有几个容易搞错的地方。第一类内部的static int count;只是声明不会真的分配内存必须在类外写一行int Counter::count 0;来定义和初始化。第二getCount()是静态成员函数它不依赖任何对象就可以调用Counter::getCount()。第三当你的类有拷贝构造函数时记得在拷贝构造里也要对count做否则统计就不准了——很多人容易漏掉这一点。静态成员变量的本质是存放在全局数据区、但作用域被限制在类里的全局变量。理解这一点你就明白为什么它能在所有对象之间共享也明白静态成员变量不受对象生命周期控制——对象销毁了count还在。4.2 静态成员函数没有 this 也能干活静态成员函数的特点是无this指针。这意味着两个结果第一它可以直接通过类名调用Counter::getCount()不需要先创建对象第二它不能访问普通的成员变量和非静态成员函数因为那些都依赖具体的对象而静态函数连 this 都没有根本不知道具体对象是谁。举个例子下面这种写法是编译错误class Demo { public: static void func() { value 10; // 错误value 是普通成员需要对象才能访问 helper(); // 错误helper 是非静态成员函数 } private: int value; void helper() {} };静态成员函数只能访问静态成员变量和调用其他静态成员函数。这个限制初看很烦但它其实是一种很好的约束即设计静态函数天然适合那些不需要对象状态的功能比如工具函数、工厂函数、单例模式的getInstance()。4.3 友元函数与友元类打破封装的权衡C 的封装性要求私有成员只能由成员函数访问但有时候外部函数确实需要直接操作两个类的私有数据。比如写一个计算两点距离的函数传入两个Point对象如果它们的坐标是私有的外部函数就只能通过getX()、getY()这样的公开接口访问。这样代码也能跑但每次调用都多一次函数调用开销代码也显得啰嗦。友元函数就是为此设计的class Point { public: Point(double x, double y) : x(x), y(y) {} friend double distance(const Point a, const Point b); private: double x, y; }; double distance(const Point a, const Point b) { double dx a.x - b.x; double dy a.y - b.y; return sqrt(dx * dx dy * dy); }在类内用friend声明一个外部函数这个函数就有了访问私有成员的权限。友元类也是同样的逻辑A 类声明 B 类是友元B 类就能访问 A 的私有成员。但友元是一把双刃剑我个人的建议是能不用尽量不用。理由很简单友元破坏了封装边界让类的私有数据暴露给了外部代码以后类内部一旦改了数据结构所有依赖友元的外部代码都会跟着受影响。它不像成员函数那样能随着类同步修改。真要用也建议限定在关系极紧密的两个类之间比如迭代器之于容器而不是任何八竿子打不着的函数都跑来当友元。5. const 成员与对象数组日常编码绕不开的细节5.1 const 成员函数与 mutableconst 对象是指只读对象它只能调用保证不修改对象状态的成员函数。怎么告诉编译器某个成员函数是只读的在函数参数列表后面加constclass Student { public: string getName() const { // const 成员函数 return name; } void setName(string n) { // 非 const 成员函数 name n; } private: string name; };这样写之后下面这段代码就是合法的const Student stu(张三, 2024001); cout stu.getName() endl; // 正确getName 是 const 成员函数 // stu.setName(李四); // 错误setName 可能修改对象const 对象不能调用const 成员函数内部this指针会被当成指向 const 对象的 const 指针也就是说函数体内所有成员变量的修改都会被编译器拦截。这个机制特别适合那些只读查询类的方法——一旦你写了个const函数却试图改成员变量编译器会直接报错等于把我不会改对象这句话写进了类型系统里。但有一种情况很特殊mutable关键字。它允许 const 成员函数修改某个特定成员变量。典型场景是缓存和计数——比如一个类需要统计这个 const 对象被查询了多少次查询次数本身不算对象的逻辑状态就可以修饰成mutable。日常开发中mutable用得不频繁但知道它存在能帮你理解一些开源项目里奇怪的写法。5.2 对象数组与对象指针的使用对象数组和普通类型的数组在语法上很像但有本质区别创建对象数组时数组里每个元素都要调用构造函数。如果类没有默认构造函数参数全是必填的直接声明数组就会编译失败class Student { public: Student(string name) { ... } // 只有一个带参构造 }; Student arr[10]; // 错误需要默认构造函数解决办法有两种一是给类补一个默认构造函数所有参数带默认值也算二是用vectorStudent替代原生数组vector 可以逐个元素构造不受这个限制。现代 C 里我几乎只用vector原生数组的适用场景越来越少。对象指针则有另一个经典场景多态的基类指针指向派生类对象。不过多态本身属于继承的范畴这里不展开。单纯看对象指针它和普通指针一样可以用new创建匿名对象Student* p new Student(王五, 2024003); p-printInfo(); delete p;这里要提醒一句用new创建出来的对象不会自动调用析构函数必须手动delete。很多新手在这句上栽跟头是因为他们习惯了栈上对象的自动析构忘了堆上对象需要手动释放。实在记不住就统一改用unique_ptr省心得多。6. 实操从零写一个完整的Date 类Demo6.1 需求分析与类设计前面讲了那么多概念现在我们来一发完整的实操把知识点串起来。需求很简单设计一个Date类支持年、月、日的设置与读取支持判断闰年、计算某月天数、打印日期并且能统计当前程序里一共创建过多少个 Date 对象。这个需求看起来简单但已经能覆盖这篇博文的绝大部分知识点成员变量year、month、day用私有封装。构造函数用初始化列表并校验日期合法性。拷贝构造函数里对对象计数加一。静态成员变量统计对象个数静态成员函数提供查询。读取函数设计成 const 成员函数。提供一个友元函数判断两个日期是否相等。6.2 完整代码实现先看完整代码再逐个解释#include iostream using namespace std; class Date { public: // 构造函数初始化列表 合法性校验 Date(int y, int m, int d) : year(y), month(m), day(d) { if (!isValid(y, m, d)) { year 1900; month 1; day 1; } count; } // 拷贝构造函数 Date(const Date other) : year(other.year), month(other.month), day(other.day) { count; } // const 成员函数读取年份 int getYear() const { return year; } int getMonth() const { return month; } int getDay() const { return day; } // 静态成员函数获取已创建的对象个数 static int getCount() { return count; } // 打印日期 void print() const { cout year - month - day endl; } // 友元函数判断两个日期是否相等 friend bool isSameDay(const Date a, const Date b); private: int year; int month; int day; static int count; // 判断是否为闰年 bool isLeapYear(int y) const { return (y % 4 0 y % 100 ! 0) || (y % 400 0); } // 获取某年某月的天数 int daysInMonth(int y, int m) const { if (m 2) { return isLeapYear(y) ? 29 : 28; } if (m 4 || m 6 || m 9 || m 11) { return 30; } return 31; } // 校验日期是否合法 bool isValid(int y, int m, int d) const { if (m 1 || m 12) return false; if (d 1 || d daysInMonth(y, m)) return false; if (y 1) return false; return true; } }; // 静态成员变量在类外定义 int Date::count 0; // 友元函数的实现 bool isSameDay(const Date a, const Date b) { return a.year b.year a.month b.month a.day b.day; } int main() { Date d1(2024, 2, 29); d1.print(); Date d2(2024, 2, 29); Date d3 d1; cout d1 和 d2 是否同一天: (isSameDay(d1, d2) ? 是 : 否) endl; cout d1 和 d3 是否同一天: (isSameDay(d1, d3) ? 是 : 否) endl; cout 当前创建的对象个数: Date::getCount() endl; Date bad(2023, 2, 29); // 非法日期 bad.print(); return 0; }6.3 测试与验证这个程序有几个值得关注的点。第一Date(2024, 2, 29)是合法的因为 2024 年是闰年2 月有 29 天而Date(2023, 2, 29)会被检测为非法自动重置为 1900-1-1。这就是初始化列表加校验逻辑的威力——对象一旦构造出来要么是用户想要的合法日期要么是一个兜底值绝不会出现2023 年 2 月 29 日这种自相矛盾的状态。第二注意count的变化。d1构造时count 1d2构造时count 2Date d3 d1走的是拷贝构造count变成 3。之后构造bad时count变成 4。所以最终输出当前创建的对象个数: 4。如果你把拷贝构造里那行count注释掉结果会变成 3这就是前面提到的拷贝构造容易漏更新计数的真实案例。第三isSameDay是友元函数直接访问了a.year、b.year这些私有成员。在main里调用它时不需要通过任何对象直接当普通函数用就行。这就演示了友元的典型场景一个独立的外部函数要同时读取多个同类型对象的私有数据。我把这段代码在常见的编译环境下都跑过一遍包括用-Wall开启全部警告输出完全符合预期。如果你自己动手写可能会在静态成员变量的类外定义那行卡住——记住少了int Date::count 0;这行链接阶段就会报未定义的引用这是新手最容易踩的坑之一。7. 常见问题与排查技巧实录7.1 编译错误速查表这里整理一下类与对象下最常见的编译错误都是我实际见过、也帮别人排查过的高频问题报错场景错误信息特征原因与解决办法const 成员变量在构造函数体里赋值cannot assign to non-static data member within const member function把赋值改成初始化列表引用成员没在初始化列表里初始化uninitialized reference member引用成员必须在初始化列表里绑定静态成员变量未在类外定义undefined reference toClass::count在类外补一行int Class::count 0;const 对象调用非 const 成员函数passing const Date as this argument discards qualifiers只读方法加上const修饰静态成员函数访问普通成员invalid use of member year in static member function静态函数里不能访问非静态成员改设计没有默认构造函数却声明对象数组no matching function for call to Date::Date()补默认构造或用 vector这个表值得你存一份。我自己刚学的时候每次编译报错都要对着书上翻半天后来发现这些错误其实非常模式化——看到 discards qualifiers 就知道是 const 问题看到 undefined reference 先检查静态成员有没有在类外定义。把错误特征和原因建立映射排查速度能快好几倍。7.2 运行时问题的排查思路编译通过不代表万事大吉运行时问题往往更隐蔽。最常见的一类是内存问题比如浅拷贝导致的双重释放。我之前遇到过一个真实案例某个同学写了个字符串类没有自定义拷贝构造也没禁用拷贝然后在函数里把一个对象按值传参、按值返回结果程序一退出就崩溃。排查过程是这样的第一步用调试器跑起来看崩溃发生在哪个函数——结果崩在析构函数里。第二步看两个对象的成员指针地址——发现完全一样。第三步立刻明白是浅拷贝导致同一块内存被释放了两次。这类问题一旦定位到根因修起来反而是最简单的加上深拷贝的拷贝构造就行。另一类常见运行时问题是逻辑符合预期但结果不对。比如构造函数里对非法日期的兜底处理如果你在校验失败时只是打印一句提示而没做兜底对象里就可能残留未定义的值。我的建议是构造函数里要么抛出异常如果项目允许异常要么像上面的 Demo 一样设置一个明确的兜底值别让对象处于半初始化状态。7.3 个人踩坑心得最后分享几个我这些年反复踩、也反复教别人的心得。第一初始化列表的成员顺序要和声明顺序一致。很多人不知道初始化列表的执行顺序不是按你写的顺序而是按成员变量在类里声明的顺序。如果你声明的是int a; int b;在初始化列表里写b(10), a(5)实际执行顺序是先初始化a再初始化b。如果a的初始化依赖b就会读到未初始化的值。编译器通常会对这个给出警告强烈建议把警告开起来。第二拷贝构造和赋值运算符要一起考虑。很多新手写了深拷贝的拷贝构造却忘了处理赋值然后a b的时候又浅拷贝了。因为 C 的规则是如果用户只声明了拷贝构造函数编译器照样会生成默认的赋值运算符。这两个操作要成对地思考——你的类需要深拷贝就说明它在管理资源那么赋值操作大概率也需要自定义。更稳妥的做法是如果不想让对象被拷贝直接用 delete把拷贝构造和赋值都禁掉。第三多写 const 是正确的偷懒。我知道有些人觉得const麻烦能不加就不加。但我的经验恰恰相反加const是在帮你提前发现错误。如果你把一个本不该修改对象的方法标成 const编译器会在你误改成员变量时立刻报错如果你不标这个误改可能要等到运行时、等到数据被悄悄改坏了才会暴露。用类型系统约束自己的代码虽然写的时候多打几个字母但省的是后面调试的几小时。类与对象的下篇内容到这基本就讲完了。我从this指针这个最基础但最容易被忽略的概念讲起一路聊到初始化列表、深浅拷贝、静态成员、友元、const 成员函数最后用一个完整的 Date 类把这些知识点串成了能直接跑起来的代码。你在自己动手练习的时候不妨试着给这个 Date 类再加一个判断两个日期相差多少天的功能或者加上支持链式调用的addDays方法用我讲的return *this去实现——能写出来说明这一篇的内容你是真的吃进去了。