Skip to content

面向对象与多态

structclass

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

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

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

面向对象三大特性

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

重载、重写与隐藏

  • 重载(overload):同一作用域内同名函数,参数列表不同;编译器在编译期选择调用目标。仅返回类型不同不能构成重载。
  • 重写(override):派生类以兼容签名实现基类的 virtual 函数,运行期通过基类指针/引用分派。应总是写 override,让编译器检查。
  • 名称隐藏(name hiding):派生类声明同名函数会隐藏基类所有同名重载,即使参数不同;可使用 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_;
};

静态与动态多态

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

虚函数、纯虚函数与虚表

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

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

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

构造函数与析构函数

构造函数

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

  • 默认构造函数;
  • 带参构造函数;
  • 拷贝构造函数;
  • 移动构造函数;
  • 委托构造函数;
  • 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 构建