Skip to content

面向对象与多态 ​

struct 与 class ​

在 C++ 中二者都可以拥有数据成员、成员函数、构造/析构函数、模板、继承和访问控制。关键默认值不同:

特性structclass
成员默认访问权限publicprivate
默认继承方式publicprivate

工程上常用 struct 表示公开、轻量的数据聚合,用 class 表示要隐藏不变量和提供行为的抽象;这只是约定,不是语言强制。

面向对象三大特性 ​

  1. 封装:把数据与操作封装在类型中,隐藏实现细节,通过受控接口维护不变量;
  2. 继承:派生类复用/扩展基类接口和实现,表达“is-a”关系;不应只为代码复用而滥用;
  3. 多态:同一接口可按对象实际类型表现不同,实现可扩展替换。

封装是维护不变量的基础;继承只是复用与扩展的一种手段;多态也可以由模板或 std::variant 等机制实现,并不等于“必须有继承”。

重载、重写与隐藏 ​

  • 重载(overload):同一作用域内同名函数,参数列表不同;编译器在编译期选择调用目标。仅返回类型不同不能构成重载。
  • 重写(override):派生类以兼容签名实现基类的 virtual 函数,运行期通过基类指针/引用分派。应总是写 override,让编译器检查。
  • 名称隐藏(name hiding):派生类声明同名函数会隐藏基类所有同名重载,即使参数不同,也与其余重载是否为 virtual 无关;可使用 using Base::func; 恢复重载集合。
cpp
class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

class Circle final : public Shape {
public:
    explicit Circle(double r) : r_(r) {}
    double area() const override { return 3.14159 * r_ * r_; }
private:
    double r_;
};

混合虚/非虚重载:隐藏发生在虚分派之前 ​

同名重载中的每个签名彼此独立:想让哪个签名参与动态分派,就必须将对应的基类函数分别声明为 virtual。但派生类声明任意一个同名函数后,名称隐藏先于重载决议和虚分派发生,因此其余基类同名函数(包括非虚函数)都会先从派生类对象的候选集合中消失:

cpp
struct Base {
    virtual void handle(int value); // 此签名可动态分派
    void handle(double value);      // 非虚函数
    void handle(const char* text);  // 非虚函数
};

struct Derived : Base {
    void handle(int value) override; // 声明同名函数,隐藏 Base 的整个 handle 集合
};

Derived derived;

derived.handle(42);     // Derived::handle(int)
derived.handle(3.14);   // 不是 Base::handle(double),而是转换后调用 Derived::handle(int)
// derived.handle("ok"); // 错误:Base::handle(const char*) 已被隐藏,Derived 中无可用候选

这也是为什么隐藏比“直接编译报错”更危险:数值类型有时仍可转换到派生类保留下来的重载,代码能编译却调用了非预期函数。若派生类希望覆盖其中一个签名,同时保留基类其余重载,应显式引入整个基类重载集合:

cpp
struct Derived : Base {
    using Base::handle;
    void handle(int value) override;
};

Derived derived;
derived.handle(42);     // Derived::handle(int)
derived.handle(3.14);   // Base::handle(double)
derived.handle("ok");  // Base::handle(const char*)

通过基类引用或指针调用时,名称查找和重载决议则由**静态类型 Base**完成;只有选中的签名本身是虚函数,才会在下一步按动态类型分派:

cpp
Derived derived;
Base& base = derived;

base.handle(42);     // 选中 Base::handle(int),虚分派到 Derived::handle(int)
base.handle(3.14);   // 选中 Base::handle(double),非虚,调用 Base 版本
base.handle("ok");  // 选中 Base::handle(const char*),非虚,调用 Base 版本

结论:using Base::handle; 只恢复名称查找中的重载集合,不会把非虚函数变成虚函数;若 int、double 等多个签名都需要动态分派,应在基类中把每个对应重载分别标记 virtual,并在派生类对实际覆盖的每个版本写 override。

静态与动态多态 ​

  • 静态(编译期)多态:函数/运算符重载、模板、CRTP 等,调用目标编译期确定,容易内联,通常零运行期开销。
  • 动态(运行期)多态:虚函数、接口/抽象基类;通过基类指针或引用,在运行期依对象动态类型分派。

虚函数、纯虚函数与虚表 ​

  • 虚函数可以有默认实现,派生类可选择是否重写;
  • 纯虚函数写作 virtual void f() = 0;,使类成为抽象类,不能直接实例化;普通纯虚函数也可以在类外提供定义,但派生类若要变为具体类仍须完成覆盖;
  • 抽象基类应有虚析构函数,保证经基类指针销毁派生对象时析构链完整。

纯虚声明和函数定义是两件事。尤其是纯虚析构函数必须提供定义:销毁具体派生对象时,析构链最终仍会调用基类析构函数。普通纯虚函数也可以有默认公共实现;这不会消除纯虚属性,基类仍是抽象类,具体派生类仍需覆盖该函数:

cpp
struct Renderer {
    virtual ~Renderer() = 0; // 纯虚声明:Renderer 仍是抽象类
    virtual void prepare() = 0;
};

Renderer::~Renderer() = default; // 必须有定义
void Renderer::prepare() { /* 可复用的公共准备逻辑 */ }

struct OpenGLRenderer : Renderer {
    ~OpenGLRenderer() override = default;
    void prepare() override {
        Renderer::prepare(); // 可显式复用纯虚函数的定义
        // OpenGL 专有逻辑
    }
};

抽象类不能按值创建、传参或返回,也不能作为普通成员、数组或 std::vector<Abstract> 的元素;但它正适合作为多态接口的引用、指针和智能指针类型:

cpp
struct Shape {
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

void print_area(const Shape& shape);        // 合法:绑定具体派生对象
Shape& current_shape();                      // 合法:调用方不拥有对象
std::unique_ptr<Shape> create_shape();       // 合法:返回多态对象所有权

// void print(Shape shape);                  // 错误:按值参数需要构造抽象类对象
// Shape make_shape();                       // 错误:按值返回需要构造抽象类对象

大多数 C++ ABI 会用隐藏的 vptr 指向虚函数表(vtable)实现动态分派:表中存放对应虚函数的入口,派生类覆盖后使用自己的入口。这是主流实现方式,不是 C++ 标准规定的对象布局;多重继承、虚继承时布局和调整更复杂。

图只表达动态分派的逻辑路径;vptr 是否存在、位置、虚表格式和函数入口数量均由编译器 ABI 决定。

动态分派有一次间接调用和潜在优化限制的成本,通常很小,但不应在极端热点中不经评估地滥用。

构造函数与析构函数 ​

构造函数 ​

构造函数在对象创建时初始化基类和成员,名称与类相同、没有返回类型。常见形式:

  • 默认构造函数;
  • 带参构造函数;
  • 拷贝构造函数;
  • 移动构造函数;
  • 委托构造函数;
  • initializer_list 构造函数;
  • explicit 转换构造函数(避免不期望的隐式转换);
  • constexpr 构造函数(允许满足条件时常量求值)。

“初始化列表构造函数”“常量构造函数”不是与上述并列的标准构造函数分类。成员应优先在成员初始化列表中初始化,尤其是 const 成员、引用成员和无默认构造函数的成员。

构造函数能否使用虚函数? ​

构造函数本身不能声明为虚函数。 构造函数的职责正是创建指定类型的对象,调用哪一个构造函数必须在编译期由 new Derived、局部变量声明等方式确定,因此没有“通过基类接口按实际对象类型动态分派构造函数”的语义。也不要把原因简单归结为“对象还没有内存”或“还没有虚表”:对象存储会先被取得,而 vptr/vtable 是常见 ABI 实现细节,不是语言标准保证。

构造函数内部可以在语法上调用虚函数,但要非常谨慎。 在基类构造函数执行期间,派生类子对象尚未构造完成;为了避免访问未初始化的派生状态,虚调用只会分派到当前正在构造的类的最终覆盖版本,而不会调用更派生类的覆盖:

cpp
#include <iostream>

class Base {
public:
    Base() { describe(); } // 输出 Base,而非 Derived
    virtual void describe() const { std::cout << "Base\\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    Derived() : name_("ready") {}
    void describe() const override { std::cout << name_ << "\\n"; }
private:
    std::string name_;
};

// 构造 Derived 时,先执行 Base(),此时 name_ 尚未初始化,
// 因此 Base() 中的 describe() 调用 Base::describe()。

在 Derived 自己的构造函数体中调用该虚函数时,会分派到 Derived 的实现,但若它还有更派生类,仍不会分派到更派生类。即便如此,也应确保函数只依赖已经初始化的状态;工程上通常避免在构造函数中调用可被重写的虚函数,改用私有/非虚初始化辅助函数,或在对象完整构造后由工厂/显式初始化步骤调用扩展钩子。

若在构造或析构过程中调用纯虚函数,且该调用会落到纯虚函数作为最终覆盖版本,行为未定义(许多运行库会报 pure virtual method called)。因此不要让构造/析构路径依赖纯虚函数。

析构过程规则相反但原因相同:派生类已先析构,基类析构函数中的虚调用只分派到基类版本,不能依赖派生类资源。

析构函数 ​

析构函数写作 ~ClassName(),对象结束生命周期时执行清理。它可以且经常应在多态基类中声明为 virtual。若基类析构非虚,却经由 Base* 删除 Derived 对象,行为未定义,派生资源可能无法正确释放。

构造阶段的虚调用不会越过“当前正在构造的类”分派到尚未构造完成的派生部分;析构阶段则不会分派到已经销毁的派生部分。

使用 Markdown 与 VitePress 构建