在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; } };另外,如果要在一个成员函数里返回当前对象本身,也要用到*this:
class 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;,链接时报错说"未定义",然后一头雾水。原因就是静态成员变量的内存不是跟着对象走的,它跟整个程序一样长寿命,需要在类外面为它分配实际空间。
C++17之后可以用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必须有默认构造函数如果类只有带参数的构造函数,你可以在C++11之后这样写:
Student arr[] = {Student("张三", 18), Student("李四", 19)};或者用动态数组:
Student* arr = new Student[5];但new[]这种方式同样要求默认构造函数,因为C++在分配数组时无法逐个传参。
对象指针就灵活多了。你不需要一次性创建所有对象,而是先创建一个指针数组,再按需new:
Student* students[10]; students[0] = new Student("张三", 18); students[1] = new Student("李四", 19); // 不用的时候逐个delete delete students[0]; delete students[1];用对象指针的好处是灵活,对象生命周期完全由你控制;坏处是你得自己记得delete,忘了就泄漏。实践中我建议优先用标准库的vector替代裸的new动态数组,vector<Student> 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 vector<Student>& students) { double sum = 0.0; for (const auto& s : students) { sum += s.getScore(); } return students.empty() ? 0.0 : sum / students.size(); } int main() { vector<Student> 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 vector<Student>&,所以getScore和getName必须加const才能调用,这就是前面讲const成员函数的原因。vector里存储的是对象本身,emplace_back直接构造避免了一次不必要的拷贝,这也体现了现代C++对拷贝开销的重视。
程序结束前,vector析构会逐个销毁里面的Student对象,每次析构totalCount减1,最后总数归零,静态计数器完美地反映了对象存活情况。
写到这里,构造函数、析构函数、拷贝构造函数、this指针、const成员函数、静态成员和友元这些"类与对象(中)"的核心点就都覆盖了。最后分享一点自己的体会:学这些概念时不要死记规则,多去理解语言设计者的处境。为什么需要析构函数?因为一个对象可能持有你借来的资源。为什么要深拷贝?因为两个对象共享同一块内存会出双重释放。为什么会有this?因为一份成员函数代码要服务所有对象。
把这些问题想通了,规则自然就记住了,而且很多莫名其妙的编译错误也能一眼看穿。下面两篇里还会有运算符重载、继承多态这些更有意思的内容,到那时候你会发现,今天学的构造函数和拷贝构造,是撑起整个C++面向对象体系的基石。