读书笔记 effective c++ Item 34 区分接口继承和实现继承

简介: 看上去最为简单的(public)继承的概念由两个单独部分组成:函数接口的继承和函数模板继承。这两种继承之间的区别同本书介绍部分讨论的函数声明和函数定义之间的区别完全对应。 1. 类函数的三种实现 作为一个类设计者,有时候你只想派生类继承成员函数的接口(声明)。

 

看上去最为简单的(public)继承的概念由两个单独部分组成:函数接口的继承和函数模板继承。这两种继承之间的区别同本书介绍部分讨论的函数声明和函数定义之间的区别完全对应。

1. 类函数的三种实现

作为一个类设计者,有时候你只想派生类继承成员函数的接口(声明)。有时候你想让派生类同时继承接口和实现,但是你允许它们覆盖掉继承而来的函数实现。但有时候你却想让派生类继承一个函数的接口和实现并且不允许它们被覆盖掉

为了对这些不同的选择有一个更好的理解,考虑表示几何图形的类继承体系:

1 class Shape {
2 public:
3 virtual void draw() const = 0;
4 virtual void error(const std::string& msg);
5 int objectID() const;
6 ...
7 };
8 class Rectangle: public Shape { ... };
9 class Ellipse: public Shape { ... };

 

Shape是一个抽象类;是由纯虚函数draw所标记的。因此客户不能创建Shape类的实例,而只有它的派生类才可以。尽管如此,Shape对(public)继承自它的所有类会产生很大的影响,因为:

  • 成员函数接口总是被继承,正如Item 32中解释的,public继承意味着“is-a”,也就是对基类来说为真的任何东西对派生类来说也必须为真。因此,如果一个函数可以被应用在一个类中,它也必须能被应用到它的派生类中。

Shape类中声明了三个函数。第一个,draw,画出当前对象;第二个,error,当error需要被记录的时候被调用。第三个,objectID,为当前对象返回一个唯一的整型标识符。每个函数以一种不同的方式被声明:draw是纯虚函数;error是简单的(不是纯的)虚函数;objectID是非虚函数。这些不同声明隐藏的含义是什么呢?

 

1.1 纯虚函数

考虑第一个纯虚函数draw:

1 class Shape {
2 public:
3 virtual void draw() const = 0;
4 ...
5 };

 

纯虚函数的两个最具特色的特征是:它们必须被继承它们的任何具现类重新声明;在抽象类中它们通常情况下没有定义。将这两个特征放在一起,你就会发现:

  • 声明纯虚函数的意图是让派生类只继承函数接口

这使得Shape::draw函数是非常有意义的,因为对于所有的Shape对象来说能够被画出来是一个合理的需求,但是Shape类不能为这个函数提供合理的默认实现,比如,画一个椭圆的算法和画一个矩形的算法是不一样的。Shape::draw的声明对派生具现类的设计者说,“你必须提供一个draw函数,但是我并不知道你该如何实现它。”

顺便说一下,为一个纯虚函数提供一个定义也是可能的。也就是你可以为Shape::draw提供一个实现,C++不会发出抱怨,但是调用它的唯一方式是在函数名前加上类名限定符:

 1 Shape *ps = new Shape;     // error! Shape is abstract
 2 
 3 Shape *ps1 = new Rectangle; // fine
 4 
 5  
 6 
 7 ps1->draw();                // calls Rectangle::draw
 8 
 9 Shape *ps2 = new Ellipse;      // fine
10 
11  
12 
13 ps2->draw();        // calls Ellipse::draw
14 
15 ps1->Shape::draw(); // calls Shape::draw
16 
17 ps2->Shape::draw(); // calls Shape::draw

 

除了帮助你在鸡尾酒会上给你的程序员伙伴留下深刻印象之外,这个特性通常来说效用有限。然而,你在下面会看到,它可以作为一种机制为简单的(非纯的)虚函数提供比平常更加安全的默认实现。

1.2 非纯的虚函数

 

简单虚函数背后的故事同纯虚函数有些不太一样。通常情况下来说,派生类继承函数接口,但是简单虚函数提供了可能会被派生类覆盖的实现。如果你再想想,你会意识到:

  • 声明一个简单虚函数的目的是让派生类继承一个函数接口或者一个默认实现。

考虑Shape::error的情况:

1 class Shape {
2 public:
3 virtual void error(const std::string& msg);
4 ...
5 };

 

这个接口表明在遇到错误的时候每个类必须提供一个错误函数,但是每个类对错误如何进行处理可以自由控制。如果一个类不想做任何特殊的事情,那么调用基类Shape中error的默认实现就可以了。也就是Shape::error的声明对派生类的设计者说,“你可以支持error函数,但如果你不想自己实现,你可以使用Shape类中的默认版本。”

1.2.1 同时为简单虚函数提供函数接口和默认实现是危险的

同时为简单虚函数提供函数接口和默认实现是危险的。为什么?考虑为XYZ航空公司设计了飞机继承体系。XYZ只有两种类型的的飞机,型号A和型号B,同种飞机的飞行方式相同。因此,XYZ设计了如下的继承体系:

 1 class Airport { ... };                                                                    // represents airports
 2 
 3 class Airplane {                                                                       
 4 
 5 public:                                                                                     
 6 
 7 virtual void fly(const Airport& destination);                           
 8 
 9 ...                                                                                             
10 
11 };                                                                                             
12 
13 void Airplane::fly(const Airport& destination)                        
14 
15 {                                                                                              
16 
17 default code for flying an airplane to the given destination      
18 
19 }                                                                                              
20 
21 class ModelA: public Airplane { ... };                                         
22 
23 class ModelB: public Airplane { ... };

 

为了表示所有的飞机必须支持fly函数,还有不同型号的飞机可能需要fly的不同实现,因此Airplane::fly被声明为virtual。然而,为了防止在ModelA和ModelB中实现同一份代码,我们为Airplane::fly提供了默认实现,ModelA和ModelB可以同时继承。

 

这是典型的面向对象设计。两个类分享同一个特征(实现fly的方式),所以一般的特征都会移到基类中,然后被派生类继承。这种设计使得类的普通特性比较清晰,防止代码重复,可以促进将来的增强实现,使长期维护更加容易——这是面向对象如此受欢迎的原因,XYZ应该为此感到骄傲。

 

现在假设XYZ公司界定引入新类型的飞机,Model C。型号C和型号A和B不一样,它的飞行方式变了。

 

XYZ的程序员为Model C在继承体系中添加了新类,但是他们如此匆忙的添加新类,以至于忘了重新定义fly函数:

1 class ModelC: public Airplane {
2 
3  
4 
5 ...                                            // no fly function is declared
6 
7 };            

                         

 

在他们的代码中有类似下面的实现:

1 Airport PDX(...);                              // PDX is the airport near my home
2 
3 Airplane *pa = new ModelC;          
4 
5 ...                                                   
6 
7 
8 pa->fly(PDX); // calls Airplane::fly!

 

这会是一个灾难:型号C的飞机尝试用型号A或者型号B的飞行方式去飞行。这不是增加旅客信心的行为。

 

1.2.2 解决方法一,将默认实现分离成单独函数

这里的问题不在于Airplane::fly有默认的行为,而在于允许 Model C在没有明确说明它需要基类行为的情况下继承了基类的行为。幸运的是,很容易为派生类提供只有在它们需要的情况下才为其提供的默认行为。这个窍门断绝了虚函数接口和默认实现之间的联系。下面是实现的方法:

 1 class Airplane {
 2 public:
 3 virtual void fly(const Airport& destination) = 0;
 4 ...
 5 protected:
 6 void defaultFly(const Airport& destination);
 7 };
 8 void Airplane::defaultFly(const Airport& destination)
 9 {
10 default code for flying an airplane to the given destination
11 }

 

注意Airplane::fly已经转成了一个纯虚函数。它为飞行提供了接口。在Airplane类中同样展示出了默认实现,但是现在它是以独立函数的形式存在,defaultFly。像ModelA和ModelB这样的类如果想使用默认实现,只要在fly函数体内调用Inline函数defaultFly就可以了(Item30中有inline函数和虚函数之间交互的信息):

 1 class ModelA: public Airplane {
 2 public:
 3 virtual void fly(const Airport& destination)
 4 { defaultFly(destination); }
 5 ...
 6 };
 7 class ModelB: public Airplane {
 8 public:
 9 virtual void fly(const Airport& destination)
10 { defaultFly(destination); }
11 ...
12 };

 

对于ModelC类来说,偶然的继承fly的不正确实现将不再可能,因为Airplane中的纯虚函数强制ModelC提供它自己版本的fly。

1 class ModelC: public Airplane {
2 public:
3 virtual void fly(const Airport& destination);
4 ...
5 };
6 void ModelC::fly(const Airport& destination)
7 {
8 code for flying a ModelC airplane to the given destination
9 }

 

 

这个机制也不是十分安全的(程序员仍然能够复制粘贴而导致错误),但是它比原来的设计可靠多了。因为对于Airplane::defaultFly来说,它是protected的是因为它是Airplane和它的派生类中的实现细节。使用airplane的客户只关心它们能够起飞,而不管飞行是如何实现的。

Airplane::defaultFly是一个非虚函数同样重要。因为没有派生类可以重定义这个函数,这也是Item36所描述的真理。如果defaultFly是虚的,就会有一个循环问题:当派生类想重新定义defaultFly但是忘了会怎样? 

1.2.3 解决方法二,利用纯虚函数提供默认实现

一些人反对将函数接口和默认实现分离的想法,就像上面的fly和defaultFly一样。首先,它们意识到,繁殖出十分相关的函数名字污染了类命名空间。但是它们仍然同意将函数接口和默认实现分离。它们如何处理这种看上去矛盾的事情呢?通过利用纯虚函数必须在具现派生类中重新声明这个事实,但是它们也有可能有自己的实现。下面的例子展示了Airplane继承体系是如何利用定义纯虚函数的能力的:

 1 class Airplane {
 2 public:
 3 virtual void fly(const Airport& destination) = 0;
 4 ...
 5 };
 6 
 7 void Airplane::fly(const Airport& destination) // an implementation of
 8 { // a pure virtual function
 9 default code for flying an airplane to
10 the given destination
11 }
12 class ModelA: public Airplane {
13 public:
14 virtual void fly(const Airport& destination)
15 { Airplane::fly(destination); }
16 ...
17 };
18 class ModelB: public Airplane {
19 public:
20 virtual void fly(const Airport& destination)
21 { Airplane::fly(destination); }
22 ...
23 };
24 class ModelC: public Airplane {
25 public:
26 virtual void fly(const Airport& destination);
27 ...
28 };
29 void ModelC::fly(const Airport& destination)
30 {
31 code for flying a ModelC airplane to the given destination
32 }

 

这个设计同前面的设计是基本相同的,除了纯虚函数体Airplane::fly代替了独立函数Airplane::defaultFly。从本质上来说,fly已经被分成了两个基本的组件。它的声明指定了接口(派生类必须使用它),同时它的定义指定了默认行为(派生类可能会使用,但是只有在显示的请求的时候才会使用)。将fly和defaultFly合并到一起,你就会失去为两个函数提供不同保护级别的能力:过去是protected的代码(在defaultFly中)现在变成了public的(因为它在fly中)。

 

1.3 非虚函数

最后,让我们看一看Shape的非虚函数,objectID:

1 class Shape {
2 public:
3 int objectID() const;
4 ...
5 };

 

 

当一个成员函数是非虚的,就不想其在派生类中有不同的行为。事实上,一个非虚成员函数指定了一种超越特化的不变性(invariant over specialization),无论一个派生类被如何特化,它的行为不可改变。

  • 声明一个非虚函数的意图在于让派生类继承一个函数接口,并且有一个强制的实现

你可以将Shape::objectID的声明想象成如下,“每一个Shape对象都有一个函数来产生一个对象标识符,这个对象标识符以相同的方式计算出来。计算方式由Shape::objectID的定义来决定,任何派生类都不应该尝试去修改它的定义”。因为一个非虚函数确定了一个超越特化的不变性,它永远不会在派生类中被定义,这一点将在Item36中进行讨论。 

 

2. 类设计者容易犯的两种错误

对纯虚函数,简单虚函数和非虚函数进行声明的不同点在于允许你精确的指定派生类会继承什么:只继承接口,继承接口和默认实现或者接口和强制实现。因为从根本上来说这些不同的声明类型意味着不同的东西,在你声明成员函数的时候你必须在他们之间进行选择。如果你这么做了,你就应该能够避免没有经验的类设计者才会犯的两种普通错误。

 2.1 错误一,将所有函数声明为非虚

第一种错误是将所有函数声明成非虚。这没有给派生类的特化留下任何余地;特别对于非虚析构函数来说是有问题的(Item 7)。当然,我们有足够的理由设计一个不被当作基类的类,在这种情况下,只声明非虚函数是合适的。然而通常情况下,这些类在下面两种情况下被创建出来:要么是忽略了虚函数和非虚函数的区别,要么就是过度担心虚函数所花费的开销。事实是基本上任何被用作基类的类都会使用虚函数。(Item 7

 

如果你关心虚函数的开销,允许我拿出80-20法则(Item 30也提到了),它表明了在一个典型的程序中,20%的代码会花费80%的运行时间。这个法则很重要,因为它意味着,平均来说,你的程序中的80%的函数调用可以是虚函数调用,但对你的程序的性能影响却是很轻微的。在你对能否负担的起虚函数的开销进行担心之前,确保你所关注的代码是对程序有重大影响的20%的那一部分。

 

 2.1 错误二,将所有函数声明为虚函数

另外一个普通的问题是将所有成员函数声明成虚函数。有时候这么做是对的——Item 31中的接口类就是这么做的。然而,这也是一个类设计者缺乏坚定立场的标志。一些函数不应该在派生类中被重定义,当碰到这种情况,你就应该把这个函数定义为非虚。不是说只要花费一点时间对函数进行重定义,就能使使类满足所有人的需求。如果你需要特化上的不变性,不要害怕说不!

3. 总结

    • 接口继承不同于实现继承。在public继承下,派生类总是会继承基类接口。
    • 纯虚函数只是指定了接口继承。
    • 简单虚函数指定了接口继承外加一个默认实现。
    • 非虚函数指定了一个接口继承外加一个强制实现。


作者: HarlanC

博客地址: http://www.cnblogs.com/harlanc/
个人博客: http://www.harlancn.me/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出, 原文链接

如果觉的博主写的可以,收到您的赞会是很大的动力,如果您觉的不好,您可以投反对票,但麻烦您留言写下问题在哪里,这样才能共同进步。谢谢!

目录
相关文章
|
1月前
|
存储 安全 Java
c++--继承
c++作为面向对象的语言三大特点其中之一就是继承,那么继承到底有何奥妙呢?继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用,继承就是类方法的复用。
57 0
|
4月前
|
安全 C++
【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)
本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。
234 6
|
6月前
|
C++ 开发者
C++学习之继承
通过继承,C++可以实现代码重用、扩展类的功能并支持多态性。理解继承的类型、重写与重载、多重继承及其相关问题,对于掌握C++面向对象编程至关重要。希望本文能为您的C++学习和开发提供实用的指导。
110 16
|
6月前
|
编译器 数据安全/隐私保护 C++
【C++面向对象——继承与派生】派生类的应用(头歌实践教学平台习题)【合集】
本实验旨在学习类的继承关系、不同继承方式下的访问控制及利用虚基类解决二义性问题。主要内容包括: 1. **类的继承关系基础概念**:介绍继承的定义及声明派生类的语法。 2. **不同继承方式下对基类成员的访问控制**:详细说明`public`、`private`和`protected`继承方式对基类成员的访问权限影响。 3. **利用虚基类解决二义性问题**:解释多继承中可能出现的二义性及其解决方案——虚基类。 实验任务要求从`people`类派生出`student`、`teacher`、`graduate`和`TA`类,添加特定属性并测试这些类的功能。最终通过创建教师和助教实例,验证代码
149 5
|
8月前
|
编译器 C++ 开发者
【C++】继承
C++中的继承是面向对象编程的核心特性之一,允许派生类继承基类的属性和方法,实现代码复用和类的层次结构。继承有三种类型:公有、私有和受保护继承,每种类型决定了派生类如何访问基类成员。此外,继承还涉及构造函数、析构函数、拷贝构造函数和赋值运算符的调用规则,以及解决多继承带来的二义性和数据冗余问题的虚拟继承。在设计类时,应谨慎选择继承和组合,以降低耦合度并提高代码的可维护性。
94 1
【C++】继承
|
5月前
|
编译器 C++ 开发者
【C++篇】深度解析类与对象(下)
在上一篇博客中,我们学习了C++的基础类与对象概念,包括类的定义、对象的使用和构造函数的作用。在这一篇,我们将深入探讨C++类的一些重要特性,如构造函数的高级用法、类型转换、static成员、友元、内部类、匿名对象,以及对象拷贝优化等。这些内容可以帮助你更好地理解和应用面向对象编程的核心理念,提升代码的健壮性、灵活性和可维护性。
|
1月前
|
人工智能 机器人 编译器
c++模板初阶----函数模板与类模板
class 类模板名private://类内成员声明class Apublic:A(T val):a(val){}private:T a;return 0;运行结果:注意:类模板中的成员函数若是放在类外定义时,需要加模板参数列表。return 0;
43 0
|
1月前
|
存储 编译器 程序员
c++的类(附含explicit关键字,友元,内部类)
本文介绍了C++中类的核心概念与用法,涵盖封装、继承、多态三大特性。重点讲解了类的定义(`class`与`struct`)、访问限定符(`private`、`public`、`protected`)、类的作用域及成员函数的声明与定义分离。同时深入探讨了类的大小计算、`this`指针、默认成员函数(构造函数、析构函数、拷贝构造、赋值重载)以及运算符重载等内容。 文章还详细分析了`explicit`关键字的作用、静态成员(变量与函数)、友元(友元函数与友元类)的概念及其使用场景,并简要介绍了内部类的特性。
110 0
|
3月前
|
编译器 C++ 容器
【c++11】c++11新特性(上)(列表初始化、右值引用和移动语义、类的新默认成员函数、lambda表达式)
C++11为C++带来了革命性变化,引入了列表初始化、右值引用、移动语义、类的新默认成员函数和lambda表达式等特性。列表初始化统一了对象初始化方式,initializer_list简化了容器多元素初始化;右值引用和移动语义优化了资源管理,减少拷贝开销;类新增移动构造和移动赋值函数提升性能;lambda表达式提供匿名函数对象,增强代码简洁性和灵活性。这些特性共同推动了现代C++编程的发展,提升了开发效率与程序性能。
112 12
|
4月前
|
设计模式 安全 C++
【C++进阶】特殊类设计 && 单例模式
通过对特殊类设计和单例模式的深入探讨,我们可以更好地设计和实现复杂的C++程序。特殊类设计提高了代码的安全性和可维护性,而单例模式则确保类的唯一实例性和全局访问性。理解并掌握这些高级设计技巧,对于提升C++编程水平至关重要。
97 16