CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
C++ 语言入门/课时/面向对象
RawBlame
课时目录12 节

C++ 语言入门

  1. 01C++ 是什么
  2. 02基础语法
  3. 03指针与引用
  4. 04面向对象
  5. 05内存与 RAII
  6. 06STL 容器与算法
  7. 07模板基础
  8. 08现代 C++ 特性
  9. 09多线程
  10. 10构建与依赖
  11. 11调试与测试
  12. 12综合练习

面向对象

classes、inheritance、polymorphism、virtual methods。

40 分钟

面向对象

这一课你将能做到

  1. 定义带构造函数、public/private 访问控制的类,并解释栈上对象「离开作用域自动析构」的行为
  2. 写出一个 RAII 类(如判题计时器),让资源的生命周期绑定到对象生命周期
  3. 用 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 才有多态。

讲清楚要点

  1. 对象三生三世:栈对象(作用域决定生死,自动析构)、全局/静态对象(程序起止决定)、堆对象(new 创建,delete 亲手销毁,忘了就泄漏)。RAII 的意义就是把堆资源也「托管」给栈对象的析构。
  2. 初始化列表优于构造体内赋值:: user_(u), id_(i) 直接以目标值构造成员;在构造体里 user_ = u 是先默认构造再赋值,多一步。const 成员和引用成员只能用初始化列表。
  3. 多态基类的析构必须 virtual:JudgeStrategy* p = new FloatJudge(); delete p; 若基类析构非虚,delete 只会执行基类析构,派生类部分不会被正确销毁,是内存泄漏与 UB 的经典来源。规则记死:有虚函数的类,析构函数必虚;反之别给普通类加虚函数(会多出虚表指针)。
  4. C++ 支持多继承:一个类可以 class D : public A, public B,Java 只有接口多实现。多继承引入菱形继承问题,需要虚继承解决——工程里更常用组合代替,初学阶段知道有这回事即可。
  5. 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 让编译器把关

卡点急救

  1. undefined reference to vtable:声明了虚函数但没定义(尤其虚析构只声明未实现)。给虚函数加实现,或用 = default。
  2. 构造函数「名不副类」报错:多半是手滑把类名打错,或误写了返回类型——构造函数不能有任何返回类型(连 void 都不行)。
  3. 想禁止拷贝: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。虚析构保证先派生类后基类的完整析构链。

自测三问

  1. 栈上对象在什么时刻执行析构函数?抛异常时也会吗?
  2. 纯虚函数(= 0)等价于 Java 的什么?含有纯虚函数的类能实例化吗?
  3. 拷贝构造函数的签名长什么样?什么时候被触发?

小结一句话

C++ 类的精髓不在封装继承多态的语法,而在「析构函数被语言保证精确执行」这件事上——RAII 把资源托管给作用域,是 C++ 取代 GC 的核心设计。

延伸

  • cppreference:类与析构函数
  • cppreference:虚函数与多态
  • 下一课:内存与智能指针——没有 GC 的世界如何管内存。
加载本节练习…
← 上一课指针与引用下一课 →内存与 RAII