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 的优点:
-
增强封装性:对外部隐藏了
virtual
函数的具体实现,仅暴露稳定的接口,避免了外部直接调用虚函数。 -
接口稳定性:通过控制
public
接口,后续可以在public
函数中添加通用逻辑,而无需更改子类实现。 -
增强灵活性:可以在接口内部增加通用操作,比如前置检查、日志记录或后置操作。
- (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)>
后,客户代码不仅可以传递相同签名的普通函数,还可以传递以下任意“可调用物”:-
普通函数:如
void strategyA(int)
。 -
静态成员函数:与普通函数相同。
-
Lambda 表达式:符合签名的 lambda 表达式,例如
[](int data) { ... }
。 -
绑定对象:通过
std::bind
将其他函数或成员函数绑定出一个符合要求的函数。 -
仿函数(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)
- 定义:复合是指一个类包含另一个类的实例作为其成员。这种关系通常被描述为“拥有”(has-a)关系。
- 灵活性:使用复合时,可以动态地组合不同的类,增加系统的灵活性和可扩展性。更改组合中的某个类不会影响其他类。
- 生命周期:复合关系中的对象通常在父对象的生命周期内存在,父对象可以控制其创建和销毁。
- 代码重用:复合鼓励通过组合现有类来创建新功能,而不是通过继承来扩展功能。
公共继承(Public Inheritance)
- 定义:公共继承是一种“是一个”(is-a)关系,表示子类继承父类的特性和行为。
- 关系紧密:公共继承创建了强依赖关系,子类通常被视为父类的特化,这使得子类不仅获得了父类的属性和方法,还可以重写它们。
- 多态性:公共继承支持多态性,允许使用父类的指针或引用来调用子类的重写方法。
- 可变性:父类的变化可能会影响所有子类,维护和理解起来可能会更加复杂。
复合和公共继承各有优缺点,选择使用哪种方式通常取决于具体的设计需求。如果需要更强的灵活性和可维护性,复合通常是更好的选择;如果需要表示强的层次关系和多态性,公共继承可能更合适。
-
② “在应用域(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 函数时,这么设计是合理的。”
- 访问基类的
protected
成员:当派生类需要直接访问基类的protected
成员时,私有继承提供了这个权限,而使用复合(成员变量)却无法访问protected
成员。因此,如果派生类要利用基类的部分实现,但又不希望公开继承(不会造成派生类“是”基类的关系),就可以采用私有继承。 - 重定义(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 的实例
通过在
B
和C
中使用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
),但这些细节不会对外部暴露。