1. 从“过程”到“对象”:为什么我们需要面向对象编程?
如果你已经跟着这个系列走过了前三篇,从变量、循环、函数一路打怪升级过来,那么恭喜你,你已经掌握了C++作为一门“高级C语言”的大部分核心玩法。但如果你觉得写代码还是像在写一份冗长的“操作说明书”——先做A,再做B,检查C,然后处理D——那么,是时候推开一扇新世界的大门了:面向对象编程。
我刚开始学C++时,对“面向对象”这个词充满了敬畏和困惑。听起来很高大上,但到底有什么用?直到我接手维护一个几千行的、用纯C风格(或者说,面向过程风格)写的学生管理系统。那个程序里,学生的“数据”(比如姓名、学号、成绩)和“操作”(比如计算平均分、打印信息)是割裂的。数据用一堆全局数组存着,操作它们的函数散落在各个角落。想加一个“班级”的概念?几乎意味着要把整个程序重写一遍。那感觉,就像在管理一个没有文件夹、所有文件都堆在桌面上的电脑,找什么都得靠搜索,改一点东西就可能引发连锁崩溃。
面向对象编程就是为了解决这种混乱而生的。它的核心思想很简单:把数据和操作这些数据的方法,打包成一个整体,这个整体就叫“对象”。比如,一个“学生”对象,它内部既有姓名、学号这些数据,也有“计算绩点”、“打印信息”这些方法。外界想了解这个学生,不用去翻一堆全局变量和函数,直接跟这个“学生对象”打交道就行。这就像现实世界,你想用手机,不需要知道它内部CPU怎么运算、屏幕怎么发光,你只需要知道按哪个键能打电话、划哪里能看视频。手机这个“对象”把复杂的内部细节“封装”起来,只给你提供简单易用的接口。
这种思维方式带来了三大核心武器:封装、继承和多态。封装让你代码更安全、更模块化;继承让你能复用已有的代码,像搭积木一样构建新功能;多态则让你的程序更灵活,能应对未来的变化。接下来,我们就从最基础的“类与对象”开始,一步步拆解这三大利器。别担心,我会用最“人话”的例子,带你绕过我当年踩过的所有坑。
2. 蓝图与实物:理解类与对象
面向对象编程里最基础、也最重要的两个概念就是“类”和“对象”。你可以这样理解:
- 类:就像一张设计蓝图。它定义了某一类事物应该长什么样,有什么属性,能做什么事。比如,“汽车”的设计蓝图规定了它有轮子、发动机、方向盘等属性,以及启动、加速、刹车等方法。但蓝图本身不能开,它只是一个抽象的描述。
- 对象:就是根据蓝图制造出来的具体实物。比如,根据“汽车”蓝图生产出来的你家那台车,车牌是“京A·12345”,颜色是红色。这个具体的车,就是一个“对象”。程序运行时,我们操作的都是一个个具体的对象。
2.1 定义一个类:从设计学生开始
让我们动手定义一个最简单的Student类。假设我们只需要记录学生的姓名和年龄。
class Student { public: // 访问权限,后面会细说 // 成员变量(属性) string name; int age; // 成员函数(方法) void introduce() { cout << "大家好,我叫" << name << ",今年" << age << "岁。" << endl; } };看,这就是一个最简单的类定义。class是关键字,Student是类名。大括号{}里面定义了类的“成员”:name和age是成员变量(也叫属性),introduce是成员函数(也叫方法)。public:表示这些成员是公开的,外部可以直接访问。
注意:类定义末尾的分号
;千万不能省略!这是很多新手容易忘记的地方,编译器会报一堆你看不懂的错误。
2.2 创建并使用对象:让蓝图变成现实
定义了蓝图,现在我们来“造”几个学生:
int main() { // 创建对象(实例化) Student stu1; // stu1 是一个Student类的对象 Student stu2; // 访问对象的成员:使用点运算符 . stu1.name = "张三"; stu1.age = 18; stu1.introduce(); // 输出:大家好,我叫张三,今年18岁。 stu2.name = "李四"; stu2.age = 20; stu2.introduce(); // 输出:大家好,我叫李四,今年20岁。 return 0; }Student stu1;这行代码,就叫做“实例化”,它根据Student类这个蓝图,在内存中创建了一个实实在在的stu1对象。stu1和stu2虽然都是Student类型,但它们各自拥有独立的内存空间,修改stu1的name不会影响stu2。这个“点运算符.”就是你和对象“对话”的方式,用来访问它的内部成员。
实操心得:刚开始,你可能会觉得“这不就是struct(结构体)加了个函数吗?”没错,在C++中,class和struct在底层非常相似。关键区别在于默认的访问权限:class默认成员是private(私有的),而struct默认是public(公共的)。我们通常用class来强调数据封装,用struct来组织纯粹的数据集合。但理解了这个,你就抓住了类与对象的物理本质——它就是一块包含了数据和函数指针的自定义内存区域。
3. 封装:把秘密藏起来,只留一扇门
上面例子中,我们把name和age都设为public,这意味着在main函数里,我可以随意写stu1.age = -5;。这显然不合理,年龄怎么能是负数呢?这就是不封装的坏处:数据完全暴露,毫无安全性可言。
封装的理念就是:将对象的属性和实现细节隐藏起来,仅对外公开有限的接口(函数)进行交互。就像你的银行账户,余额(属性)是隐藏的,你不能直接改,但可以通过“取款”、“查询”这些公开接口(方法)来安全地操作它。
3.1 访问权限控制:public, private, protected
C++用三个关键字来控制类成员的访问权限:
- public(公有):类内、类外都可以自由访问。就像客厅,谁都能进。
- private(私有):只有类自己的成员函数可以访问。就像你的卧室,外人不能进。这是封装的基石,类的数据成员通常设为private。
- protected(受保护):介于两者之间,类内和派生类(子类)可以访问。这个我们讲到继承时再说。
我们来改造一下Student类,实现封装:
class Student { private: // 将数据隐藏起来 string name; int age; public: // 对外提供操作接口 // 设置年龄,可以加入合法性检查 void setAge(int newAge) { if (newAge > 0 && newAge < 150) { // 简单的数据验证 age = newAge; } else { cout << "年龄输入不合法!" << endl; } } // 获取年龄 int getAge() { return age; } // 设置姓名 void setName(string newName) { name = newName; } // 获取姓名 string getName() { return name; } void introduce() { cout << "大家好,我叫" << name << ",今年" << age << "岁。" << endl; } }; int main() { Student stu; // stu.age = 18; // 错误!age是private,无法直接访问 stu.setName("王五"); stu.setAge(18); // 通过公共接口安全地设置 // stu.setAge(-5); // 这里会被接口内的检查拦截,年龄不会被修改 cout << stu.getName() << "的年龄是:" << stu.getAge() << endl; // 通过接口获取 stu.introduce(); return 0; }看到了吗?现在main函数无法直接触碰name和age。所有操作都必须通过setAge、getAge这些公开的成员函数。在setAge函数里,我们加入了简单的数据验证,确保了数据的有效性。这就是封装的力量:数据安全了,代码也更清晰了。你想修改学生信息?去找对应的set函数。你想获取信息?去找对应的get函数。这种函数就是我们常说的“Getter”和“Setter”。
3.2 构造函数与析构函数:对象的生与死
对象被创建时,需要初始化;对象生命结束时,可能需要清理资源(比如关闭文件、释放内存)。这两个特殊时刻由两个特殊的成员函数管理:
- 构造函数:在对象创建时自动调用,用于初始化对象。函数名与类名相同,没有返回类型。
- 析构函数:在对象销毁时自动调用,用于清理工作。函数名是类名前加一个波浪线
~,没有参数和返回类型。
class Student { private: string name; int age; int* scores; // 假设我们用一个动态数组来存储多门课成绩 public: // 1. 默认构造函数(无参) Student() { name = "未知"; age = 0; scores = nullptr; // 初始化为空指针 cout << "调用默认构造函数" << endl; } // 2. 带参数的构造函数(重载) Student(string stuName, int stuAge) { name = stuName; age = stuAge; scores = new int[5]; // 为5门课成绩分配堆内存 for(int i = 0; i < 5; ++i) scores[i] = 0; // 初始化为0 cout << "调用带参构造函数,姓名为:" << name << endl; } // 3. 析构函数 ~Student() { if (scores != nullptr) { delete[] scores; // 释放动态分配的内存,防止内存泄漏! scores = nullptr; } cout << "析构函数被调用,清理了" << name << "的资源" << endl; } // ... 其他的Getter、Setter和introduce函数 }; int main() { Student stu1; // 调用默认构造函数 Student stu2("赵六", 19); // 调用带参构造函数 { Student stu3("临时学生", 20); // 在作用域内创建 } // 作用域结束,stu3被销毁,自动调用析构函数 cout << "main函数即将结束" << endl; return 0; } // main结束,stu1和stu2被销毁,自动调用析构函数运行这段代码,你会清晰地看到构造函数和析构函数被调用的顺序。这是C++管理资源生命周期的核心机制。特别是当类中有指针成员并指向动态分配的内存(堆内存)时,必须在析构函数中释放,否则会造成“内存泄漏”。这是新手常踩的大坑!
注意事项:
- 如果你没写任何构造函数,编译器会生成一个什么都不做的默认构造函数。但只要你定义了一个带参数的构造函数,编译器就不再提供默认构造函数。此时如果你写
Student stu;就会编译错误。一个好的习惯是,总是显式地定义一个无参的默认构造函数。 - 构造函数可以重载,根据参数不同执行不同的初始化逻辑。
- 析构函数只有一个,不能重载。对于管理资源的类,编写析构函数是必须的。
4. 继承:站在巨人的肩膀上写代码
想象一下,你要开发一个游戏,里面有Enemy(敌人)这个类,它有health(血量)、attack()(攻击)等方法。现在你需要两种具体的敌人:Goblin(哥布林)和Dragon(龙)。龙会飞,会喷火,哥布林会偷东西。如果分别从头写这两个类,你会写很多重复的代码(比如health和基础的attack())。
继承就是为了解决代码复用的问题。它允许我们定义一个类(派生类/子类)基于另一个类(基类/父类)。子类会自动获得父类的所有属性和方法(除了构造函数、析构函数和私有成员),并且可以添加自己特有的属性和方法,或者修改父类的方法。
4.1 基础的继承语法
// 基类(父类) class Enemy { protected: // 让子类可以访问 int health; int attackPower; public: Enemy(int h, int ap) : health(h), attackPower(ap) {} // 构造函数初始化列表 void takeDamage(int damage) { health -= damage; if (health < 0) health = 0; cout << "敌人受到" << damage << "点伤害,剩余血量:" << health << endl; } virtual void attack() { // virtual关键字,为多态做准备 cout << "敌人发动了普通攻击,造成" << attackPower << "点伤害!" << endl; } }; // 派生类(子类)Goblin, 公有继承自Enemy class Goblin : public Enemy { private: int stealPower; // 哥布林特有的属性:偷窃能力 public: // 子类构造函数,需要调用父类构造函数初始化继承来的成员 Goblin(int h, int ap, int sp) : Enemy(h, ap), stealPower(sp) { cout << "一只哥布林出现了!" << endl; } // 重写父类的attack方法 void attack() override { // override关键字(C++11),明确表示重写,提高可读性 cout << "哥布林用短刀砍击,造成" << attackPower << "点伤害!" << endl; } // 子类特有的方法 void steal() { cout << "哥布林偷走了你的" << stealPower << "枚金币!" << endl; } }; // 派生类(子类)Dragon class Dragon : public Enemy { public: Dragon(int h, int ap) : Enemy(h, ap) { cout << "一条巨龙降临了!" << endl; } void attack() override { cout << "巨龙喷出炽热的火焰,造成" << attackPower * 2 << "点范围伤害!" << endl; // 伤害加倍 } void fly() { cout << "巨龙腾空而起!" << endl; } }; int main() { Goblin g(50, 10, 5); // 血量50,攻击力10,偷窃力5 Dragon d(200, 25); // 血量200,攻击力25 g.attack(); // 调用Goblin自己的attack g.steal(); // 调用Goblin特有的steal g.takeDamage(30); // 调用继承自Enemy的takeDamage d.attack(); // 调用Dragon自己的attack d.fly(); // 调用Dragon特有的fly d.takeDamage(100); // 调用继承自Enemy的takeDamage return 0; }语法class Goblin : public Enemy表示Goblin类以公有继承的方式继承Enemy类。这意味着:
Enemy中的public成员,在Goblin中仍然是public。Enemy中的protected成员,在Goblin中仍然是protected。Enemy中的private成员,在Goblin中不可直接访问(但可以通过父类的public或protected方法间接访问)。
初始化列表: Enemy(h, ap)在子类构造函数中至关重要,它用来显式调用父类的构造函数,初始化从父类继承来的成员。子类无法直接初始化父类的私有成员。
4.2 继承中的构造与析构顺序
当创建一个子类对象时:
- 调用父类的构造函数。
- 调用子类成员对象的构造函数(如果有的话)。
- 调用子类的构造函数体。 析构的顺序则完全相反:子类析构 -> 子类成员析构 -> 父类析构。
这个顺序是自动的,理解它有助于你调试和理解对象的生命周期。
常见问题:“我该用公有继承、保护继承还是私有继承?”
- 公有继承:最常用,表示“是一个”的关系。
Goblin是一个Enemy。这是逻辑上的继承。 - 保护/私有继承:很少用,表示“以...实现”的关系。更多是技术上的复用,而非逻辑上的派生。对于新手,99%的情况使用公有继承就够了。如果你不确定,那就用公有继承。
5. 多态:同一操作,不同行为
多态是面向对象最精妙、也最强大的特性。字面意思是“多种形态”。在C++中,多态通常指:通过基类的指针或引用调用一个虚函数时,实际执行的是派生类中重写的那个函数版本。
这有什么用?回到游戏的例子。假设我们有一个Enemy指针数组,里面装着各种实际是Goblin或Dragon的敌人。在游戏主循环中,我们想让他们全部攻击玩家。如果没有多态,我们需要判断每个指针具体指向哪种敌人,然后分别调用不同的attack函数,代码会非常冗长。
有了多态,我们可以这样写:
int main() { // 创建一个基类指针数组,指向不同的派生类对象 Enemy* enemies[3]; enemies[0] = new Goblin(50, 10, 5); enemies[1] = new Dragon(200, 25); enemies[2] = new Goblin(40, 8, 3); cout << "\n--- 敌人全体攻击!---" << endl; for (int i = 0; i < 3; ++i) { enemies[i]->attack(); // 关键在这里! // 虽然enemies[i]是Enemy*类型,但它实际指向Goblin或Dragon对象 // 因此这里调用的是各自派生类重写的attack版本 } cout << "\n--- 战斗结束,清理战场 ---" << endl; for (int i = 0; i < 3; ++i) { delete enemies[i]; // 记得释放内存 } return 0; }输出会是:
一只哥布林出现了! 一条巨龙降临了! 一只哥布林出现了! --- 敌人全体攻击!--- 哥布林用短刀砍击,造成10点伤害! 巨龙喷出炽热的火焰,造成50点范围伤害! 哥布林用短刀砍击,造成8点伤害! --- 战斗结束,清理战场 ---神奇吗?enemies[i]在编译时是Enemy*类型,编译器只知道它指向一个Enemy。但在运行时,它才知道这个指针实际指向的是Goblin还是Dragon,并调用对应类的attack方法。这就是“运行时多态”或“动态绑定”。
5.1 实现多态的关键:虚函数
要实现上述效果,有两个必要条件:
- 基类中的函数必须声明为
virtual(虚函数)。就像我们之前在Enemy::attack()前加的那样。 - 派生类必须重写这个虚函数。最好加上
override关键字(C++11),这样如果你不小心写错了函数签名(参数或返回类型),编译器会报错,这是一个很好的安全措施。
虚函数表:这是多态的实现原理。每个有虚函数的类(或从有虚函数的类派生而来)都有一个隐藏的成员——虚函数表指针(vptr),它指向一个虚函数表(vtable)。表中存放了该类所有虚函数的实际地址。当通过基类指针调用虚函数时,程序会通过vptr找到vtable,再找到正确的函数地址进行调用。这个过程发生在运行时,因此有少量性能开销,但对于现代计算机,这点开销在大多数场景下完全可以接受。
5.2 纯虚函数与抽象类
有时候,基类中的某个函数在逻辑上无法给出一个有意义的实现。比如,Shape(形状)基类有一个draw()(绘制)方法。但“形状”本身太抽象了,不知道怎么画。这时,我们可以将它声明为纯虚函数。
class Shape { public: virtual void draw() = 0; // = 0 表示这是一个纯虚函数 virtual double area() = 0; // 计算面积,也是纯虚函数 // 可以包含非虚函数和成员变量 void printInfo() { cout << "这是一个形状。" << endl; } };包含纯虚函数的类叫做抽象类。抽象类不能实例化对象,即你不能写Shape s;。它的作用就是作为一个接口,强制要求它的所有派生类(除非也是抽象类)必须实现这些纯虚函数。
class Circle : public Shape { private: double radius; public: Circle(double r) : radius(r) {} // 必须实现基类的所有纯虚函数,否则Circle也会变成抽象类 void draw() override { cout << "绘制一个半径为" << radius << "的圆形。" << endl; } double area() override { return 3.14159 * radius * radius; } }; class Rectangle : public Shape { private: double width, height; public: Rectangle(double w, double h) : width(w), height(h) {} void draw() override { cout << "绘制一个" << width << "x" << height << "的矩形。" << endl; } double area() override { return width * height; } }; int main() { // Shape s; // 错误!Shape是抽象类,不能创建对象 Shape* shapes[2]; shapes[0] = new Circle(5.0); shapes[1] = new Rectangle(4.0, 6.0); for (int i = 0; i < 2; ++i) { shapes[i]->draw(); cout << "面积是:" << shapes[i]->area() << endl; shapes[i]->printInfo(); // 调用继承来的非虚函数 delete shapes[i]; } return 0; }抽象类和纯虚函数定义了接口规范,是实现多态和大型项目架构设计的强大工具。它确保了所有派生类都遵循同一套操作标准。
6. 面向对象实战:设计一个简单的银行账户系统
让我们把封装、继承、多态综合运用起来,设计一个迷你银行账户系统。
6.1 基类 Account(账户)
首先,定义一个抽象的Account基类,规定所有账户都必须有存款、取款、查询余额的功能。
#include <iostream> #include <string> using namespace std; // 抽象基类:账户 class Account { protected: string accountNumber; // 账号 string ownerName; // 户主名 double balance; // 余额 public: Account(string accNum, string name, double initBalance) : accountNumber(accNum), ownerName(name), balance(initBalance) {} virtual ~Account() {} // 虚析构函数,确保派生类对象被基类指针删除时能正确调用派生类析构函数 // 纯虚函数,定义接口 virtual void deposit(double amount) = 0; // 存款 virtual bool withdraw(double amount) = 0; // 取款,返回是否成功 virtual void display() const = 0; // 显示账户信息,const表示不修改对象 // 非虚函数,通用功能 double getBalance() const { return balance; } string getAccountNumber() const { return accountNumber; } };6.2 派生类 SavingsAccount(储蓄账户)
储蓄账户可能有最低余额限制,取款不能低于这个限制。
class SavingsAccount : public Account { private: double minBalance; // 最低余额要求 public: SavingsAccount(string accNum, string name, double initBalance, double minBal) : Account(accNum, name, initBalance), minBalance(minBal) { if (initBalance < minBal) { cout << "警告:初始余额低于最低要求!" << endl; } } void deposit(double amount) override { if (amount > 0) { balance += amount; cout << ownerName << "的储蓄账户存入 " << amount << " 元。当前余额:" << balance << endl; } else { cout << "存款金额必须为正数!" << endl; } } bool withdraw(double amount) override { if (amount > 0 && (balance - amount) >= minBalance) { balance -= amount; cout << ownerName << "的储蓄账户取款 " << amount << " 元。当前余额:" << balance << endl; return true; } else { cout << "取款失败!金额无效或余额将低于最低要求(" << minBalance << "元)。" << endl; return false; } } void display() const override { cout << "--- 储蓄账户信息 ---" << endl; cout << "账号:" << accountNumber << endl; cout << "户主:" << ownerName << endl; cout << "余额:" << balance << " 元" << endl; cout << "最低余额要求:" << minBalance << " 元" << endl; } };6.3 派生类 CheckingAccount(支票账户)
支票账户可能没有最低余额限制,但每次取款可能会收取小额手续费。
class CheckingAccount : public Account { private: double transactionFee; // 每笔取款手续费 public: CheckingAccount(string accNum, string name, double initBalance, double fee) : Account(accNum, name, initBalance), transactionFee(fee) {} void deposit(double amount) override { if (amount > 0) { balance += amount; cout << ownerName << "的支票账户存入 " << amount << " 元。当前余额:" << balance << endl; } else { cout << "存款金额必须为正数!" << endl; } } bool withdraw(double amount) override { double totalDeduction = amount + transactionFee; if (amount > 0 && balance >= totalDeduction) { balance -= totalDeduction; cout << ownerName << "的支票账户取款 " << amount << " 元,手续费 " << transactionFee; cout << " 元。当前余额:" << balance << endl; return true; } else { cout << "取款失败!金额无效或余额不足(含手续费)。" << endl; return false; } } void display() const override { cout << "--- 支票账户信息 ---" << endl; cout << "账号:" << accountNumber << endl; cout << "户主:" << ownerName << endl; cout << "余额:" << balance << " 元" << endl; cout << "取款手续费:" << transactionFee << " 元/笔" << endl; } };6.4 使用多态管理多种账户
现在,我们可以用基类指针来统一管理不同类型的账户。
int main() { // 创建不同类型的账户对象,用基类指针指向它们 Account* myAccounts[3]; myAccounts[0] = new SavingsAccount("SA001", "张三", 1000.0, 100.0); // 最低余额100 myAccounts[1] = new CheckingAccount("CA001", "李四", 500.0, 2.0); // 手续费2元 myAccounts[2] = new SavingsAccount("SA002", "王五", 2000.0, 500.0); cout << "\n=== 初始账户状态 ===" << endl; for (int i = 0; i < 3; ++i) { myAccounts[i]->display(); cout << endl; } cout << "\n=== 进行一系列交易 ===" << endl; // 多态的威力:无需知道具体是哪种账户,统一调用接口 myAccounts[0]->deposit(500); // 张三存钱 myAccounts[0]->withdraw(2000); // 张三取钱(会失败,低于最低余额) myAccounts[0]->withdraw(800); // 张三取钱(成功) myAccounts[1]->withdraw(100); // 李四取钱(含手续费) myAccounts[1]->deposit(300); // 李四存钱 myAccounts[2]->withdraw(1600); // 王五取钱 cout << "\n=== 最终账户状态 ===" << endl; for (int i = 0; i < 3; ++i) { myAccounts[i]->display(); cout << endl; } // 清理内存 for (int i = 0; i < 3; ++i) { delete myAccounts[i]; } return 0; }这个例子展示了面向对象的核心优势:
- 封装:每个账户的内部数据(余额、最低限额、手续费)被保护起来,只能通过规定的
deposit、withdraw方法操作,保证了数据安全。 - 继承:
SavingsAccount和CheckingAccount复用Account的代码(账号、户主名、余额等),只需关注自己特有的逻辑(最低余额、手续费)。 - 多态:
main函数中的数组和循环代码完全不用关心具体是哪种账户。无论是储蓄账户还是支票账户,都通过统一的Account指针接口来操作。如果要新增一种“信用卡账户”,只需要从Account派生一个新类并实现那几个纯虚函数,main函数里的管理代码一行都不用改!这就是“对扩展开放,对修改关闭”的开闭原则的体现。
7. 进阶话题与避坑指南
掌握了三大特性,你已经入门了面向对象。但在实际项目中,还有一些细节和陷阱需要特别注意。
7.1 对象的拷贝:深拷贝与浅拷贝
这是C++面试的经典问题,也是实际开发中Bug的高发区。当你用一个对象初始化另一个对象,或者将对象作为参数传值给函数时,会发生“拷贝”。
class SimpleClass { public: int* data; SimpleClass(int value) { data = new int(value); // 在堆上分配内存 } ~SimpleClass() { delete data; // 释放内存 } }; int main() { SimpleClass obj1(10); SimpleClass obj2 = obj1; // 默认拷贝构造:浅拷贝! // 问题来了:obj1.data 和 obj2.data 指向同一块堆内存 // main函数结束时,obj2先析构,delete了那块内存 // 然后obj1再析构,试图delete同一块已经释放的内存 -> 程序崩溃! }默认的拷贝行为是“浅拷贝”,即只复制指针的值(地址),而不复制指针指向的内容。这会导致多个对象共享同一资源,析构时重复释放,引发未定义行为(通常是崩溃)。
解决方案:自定义拷贝构造函数和拷贝赋值运算符,实现“深拷贝”。
class SafeClass { public: int* data; int size; SafeClass(int value) : size(1) { data = new int(value); } // 1. 拷贝构造函数 SafeClass(const SafeClass& other) : size(other.size) { data = new int[size]; for(int i = 0; i < size; ++i) { data[i] = other.data[i]; // 复制内容,而不是地址 } cout << "调用了拷贝构造函数" << endl; } // 2. 拷贝赋值运算符 SafeClass& operator=(const SafeClass& other) { if (this != &other) { // 防止自我赋值 delete[] data; // 释放原有资源 size = other.size; data = new int[size]; for(int i = 0; i < size; ++i) { data[i] = other.data[i]; } } cout << "调用了拷贝赋值运算符" << endl; return *this; } ~SafeClass() { delete[] data; } };经验法则:如果你的类管理了动态分配的内存、文件句柄、网络连接等资源,那么你很可能需要自己定义拷贝构造函数、拷贝赋值运算符和析构函数。这被称为“三法则”。在C++11之后,还有“五法则”(加上移动构造函数和移动赋值运算符)。对于新手,一个简单的规避策略是:禁用拷贝,使用unique_ptr等智能指针管理资源。
class NonCopyable { public: NonCopyable() = default; // 删除拷贝构造和拷贝赋值 NonCopyable(const NonCopyable&) = delete; NonCopyable& operator=(const NonCopyable&) = delete; };7.2 友元:打破封装的特权
封装是把数据藏起来,但有时候,某些特定的外部函数或类需要直接访问这些私有数据。比如,一个打印函数需要直接读取对象的私有成员来格式化输出。这时可以使用friend(友元)关键字。
class MyClass { private: int secret; public: MyClass(int s) : secret(s) {} // 声明一个全局函数为友元 friend void printSecret(const MyClass& obj); // 声明另一个类为友元 friend class FriendClass; }; void printSecret(const MyClass& obj) { // 这个函数不是MyClass的成员,但可以访问其私有成员 cout << "The secret is: " << obj.secret << endl; } class FriendClass { public: void peek(const MyClass& obj) { cout << "FriendClass sees: " << obj.secret << endl; } };慎用友元!友元破坏了封装性,降低了代码的可维护性。它应该被视为一种例外手段,而不是常规工具。在大多数情况下,通过提供良好的公有接口(Getter/Setter)来访问数据是更好的选择。
7.3 静态成员:属于类本身的成员
有时候,我们需要一些数据或函数属于整个类,而不是类的某个对象。比如,统计一共创建了多少个某个类的对象。这就需要static(静态)成员。
class Counter { private: static int count; // 静态成员变量声明,记录对象总数 int id; public: Counter() { id = ++count; // 每创建一个对象,count加1,并赋予id cout << "创建了第" << id << "个Counter对象。" << endl; } ~Counter() { cout << "销毁了第" << id << "个Counter对象。" << endl; --count; } static int getTotalCount() { // 静态成员函数 return count; } }; // 静态成员变量必须在类外定义和初始化 int Counter::count = 0; int main() { cout << "当前对象数:" << Counter::getTotalCount() << endl; // 通过类名直接访问静态函数 Counter c1; Counter c2; { Counter c3; cout << "当前对象数:" << Counter::getTotalCount() << endl; } // c3离开作用域被销毁 cout << "当前对象数:" << Counter::getTotalCount() << endl; return 0; }关键点:
- 静态成员变量不属于任何对象,它在所有对象间共享,只有一份内存。
- 静态成员变量必须在类外(通常是.cpp文件)单独定义和初始化,格式为
类型 类名::变量名 = 值;。 - 静态成员函数没有
this指针,因此它不能访问类的非静态成员(因为非静态成员需要通过this指针来访问)。静态成员函数通常用于操作静态成员变量。
面向对象编程是一个庞大而精深的体系,这一篇我们只是打下了最核心的基础。从把数据和函数打包成“对象”,到用“封装”保护内部状态,用“继承”复用已有代码,再用“多态”写出灵活而强大的程序,这条路径是理解现代软件设计的基石。我建议你多动手把文中的例子敲一遍,然后尝试设计一些自己的小类,比如Book、Library、Vehicle、Car。遇到问题就去查、去调试,这才是学习编程最快的方式。当你开始习惯用对象的视角去思考问题时,你会发现很多复杂的系统设计突然变得清晰起来。