C++类与对象进阶:构造函数、深拷贝、this指针与静态成员全解析
在C里学了基本语法、函数、数组之后大部分人都会卡在类与对象这一块。前面我们知道了什么叫封装、怎么定义成员变量和成员函数但真正让我觉得类开始好用的确实是进入类的中级阶段之后。类与对象中这一篇我们直奔主题对象是怎么被创建和销毁的、对象自我复制时隐藏的坑、对象怎么知道自己在哪、还有一批属于整个类而不是某个对象的数据和函数该怎么用。这篇文章适合已经看过类与对象基础、但还没搞懂构造函数、拷贝构造、this、静态成员这些概念的读者。我会结合实际的代码和踩坑经验来写尽量把每个机制的为什么讲透而不是只丢结论。1. 构造函数与析构函数对象的出生与告别1.1 为什么有了成员函数还不够很多初学者会有这个疑问定义一个类之后里面的成员变量是private的我想给它们赋初值写一个init函数不就行了为什么非要搞一个构造函数我一开始也这么写过class Student { private: string name; int age; public: void init(string n, int a) { name n; age a; } };然后创建对象的时候调用stu.init(张三, 18)。这样确实能用但两个问题很痛第一如果你忘了调用init对象里的name是空的、age是随机值整个对象处于一种半成品状态。C的设计哲学里有一个很重要的原则——对象一旦创建它就应该是一个完整可用的状态。构造函数就是来保证这一点的。第二每次创建对象都要手动调一次init代码重复率太高。如果你创建了100个对象就要写100次init调用还容易漏掉。构造函数是一种特殊的成员函数名字和类名完全相同没有返回值创建对象时会自动被调用。正因为这个自动才让对象的初始化不再依赖程序员记性。1.2 构造函数的重载和默认参数构造函数可以重载这意味着你可以给一个类设计多种创建方式。比如一个日期类你可以用年月日创建也可以只传年月或者只传年class Date { private: int year, month, day; public: Date() : year(2025), month(1), day(1) {} // 无参构造 Date(int y, int m, int d) : year(y), month(m), day(d) {} // 全参构造 Date(int y) : year(y), month(1), day(1) {} // 只传年 };这段代码里我用的是初始化列表这个稍后细说。先注意一个细节构造函数一旦你自定义了任何一个编译器就不再生成默认无参构造函数。这里有个常见的坑——你写了一个带三个参数的构造然后创建一个无参对象Date d1; // 编译错误没有默认构造函数 Date d2(2024, 3, 15); // 正确为什么因为你已经声明我要亲手控制对象的初始化方式编译器就认为你不再需要它帮你生成的那个无参版本了。如果你确实还需要无参创建就得像我上面那样自己写上Date()。再来说默认参数。构造函数和普通函数一样支持默认参数比如class Date { private: int year, month, day; public: Date(int y 2025, int m 1, int d 1) : year(y), month(m), day(d) {} };这样的话Date d1;、Date d2(2024);、Date d3(2024, 6);、Date d4(2024, 6, 15);都是合法的。注意如果你同时写了带默认参数的构造函数和无参构造函数编译器会分不清Date d1;到底该用哪个直接报错。重载和默认参数不要混用在歧义的边界上。1.3 析构函数构造函数反过来办事有出生就必然有告别。析构函数的名字是在类名前面加个波浪号~没有参数、没有返回值、一个类只能有一个。它的调用时机是对象生命周期结束时。栈上对象在离开作用域时自动析构堆上对象在你delete时析构。这时很多新手会问析构函数到底有什么实际用途如果成员变量只是一些int和string析构函数确实看起来没什么用编译器默认生成的析构函数就够用了。但如果你在构造函数里new了内存、打开了文件、建了数据库连接那么析构函数就是收拾烂摊子的唯一可靠时机。我自己最常遇到的场景是动态内存管理。比如有个字符串拼接类class MyString { private: char* data; int size; public: MyString(const char* str) { size strlen(str); data new char[size 1]; strcpy(data, str); } ~MyString() { delete[] data; } };这里如果不写析构函数data指向的内存在对象销毁时不会自动释放。这个漏洞每泄漏一点程序跑久了内存越占越大最后崩溃都不知道因为什么。提示new和delete一定要配对new[]和delete[]也要配对别混用。混用虽然有时候不会立刻报错但属于未定义行为换编译器就翻车。1.4 初始化列表为什么它比函数体赋值更讲究在构造函数里给成员变量赋值有两种写法// 写法一函数体赋值 Student(string n, int a) { name n; age a; } // 写法二初始化列表 Student(string n, int a) : name(n), age(a) {}很多人只知道写法二更简洁但不知道它和写法一有本质区别。写法一里的name n是发生在对象已经构造完成之后此时name先被默认构造了一次然后再调用赋值运算符。写法二是直接在name构造的时候就传入n少了一次默认构造。对于int这种基础类型两种写法差别可以忽略。但如果成员是const类型、引用类型、或者没有默认构造函数的类类型那就只能写法二。比如class Account { private: const int id; double balance; public: Account(int i, double b) : id(i), balance(b) {} };id是const成员它一旦确定就不能再变必须在这个对象构造的那一刻就确定下来。如果你写成Account(int i, double b) { id i; }编译都过不了——因为当函数体开始执行const成员已经用垃圾值初始化完了你再想给它赋值就是犯规。一个经验凡是能写在初始化列表里的就尽量写在初始化列表里。不只是为了规范更是为了让代码在未来维护时少一些奇怪的初始化顺序问题。初始化列表的赋值顺序是成员变量在类中声明的顺序不是初始化列表书写的顺序这一点很多人踩过坑。变量a声明在前、b在后你写: b(1), a(2)实际执行还是先初始化a再初始化b。2. 拷贝构造函数与深拷贝复制对象没你想的那么简单2.1 什么时候会触发拷贝构造拷贝构造函数是参数为自己的引用类型的构造函数。它的作用是用一个已有对象去初始化另一个同类对象。很多人会把拷贝构造和赋值混淆我先区分清楚对象创建时用已有的另一个对象来初始化——这是拷贝构造对象已经创建好了再用另一个对象给它赋值——这是赋值运算符。Student a(张三, 18); Student b(a); // 拷贝构造 Student c a; // 拷贝构造注意虽然用了但这里还是拷贝构造 c b; // 赋值运算符重载这四行里第三行是最容易看错的。Student c a;虽然长得像赋值但因为c此时是正在创建的新对象所以调用的是拷贝构造。真正的赋值发生在第四行因为c已经存在了。触发拷贝构造的场景不止这一种Student b(a)这样的显式创建Student c a这种等效写法函数传参时按值传递void func(Student s)实参a传入形参s时s就是通过a拷贝构造出来的函数返回对象时按值返回Student getStu()返回时也会发生拷贝这里就引出一个重要的性能问题如果你有大对象按值传参每一次传参都是一次深拷贝开销很大。所以现代C都提倡传引用、传常量引用除非你确实需要一份数据副本。2.2 默认拷贝构造的隐患——浅拷贝如果一个类没写拷贝构造函数编译器会自动生成一个它做的事情是逐个成员变量的拷贝。对int、double这类基础类型没问题但遇到指针成员就出事了。看这个例子——还是一个MyString类class MyString { private: char* data; int size; public: MyString(const char* str ) { size strlen(str); data new char[size 1]; strcpy(data, str); } ~MyString() { delete[] data; } char* getData() const { return data; } }; int main() { MyString s1(hello); MyString s2(s1); cout s2.getData() endl; // 打印hello看起来没问题 } // 程序结束时s1和s2各自调用析构函数这里最阴险的地方在于程序的输出是正常的hello看起来一切正常。崩溃发生在程序结束时s1的data和s2的data指向了同一块内存s1析构时delete掉了这块内存s2析构时再去delete一块已经释放过的内存——双重释放。在某些环境下可能不报错在另一个环境下直接崩溃或者恰好释放后又被其他内存占用出现随机性的数据错乱。这种逐成员复制的方式叫浅拷贝。默认拷贝构造不认得的潜在后果指针成员在构造和析构的过程中出现了资源所有权冲突。2.3 深拷贝的正确实现深拷贝的思想很简单拷贝的时候把指针所指的那块内存也重新复制一份让两个对象各自拥有独立的内存而不是共享同一块。class MyString { public: MyString(const MyString other) { size other.size; data new char[size 1]; strcpy(data, other.data); } private: char* data; int size; };这样s1和s2各有各的data空间指向不同的内存各自析构各的互不干扰。判断一个类要不要写深拷贝拷贝构造函数就看它有没有指针成员、有没有管理外部资源。如果你遇到了程序运行莫名崩溃而且崩溃位置在析构函数这种抽丝剥茧下来的凶手闭眼入排查方向检查所有类里的指针成员看拷贝后是不是出现了两个指针指向同一块地址的情况。对包含string、vector这类标准库容器的类默认浅拷贝其实是安全的因为这些容器自身的拷贝构造已经实现了深拷贝。真正要手动深拷贝的还是C风格的内存资源比如FILE*、char*、或者自己申请的缓冲区。3. this指针与const成员函数对象怎么找到自己3.1 this指针从哪里来你有没有想过一个问题一个类的成员函数比如void Student::setName(string n)它怎么知道操作的是哪个对象的name你调用a.setName(张三)它改a调用b.setName(李四)它改b代码只有一份凭什么答案是this指针。每一个非静态成员函数内部都有一个隐式的this指针它指向调用这个函数的那个对象。上面的调用等价于说把a的地址传给了setName函数内部通过这个地址去定位name成员。你可以显式地使用this尽管大多数时候不写也没事。一个最常见的场景是参数名和成员变量名撞车class Student { private: string name; public: void setName(string name) { // this-name 指的是成员变量name右边name是参数 this-name name; } };另外如果要在一个成员函数里返回当前对象本身也要用到*thisclass Counter { private: int value; public: Counter add(int n) { value n; return *this; // 返回当前对象本身 } };这样就能链式调用了counter.add(3).add(5).add(10);这种风格在C的流式操作里到处都是。3.2 const成员函数到底限制了谁const成员函数是指函数声明末尾带const的成员函数比如int getAge() const。它的含义是这个函数保证不修改对象的成员变量。编译器会把函数里的this指针从Student*变成const Student*相当于你拿到的this是一个指向常量对象的指针。你在这个函数里试图给成员变量赋值编译都过不了。这样设计的意义在于一个const对象用const Student s;创建的对象只能调用const成员函数。假如getAge没加const那么s.getAge()就直接编译失败。这看起来是个小细节但在代码里传递常量引用时影响很大void printInfo(const Student s) { cout s.getAge(); // 如果getAge不是const这里就编译不过 }我在游戏开发里见过不少因为const修饰不完整导致的蔓延式代码改动一个函数忘了加const导致所有调用它的地方全要跟着调整签名最后哪里没改就报错。所以我的习惯是凡是理论上不会修改对象状态的成员函数一律加const比如取值器getter、查询函数、打印函数。这算是相对简单却又实实在在的工程经验。3.3 const对象和mutable关键字有时候你想在const成员函数里修改某个成员怎么办C给我们留了一个后门mutable关键字修饰的成员变量即使在const成员函数里、即使是const对象也可以修改。class Server { private: mutable int requestCount; public: int getCount() const { requestCount; // mutable允许在const函数里修改 return requestCount; } };mutable的经典用途是统计调用次数、做缓存、加锁同步这些不影响对象逻辑状态的操作。我说它是后门因为它确实突破了const的约束所以用的时候要有节制。如果为了编译通过到处加mutable那const整个就形同虚设了。4. 静态成员属于整个类的数据和函数4.1 静态成员变量必须在类外定义普通的成员变量每个对象各有一份你定义100个Student对象就有100个name。但静态成员变量是整个类只有一份不管创建多少个对象大家共用同一个存储空间。这个大家共用一份的特性让它在很多场景很好用比如统计当前存在的对象个数class Student { private: string name; static int totalCount; public: Student(string n) : name(n) { totalCount; } ~Student() { totalCount--; } static int getCount() { return totalCount; } }; int Student::totalCount 0;这里有个新手必踩的坑静态成员变量在类内只是声明必须在类外定义一次并初始化。int Student::totalCount 0;这行不能省。不少人在学习时只在类里写了static int totalCount;链接时报错说未定义然后一头雾水。原因就是静态成员变量的内存不是跟着对象走的它跟整个程序一样长寿命需要在类外面为它分配实际空间。C17之后可以用inline static int totalCount 0;直接在类内初始化编译时就能通过省得去类外写定义。4.2 静态成员函数能做什么、不能做什么静态成员函数和静态成员变量类似它不依托于某个具体对象。它的特点是没有this指针。记住这个推论静态成员函数内部不能访问非静态成员变量也不能调用非静态成员函数。因为非静态成员变量是依附于对象的而静态成员函数连this都没有它都不知道你是哪个对象的。class Student { private: string name; static int totalCount; public: static int getCount() { return totalCount; } // 正确totalCount是静态的 // static string getName() { return name; } // 错误没有this无法访问name };静态成员函数通常用来访问静态成员变量或者做一些不依赖具体对象的工具性操作。常见的写法是类名::函数名()直接调用不需要创建对象cout Student::getCount() endl;4.3 实际场景对象计数器与全局配置静态成员的典型用处先说对象计数器。结合构造函数和析构函数每次创建对象totalCount加1每次销毁减1就能实时知道当前还有多少个对象存活。这在排查内存泄漏时很有用程序结束时如果计数器不为0说明有的对象没被正确析构。第二个典型用处是全局配置。比如一个游戏引擎里把窗口宽度、渲染模式、帧率上限设为静态成员整个程序共享这一份配置通过Config::width来访问。相比全局变量静态成员有类型约束、有访问控制更安全。有一个容易混淆的地方静态成员变量不是不能定义为private它跟普通成员一样可以设置为private然后通过静态成员函数来访问。刚才的totalCount就是private的外部只能通过getCount这个静态函数读取没法直接改这就封装得很好。5. 友元、对象数组与对象指针打破封闭和批量管理5.1 友元函数和友元类前面学了封装成员变量private之后外部不能直接访问。但现实中有一些函数跟类的关系太密切比如打印一个类的调试信息、重载运算符它们需要穿透private访问逐个调getter又显得很蠢。C给了友元这个特批通道。友元函数在类的内部声明前面加friend关键字。它不属于这个类的成员函数但可以访问这个类的所有私有成员class Matrix { private: int rows, cols; public: Matrix(int r, int c) : rows(r), cols(c) {} friend void printInfo(const Matrix m); }; void printInfo(const Matrix m) { cout 行数 m.rows 列数 m.cols endl; }友元类也一样一个类A声明另一个类B为友元后B的所有成员函数都可以访问A的私有内容。用友元要克制。它确实方便但代价是破坏了封装性。别人本来只能通过公有接口操作你的对象现在友元等于给了某个人一把万能钥匙他爱怎么翻就怎么翻。我通常会先问自己能不能用公有接口解决比如增加一个公有getter——虽然啰嗦但安全。只有像运算符重载这种必须强制访问私有成员的情况我才用友元。5.2 对象数组与对象指针对象数组就是存储对象的数组。创建对象数组时如果类没有默认构造函数会直接编译失败因为数组创建要求每个元素都能被构造Student arr[5]; // 要求Student必须有默认构造函数如果类只有带参数的构造函数你可以在C11之后这样写Student arr[] {Student(张三, 18), Student(李四, 19)};或者用动态数组Student* arr new Student[5];但new[]这种方式同样要求默认构造函数因为C在分配数组时无法逐个传参。对象指针就灵活多了。你不需要一次性创建所有对象而是先创建一个指针数组再按需newStudent* students[10]; students[0] new Student(张三, 18); students[1] new Student(李四, 19); // 不用的时候逐个delete delete students[0]; delete students[1];用对象指针的好处是灵活对象生命周期完全由你控制坏处是你得自己记得delete忘了就泄漏。实践中我建议优先用标准库的vector替代裸的new动态数组vectorStudent students;配合students.emplace_back(张三, 18);既不用管内存释放也不需要一定存在默认构造函数比原始指针管理舒服得多。5.3 一个综合小例子学生成绩管理系统把这一章的知识串起来写一个简单但完整的小程序——学生成绩管理系统。它用到构造函数、析构函数、静态计数、对象数组、拷贝等概念。#include iostream #include string #include vector using namespace std; class Student { private: string name; double score; static int totalCount; public: Student(string n 未知, double s 0.0) : name(n), score(s) { totalCount; cout 创建学生 name endl; } Student(const Student other) : name(other.name), score(other.score) { totalCount; cout 拷贝学生 name endl; } ~Student() { totalCount--; cout 销毁学生 name endl; } void setScore(double s) { score s; } double getScore() const { return score; } string getName() const { return name; } static int getTotalCount() { return totalCount; } }; int Student::totalCount 0; double calculateAverage(const vectorStudent students) { double sum 0.0; for (const auto s : students) { sum s.getScore(); } return students.empty() ? 0.0 : sum / students.size(); } int main() { vectorStudent students; students.emplace_back(张三, 82.5); students.emplace_back(李四, 91.0); students.emplace_back(王五, 74.5); cout 当前学生总数 Student::getTotalCount() endl; cout 平均分 calculateAverage(students) endl; return 0; }这里calculateAverage接的是const vectorStudent所以getScore和getName必须加const才能调用这就是前面讲const成员函数的原因。vector里存储的是对象本身emplace_back直接构造避免了一次不必要的拷贝这也体现了现代C对拷贝开销的重视。程序结束前vector析构会逐个销毁里面的Student对象每次析构totalCount减1最后总数归零静态计数器完美地反映了对象存活情况。写到这里构造函数、析构函数、拷贝构造函数、this指针、const成员函数、静态成员和友元这些类与对象中的核心点就都覆盖了。最后分享一点自己的体会学这些概念时不要死记规则多去理解语言设计者的处境。为什么需要析构函数因为一个对象可能持有你借来的资源。为什么要深拷贝因为两个对象共享同一块内存会出双重释放。为什么会有this因为一份成员函数代码要服务所有对象。把这些问题想通了规则自然就记住了而且很多莫名其妙的编译错误也能一眼看穿。下面两篇里还会有运算符重载、继承多态这些更有意思的内容到那时候你会发现今天学的构造函数和拷贝构造是撑起整个C面向对象体系的基石。