第四章:类与对象——从封装到对象生命周期的全面掌控

在C++中,类(Class)是用户自定义的数据类型,它将数据成员(属性)和成员函数(行为)封装为一个整体;而对象(Object)则是类的具体实例。如果说前三章的函数与指针是C++的“肌肉”与“骨骼”,那么类与对象就是C++的“心脏”。本章将围绕类的定义、访问控制、构造与析构、拷贝语义、赋值运算符、this指针以及静态成员展开,帮助你建立面向对象编程的完整认知框架。我们将不仅讨论语法,更会深入探讨编译器在背后做了什么,以及如何避免资源管理中的经典陷阱。

class Student 数据成员 + 成员函数 访问控制 public: 外部可访问 private: 仅类内可访问 protected: 派生类可访问 默认:struct=public class=private 生命周期 构造函数(初始化) 拷贝构造/赋值 this指针(自引用) 静态成员(共享) 析构函数(清理) 核心机制 默认构造 / 拷贝构造 / 赋值运算符 / 析构 编译器自动生成 vs 用户自定义

上图展示了本章的核心知识框架:类定义是基础,访问控制决定了封装边界,而对象的生命周期则由构造、拷贝、赋值和析构共同管理。接下来,我们将逐一深入每个环节。

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 后),并优先使用智能指针和标准库容器来减少手动资源管理的负担。