面向对象
这一课你将能做到
- 定义带构造函数、
public/private访问控制的类,并解释栈上对象「离开作用域自动析构」的行为 - 写出一个 RAII 类(如判题计时器),让资源的生命周期绑定到对象生命周期
- 用
virtual+override实现多态,说清基类析构函数为什么必须是 virtual
为什么要学
C++ 的面向对象和 Java 长得像、骨子里不同。Java 对象生在堆上、由 GC 送走;C++ 对象可以生在栈上,作用域结束的那一刻析构函数被精确调用——没有 GC,也就没有「对象什么时候死」的不确定性。这个特性衍生出 C++ 最重要的编程范式 RAII(资源获取即初始化):打开的文件、加的锁、申请的内存,全部封装进对象,析构函数负责释放,从此不会忘。
判题沙箱就是 RAII 的教科书案例:每个评测任务封装成对象,构造时启动用户进程、计时,析构时无论成功还是中途异常,都保证杀进程、关管道、回收句柄。学完这一课,你就明白 C++ 程序为什么「不靠 finally 也不会漏」。
先建立图像
Java: C++(栈上对象):
User u = new User(); User u; // 构造
... ...
u = null; ← 等待 GC } ← 作用域结束的瞬间
↓ 析构函数立即执行,确定性释放
未知时刻 GC 回收
对象生命周期 = 资源生命周期(RAII)
构造函数:拿资源(开文件 / 加锁 / 分配内存 / 启动计时)
析构函数:还资源(关文件 / 解锁 / 释放内存 / 停止计时)
无论函数正常返回、提前 return 还是抛异常,
析构都必然执行 —— 这是语言保证,等价于 Java 的 try-with-resources
跟着做
步骤 1:定义第一个类
新建 oop.cpp,写一个提交记录类:
#include <iostream>
#include <string>
class Submission {
public: // 外部可访问,对应 Java 的 public
Submission(const std::string& user, int id) // 构造函数:与类同名、无返回类型
: user_(user), id_(id) { // 初始化列表:成员的「出生即赋值」,比构造体内赋值高效
std::cout << "构造 提交#" << id_ << "\n";
}
~Submission() { // 析构函数:~类名,无参无返回,至多一个
std::cout << "析构 提交#" << id_ << "\n";
}
void print() const { // 末尾 const:承诺不修改成员,相当于只读方法
std::cout << user_ << " 提交了 #" << id_ << "\n";
}
private: // 外部不可访问,Java 默认包私有,C++ 默认是 private
std::string user_;
int id_;
};
int main() {
Submission s("zhang", 101);
s.print();
std::cout << "--- main 即将结束 ---\n";
return 0;
} // ← 作用域结束,s 的析构函数在这里自动执行
g++ -std=c++17 oop.cpp -o oop && ./oop
输出:
构造 提交#101
zhang 提交了 #101
--- main 即将结束 ---
析构 提交#101
没人手动调用析构——它是语言保证的自动行为。Java 里要靠 try-with-resources 或 finally 做的事,C++ 交给析构函数。
步骤 2:观察多个对象的生命周期
#include <iostream>
class Tracer {
public:
explicit Tracer(const char* name) : name_(name) { // explicit:禁止隐式转换
std::cout << "进入 " << name_ << "\n";
}
~Tracer() { std::cout << "离开 " << name_ << "\n"; }
private:
const char* name_;
};
void taskB() {
Tracer t("taskB");
std::cout << "taskB 干活\n";
}
void taskA() {
Tracer t("taskA");
taskB();
std::cout << "taskA 收尾\n";
} // taskA 析构
int main() {
Tracer t("main");
taskA();
return 0;
} // main 析构
输出:
进入 main
进入 taskA
进入 taskB
taskB 干活
离开 taskB ← 先构造的后析构
taskA 收尾
离开 taskA
离开 main
栈上对象像俄罗斯套娃:构造顺序与声明顺序一致,析构严格逆序。
步骤 3:写一个 RAII 计时器
判题要精确计量用户程序的耗时,用 RAII 写法最自然:
#include <chrono>
#include <iostream>
class Timer { // 构造即开始计时,析构即报告
public:
explicit Timer(const std::string& tag)
: tag_(tag), start_(std::chrono::steady_clock::now()) {}
~Timer() {
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration<double, std::milli>(end - start_).count();
std::cout << "[" << tag_ << "] 耗时 " << ms << "ms\n";
}
private:
std::string tag_;
std::chrono::steady_clock::time_point start_;
};
void runUserProgram() {
Timer t("评测"); // 进入作用域开始计时
long long s = 0;
for (int i = 0; i < 10'000'000; ++i) s += i; // 模拟用户程序
std::cout << "sum=" << s << "\n";
} // 析构自动打印耗时,哪怕中途 return / 抛异常也不会漏
int main() {
runUserProgram();
return 0;
}
对照 Java:要写 try (Timer t = new Timer(...)) { ... } 并实现 AutoCloseable;C++ 直接依赖作用域,连 try 都不用写。
步骤 4:继承与多态
判题器分多种策略:严格比对(ACM)、浮点容差比对(Special Judge)。用继承建模:
#include <iostream>
#include <string>
class JudgeStrategy {
public:
virtual ~JudgeStrategy() = default; // 虚析构:见本课要点 3,多态基类必备
virtual bool compare(const std::string& expect,
const std::string& actual) const = 0; // 纯虚函数 = Java 抽象方法
};
class ExactJudge : public JudgeStrategy { // public 继承:C++ 默认是 private 继承,必须写 public
public:
bool compare(const std::string& e, const std::string& a) const override {
return e == a; // override:让编译器检查确实重写了虚函数
}
};
class FloatJudge : public JudgeStrategy {
public:
bool compare(const std::string& e, const std::string& a) const override {
double de = std::stod(e), da = std::stod(a);
return de - da < 1e-6 && da - de < 1e-6; // 浮点容差比较
}
};
void judgeOne(const JudgeStrategy& js) { // 通过基类引用做多态调用
std::cout << (js.compare("3.14", "3.1400") ? "AC" : "WA") << "\n";
}
int main() {
ExactJudge ej;
FloatJudge fj;
judgeOne(ej); // WA:字符串 "3.14" != "3.1400"
judgeOne(fj); // AC:数值容差内相等
return 0;
}
virtual 让调用在运行期按「实际类型」分派——机制等价于 Java 方法默认都是 virtual;C++ 出于零开销原则反过来:默认非虚,你显式声明 virtual 才有多态。
讲清楚要点
- 对象三生三世:栈对象(作用域决定生死,自动析构)、全局/静态对象(程序起止决定)、堆对象(
new创建,delete亲手销毁,忘了就泄漏)。RAII 的意义就是把堆资源也「托管」给栈对象的析构。 - 初始化列表优于构造体内赋值:
: user_(u), id_(i)直接以目标值构造成员;在构造体里user_ = u是先默认构造再赋值,多一步。const 成员和引用成员只能用初始化列表。 - 多态基类的析构必须 virtual:
JudgeStrategy* p = new FloatJudge(); delete p;若基类析构非虚,delete 只会执行基类析构,派生类部分不会被正确销毁,是内存泄漏与 UB 的经典来源。规则记死:有虚函数的类,析构函数必虚;反之别给普通类加虚函数(会多出虚表指针)。 - C++ 支持多继承:一个类可以
class D : public A, public B,Java 只有接口多实现。多继承引入菱形继承问题,需要虚继承解决——工程里更常用组合代替,初学阶段知道有这回事即可。 explicit防隐式转换:单参构造函数不加 explicit,Submission s = 101;这种「拿 int 当对象」的隐式转换就合法了,几乎总是 bug。规范:单参构造一律 explicit。
你可能会懵的地方
-
struct 和 class 到底差在哪? 只差默认访问权限:struct 默认 public,class 默认 private。纯数据包用 struct,有行为封装用 class,是风格共识而非语言规则。
-
构造函数能重载几个? 任意多个,只要参数列表不同——和 Java 相同。C++ 还多一种「拷贝构造」
Submission(const Submission& other)与「拷贝赋值」,涉及深浅拷贝;C++11 起又有移动版本,第 5 课展开。 -
什么时候会触发拷贝? 以值传参、以值返回、用一个对象初始化另一个对象都会。这也是上一课强调大对象传
const T&的原因——避免触发拷贝构造。Java 里对象变量全是引用,从来没有「隐式深拷贝」这回事,这是两种语言思维最大的分岔。 -
this 指针和 Java 的 this 一样吗? 语义相同,但 C++ 的 this 是指针(
this->id_),Java 的 this 是引用(this.id)。静态成员函数没有 this。 -
什么时候用继承,什么时候用组合? 「is-a」且需要多态 → 继承;「has-a」或只想复用 → 组合。和 Java 一样的原则,只是 C++ 继承默认 private、还能多继承,坑更多,所以工程里组合比例更高。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 基类析构忘写 virtual | 通过基类指针 delete 派生类对象 → 泄漏 | 多态基类析构写 virtual ~X() = default; |
class D : A(漏 public) | 默认 private 继承,外部访问全被切断 | class D : public A |
| 单参构造不写 explicit | 允许隐式类型转换,制造隐性 bug | 单参构造一律 explicit |
| 构造体内逐个给成员赋值 | 先默认构造再赋值,浪费 | 用初始化列表 : a_(x), b_(y) |
| 派生类重写忘写 override | 签名打错时变成「新函数」,多态失效 | 重写必加 override 让编译器把关 |
卡点急救
undefined reference to vtable:声明了虚函数但没定义(尤其虚析构只声明未实现)。给虚函数加实现,或用= default。- 构造函数「名不副类」报错:多半是手滑把类名打错,或误写了返回类型——构造函数不能有任何返回类型(连 void 都不行)。
- 想禁止拷贝:C++17 惯用法
Submission(const Submission&) = delete;(= delete 同样适用于禁止赋值),相当于 Java 抛 UnsupportedOperationException 的 cleaner 写法。
面试常问
-
什么是 RAII?和 Java 的资源管理比优势在哪? RAII 把资源生命周期绑定到栈对象生命周期:构造获取、析构释放,靠语言保证的作用域规则自动执行,异常路径也不漏。Java 依赖 GC 管内存、try-with-resources 管其他资源,需要程序员自觉包裹 try;C++ 中析构是无条件语言行为,不存在「忘记 close」。
-
虚函数的实现机制? 每个含虚函数的类有一张虚函数表(vtable),对象头部存虚表指针(vptr);调用虚函数时经 vptr 找 vtable 间接跳转,实现运行期按实际类型分派。代价是一次间接寻址和 8 字节 vptr,所以 C++ 默认非虚。
-
为什么基类析构函数要声明为虚函数? 用基类指针 delete 派生类对象时,若析构非虚,只调用基类析构,派生类新增成员的资源不会被释放,造成泄漏与 UB。虚析构保证先派生类后基类的完整析构链。
自测三问
- 栈上对象在什么时刻执行析构函数?抛异常时也会吗?
- 纯虚函数(
= 0)等价于 Java 的什么?含有纯虚函数的类能实例化吗? - 拷贝构造函数的签名长什么样?什么时候被触发?
小结一句话
C++ 类的精髓不在封装继承多态的语法,而在「析构函数被语言保证精确执行」这件事上——RAII 把资源托管给作用域,是 C++ 取代 GC 的核心设计。
延伸
- cppreference:类与析构函数
- cppreference:虚函数与多态
- 下一课:内存与智能指针——没有 GC 的世界如何管内存。