C++面向对象入门:类、封装、继承与多态核心笔记 如果你正在学 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 部分会被“切掉”在函数内部里它就是一个纯碎的 Personadvisor 数据完全丢失。这通常不是你想要的。避免方法也很简单函数参数尽量改成const Person用引用传递而不是值传递这样既避免切片又省一次拷贝开销。还有一类问题是虚函数没写成功。你想重写基类的 virtual 函数结果函数名拼错了或者参数列表不一致——这时候编译器不会报错但运行时调用的是基类的版本。怎么排查用override关键字编译器就会帮你检查加上之后如果拼错了编译阶段直接报错。这是现代 C 强烈推荐的做法。4.3 调试技巧与避坑速查表调试面向对象程序我最大的心得是不要头脑一热就打一堆断点。先用眼睛检查代码逻辑特别是构造和析构的调用顺序、虚函数的重写签名、new 和 delete 的配对。这比盲目的断点调试高效得多。实在需要断点调试时优先在构造函数、析构函数、虚函数里下断点。看调用栈能帮你理清谁调用了谁尤其是遇到“析构了两次”“构造了个幽灵对象”这类问题时调用栈是救命稻草。我把常遇到的问题整理成了一张速查表方便你复习时快速定位现象可能原因解决方法编译报错提示缺少分号class 定义结尾忘写 ;检查类定义补分号无法访问成员变量成员为 private通过 public 成员函数访问创建对象报没有构造函数构造函数参数不匹配检查调用参数与构造函数定义同名函数调用了错误版本虚函数拼写错误或缺少 override加 override 关键字强制检查程序退出时崩溃资源重复释放 / 浅拷贝写深拷贝、正确管理 new/delete传到基类的信息丢失按值传递导致的切片用引用或指针传递参数派生类无法访问基类成员基类成员是 private改为 protected 或提供 getternew 出来的内存没有释放缺少析构函数/delete在析构函数中释放所有资源4.4 学习路线上容易踩的坑很多同学学 OOP 时会陷入一个误区把所有东西都堆进一个类里。比如写一个 Animal 类把 eat、sleep、fly、swim 全塞进去然后每个方法都判断一下类型。这种“万能类”完全违背了面向对象的初衷代码会迅速膨胀到没法维护。正确做法是每个类只负责一个清晰的职责不同职责放在不同的类里。抽象的判定标准是“这个类是不是有且只有一个改变的理由”。一个类如果既要处理文件读写又要处理界面显示还要管数据库那它的任何需求变化都会导致连锁修改。我自己走过不少弯路。当初学继承时为了炫技把明明该用组合的两个类硬写成继承关系结果改了一处需求两个类都要动。后来我给自己定了一个规矩拿不准的时候默认用组合除非能说出“派生类确实是一种基类”这种明确的理由。这个习惯帮我躲掉了很多麻烦。还有一件事必须提醒你一定不要只看视频不动手。面向对象是真刀真枪练出来的不是在笔记里画几个箭头就能学会的。你把这篇笔记里的每个代码示例都亲手敲一遍、运行一遍、故意改错几个地方再调试一遍收获会比读十遍文章大得多。学 C 面向对象没有什么捷径但有一条很踏实的路先把单一职责、封装、继承、多态这几个基石理解透然后动手写代码再带着问题回来理解理论。这样循环几轮你自然会发现之前觉得晦涩的概念其实都是为了解决真实问题而存在的。