☰
C++面向对象入门:类、封装、继承与多态核心笔记
2026/10/14 1:12:18 网站建设 项目流程

如果你正在学 C++,或者被“面向对象”这几个字搞得一头雾水,那这份笔记就是写给你看的。我花了不少时间把 C++ 面向对象程序设计(OOP)的核心知识点重新捋了一遍,用最直白的话、最简短的代码,把类、对象、封装、继承、多态这些概念讲清楚,不堆砌术语,不绕弯子。

这份笔记覆盖了 OOP 最核心的几块内容:类与对象的基本写法、构造函数和析构函数的作用、封装的意义、继承与多态的底层逻辑,以及一堆我在实际写代码时踩过的坑。适合正在上 C++ 课程的学生、准备期末考试的初学者,以及想快速回顾 OOP 核心概念的开发者。看完你能理解面向对象到底在解决什么问题,也能自己写出像样的类。

1. 内容整体设计与思路拆解

1.1 为什么课程笔记要从“类”讲起

很多初学者学 C++ 面向对象,死记硬背“类是模板,对象是实例”,但根本不知道为什么要这么设计。我当年也一样,直到自己动手写过几个项目,才明白面向对象的核心动机其实很简单:把数据和操作数据的方法绑定在一起。

传统面向过程编程,比如 C 语言,数据和函数是分离的。你要处理一个学生的信息,得定义一堆结构体变量,再写一堆函数去操作它们。代码一多,维护起来特别痛苦。你改了一个数据结构,所有操作这个结构的函数都得跟着改,稍不留神就漏了。

面向对象则把“学生”这个抽象概念,连同它的属性(姓名、年龄、成绩)和行为(打印信息、计算平均分)打包成一个整体,也就是“类”。这样做的好处有三个:第一,代码结构更清晰,一个类就是一个独立模块;第二,数据安全性更高,外部不能随便修改内部状态;第三,复用性更强,一个写好的类可以在多个项目里重复使用。

所以这份笔记的整体思路就是:先理解类与对象这个基础模型,再一步步往上加封装、继承、多态这些特性,最后配合实操代码把这些概念串起来。

1.2 笔记内容的编排逻辑和学习路径

这份笔记的设计遵循一条主线:从“怎么定义一个类”开始,到“怎么用这个类创建对象”,再到“怎么让类与类之间产生关系”。

具体来说,分四个阶段递进。第一阶段是类和对象,包括成员的访问权限、成员函数的定义方式;第二阶段是构造与析构,解决“对象创建时怎么初始化”“销毁时怎么清理资源”;第三阶段是继承与多态,这是 C++ OOP 的精髓,也是面试和考试的重灾区;第四阶段是综合应用,把前面所有知识组合起来解决实际问题。

为什么这么编排?因为面向对象概念之间有强烈的依赖关系。你不理解构造函数,就看不懂派生类构造函数的调用顺序;不理解虚函数,就搞不懂多态的实现机制。按这条路径走,每一步都是在为下一步铺路,不会出现“学了后面忘了前面”的情况。

我自己学习时还发现一个诀窍:每个知识点都要配合一个“反例”来理解。比如讲封装时,不仅要看正确的写法,还要看如果不封装会发生什么;讲虚函数时,不仅要看加上 virtual 的效果,还要看去掉它之后发生了什么。这种对比学习法,比单纯背概念高效得多。

2. 核心细节解析与实操要点

2.1 类与对象:定义、访问权限与成员函数

类就是你自己定义的一种“自定义数据类型”。定义类用class关键字,大括号里面写成员变量(也叫属性)和成员函数(也叫方法),最后别忘了加分号。一个最简单的类长这样:

class Student { public: string name; int age; void show() { cout << name << " " << age << endl; } };

这里的public是访问权限控制符,它和private、protected一起构成了 C++ 封装机制的基础。public成员在类外面可以直接访问,private成员只能在类内部访问,protected则和继承相关,后面再细说。

访问权限是最容易被初学者忽略的细节。我见过不少同学的代码,所有成员全写成public,图省事。这在平时练习没问题,但到写真实项目时会埋下巨大的隐患。比如你有一个 BankAccount 类,里面有个 balance 成员,如果它是 public 的,外部代码可以直接把它改成负数,这显然不合理。正确的做法是把成员变量设为 private,通过 public 的成员函数来读写。

成员函数可以在类内部直接定义(隐式内联),也可以在类里面只声明、在类外面用类名::函数名的方式定义。我建议初学者两种都练一练,因为 C++ 大量项目代码用的是先声明后定义的方式,提前熟悉省得以后不适应。

2.2 构造函数与析构函数:对象的始与终

构造函数是对象被创建时自动调用的特殊成员函数,名字和类名相同,没有返回值(注意不能写 void)。它的作用就是初始化成员变量。写构造函数有几种常见写法,最推荐的是初始化列表:

class Student { private: string name; int age; public: Student(string n, int a) : name(n), age(a) {} };

用初始化列表的好处是,成员变量在创建时就被直接初始化,而不是先创建再赋值。对于 string、vector 这些类型,初始化列表的性能更好,也更符合 C++ 的设计哲学。对于 int、double 这种基本类型,两种写法差别不大,但从一开始养成用初始化列表的习惯,以后写复杂类时受益无穷。

析构函数则是对象生命周期结束时自动调用的函数,名字是~类名。它的用处主要是释放资源。如果你的类里用 new 分配了内存,那析构函数里必须对应 delete。这里有个重要的规则叫“三/五法则”:如果你自己写了析构函数、拷贝构造函数或赋值运算符中的任何一个,通常意味着另外两个也应该自己写,因为你的类很可能在管理某种资源。初学者可以先记结论:凡是用了 new 的类,除了析构函数,拷贝构造和赋值运算符大概率也需要考虑。

我在学习时就吃过亏:写了一个字符串类,里面有char* str和 new 分配的内存,结果没用深拷贝,两个对象共享同一块内存,析构时二次释放,程序直接崩溃。后来老老实实写了拷贝构造函数和赋值运算符,才明白浅拷贝和深拷贝的区别有多重要。

2.3 重载与运算符重载:让代码更自然

C++ 允许函数名相同但参数列表不同,这叫函数重载。比如你可以定义print(int)、print(double)、print(string),调用时编译器会根据实参类型自动选择正确的版本。这是 C++ 一个非常实用的特性,让代码更简洁易读。

运算符重载则是函数重载的延伸,你可以让+、-、==这些运算符直接作用于自定义类型。比如定义一个 Complex 复数类:

class Complex { public: double real, imag; Complex operator+(const Complex& other) { return Complex(real + other.real, imag + other.imag); } };

写完之后,两个复数对象就能直接用a + b相加,不用写a.add(b),代码直观很多。

不过运算符重载也是初学者容易误用的特性。我的建议是:只有运算符的含义对自定义类型来说是“自然而然”的,才值得重载。比如复数的 +、字符串的比较,这些重载是合理的;但如果让一个 Employee 类重载+来表示“合并员工信息”,就有点莫名其妙了,这时候用一个叫merge的成员函数反而更清晰。

3. 实操过程与核心环节实现

3.1 代码实操:从零实现一个完整的类

纸上谈兵没意思,我们直接写一个稍微完整点的例子。假设要做一个简单的“银行账户”类,包含账户名、余额,支持存款、取款、显示余额操作。

先定义头文件的类声明部分。我在代码里把 getter 和 setter 都写出来,哪怕有些暂时用不到。因为在真实项目里,类接口的稳定性很重要,宁可有冗余的接口,也不要因为缺接口导致外部代码无法完成某个操作。

#include <iostream> #include <string> using namespace std; class BankAccount { private: string owner; double balance; public: BankAccount(string o, double b); void deposit(double amount); bool withdraw(double amount); double getBalance() const; void showInfo() const; };

构造函数负责初始化,这里用初始化列表:

BankAccount::BankAccount(string o, double b) : owner(o), balance(b) { if (balance < 0) { balance = 0; } }

存款、取款实现如下,注意 withdraw 返回 bool 表示是否成功,而不是直接修改余额。这叫“失败可见”,调用方可以根据返回值决定下一步操作:

void BankAccount::deposit(double amount) { if (amount > 0) { balance += amount; } } bool BankAccount::withdraw(double amount) { if (amount > 0 && amount <= balance) { balance -= amount; return true; } return false; }

getter 和 showInfo 实现如下,注意后面加了const关键字。这个 const 表示“该成员函数不会修改对象的状态”,是 C++ 里很重要的规范。凡是只读操作,都应该加 const,这样编译器能帮你检查出意外的修改,也能让 const 对象正常调用这些函数:

double BankAccount::getBalance() const { return balance; } void BankAccount::showInfo() const { cout << "户主: " << owner << ", 余额: " << balance << endl; }

主函数里怎么用?很简单,创建对象、调用方法:

int main() { BankAccount acc("张三", 1000); acc.deposit(500); if (acc.withdraw(200)) { cout << "取款成功" << endl; } acc.showInfo(); return 0; }

这个例子麻雀虽小五脏俱全:封装(private + public 接口)、构造函数、const 成员函数、带返回值的操作,全都有了。在这个类的使用方式里,用户永远无法直接把 balance 改为负数,因为所有对 balance 的修改都经过 deposit / withdraw 这两个受控函数。这就是封装的实战价值。

3.2 继承的实现:派生类的写法与构造顺序

继承是面向对象里最实用的复用手段。它的语法很简单:在类名后面加: public 基类名。比如学生和研究生之间的关系:

class Person { protected: string name; int age; public: Person(string n, int a) : name(n), age(a) {} void sayHello() const { cout << "大家好,我是" << name << endl; } }; class GraduateStudent : public Person { private: string advisor; public: GraduateStudent(string n, int a, string adv) : Person(n, a), advisor(adv) {} void showAdvisor() const { cout << "导师: " << advisor << endl; } };

这里有三个关键点要记住。第一,继承方式有三种:public、protected、private。最常用的是 public 继承,它表达的是 is-a 关系,意思是“研究生是一个学生”。protected 成员则恰恰可以在派生类中被访问,这是它在访问权限中的特殊地位。我们把 name 和 age 放在 protected 里,就是让派生类能够直接读取,而外部仍然无法访问。

第二,派生类的构造函数必须先调用基类的构造函数,通过初始化列表完成,比如上面代码里的Person(n, a)。如果你不写,编译器会尝试调用基类的默认构造函数,而 Person 没有默认构造函数,就会报错。所以“基类没有默认构造时,派生类构造函数必须显式调用基类构造”这个规则要牢记。

第三,构造顺序是:先基类后派生类;析构顺序正好反过来:先派生类后基类。你可以写两个类分别打印构造和析构信息,运行一下,就能直观地看到这个顺序。这个顺序很重要,因为派生类的构造依赖于基类已经初始化完毕,而析构时派生类需要先释放自己独有的资源,再让基类清理公共资源。

3.3 多态与虚函数:让接口统一,行为各异

继承和虚函数结合起来,就能实现 C++ 最强大的特性——多态。简单来说,就是通过基类指针或引用调用虚函数时,实际执行的是派生类里的那个版本。

看这个经典例子:

class Shape { public: virtual double area() const { return 0; } virtual ~Shape() {} }; class Circle : public Shape { private: double r; public: Circle(double radius) : r(radius) {} double area() const override { return 3.14159 * r * r; } }; class Rectangle : public Shape { private: double w, h; public: Rectangle(double w_, double h_) : w(w_), h(h_) {} double area() const override { return w * h; } }; void printArea(const Shape& s) { cout << "面积: " << s.area() << endl; }

注意打印函数接收的是const Shape&,不管传入 Circle 还是 Rectangle,都会调用各自重写过的 area()。这就是运行时多态:程序在运行时根据对象的实际类型,而不是指针的静态类型,来决定调用哪个函数。虚函数表的机制,本质上就是每个含有虚函数的类都有一张表,记录虚函数入口地址,对象里隐藏一个指向该表的指针。调用虚函数时,通过这个指针去查表,找到实际要执行的函数。

这里必须注意几点。第一,基类里写了虚函数,派生类重写时应该在函数后面加上override关键字,这是个指示符,告诉编译器“我确实在重写一个虚函数”。如果你拼错了函数名,编译器会报错,这能帮你提前发现问题。第二,基类的析构函数几乎总是应该声明为 virtual。否则你通过Shape*指向一个Circle对象,然后 delete 这个指针时,只会调用基类的析构函数,而派生类可能来不及清理自己分配的资源,造成内存泄漏。这是 C++ 里极其隐蔽又极其常见的错误。第三,纯虚函数和抽象类。把虚函数写成virtual double area() const = 0;,这就成了纯虚函数,含有纯虚函数的类叫抽象类,不能直接创建对象,只能作为基类被继承。抽象类定义了一个“接口契约”,强制每个派生类必须实现这个函数。这种设计在大型项目里非常有用,它就是“基于接口编程”思想的底层体现。

3.4 组合与继承怎么选

很多初学者分不清什么时候该用继承,什么时候该用组合。一个简单实用的判断标准:如果 B 是 A 的一种特殊类型,用继承;如果 B 拥有 A 作为组成部分,用组合。

举例:研究生是学生的一种,用继承;汽车有引擎,用组合。继承暴露了基类的接口,耦合度更高;组合更灵活,运行时可以更换组件。在真实工程中,组合优先于继承是一个被广泛推荐的实践。

纯继承容易出现“菱形继承”问题。比如 D 同时继承 B 和 C,而 B 和 C 都继承自 A,那么 D 里面会有两份 A 的成员,访问时会冲突。C++ 提供了虚继承解决这个问题,但虚继承的语法和规则都比较复杂,初学者没有必要深挖,只需要知道“组合能在绝大多数场景下避开这个坑”就好。

4. 常见问题与排查技巧实录

4.1 编译阶段的高频报错

学习 C++ 面向对象时,编译错误占了调试时间的一大半。我整理的这些是最高频的几种,几乎每个初学者都会遇到。

最常见的是“类定义结尾忘了分号”。class Student { ... };最后这个分号是必需的,很多人写完大括号就回车了,编译器立刻报错,而且报错信息经常指向下一行代码,让人一头雾水。排查方法是看错误提示附近的代码,如果发现“ missing ';' before ...”这类信息,优先检查类定义结尾。

第二高频的是“无法访问 private 成员”。比如在 main 函数里直接写acc.balance = 100;,而 balance 是 private 的。编译器会提示“BankAccount::balance is private”。这个时候不要想着把成员改成 public 来解决问题,那等于从根上破坏了封装。正确做法是检查这个操作应该在哪一层完成,如果是类自己的内部逻辑,就把它封装成成员函数。

第三是“没有匹配的构造函数”。创建对象时传的参数和类里定义的构造函数参数不对应。比如定义了BankAccount(string, double),你却写BankAccount acc;,编译器会报错。此时要么补一个默认构造函数,要么改成匹配的调用方式。

4.2 运行阶段的隐蔽 bug

编译通过不代表程序正确。运行阶段最常见的两类问题,一类是“析构时崩溃”,另一类是“切片”。

先说说析构崩溃。如果你的类里有动态分配的内存,而析构函数写得不对,或者忘记写析构函数,程序可能在退出时崩溃,也可能内存泄漏(运行时不一定立刻报错,但内存占用会持续上涨)。排查时可以用工具检测,也可以用最简单的笨办法:在析构函数里打印一行字,看看它被调用了多少次、什么时候被调用。

切片问题更隐蔽。看这个场景:你有一个派生类 GraduateStudent,把它按值传给一个接收 Person 参数的函数:

void func(Person p) { ... } GraduateStudent gs("李四", 24, "王老师"); func(gs);

这时候gs的 GraduateStudent 部分会被“切掉”,在函数内部里它就是一个纯碎的 Person,advisor 数据完全丢失。这通常不是你想要的。避免方法也很简单:函数参数尽量改成const Person&,用引用传递而不是值传递,这样既避免切片,又省一次拷贝开销。

还有一类问题是虚函数没写成功。你想重写基类的 virtual 函数,结果函数名拼错了,或者参数列表不一致——这时候编译器不会报错,但运行时调用的是基类的版本。怎么排查?用override关键字,编译器就会帮你检查:加上之后如果拼错了,编译阶段直接报错。这是现代 C++ 强烈推荐的做法。

4.3 调试技巧与避坑速查表

调试面向对象程序,我最大的心得是:不要头脑一热就打一堆断点。先用眼睛检查代码逻辑,特别是构造和析构的调用顺序、虚函数的重写签名、new 和 delete 的配对。这比盲目的断点调试高效得多。

实在需要断点调试时,优先在构造函数、析构函数、虚函数里下断点。看调用栈能帮你理清谁调用了谁,尤其是遇到“析构了两次”“构造了个幽灵对象”这类问题时,调用栈是救命稻草。

我把常遇到的问题整理成了一张速查表,方便你复习时快速定位:

现象可能原因解决方法
编译报错,提示缺少分号class 定义结尾忘写 ;检查类定义,补分号
无法访问成员变量成员为 private通过 public 成员函数访问
创建对象报没有构造函数构造函数参数不匹配检查调用参数与构造函数定义
同名函数调用了错误版本虚函数拼写错误或缺少 override加 override 关键字强制检查
程序退出时崩溃资源重复释放 / 浅拷贝写深拷贝、正确管理 new/delete
传到基类的信息丢失按值传递导致的切片用引用或指针传递参数
派生类无法访问基类成员基类成员是 private改为 protected 或提供 getter
new 出来的内存没有释放缺少析构函数/delete在析构函数中释放所有资源

4.4 学习路线上容易踩的坑

很多同学学 OOP 时会陷入一个误区:把所有东西都堆进一个类里。比如写一个 Animal 类,把 eat、sleep、fly、swim 全塞进去,然后每个方法都判断一下类型。这种“万能类”完全违背了面向对象的初衷,代码会迅速膨胀到没法维护。

正确做法是:每个类只负责一个清晰的职责,不同职责放在不同的类里。抽象的判定标准是“这个类是不是有且只有一个改变的理由”。一个类如果既要处理文件读写,又要处理界面显示,还要管数据库,那它的任何需求变化都会导致连锁修改。

我自己走过不少弯路。当初学继承时,为了炫技,把明明该用组合的两个类硬写成继承关系,结果改了一处需求,两个类都要动。后来我给自己定了一个规矩:拿不准的时候默认用组合,除非能说出“派生类确实是一种基类”这种明确的理由。这个习惯帮我躲掉了很多麻烦。

还有一件事必须提醒你:一定不要只看视频不动手。面向对象是真刀真枪练出来的,不是在笔记里画几个箭头就能学会的。你把这篇笔记里的每个代码示例都亲手敲一遍、运行一遍、故意改错几个地方再调试一遍,收获会比读十遍文章大得多。

学 C++ 面向对象,没有什么捷径,但有一条很踏实的路:先把单一职责、封装、继承、多态这几个基石理解透,然后动手写代码,再带着问题回来理解理论。这样循环几轮,你自然会发现,之前觉得晦涩的概念,其实都是为了解决真实问题而存在的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询