Appearance
面向对象与多态
struct 与 class
在 C++ 中二者都可以拥有数据成员、成员函数、构造/析构函数、模板、继承和访问控制。关键默认值不同:
| 特性 | struct | class |
|---|---|---|
| 成员默认访问权限 | public | private |
| 默认继承方式 | public | private |
工程上常用 struct 表示公开、轻量的数据聚合,用 class 表示要隐藏不变量和提供行为的抽象;这只是约定,不是语言强制。
面向对象三大特性
- 封装:把数据与操作封装在类型中,隐藏实现细节,通过受控接口维护不变量;
- 继承:派生类复用/扩展基类接口和实现,表达“is-a”关系;不应只为代码复用而滥用;
- 多态:同一接口可按对象实际类型表现不同,实现可扩展替换。
封装是维护不变量的基础;继承只是复用与扩展的一种手段;多态也可以由模板或 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 对象,行为未定义,派生资源可能无法正确释放。
构造阶段的虚调用不会越过“当前正在构造的类”分派到尚未构造完成的派生部分;析构阶段则不会分派到已经销毁的派生部分。