欢迎光临
我们一直在努力

Effective C++ 条款36:绝不重新定义继承而来的 non-virtual 函数

Effective C++ 条款36:绝不重新定义继承而来的 non-virtual 函数

本篇为《Effective C++:改善程序与设计的 55 个具体做法》读书笔记系列第 36 篇。

开篇引言

在 C++ 的面向对象编程中,继承和多态是两个核心概念。很多开发者习惯性地认为:“子类可以重写父类的任何函数”。然而,Scott Meyers 在条款 36 中明确警告:绝不重新定义继承而来的 non-virtual 函数。这看似反直觉的建议背后,隐藏着 C++ 对象模型的深层机制。理解这一点,对于编写健壮、可维护的 C++ 代码至关重要。

核心问题:一个令人困惑的代码示例

让我们从一个简单的例子开始,看看会发生什么意想不到的事情:

#include <iostream>

class Base {
public:
void func() {
std::cout << "Base::func() called" << std::endl;
}
};

class Derived : public Base {
public:
void func() { // 警告:重新定义了继承而来的 non-virtual 函数!
std::cout << "Derived::func() called" << std::endl;
}
};

int main() {
Derived d;

Base* pB = &d; // 基类指针指向派生类对象
Derived* pD = &d; // 派生类指针指向派生类对象

pB->func(); // 输出:Base::func() called
pD->func(); // 输出:Derived::func() called

return 0;
}

令人震惊的结果

同一个对象 d,通过不同类型的指针调用同一个函数,却产生了完全不同的行为!

调用方式实际调用的函数原因
pB->func() Base::func() 静态绑定:指针类型是 Base*
pD->func() Derived::func() 静态绑定:指针类型是 Derived*

这种行为的分裂性,正是条款 36 要禁止重新定义 non-virtual 函数的根本原因。

原理深度解析

静态绑定 vs 动态绑定

要理解这个问题,我们必须深入 C++ 的函数调用机制:

1. 静态绑定(Static Binding)

Non-virtual 函数采用静态绑定(也称为早期绑定):

class Base {
public:
void nonVirtualFunc() { /* … */ } // non-virtual
};

Base* p = new Derived();
p->nonVirtualFunc(); // 编译器根据 p 的声明类型(Base*)决定调用 Base::nonVirtualFunc

  • 调用哪个函数在编译期就已经确定
  • 只与指针/引用的声明类型有关
  • 与指针实际指向的对象类型无关
2. 动态绑定(Dynamic Binding)

Virtual 函数采用动态绑定(也称为晚期绑定):

class Base {
public:
virtual void virtualFunc() { /* … */ } // virtual
};

Base* p = new Derived();
p->virtualFunc(); // 运行期根据 p 实际指向的对象类型决定调用哪个版本

  • 调用哪个函数在运行期才能确定
  • 与指针实际指向的对象类型有关
  • 通过虚函数表(vtable)机制实现

虚函数表机制简析

// 编译器为包含 virtual 函数的类生成虚函数表
class Base {
public:
virtual void vf() { /* Base 实现 */ }
void nf() { /* Base 实现 */ } // 无 vtable 条目
};

class Derived : public Base {
public:
void vf() override { /* Derived 实现 */ } // 覆盖 vtable 条目
void nf() { /* Derived 实现 */ } // 与 vtable 无关
};

机制Non-virtual 函数Virtual 函数
绑定时机 编译期 运行期
决定因素 指针/引用的声明类型 对象的实际类型
实现方式 直接函数调用 通过 vtable 间接调用
性能开销 无额外开销 一次间接寻址

为什么这是设计上的矛盾?

Public 继承的 is-a 关系

回顾条款 32:public 继承意味着 is-a 关系。如果 Derived public 继承自 Base,那么 “每一个 Derived 对象都是一个 Base 对象”。

Non-virtual 函数在设计上代表不变性凌驾于特异性之上:

class Base {
public:
void invariantBehavior() {
// 这个行为对所有 Base 及其派生类都应该是一致的
// 它反映了 Base 的"不变性"
}
};

如果 Derived 重新定义了 invariantBehavior(),就会出现逻辑矛盾:

  • 如果 Derived 确实需要不同的行为:说明 “Derived is a Base” 不成立,那么不应该使用 public 继承
  • 如果 Derived 确实应该是 Base 的一种:那么它不应该改变 Base 承诺的不变性
  • 如果行为应该因类型而异:那么函数应该声明为 virtual
  • 代码示例:设计矛盾的三难困境

    #include <iostream>

    // 场景1:如果 Base::func 应该反映"不变性"
    class Animal {
    public:
    void breathe() { // non-virtual:所有动物呼吸方式相同
    std::cout << "Breathing…" << std::endl;
    }
    };

    class Fish : public Animal {
    public:
    void breathe() { // 错误!鱼用鳃呼吸,但不应该重写 non-virtual
    std::cout << "Breathing through gills…" << std::endl;
    }
    };

    // 场景2:正确的做法 —— 使用 virtual
    class AnimalCorrect {
    public:
    virtual void breathe() {
    std::cout << "Breathing…" << std::endl;
    }
    virtual ~AnimalCorrect() = default;
    };

    class FishCorrect : public AnimalCorrect {
    public:
    void breathe() override {
    std::cout << "Breathing through gills…" << std::endl;
    }
    };

    // 场景3:如果行为确实应该统一,不需要 virtual
    class Shape {
    public:
    void printType() const { // 所有形状都需要打印类型信息,方式相同
    std::cout << "This is a shape" << std::endl;
    }
    };

    实际应用场景

    场景 1:企业级系统中的账户类

    #include <iostream>
    #include <string>

    class Account {
    public:
    // non-virtual:所有账户的日志记录方式应该一致
    void logTransaction(const std::string& info) const {
    std::cout << "[LOG] Account transaction: " << info << std::endl;
    }

    // virtual:不同账户类型计算利息的方式不同
    virtual double calculateInterest() const = 0;
    virtual ~Account() = default;
    };

    class SavingsAccount : public Account {
    public:
    double calculateInterest() const override {
    return balance * 0.03; // 年利率 3%
    }

    // 错误做法:
    // void logTransaction(const std::string& info) const {
    // std::cout << "[SAVINGS LOG] " << info << std::endl;
    // }
    // 这会导致通过 Account* 和 SavingsAccount* 调用产生不同行为!

    private:
    double balance = 10000.0;
    };

    class CheckingAccount : public Account {
    public:
    double calculateInterest() const override {
    return 0.0; // 支票账户无利息
    }
    private:
    double balance = 5000.0;
    };

    void processAccount(Account* account) {
    // 统一的日志记录(non-virtual,行为一致)
    account->logTransaction("Interest calculated");

    // 多态的利息计算(virtual,行为因类型而异)
    double interest = account->calculateInterest();
    std::cout << "Interest: " << interest << std::endl;
    }

    场景 2:游戏引擎中的组件系统

    class GameComponent {
    public:
    // non-virtual:所有组件的启用/禁用逻辑相同
    void setEnabled(bool enabled) {
    if (this->enabled != enabled) {
    this->enabled = enabled;
    onEnableStateChanged();
    }
    }

    bool isEnabled() const { return enabled; }

    // virtual:不同组件的更新逻辑不同
    virtual void update(float deltaTime) = 0;

    virtual ~GameComponent() = default;

    protected:
    // virtual:允许派生类响应状态变化
    virtual void onEnableStateChanged() {}

    private:
    bool enabled = true;
    };

    class RenderComponent : public GameComponent {
    public:
    void update(float deltaTime) override {
    if (!isEnabled()) return;
    // 渲染逻辑…
    }

    // 错误:不要重写 setEnabled!
    // void setEnabled(bool enabled) { … }
    };

    常见误区与解决方案

    误区 1:“我只是想加个默认参数”

    class Base {
    public:
    void func(int x = 10) { /* … */ }
    };

    class Derived : public Base {
    public:
    void func(int x = 20) { /* … */ } // 错误!同时改变了默认参数和隐藏了基类版本
    };

    注意:这还涉及条款 37(绝不重新定义继承而来的缺省参数值)的问题。

    误区 2:“我想隐藏基类的实现”

    class Base {
    public:
    void func() { /* 基类实现 */ }
    };

    class Derived : public Base {
    private:
    void func() { /* 派生类实现 */ } // 极度危险!不是重写,而是隐藏!
    };

    这不会重写基类函数,而是隐藏了它。通过 Base* 调用的仍然是 Base::func()。

    正确的设计模式

    需求正确做法说明
    所有派生类行为一致 non-virtual 反映不变性
    不同派生类行为不同 virtual 支持动态绑定
    需要扩展基类行为 virtual + 基类默认实现 impure virtual
    必须强制派生类实现 pure virtual 接口继承

    class Base {
    public:
    // 情况1:不变性 —— non-virtual
    void invariantOperation() {
    // 所有派生类共享相同实现
    }

    // 情况2:可定制行为 —— pure virtual
    virtual void mustImplement() = 0;

    // 情况3:有默认实现但可覆盖 —— impure virtual
    virtual void customizableOperation() {
    // 默认实现
    }

    virtual ~Base() = default;
    };

    编译器警告与最佳实践

    现代编译器通常会对隐藏基类 non-virtual 函数的行为发出警告:

    # GCC/Clang
    -Woverloaded-virtual # 警告隐藏的虚函数
    -Wshadow # 警告名称隐藏

    # MSVC
    /w14263 # 警告隐藏的函数

    最佳实践清单

  • 明确设计意图:在声明函数时就想清楚它应该是 virtual 还是 non-virtual
  • 使用 override 关键字:C++11 引入的 override 可以帮助捕获错误(虽然不能防止 non-virtual 的重定义,但可以防止 virtual 函数的签名错误)
  • 遵循 Liskov 替换原则:派生类应该能够替换基类而不改变程序正确性
  • 代码审查:特别关注派生类中是否有与基类同名的 non-virtual 函数
  • 总结

    核心要点

    要点说明
    Non-virtual 函数是静态绑定的 调用哪个版本由指针/引用的声明类型决定
    Public 继承意味着 is-a 重定义 non-virtual 函数破坏这一语义
    Non-virtual 函数代表不变性 它应该在继承体系中保持一致
    需要多态时使用 virtual 这是 C++ 支持运行时多态的正确机制

    记忆口诀

    Non-virtual 不覆盖,is-a 语义要维护。
    静态绑定看类型,动态绑定看对象。
    不变性用 non-virtual,特异性用 virtual。

    条款 36 的核心建议

    绝不重新定义继承而来的 non-virtual 函数。 如果你发现需要这样做,请重新审视你的继承关系:

  • 也许不应该使用 public 继承
  • 也许这个函数应该声明为 virtual
  • 也许你的设计需要重构

  • 参考阅读:

    • 《Effective C++》Scott Meyers,条款 36
    • 《C++ Primer》Stanley B. Lippman 等,关于虚函数和绑定的章节
    • 《设计模式》GoF,关于继承与组合的探讨

    系列预告: 下一篇将深入解析条款 37——绝不重新定义继承而来的缺省参数值,探讨静态绑定与动态绑定在参数默认值上的微妙陷阱。


    如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。

    赞(0)
    未经允许不得转载:171主机测评 » Effective C++ 条款36:绝不重新定义继承而来的 non-virtual 函数
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址