《Effective C++》——继承与面向对象设计(item 32 ~ item 40)

66 阅读16分钟

Item 32:确定你的 public 继承塑模出 is-a 关系

  • “‘public 继承意味着 is-a。适用于 base classes 身上的每一件事情一定也适用于 derived classes 身上,因为每一个 derived classes 对象也都是一个 base class 对象。’”

Item 33:避免遮掩继承而来的名称

  • “derived classes 内的名称会遮掩 base classes 内的名称。在 public 继承下从来没有人希望如此。”
class Base {
private:
	int x;
public:
	virtual void mf1() = 0;
	virtual void mf1(int);
	virtual void mf2();
	void mf3();
	void mf3(double);

};


class Derived :public Base {
public:
	virtual void mf1();
	void mf3();
	void mf4();

};


int main()
{
	Derived d;
	int x;

	d.mf1();	// 调用 Derived::mf1
	d.mf1(x);	// 错误!Derived::mf1 遮掩了 Base::mf1
	d.mf2();	// 调用 Base::mf2
	d.mf3();	// 调用 Derived::mf3
	d.mf3(x);	// 错误!Derived::mf3 遮掩了 Base::mf3
}
  • “为了让被遮掩的名称再见天日,可使用 using 声明式或转交函数(forwarding functions)。”

    • using 声明式:
    class Base {
    private:
            int x;
    public:
            virtual void mf1() = 0;
            virtual void mf1(int);
            virtual void mf2();
            void mf3();
            void mf3(double);
    
    };
    
    
    class Derived :public Base {
    public:
            // 让 Base class 内名为 mf1 和 mf3 的所有东西在 Derived 作用域内都可见(并且 public)
            using Base::mf1;
            using Base::mf3;
    
    
            virtual void mf1();
            void mf3();
            void mf4();
    
    };
    
    
    int main()
    {
            Derived d;
            int x;
    
            d.mf1();	// 调用 Derived::mf1
            d.mf1(x);	// 调用 Base::mf1
            d.mf2();	// 调用 Base::mf2
            d.mf3();	// 调用 Derived::mf3
            d.mf3(x);	// 调用 Base::mf3
    }
    
    • 转交函数:

    当 Derived 唯一想继承的是 mf1 的无参版本。using 声明式会令继承而来的某给定名称之所有同名函数在 derived calss 中都可见,所以在这里派不上用场。然而使用转交函数即可实现:

    class Base {
    private:
            int x;
    public:
            virtual void mf1() = 0;
            virtual void mf1(int);
    
    };
    
    
    class Derived :private Base {
    public:
            virtual void mf1()	// 转交函数
            {
                    Base::mf1();
            }
    };
    
    
    int main()
    {
            Derived d;
            int x;
    
            d.mf1();	// 调用 Derived::mf1
            d.mf1(x);   // 错误!Base::mf1()被遮掩了
    }
    

Item 34:区分接口继承和实现继承

  • “接口继承和实现继承不同。在 public 继承之下,derived classes 总是继承 base class 的接口。”

    • 接口继承:

    接口继承指的是派生类继承了基类中声明的函数签名,即派生类必须遵循基类的函数声明和接口约定。无论基类中这些函数是否有实现,派生类都继承了它们的接口。换句话说,派生类承诺能够提供基类接口中定义的功能。

    • 实现继承:

    实现继承是指派生类继承了基类的函数实现。如果基类中某个函数已经有具体的实现,那么派生类也可以直接使用这个实现,而不需要自己重新定义。

  • “pure virtual 函数只具体指定接口继承。”

  • “简朴的(非纯)impure virtual 函数具体指定接口继承及缺省的实现继承。”

  • “non-virtual 函数具体指定接口继承以及强制性实现继承。”

  • 提供缺省实现给 derived classes,但除非它们明白要求,否则免谈

    此间技俩在于切断“ virtual 函数接口” 和其 “缺省实现” 之间的连接:

    class Airplane {
    public:
            virtual void fly(const Airport& destination) = 0;
            ...
    protected:
            void defaultFly(const Airport& destination);
    };
    
    void Airplane::defaultFly(const Airport& destination)
    {
            // 缺省行为(即默认行为)
    }
    
    
    class ModelA : public Airplane {
    public:
            void fly(const Airport& destination) override
            {
                    defaultFly(destination);
            }
            ...
    };
    
    class ModelB : public Airplane {
    public:
            void fly(const Airport& destination) override
            {
                    defaultFly(destination);
            }
            ...
    };
    

    有些人反对以不同的函数分别提供接口和缺省实现。它们关心因过度雷同的函数名称而引起的 class 命名空间污染问题。但是它们也同意,接口和缺省实现应该分开。这个表面上看起来的矛盾该如何解决? 可以利用 “pure virtual 函数必须在 derived classes 中重新声明,但它们也可以拥有自己的实现” 这一事实。

    class Airplane {
    public:
            virtual void fly(const Airport& destination) = 0;
            ...
    
    };
    
    void Airplane::fly(const Airport& destination)	// 纯虚函数的实现
    {
            // 缺省行为
    
    }
    
    void Airplane::defaultFly(const Airport& destination)
    {
            // 缺省行为(即默认行为)
    }
    
    
    class ModelA : public Airplane {
    public:
            void fly(const Airport& destination) override
            {
                    Airplane::fly(destination);
            }
            ...
    };
    
    class ModelB : public Airplane {
    public:
            virtual void fly(const Airport& destination);
            ...
    
    };
    
    void ModelB::fly(const Airport& destination)
    {
            // B 的 fly 特化
    }
    

Item 35:考虑 virtual 函数以外的其他选择

  • “virtual 函数的替代方案包括 NVI 手法及 Strategy 设计模式的多种形式。NVI 手法自身是一个特殊形式的 Template Method 设计模式。”

  • “将机能从成员函数移到 class 外部函数,带来的一个缺点是:非成员函数无法访问 class 的 non-public 成员。”

  • “function 对象的行为就像一般函数指针。这样的对象可接纳 ‘与给定之目标签名式(target signature)兼容’ 的所有可调用物(callable entities)。”

    假设一个 function “接受一个 reference 指向 const GameCharacter,并返回 int”。所谓兼容,意思是这个可调用物的参数可被隐式转换为 const GameCharacter&,而其返回类型可被隐式转换为 int。

  • 下面是 4 种 virtual 函数的替代方案:

    • (1)使用 non-virtual interface(NIV) 手法,那是 Template Method 设计模式的一种特殊形式。它以 public non-virtual 成员函数包裹较低访问性(private 或 protected)的 virtual 函数
    #include <iostream>
    #include <memory>
    
    class Base {
    public:
        // 对外提供的非虚接口
        void process() {
            preProcess();   // 前置处理
            doProcess();    // 调用子类实现的功能
            postProcess();  // 后置处理
        }
    
    protected:
        virtual void doProcess() = 0;   // 需要子类实现的虚函数
    
    private:
        void preProcess() { 
            std::cout << "Common pre-process steps." << std::endl; 
        }
    
        void postProcess() { 
            std::cout << "Common post-process steps." << std::endl; 
        }
    };
    
    class DerivedA : public Base {
    protected:
        void doProcess() override {
            std::cout << "DerivedA specific processing." << std::endl;
        }
    };
    
    class DerivedB : public Base {
    protected:
        void doProcess() override {
            std::cout << "DerivedB specific processing." << std::endl;
        }
    };
    
    int main() {
        std::unique_ptr<Base> objA = std::make_unique<DerivedA>();
        std::unique_ptr<Base> objB = std::make_unique<DerivedB>();
    
        objA->process();  // 调用 DerivedA 的特定实现
        objB->process();  // 调用 DerivedB 的特定实现
    
        return 0;
    }
    
    Common pre-process steps.
    DerivedA specific processing.
    Common post-process steps.
    Common pre-process steps.
    DerivedB specific processing.
    Common post-process steps.
    
    

    NVI 的优点:

    1. 增强封装性:对外部隐藏了 virtual 函数的具体实现,仅暴露稳定的接口,避免了外部直接调用虚函数。

    2. 接口稳定性:通过控制 public 接口,后续可以在 public 函数中添加通用逻辑,而无需更改子类实现。

    3. 增强灵活性:可以在接口内部增加通用操作,比如前置检查、日志记录或后置操作。

    • (2)将 virtual 函数替换为 “函数指针成员变量”,这是 Strategy 设计模式的一种分解表现形式。
    #include <iostream>
    #include <functional>
    
    // 定义策略接口的函数签名
    using StrategyFunction = void(*)(int);
    
    // 不同的策略实现
    void strategyA(int data) {
        std::cout << "Executing Strategy A with data: " << data << std::endl;
    }
    
    void strategyB(int data) {
        std::cout << "Executing Strategy B with data: " << data << std::endl;
    }
    
    // 上下文类,原本可能使用虚函数来选择策略
    class Context {
    public:
        // 构造时可以设置默认策略
        explicit Context(StrategyFunction strategy = strategyA) 
            : m_strategy(strategy) {}
    
        // 执行当前策略
        void executeStrategy(int data) const {
            m_strategy(data);
        }
    
        // 修改策略
        void setStrategy(StrategyFunction strategy) {
            m_strategy = strategy;
        }
    
    private:
        StrategyFunction m_strategy;
    };
    
    int main() {
        Context context;  // 使用默认策略
        Context context2(stratergB);  // 针对不同对象使用不同策略
        context.executeStrategy(42);  // 输出:Executing Strategy A with data: 42
    
        // 动态改变策略
        context.setStrategy(strategyB);
        context.executeStrategy(42);  // 输出:Executing Strategy B with data: 42
    
        return 0;
    }
    
    
    • (3)以 function 成员变量替换 virtual 函数,因而允许任何可调用物(callable entity)搭配一个兼容于需求的签名式。这也是 Strategy 设计模式的某种形式。

    假设原本使用普通的函数指针作为策略,函数指针只能指向某一个特定的函数。例如,一个 void (*)(int) 类型的指针仅能指向 void 返回类型、且接受一个 int 参数的函数。

    而使用 std::function<void(int)> 后,客户代码不仅可以传递相同签名的普通函数,还可以传递以下任意“可调用物”:

    1. 普通函数:如 void strategyA(int)

    2. 静态成员函数:与普通函数相同。

    3. Lambda 表达式:符合签名的 lambda 表达式,例如 [](int data) { ... }

    4. 绑定对象:通过 std::bind 将其他函数或成员函数绑定出一个符合要求的函数。

    5. 仿函数(Function Object) :自定义的实现了 operator() 的类对象。

    #include <iostream>
    #include <functional>
    
    void strategyA(int data) {
        std::cout << "Executing Strategy A with data: " << data << std::endl;
    }
    
    class StrategyClass {
    public:
        static void strategyB(int data) {
            std::cout << "Executing Strategy B (static) with data: " << data << std::endl;
        }
    };
    
    auto lambdaStrategy = [](int data) {
        std::cout << "Executing Lambda Strategy with data: " << data << std::endl;
    };
    
    class SomeClass {
    public:
        void someMethod(int data) const {
            std::cout << "Executing Bound Strategy with data: " << data << std::endl;
        }
    };
    
    SomeClass instance;
    auto boundStrategy = std::bind(&SomeClass::someMethod, instance, std::placeholders::_1);
    
    struct StrategyFunctor {
        void operator()(int data) const {
            std::cout << "Executing Functor Strategy with data: " << data << std::endl;
        }
    };
    
    class Context {
    public:
        explicit Context(std::function<void(int)> strategy) 
            : m_strategy(strategy) {}
    
        void executeStrategy(int data) const {
            m_strategy(data);
        }
    
    private:
        std::function<void(int)> m_strategy;
    };
    
    int main() {
        Context contextA(strategyA);           // 普通函数
        contextA.executeStrategy(1);
    
        Context contextB(&StrategyClass::strategyB);  // 静态成员函数
        contextB.executeStrategy(2);
    
        Context contextLambda(lambdaStrategy); // Lambda 表达式
        contextLambda.executeStrategy(3);
    
        Context contextBound(boundStrategy);   // 绑定对象
        contextBound.executeStrategy(4);
    
        Context contextFunctor(StrategyFunctor()); // 仿函数对象
        contextFunctor.executeStrategy(5);
    
        return 0;
    }
    
    

    其中:

    SomeClass instance; 
    auto boundStrategy = std::bind(&SomeClass::someMethod, instance, std::placeholders::_1);
    

    这行代码使用了 std::bind 来将 SomeClass 的非静态成员函数 someMethod 绑定到对象 instance 上,创建了一个新的可调用对象 boundStrategy。绑定对象后,boundStrategy 的签名变成了 void(int),可以直接在 std::function<void(int)> 类型的上下文中使用。

    • (4)将继承体系内的 virtual 函数替换为另一个继承体系内的 virtual 函数。这是 Strategy 设计模式的传统实现手法。

Item 36:绝不重新定义继承而来的 non-virtual 函数

class Base {
public:
    void show() const {
        std::cout << "Base::show()" << std::endl;
    }
};

class Derived : public Base {
public:
    void show() const {  // 重新定义了非虚函数
        std::cout << "Derived::show()" << std::endl;
    }
};

int main() {
    Base base;
    Derived derived;

    Base* p = &derived;
    p->show();  // 调用的是 Base::show()
}

在这个例子中,虽然 Derived 类重新定义了 show 函数,但由于 show 不是虚函数,当我们通过基类指针调用 show 时,调用的是 Base::show 而不是 Derived::show。这可能导致代码逻辑错误和行为不一致。

Item 37:绝不重新定义继承而来的缺省参数值

  • “绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而 virtual 函数——你唯一应该覆写的东西——却是动态绑定。”
    • 所谓静态类型,就是它在程序中被声明时所采用的类型,所谓动态类型,则是指 “目前所指对象的类型”。
#include <iostream>

class Base {
public:
    virtual void func(int x = 10) {
        std::cout << "Base func with x = " << x << std::endl;
    }
};

class Derived : public Base {
public:
    void func(int x = 20) override { // 重新定义默认参数值
        std::cout << "Derived func with x = " << x << std::endl;
    }
};

int main() {
    Base* b = new Derived();
    b->func(); // 这里调用的是 Base::func,输出 10
    b->func(30); // 这里调用的是 Derived::func,输出 30

    delete b;
    return 0;
}

  • 默认参数值是在编译时解析的,因此称为静态绑定

  • 当你在基类中定义了一个虚函数并为其提供默认参数值时,派生类如果覆盖该虚函数时,并不会改变默认参数的绑定。派生类在使用该函数时,仍然会使用基类中定义的默认参数值。

  • 这可能导致意想不到的行为,特别是在动态多态(使用虚函数)时。

Item 38:通过复合塑模出 has-a 或 “根据某物实现出”

  • “复合(composition)的意义和 public 继承完全不同。”

    复合(Composition)

    1. 定义:复合是指一个类包含另一个类的实例作为其成员。这种关系通常被描述为“拥有”(has-a)关系。
    2. 灵活性:使用复合时,可以动态地组合不同的类,增加系统的灵活性和可扩展性。更改组合中的某个类不会影响其他类。
    3. 生命周期:复合关系中的对象通常在父对象的生命周期内存在,父对象可以控制其创建和销毁。
    4. 代码重用:复合鼓励通过组合现有类来创建新功能,而不是通过继承来扩展功能。

    公共继承(Public Inheritance)

    1. 定义:公共继承是一种“是一个”(is-a)关系,表示子类继承父类的特性和行为。
    2. 关系紧密:公共继承创建了强依赖关系,子类通常被视为父类的特化,这使得子类不仅获得了父类的属性和方法,还可以重写它们。
    3. 多态性:公共继承支持多态性,允许使用父类的指针或引用来调用子类的重写方法。
    4. 可变性:父类的变化可能会影响所有子类,维护和理解起来可能会更加复杂。

    复合和公共继承各有优缺点,选择使用哪种方式通常取决于具体的设计需求。如果需要更强的灵活性和可维护性,复合通常是更好的选择;如果需要表示强的层次关系和多态性,公共继承可能更合适。

  • “在应用域(application domain),复合意味着 has-a(有一个)。在实现域(implementation domain),复合意味着 is-implemented-in-terms-of(根据某物实现出)。”

    在应用域中,复合关系通常表示“has-a”(有一个)的关系。例如,一个“汽车”类可以包含一个“引擎”类的实例,这意味着“汽车”有一个“引擎”。这种设计强调了类之间的关系,通常用于描述现实世界中的实体及其相互关系。

    在实现域中,复合关系则侧重于实现细节,通常表示“is-implemented-in-terms-of”(根据某物实现出)。这意味着一个类的功能可以通过组合其他类来实现。例如,一个“订单处理”类可能由多个其他类(如“库存管理”、“支付处理”等)组成。这里,复合不仅是一种结构关系,更是实现功能的方式,强调了如何通过组合不同组件来达到特定的功能。

Item 39:明智而审慎地使用 private 继承

  • “Private 继承意味着 is-implemented-in-terms-of(根据某物实现出)。它通常比复合(composition)的级别低。但是当 derived class 需要访问 protected base class 的成员,或需要重新定义继承而来的 virtual 函数时,这么设计是合理的。”

    1. 访问基类的protected成员:当派生类需要直接访问基类的protected成员时,私有继承提供了这个权限,而使用复合(成员变量)却无法访问protected成员。因此,如果派生类要利用基类的部分实现,但又不希望公开继承(不会造成派生类“是”基类的关系),就可以采用私有继承。
    2. 重定义(override)基类的虚函数:私有继承仍允许派生类重写(override)基类的虚函数,使得派生类可以重新定义基类的行为。这在需要改变基类方法的行为、而复合又无法直接实现这种改变时尤为有用。

    总结来说,私有继承是一种实现方式,意味着“通过基类实现派生类的功能”,但不代表“派生类是基类的类型”。只希望重用基类的部分实现细节而不公开基类的接口时,私有继承提供了一种更灵活的选择。

  • “和复合(composition)不同,private 继承可以造成 empty base 最优化。这对致力于 ‘对象尺寸最小化’ 的程序库开发者而言,可能很重要。”

    私有继承与复合(成员变量)相比,有一个潜在的优势:空基类优化(Empty Base Optimization,EBO),这对需要将对象尺寸最小化的程序库开发者来说尤为重要。

    空基类优化的原理:

    在C++中,如果一个类作为基类而不包含任何数据(即为空基类),编译器可以将它的实例的大小优化为0字节。因为私有继承可以被看作是"基于基类实现"的关系,它允许这种优化——将空基类的大小压缩掉,使其不会占用额外空间。

    class Empty {};       // 没有数据,所以其对象应该不使用任何内存、
    class HoldsAnInt{
    private:
        int x;
        Empty e;          // 应该不需要任何内存(实际并不是)
    };
    

    即使Empty类不包含任何数据,编译器通常会为其分配最小的内存空间(通常是1字节),以便e具有独立的地址。在C++中,不同的对象必须具有唯一的地址,即使它们是空的。

    class HoldsAnInt : private Empty {  // 私有继承
    private:
        int x;
    };
    

    使用私有继承,可以保证 sizeof(HoldsAnInt) == sizeof(int)

Item 40:明智而审慎地使用多重继承

  • “多重继承比单一继承复杂。它可能导致新的歧义性,以及对 virtual 继承的需要。”

    • 1. 歧义性

      多重继承会引入潜在的歧义性问题,特别是在以下情况下:

      • 成员名称冲突:如果多个基类中有同名的成员函数或成员变量,派生类在访问这些成员时可能会产生歧义。例如:
      class A {
      public:
          void foo() {}
      };
      
      class B {
      public:
          void foo() {}
      };
      
      class C : public A, public B {
      public:
          void bar() {
              foo();  // 不知道调用 A::foo() 还是 B::foo(),产生歧义
          }
      };
      

      在这种情况下,编译器无法确定要调用哪个foo(),因此会报错。

      • 菱形继承问题:当一个类通过不同路径多次继承同一个基类时,会导致“菱形继承”结构,引起重复继承。例如:
      class A {};
      class B : public A {};
      class C : public A {};
      class D : public B, public C {};  // D 会有两个 A 的实例
      

      在这种情况下,D类中会有两个独立的A基类子对象,从而造成了冗余和潜在的歧义。

    • 2. 对虚拟继承的需要

      为了解决菱形继承问题,C++引入了虚拟继承。虚拟继承确保无论通过多少路径继承某个基类,最终派生类中都只有基类的一个实例,从而消除冗余和歧义。例如:

      class A {};
      class B : virtual public A {};
      class C : virtual public A {};
      class D : public B, public C {};  // D 中只有一个 A 的实例
      

      通过在BC中使用virtual关键字继承A,可以确保D类中只有一个A的实例,从而避免菱形继承导致的冗余和歧义。

  • “virtual 继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果 virtual base classes 不带任何数据,将是最具实用价值的情况。”

  • “多重继承的确有正当用途。其中一个情节涉及 ‘public 继承某个 Interface class’ 和 ‘private 继承某个协助实现的 class’ 的两相组合。”

    class IShape {
    public:
        virtual ~IShape() = default;
        virtual void draw() const = 0;  // 纯虚函数,提供接口
    };
    
    class ShapeHelper {
    protected:
        void commonDrawLogic() const {
            // 实现共享的绘制逻辑
        }
    };
    
    class Circle : public IShape, private ShapeHelper {
    public:
        void draw() const override {
            commonDrawLogic();  // 利用 ShapeHelper 中的通用绘制逻辑
            // 执行 Circle 特定的绘制操作
        }
    };
    

    Circle通过公共继承IShape,对外表现为一种IShape类型,可以作为IShape对象使用。

    Circle通过私有继承ShapeHelper,从而复用ShapeHelper的实现细节(如commonDrawLogic),但这些细节不会对外部暴露。