C++ 面向对象学习地图:从类到模板与运算符重载
C++ 面向对象学习地图:从类到模板与运算符重载
Disboundary背景:面向对象不是一组孤立语法
面向对象程序设计很容易学成一串名词:类、对象、构造函数、封装、继承、多态、虚函数、抽象类、析构函数、模板、运算符重载。每个概念单独看都能背,但写程序时真正重要的是它们之间的关系。
这篇文章把课程笔记重组成一张学习地图。主线不是“罗列语法”,而是回答一个问题:
C++ 如何从自定义类型出发,逐步建立对象状态、生命周期、复用关系、多态接口和泛型能力?
第一层:类和对象,先能定义自己的类型
class 可以先理解为一种自己定义出来的类型。
1 |
|
在这段代码中,Student 是类,s1 是对象。类负责描述一种类型有哪些数据和行为,对象则是这个类型的一个具体实例。
这一步最重要的理解是:类不是对象本身,而是创建对象的“类型说明”。
第二层:构造函数,让对象出生时就是有效状态
如果对象创建后还要手动给每个字段赋值,就很容易忘记初始化。构造函数解决的是对象初始化问题。
1 | class Student { |
构造函数的价值不是“少写几行赋值”,而是保证对象一创建就进入合理状态。后续学习 RAII、资源管理和拷贝控制时,这个思想会反复出现。
第三层:封装,把状态保护起来
如果所有成员变量都 public,外部代码可以随意修改对象状态:
1 | student.score = -100; |
这类代码在语法上可能没问题,但在业务含义上明显不合理。封装的做法是把数据设为 private,再通过成员函数控制读写。
1 | class Student { |
封装的核心不是“把字段藏起来”,而是让对象自己维护自己的不变量。外部代码不直接改内部状态,而是通过对象提供的接口表达意图。
第四层:this、引用和指针,理解对象身份
在成员函数内部,this 指向当前对象:
1 | Student(string name) { |
当参数名和成员变量名相同时,this->name 明确表示当前对象的成员。
对象赋值、引用和指针则帮助理解“对象身份”和“对象副本”的区别:
1 | Student a("Alice"); |
如果函数直接按值传对象,会发生拷贝:
1 | void print(Student s); // 会复制对象 |
如果只是读取,通常更适合用 const 引用:
1 | void print(const Student& s); // 不复制,也不修改 |
如果想表达“可能为空”或需要重新指向别的对象,可以用指针。引用更像一个必须绑定的别名,指针更像一个可变的地址变量。
第五层:继承,用 is-a 关系复用接口和实现
继承解决的是类之间存在共性时的复用问题。比如 Student 和 Teacher 都是 Person:
1 | class Person { |
这里的 : Person(name, age) 表示先调用基类构造函数初始化 Person 那部分对象。派生类对象不是凭空出现的,它内部包含一个基类子对象。
继承要谨慎使用。它最适合表达 is-a 关系:Student is a Person。如果只是“拥有某个东西”,通常组合比继承更自然。
第六层:多态和虚函数,让行为看实际对象
继承只解决了“类型关系”,多态解决的是“同一个接口在不同对象上表现出不同行为”。
1 | class Animal { |
如果没有 virtual,通过基类引用或指针调用函数时,行为往往看静态类型。加上 virtual 后,调用会动态绑定到实际对象类型。
多态通常和指针或引用一起出现:
1 | Dog dog; |
override 的作用是告诉编译器:我就是要重写基类虚函数。它能帮你发现函数名、参数列表或 const 写错导致的“以为重写了,其实没有重写”。
第七层:抽象类和纯虚函数,用类表达接口
有些基类本身不应该被创建对象,只是为了规定接口。例如所有图形都能求面积,但“图形”本身没有具体面积公式。
1 | class Shape { |
= 0 表示纯虚函数。包含纯虚函数的类是抽象类,不能直接创建对象:
1 | // Shape s; // 错误 |
但抽象类可以作为指针或引用类型:
1 | void printArea(const Shape& shape) { |
抽象类常用来表达接口:基类规定“必须能做什么”,派生类负责给出具体实现。
第八层:析构函数和虚析构,管理对象死亡
构造函数负责对象出生,析构函数负责对象销毁。
1 | class FileHolder { |
普通对象离开作用域时会自动调用析构函数,delete 动态对象时也会调用析构函数。
如果一个类要作为多态基类使用,析构函数通常应该写成 virtual:
1 | class Animal { |
原因是:如果通过基类指针删除派生类对象,虚析构能保证先调用派生类析构,再调用基类析构。
1 | Animal* p = new Dog(); |
这是 C++ 多态资源管理里非常重要的一条规则。
第九层:拷贝控制,处理深拷贝和浅拷贝
当类里只有 int、double、string 这类成员时,默认拷贝通常够用。但如果类自己管理堆资源,就必须关心拷贝构造、赋值运算符和析构函数。
危险例子:
1 | class Buffer { |
如果直接使用默认拷贝,两个对象可能指向同一块 data,最后析构时重复释放。这就是浅拷贝风险。
传统规则是三法则:
1 | 如果你需要自定义析构函数, |
现代 C++ 的实际建议是优先使用标准库容器和智能指针,让资源管理交给成熟类型:
1 | std::vector<int> data; |
能不手写 new/delete,就尽量不手写。
第十层:模板,编译期的类型复用
虚函数是运行时多态,模板是编译期多态。模板解决的是“同一套逻辑适用于不同类型”的问题。
1 | template <typename T> |
调用时:
1 | myMax(3, 5); // T 是 int |
模板要求类型支持它内部用到的操作。上面的 myMax 使用了 >,所以传入类型必须能比较大小。
类模板也很常见:
1 | template <typename T> |
标准库里的 vector<int>、vector<string> 本质上就是类模板实例化。
第十一层:运算符重载,让自定义类型用起来更自然
运算符重载用于给已有运算符定义它在自定义类型上的含义。
1 | class Complex { |
这样就可以写:
1 | Complex c = a + b; |
如果 operator+ 是成员函数,a + b 等价于:
1 | a.operator+(b) |
重载输出运算符 << 通常写成非成员函数,并返回 ostream&,这样才能支持连续输出:
1 | cout << c << endl; |
运算符重载的原则很简单:重载后的含义应该符合运算符原本直觉。+ 表示相加,== 表示相等,[] 表示下标访问。不要为了炫技把运算符改成反直觉行为。
阶段性结论
这张学习地图可以压缩成一条链:
1 | 类和对象:定义自己的类型 |
面向对象学习的重点不是背每个概念的定义,而是知道它们分别在解决什么问题。写 C++ 时,只要能判断“这是状态问题、生命周期问题、接口问题、复用问题,还是类型泛化问题”,这些语法就会自然落到对应位置。


