Lesson 14:新的类

从替换关系、构造析构顺序、virtual、override 与对象切片审计继承。

学习目标

  • 能解释 public 继承的 is-a 替换契约,并比较继承与组合的适用边界
  • 能实现基类与派生类的构造、virtual 函数和 override,预测调用与析构顺序
  • 能分析基类指针/引用、按值传递和对象切片,发现非虚重定义与资源释放风险

来源、版次与标准边界

“Lesson 14:新的类”以 SB Creative 出版社书页核定高桥麻奈著、第 5 版、2017 年 6 月 14 日发行、ISBN 978-4-7973-9259-3;出版社页面标 596 页。CiNii Books 书目与 16 课目录记录正文 xxiii+571 页,日本国会图书馆书目记录 571 页。页数口径存在前置页差异,因此本课程不拿页码充当目录证据。

“Lesson 14:新的类”的中文说明、示例、交互、练习和答案均为独立教学重写。公开资料只确认 16 个 Lesson 标题;本页列出的细分概念是课程教学映射,不冒充原书逐级小节。技术规则参考 ISO C++ 标准入口复核,但 2017 年教材的 Visual Studio 2017 语境不会被静默升级为 C++23;现代写法只能明确标为迁移说明。

围绕“怎样证明派生对象能在所有基类契约允许的位置安全替换基类对象?”,本页要求保留“类层次、is-a 契约、构造析构顺序、静态动态类型、虚调用结果和切片反例。”。若故障“按值传递派生对象给基类参数,发生对象切片”无法在同一输入下制造首个分岔,应拒绝当前解释,而不是追加随机样例。

为什么“想复用代码”不是继承的充分理由

继承让新类在已有类基础上扩展,但 public 继承首先声明语义:任何需要基类对象的地方,都可以接收派生对象而不破坏基类契约。若只是想复用一个工具对象,却不能满足这种替换关系,组合通常更准确。

例如 Dog 是 Animal 的一种,public 继承有自然 is-a 关系;Engine 不是 Car 的一种,Car 应拥有 Engine 成员。语法允许的继承不一定是正确模型,替换测试比“能少写几行”更重要。

public 继承保留公开接口

class Animal {
public:
    void eat() const {
        std::cout << "eat\n";
    }
};
 
class Dog : public Animal {
public:
    void bark() const {
        std::cout << "woof\n";
    }
};

Dog 对象可以调用继承来的 public eat 和自己的 bark。基类 private 成员仍存在于派生对象的基类部分,但派生类不能直接访问;应通过基类 protected/public 操作维护基类不变量。

把基类数据改成 protected 虽能直接访问,却会让每个派生类依赖表示。优先把状态保持 private,并提供受保护或公开的语义操作,而不是裸字段。

构造从基类部分开始

创建派生对象时,先构造基类部分,再按声明顺序构造派生成员,最后执行派生构造函数体;析构顺序完全相反。

class Dog : public Animal {
public:
    explicit Dog(std::string name)
        : Animal(), name_(std::move(name)) {}
 
private:
    std::string name_;
};

派生构造函数通过初始化列表选择基类构造函数。若未显式写且基类没有默认构造,派生构造会失败。基类必须先建立自己的不变量,派生类再建立新增部分。

virtual 让基类接口按动态对象选择实现

普通成员调用由表达式的静态类型决定。基类把函数声明为 virtual 后,通过基类指针或引用调用时,会根据实际对象动态类型选择最终覆盖实现。

class Animal {
public:
    virtual std::string speak() const {
        return "...";
    }
    virtual ~Animal() = default;
};
 
class Dog : public Animal {
public:
    std::string speak() const override {
        return "woof";
    }
};

override 要求此函数确实覆盖基类 virtual;若参数、const 或引用限定不匹配,编译器立即拒绝,而不是悄悄创建一个同名新函数。

基类引用保留动态对象身份

void announce(const Animal& animal) {
    std::cout << animal.speak() << '\n';
}
 
Dog dog;
announce(dog); // woof

引用不复制对象,基类接口看到的动态对象仍是 Dog,因此 virtual 调用进入 Dog::speak。指针同理,但还要处理 nullptr 与生命周期。

virtual 分派只作用于声明为 virtual 的函数。派生类写一个同名普通函数不会让基类调用自动变化;需要多态行为时,应在基类接口明确 virtual,并在派生实现写 override。

基类析构必须匹配多态删除

若对象可能经基类指针 delete,基类析构必须是 virtual,才能先执行派生析构再执行基类析构。否则行为未定义,派生资源可能不释放。

Animal* animal = new Dog;
delete animal; // Animal 析构为 virtual,顺序正确

更现代的代码使用 std::unique_ptr<Animal> 表达所有权,但它最终仍通过基类析构释放;virtual 析构契约没有消失。

按值转换会产生对象切片

把 Dog 按值赋给 Animal 对象,只复制基类部分,派生新增状态被切掉,结果对象的动态类型就是 Animal。

Dog dog;
Animal copy = dog;
std::cout << copy.speak(); // Animal 的实现

需要多态时通过基类引用、指针或拥有智能指针传递;需要值语义时则设计共同值类型或显式 clone。容器 std::vector<Animal> 同样会切片,应使用适合所有权的指针容器或不依赖继承的变体。

重定义非虚函数不构成运行时多态

派生类可以声明与基类同名的非虚函数,但通过基类引用调用仍选基类版本。这不是 override,而是隐藏或静态选择。

class Base {
public:
    void label() const { std::cout << "base\n"; }
};
 
class Derived : public Base {
public:
    void label() const { std::cout << "derived\n"; }
};

若函数语义应随动态类型变化,把基类函数设计为 virtual;若不应变化,派生类就不该用同名接口制造不同含义。

替换原则要求不加强前置条件

派生类若拒绝基类允许的输入、改变返回语义或破坏异常保证,即使语法可继承,也不能安全替换。例如基类 setVolume(0..100),派生类只接受 50..100,会让原本正确的基类调用在派生对象上失败。

public 继承评审应检查每个公开方法的前置条件、后置条件和不变量,而不只画类树。无法保持契约时,使用组合或独立接口更诚实。

先预测静态类型与动态类型

对直接 Dog 对象、Animal&、Animal*、Animal 按值副本分别写出静态类型、动态类型和 speak 结果。只有引用/指针保留动态身份,且目标函数 virtual 时,才发生动态分派。

三步审计继承设计

分步1 / 3

第一步:证明 is-a 替换

写出基类契约,逐项检查派生对象能否在所有基类调用场景保持前置条件、结果与不变量;否则改用组合。

正式节点与章专属证据

  • 继承:在“Lesson 14:新的类”中核对输入、状态变化、失败模式和可复现证据;第 1 个节点必须能回到“派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。”。
  • 基类:在“Lesson 14:新的类”中核对输入、状态变化、失败模式和可复现证据;第 2 个节点必须能回到“派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。”。
  • 派生类:在“Lesson 14:新的类”中核对输入、状态变化、失败模式和可复现证据;第 3 个节点必须能回到“派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。”。
  • 重写:在“Lesson 14:新的类”中核对输入、状态变化、失败模式和可复现证据;第 4 个节点必须能回到“派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。”。
  • 虚函数:在“Lesson 14:新的类”中核对输入、状态变化、失败模式和可复现证据;第 5 个节点必须能回到“派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。”。

先用输入合同检查本页正式节点,再在相同初值下逐步比较正常和失败轨迹,最后只启用“按值传递派生对象给基类参数,发生对象切片”完成反例与复位。三个交互都必须能独立重置,且重置后再次满足“派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。”。

输入与状态合同

Lesson 14:新的类

怎样证明派生对象能在所有基类契约允许的位置安全替换基类对象?

必须先声明

为继承声明输入类型、有效范围、对象生命周期和失败策略。

可复核证据

保存Lesson 14:新的类的原始输入、初值与第一条可检查诊断。

正式节点:继承、基类、派生类、重写、虚函数

编译与运行轨迹

同一输入下比较正常与失败路径

  1. 01通过基类引用接收对象
  2. 02保留动态类型
  3. 03虚调用派发到覆盖函数
  4. 04经虚析构释放完整对象

不变量:派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。

故障定位与复位

一次只破坏一个前提

基线可继续:派生类不加强基类前置条件,虚调用和析构都保持基类公开契约。

小结

  • public 继承表达派生类可替换基类的 is-a 契约,单纯代码复用更适合组合
  • 派生对象先构造基类再构造新增部分,析构反向进行;派生体执行前基类已完成
  • virtual 在基类接口打开动态分派,override 让编译器验证覆盖签名
  • 经基类删除派生对象需要 virtual destructor;智能指针不取消这项底层契约
  • 基类按值接收派生对象会切片,保留多态身份应使用引用、指针或显式所有权抽象

练习

  1. 问题 1:选择关系。 Dog/Animal 与 Car/Engine 为什么分别适合继承和组合?
  1. 问题 2:预测调用。 Dog 经 const Animal& 调 virtual speak 与按值复制成 Animal 后分别调用什么?
  1. 问题 3:审计删除。 为什么其他函数 virtual 仍不足以保证 delete Animal* 安全?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

继承
从已有类建立新类并获得其接口的机制。
基类
提供通用接口、被其他类继承的类。
派生类
继承基类并扩展或覆盖行为的新类。
虚函数
通过基类引用或指针按动态类型选择覆盖实现的成员函数。
override
要求派生函数正确覆盖基类虚函数的说明符。
对象切片
派生对象按值转为基类对象时丢失派生部分的现象。

资料与写作方式声明

本章以高桥麻奈 2017 年 C++ 入门教材第 5 版权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…