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:放成员变量和内部辅助函数。外部不需要知道这些细节。
- 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 总结一下
面向对象的基础,说白了就是三件事:
- 封装:把数据和操作打包,用访问权限控制外部访问。
- 构造与析构:控制对象的生命周期,确保资源正确管理。
- 拷贝语义:决定对象怎么被复制,避免浅拷贝陷阱。
这些概念刚开始可能觉得抽象,但写几个类、跑几个例子就明白了。我当年学的时候也是反复写、反复调试,慢慢才找到感觉的。
记住一句话:好的类设计,用起来舒服,改起来安全,看起来清晰。