4. 面向对象基础:类与对象、访问权限、构造函数与析构函数

好,我们进入真正的C++核心地带了。

如果你是从C语言过来的,那你一定习惯了把数据和操作分开写——结构体只管存数据,函数负责处理。这种写法在小项目里还行,一旦项目上了规模,你就会发现代码越来越难维护。我当年接手过一个几十万行的C项目,光是找一个bug就要翻遍十几个文件,那感觉,真是一言难尽。

面向对象编程,说白了就是把数据和操作它的函数打包在一起。这个包,就是类。而根据这个包创建出来的具体实例,就是对象。

核心思想: 类 = 数据 + 操作数据的函数。对象 = 类的具体实例。

4.1 从struct到class

C语言里我们用struct,C++里我们也可以用class。但两者有个本质区别:struct默认所有成员都是公开的,而class默认是私有的。

先看一个最简单的例子:

// C语言风格
struct Point {
    int x;
    int y;
};

void setPoint(struct Point* p, int x, int y) {
    p->x = x;
    p->y = y;
}

// C++风格
class Point {
private:
    int x;
    int y;

public:
    void set(int newX, int newY) {
        x = newX;
        y = newY;
    }
};

你发现没有?C++版本把数据和操作封装在一起了。调用的时候也简单:

Point p;
p.set(10, 20);  // 直接调用成员函数

嗯,这里要注意:p.x = 10 这样写会编译报错,因为x是私有的。这就是封装带来的好处——你没法从外部随意修改内部数据了。

4.2 访问权限:public、protected、private

这三个关键字控制着谁能访问类里的成员。我画个图帮你理解:

类的访问权限层级 类内部(成员函数) private 只有类内部能访问 (子类也不行) protected 类内部 + 子类能访问 (外部不行) public 谁都能访问 (公开接口)

我个人习惯这样设计类:

  • private:放成员变量和内部辅助函数。外部不需要知道这些细节。
  • public:放对外接口。别人用你的类,只需要看这部分。
  • protected:留给子类用的。如果你不确定要不要开放给子类,先放private,后面再改。

经验之谈: 我建议你一开始把所有成员都设为private,然后按需开放。这样最安全。我在项目中见过太多因为public成员被随意修改导致的诡异bug了。

4.3 构造函数:对象怎么出生的

对象创建时,构造函数会被自动调用。它的名字和类名一样,没有返回值。

class Student {
private:
    string name;
    int age;
    float score;

public:
    // 默认构造函数
    Student() {
        name = "未知";
        age = 0;
        score = 0.0f;
    }
    
    // 带参数的构造函数
    Student(string n, int a, float s) {
        name = n;
        age = a;
        score = s;
    }
    
    void print() {
        cout << name << " " << age << " " << score << endl;
    }
};

使用的时候:

Student s1;              // 调用默认构造函数
Student s2("张三", 20, 95.5f);  // 调用带参构造函数

你想想看,如果没有构造函数,每次创建对象后你都得手动初始化,多麻烦。而且万一忘了初始化,数据就是随机的,那bug找起来可够呛。

注意: 如果你定义了带参数的构造函数,编译器就不会自动生成默认构造函数了。这时候 Student s1; 会编译报错。要么你手动加一个默认构造函数,要么用 Student s1 = Student() 这种写法。

4.4 初始化列表:更高效的初始化方式

上面那种在构造函数体内赋值的方式,其实效率不高。为什么呢?因为成员变量先被默认构造了一次,然后又被赋值了一次。对于int这种基础类型还好,如果是string、vector这种复杂类型,就多了一次无谓的操作。

更好的写法是用初始化列表:

class Student {
private:
    string name;
    int age;
    float score;

public:
    // 使用初始化列表
    Student(string n, int a, float s) 
        : name(n), age(a), score(s) {
        // 构造函数体可以为空
    }
};

这里有个坑:初始化列表的执行顺序是按照成员变量声明的顺序,而不是初始化列表里写的顺序。 我曾经因为这个踩过坑——两个成员变量互相依赖,结果顺序搞反了,数据全乱了。

最佳实践: 初始化列表的顺序,一定要和成员变量声明的顺序保持一致。这样既清晰又安全。

4.5 析构函数:对象怎么消亡的

有生就有死。对象销毁时,析构函数会被自动调用。它的名字是 ~类名,没有参数,没有返回值。

class FileHandler {
private:
    FILE* file;

public:
    FileHandler(const char* filename) {
        file = fopen(filename, "r");
        if (file == nullptr) {
            throw runtime_error("文件打开失败");
        }
    }
    
    ~FileHandler() {
        if (file != nullptr) {
            fclose(file);
            file = nullptr;
        }
    }
};

这个例子很实用。你在构造函数里打开了文件,在析构函数里自动关闭。这样就算你忘了关文件,或者函数中途抛异常退出了,文件也会被正确关闭。

我曾经在一个网络库项目里,就是用这种RAII(资源获取即初始化)手法管理socket连接的。创建连接时自动建立,对象销毁时自动释放。代码干净多了,内存泄漏也少了很多。

4.6 拷贝构造函数与拷贝赋值

这两个函数控制着对象怎么被复制。如果你不写,编译器会帮你生成一个——但只是简单的逐字节拷贝,这在很多情况下是有问题的。

class StringWrapper {
private:
    char* data;

public:
    StringWrapper(const char* str) {
        data = new char[strlen(str) + 1];
        strcpy(data, str);
    }
    
    // 拷贝构造函数
    StringWrapper(const StringWrapper& other) {
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data);
    }
    
    // 拷贝赋值运算符
    StringWrapper& operator=(const StringWrapper& other) {
        if (this != &other) {
            delete[] data;
            data = new char[strlen(other.data) + 1];
            strcpy(data, other.data);
        }
        return *this;
    }
    
    ~StringWrapper() {
        delete[] data;
    }
};

为什么要自己写?因为默认的拷贝只是复制指针,不复制指针指向的内容。结果两个对象指向同一块内存,一个析构了,另一个就悬空了。这就是经典的浅拷贝问题。

三/五法则: 如果你需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,那么你很可能三个都需要。C++11之后还加上了移动构造函数和移动赋值运算符,一共五个。

4.7 总结一下

面向对象的基础,说白了就是三件事:

  1. 封装:把数据和操作打包,用访问权限控制外部访问。
  2. 构造与析构:控制对象的生命周期,确保资源正确管理。
  3. 拷贝语义:决定对象怎么被复制,避免浅拷贝陷阱。

这些概念刚开始可能觉得抽象,但写几个类、跑几个例子就明白了。我当年学的时候也是反复写、反复调试,慢慢才找到感觉的。

记住一句话:好的类设计,用起来舒服,改起来安全,看起来清晰。

可可精品资料库