第四章:类与对象——从封装到对象生命周期的全面掌控
在C++中,类(Class)是用户自定义的数据类型,它将数据成员(属性)和成员函数(行为)封装为一个整体;而对象(Object)则是类的具体实例。如果说前三章的函数与指针是C++的“肌肉”与“骨骼”,那么类与对象就是C++的“心脏”。本章将围绕类的定义、访问控制、构造与析构、拷贝语义、赋值运算符、this指针以及静态成员展开,帮助你建立面向对象编程的完整认知框架。我们将不仅讨论语法,更会深入探讨编译器在背后做了什么,以及如何避免资源管理中的经典陷阱。
上图展示了本章的核心知识框架:类定义是基础,访问控制决定了封装边界,而对象的生命周期则由构造、拷贝、赋值和析构共同管理。接下来,我们将逐一深入每个环节。
4.1 类的定义与访问控制:封装的基石
类的定义以关键字 class 或 struct 开始,后跟类名和类体。类体中可以包含数据成员和成员函数。访问控制是封装的直接体现,它决定了类成员在类外部的可见性。C++提供三种访问级别:public(公有)、private(私有)和 protected(保护)。
核心原理:访问控制并非安全机制,而是设计契约。它告诉使用者“哪些是稳定接口,哪些是内部实现细节”。将数据成员设为 private,可以防止外部代码直接修改对象状态,从而保证数据完整性。成员函数通常设为 public,作为操作数据的唯一入口。
class BankAccount {
private:
double balance; // 私有数据成员,外部不可直接访问
public:
void deposit(double amount) { // 公有接口
if (amount > 0) balance += amount;
}
double getBalance() const { return balance; } // const成员函数
};
判断方法:选择访问级别时,遵循“最小权限原则”。默认情况下,class 的成员是 private,而 struct 的成员是 public。如果类体很小且主要用于数据聚合(如坐标点),使用 struct 更简洁;如果涉及业务逻辑和资源管理,应使用 class 并显式声明访问级别。
| 访问级别 | 类内访问 | 对象访问 | 派生类访问 | 典型用途 |
|---|---|---|---|---|
| public | ✓ | ✓ | ✓ | 对外接口、常量 |
| private | ✓ | ✗ | ✗ | 内部数据、辅助函数 |
| protected | ✓ | ✗ | ✓ | 供派生类使用的成员 |
实战场景:设计一个 Student 类,包含学号、姓名和成绩。学号一旦创建不应修改,成绩需要校验范围(0-100)。通过将数据设为 private,并提供 setScore() 和 getScore() 接口,可以确保成绩永远合法。
常见误区:初学者常将所有成员设为 public,导致对象状态可以被任意篡改。例如,直接修改 balance 为负数,破坏业务规则。另一个误区是滥用 protected,实际上大多数成员应优先考虑 private,仅在明确需要派生类访问时才使用 protected。
4.2 构造函数与析构函数:对象的入口与出口
构造函数(Constructor)在对象创建时自动调用,用于初始化数据成员。析构函数(Destructor)在对象销毁时自动调用,用于释放资源。构造函数可以重载,析构函数只能有一个且不能重载。
核心原理:构造函数的名字与类名相同,没有返回类型。如果类中没有定义任何构造函数,编译器会生成一个默认构造函数(无参数,对内置类型成员不初始化)。一旦用户定义了任意构造函数,编译器便不再生成默认构造函数。析构函数的名字是 ~类名,同样没有返回类型,常用于释放堆内存、关闭文件、断开网络连接等。
class FileHandler {
private:
FILE* fp;
public:
FileHandler(const char* filename) { // 构造函数
fp = fopen(filename, "r");
if (!fp) throw std::runtime_error("文件打开失败");
}
~FileHandler() { // 析构函数
if (fp) fclose(fp);
}
};
操作流程:对象创建时,先分配内存,然后调用构造函数。对象销毁时,先调用析构函数,再释放内存。对于栈上对象,析构在作用域结束时自动发生;对于堆上对象(new 创建),必须显式 delete 才会调用析构函数。
经验提醒:构造函数初始化列表比在函数体内赋值更高效。例如 Student(int id) : id_(id) {} 直接初始化成员,避免了先默认构造再赋值的两步操作。对于 const 成员和引用成员,必须使用初始化列表。
案例拆解:假设开发一个日志系统,每个 Logger 对象打开一个日志文件。构造函数接收文件名并创建文件,析构函数负责关闭文件并刷新缓冲区。如果忘记定义析构函数,文件句柄将泄漏,导致系统资源耗尽。通过 RAII(资源获取即初始化)思想,将资源生命周期与对象绑定,是C++中管理资源的标准做法。
风险说明:如果构造函数中抛出异常,析构函数不会被调用,已分配的资源可能泄漏。解决方案是使用智能指针(如 std::unique_ptr)管理资源,或使用 try-catch 在构造函数内捕获异常并清理。
4.3 拷贝构造与赋值运算符:深拷贝与浅拷贝的博弈
拷贝构造函数(Copy Constructor)用于以一个对象初始化另一个新对象。赋值运算符(Assignment Operator)用于将一个已存在的对象赋值为另一个对象。两者的默认版本执行“浅拷贝”,即逐字节复制成员。对于含有指针成员的类,浅拷贝会导致多个对象指向同一块内存,引发双重释放或悬空指针。
核心原理:拷贝构造的触发场景包括:按值传参、按值返回、直接初始化(Student s2 = s1;)。赋值运算符的触发场景是 s2 = s1;(两个对象都已存在)。区分两者的关键是看是否有新对象被创建。
class String {
private:
char* data;
public:
String(const char* str) { /* 分配内存并复制 */ }
// 拷贝构造:深拷贝
String(const String& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
}
// 赋值运算符:深拷贝 + 自赋值检查
String& operator=(const String& other) {
if (this == &other) return *this; // 自赋值检查
delete[] data; // 释放旧资源
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
return *this;
}
~String() { delete[] data; }
};
判断方法:如果类中有原始指针、文件句柄、网络连接等资源,必须自定义拷贝构造和赋值运算符。遵循“三法则”(Rule of Three):如果自定义了析构函数、拷贝构造或赋值运算符中的任何一个,通常需要同时定义另外两个。C++11 之后,移动语义(移动构造和移动赋值)可以进一步提升性能,但本章先聚焦于拷贝语义。
| 操作 | 触发时机 | 默认行为 | 风险 |
|---|---|---|---|
| 拷贝构造 | 新对象初始化 | 浅拷贝 | 双重释放 |
| 赋值运算符 | 已存在对象赋值 | 浅拷贝 | 内存泄漏 + 双重释放 |
实战场景:一个 Image 类包含像素数据指针。如果使用默认拷贝构造,两个 Image 对象将共享同一块像素内存。当其中一个对象析构时,另一个对象的指针变为悬空指针。通过深拷贝,每个对象拥有独立的内存副本,互不干扰。
复盘经验:赋值运算符必须处理自赋值情况(s = s;)。如果不检查,先 delete[] data 会释放自身资源,随后访问 other.data 将导致未定义行为。此外,赋值运算符应返回 *this 的引用,以支持链式赋值(a = b = c;)。
4.4 this指针与静态成员:对象的自我认知与类的共享数据
this 指针是每个非静态成员函数中隐含的指针,指向调用该函数的对象。静态成员(Static Member)属于类本身,而非某个具体对象,所有对象共享同一份静态数据。
核心原理:当调用 s1.deposit(100) 时,编译器隐式传递 &s1 给 deposit 函数,即 this 指针。在成员函数内部,this->balance 与 balance 等价。静态成员函数没有 this 指针,因此只能访问静态成员,不能访问非静态成员。
class Counter {
private:
static int count; // 静态数据成员声明
public:
Counter() { count++; }
~Counter() { count--; }
static int getCount() { return count; } // 静态成员函数
};
int Counter::count = 0; // 静态成员定义并初始化
操作流程:静态数据成员必须在类外定义并初始化(C++17 之前)。静态成员函数可以通过类名直接调用(Counter::getCount()),也可以通过对象调用(不推荐)。this 指针常用于:区分参数与成员同名(this->id = id)、链式调用(返回 *this 的引用)、在成员函数中获取对象地址。
经验提醒:静态成员适合存储类级别的共享信息,如实例数量、全局配置、缓存池等。但要注意线程安全问题,多线程环境下对静态成员的并发访问需要加锁。
案例拆解:设计一个 GameObject 类,需要统计当前场景中对象的总数。使用静态成员 aliveCount,构造函数中递增,析构函数中递减。通过静态函数 getAliveCount() 实时查询。同时,在 update() 函数中返回 *this 的引用,实现链式调用:obj.setX(10).setY(20).update();。
常见误区:在静态成员函数中访问非静态成员会导致编译错误。另一个误区是忘记在类外定义静态成员,导致链接错误。此外,this 指针不能用于静态成员函数,因为静态函数不依赖于具体对象。
总结复盘:本章围绕类与对象的核心机制展开。访问控制是封装的基石,构造函数和析构函数管理对象的生命周期,拷贝构造和赋值运算符决定了资源管理的正确性,this 指针提供了对象自我引用的能力,静态成员实现了类级别的数据共享。掌握这些概念,是编写健壮、可维护C++程序的关键。在实际开发中,务必遵循“三法则”或“五法则”(C++11 后),并优先使用智能指针和标准库容器来减少手动资源管理的负担。