C++ 面向对象学习地图:从类到模板与运算符重载

背景:面向对象不是一组孤立语法

面向对象程序设计很容易学成一串名词:类、对象、构造函数、封装、继承、多态、虚函数、抽象类、析构函数、模板、运算符重载。每个概念单独看都能背,但写程序时真正重要的是它们之间的关系。

这篇文章把课程笔记重组成一张学习地图。主线不是“罗列语法”,而是回答一个问题:

C++ 如何从自定义类型出发,逐步建立对象状态、生命周期、复用关系、多态接口和泛型能力?

第一层:类和对象,先能定义自己的类型

class 可以先理解为一种自己定义出来的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <string>
using namespace std;

class Student {
public:
string name;
int age;
int score;

void introduce() {
cout << "我是 " << name
<< ",今年 " << age
<< " 岁,成绩是 " << score << endl;
}
};

int main() {
Student s1;
s1.name = "Alice";
s1.age = 18;
s1.score = 95;

s1.introduce();
}

在这段代码中,Student 是类,s1 是对象。类负责描述一种类型有哪些数据和行为,对象则是这个类型的一个具体实例。

这一步最重要的理解是:类不是对象本身,而是创建对象的“类型说明”。

第二层:构造函数,让对象出生时就是有效状态

如果对象创建后还要手动给每个字段赋值,就很容易忘记初始化。构造函数解决的是对象初始化问题。

1
2
3
4
5
6
7
8
9
10
11
class Student {
private:
string name;
int age;

public:
Student(string name, int age) {
this->name = name;
this->age = age;
}
};

构造函数的价值不是“少写几行赋值”,而是保证对象一创建就进入合理状态。后续学习 RAII、资源管理和拷贝控制时,这个思想会反复出现。

第三层:封装,把状态保护起来

如果所有成员变量都 public,外部代码可以随意修改对象状态:

1
student.score = -100;

这类代码在语法上可能没问题,但在业务含义上明显不合理。封装的做法是把数据设为 private,再通过成员函数控制读写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student {
private:
int score;

public:
void setScore(int value) {
if (value >= 0 && value <= 100) {
score = value;
}
}

int getScore() const {
return score;
}
};

封装的核心不是“把字段藏起来”,而是让对象自己维护自己的不变量。外部代码不直接改内部状态,而是通过对象提供的接口表达意图。

第四层:this、引用和指针,理解对象身份

在成员函数内部,this 指向当前对象:

1
2
3
Student(string name) {
this->name = name;
}

当参数名和成员变量名相同时,this->name 明确表示当前对象的成员。

对象赋值、引用和指针则帮助理解“对象身份”和“对象副本”的区别:

1
2
Student a("Alice");
Student b = a; // b 是 a 的副本,不是同一个对象

如果函数直接按值传对象,会发生拷贝:

1
void print(Student s); // 会复制对象

如果只是读取,通常更适合用 const 引用:

1
void print(const Student& s); // 不复制,也不修改

如果想表达“可能为空”或需要重新指向别的对象,可以用指针。引用更像一个必须绑定的别名,指针更像一个可变的地址变量。

第五层:继承,用 is-a 关系复用接口和实现

继承解决的是类之间存在共性时的复用问题。比如 StudentTeacher 都是 Person

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
protected:
string name;
int age;

public:
Person(string name, int age) : name(name), age(age) {}
};

class Student : public Person {
private:
int score;

public:
Student(string name, int age, int score)
: Person(name, age), score(score) {}
};

这里的 : Person(name, age) 表示先调用基类构造函数初始化 Person 那部分对象。派生类对象不是凭空出现的,它内部包含一个基类子对象。

继承要谨慎使用。它最适合表达 is-a 关系:Student is a Person。如果只是“拥有某个东西”,通常组合比继承更自然。

第六层:多态和虚函数,让行为看实际对象

继承只解决了“类型关系”,多态解决的是“同一个接口在不同对象上表现出不同行为”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Animal {
public:
virtual void speak() {
cout << "Animal" << endl;
}
};

class Dog : public Animal {
public:
void speak() override {
cout << "Dog" << endl;
}
};

void makeSpeak(Animal& animal) {
animal.speak();
}

如果没有 virtual,通过基类引用或指针调用函数时,行为往往看静态类型。加上 virtual 后,调用会动态绑定到实际对象类型。

多态通常和指针或引用一起出现:

1
2
3
Dog dog;
Animal& a = dog;
a.speak(); // 调用 Dog::speak

override 的作用是告诉编译器:我就是要重写基类虚函数。它能帮你发现函数名、参数列表或 const 写错导致的“以为重写了,其实没有重写”。

第七层:抽象类和纯虚函数,用类表达接口

有些基类本身不应该被创建对象,只是为了规定接口。例如所有图形都能求面积,但“图形”本身没有具体面积公式。

1
2
3
4
5
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};

= 0 表示纯虚函数。包含纯虚函数的类是抽象类,不能直接创建对象:

1
// Shape s; // 错误

但抽象类可以作为指针或引用类型:

1
2
3
void printArea(const Shape& shape) {
cout << shape.area() << endl;
}

抽象类常用来表达接口:基类规定“必须能做什么”,派生类负责给出具体实现。

第八层:析构函数和虚析构,管理对象死亡

构造函数负责对象出生,析构函数负责对象销毁。

1
2
3
4
5
6
class FileHolder {
public:
~FileHolder() {
// 释放资源
}
};

普通对象离开作用域时会自动调用析构函数,delete 动态对象时也会调用析构函数。

如果一个类要作为多态基类使用,析构函数通常应该写成 virtual

1
2
3
4
class Animal {
public:
virtual ~Animal() = default;
};

原因是:如果通过基类指针删除派生类对象,虚析构能保证先调用派生类析构,再调用基类析构。

1
2
Animal* p = new Dog();
delete p; // 需要基类析构函数是 virtual

这是 C++ 多态资源管理里非常重要的一条规则。

第九层:拷贝控制,处理深拷贝和浅拷贝

当类里只有 intdoublestring 这类成员时,默认拷贝通常够用。但如果类自己管理堆资源,就必须关心拷贝构造、赋值运算符和析构函数。

危险例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Buffer {
private:
int* data;

public:
Buffer() {
data = new int[10];
}

~Buffer() {
delete[] data;
}
};

如果直接使用默认拷贝,两个对象可能指向同一块 data,最后析构时重复释放。这就是浅拷贝风险。

传统规则是三法则:

1
2
如果你需要自定义析构函数,
通常也需要自定义拷贝构造函数和赋值运算符。

现代 C++ 的实际建议是优先使用标准库容器和智能指针,让资源管理交给成熟类型:

1
2
std::vector<int> data;
std::unique_ptr<int[]> data;

能不手写 new/delete,就尽量不手写。

第十层:模板,编译期的类型复用

虚函数是运行时多态,模板是编译期多态。模板解决的是“同一套逻辑适用于不同类型”的问题。

1
2
3
4
template <typename T>
T myMax(T a, T b) {
return a > b ? a : b;
}

调用时:

1
2
myMax(3, 5);       // T 是 int
myMax(3.1, 2.4); // T 是 double

模板要求类型支持它内部用到的操作。上面的 myMax 使用了 >,所以传入类型必须能比较大小。

类模板也很常见:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T>
class Box {
private:
T value;

public:
Box(T value) : value(value) {}
T getValue() const { return value; }
};

Box<int> a(10);
Box<string> b("hello");

标准库里的 vector<int>vector<string> 本质上就是类模板实例化。

第十一层:运算符重载,让自定义类型用起来更自然

运算符重载用于给已有运算符定义它在自定义类型上的含义。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Complex {
private:
double real;
double imag;

public:
Complex(double real, double imag)
: real(real), imag(imag) {}

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

这样就可以写:

1
Complex c = a + b;

如果 operator+ 是成员函数,a + b 等价于:

1
a.operator+(b)

重载输出运算符 << 通常写成非成员函数,并返回 ostream&,这样才能支持连续输出:

1
cout << c << endl;

运算符重载的原则很简单:重载后的含义应该符合运算符原本直觉。+ 表示相加,== 表示相等,[] 表示下标访问。不要为了炫技把运算符改成反直觉行为。

阶段性结论

这张学习地图可以压缩成一条链:

1
2
3
4
5
6
7
8
9
10
11
类和对象:定义自己的类型
构造函数:保证对象出生时有效
封装:让对象维护自己的状态
this / 引用 / 指针:理解对象身份和访问方式
继承:表达 is-a 关系
虚函数:让行为根据实际对象动态绑定
抽象类:用基类规定接口
析构和虚析构:处理对象死亡和多态释放
拷贝控制:避免资源管理中的浅拷贝问题
模板:编译期复用类型无关逻辑
运算符重载:让自定义类型像内置类型一样自然使用

面向对象学习的重点不是背每个概念的定义,而是知道它们分别在解决什么问题。写 C++ 时,只要能判断“这是状态问题、生命周期问题、接口问题、复用问题,还是类型泛化问题”,这些语法就会自然落到对应位置。