C++编程思想学习笔记---第15章 多态性和虚函数

多态性(polymorphis)提供了接口与具体实现之间的另一层隔离,从而将”what”与”how”分离开来。多态性改善了代码的组织性和可读性,同时也使创建的程序具有可扩展性

15.1 C++程序员的演变

c程序员可以用三步演变为C++程序员

  1. 简单地把C++作为一个更好的C:更强的类型检查等
  2. 进入基于对象的C++:将数据结构和操作绑定,更清晰地组织代码
  3. 使用虚函数:这是面向对象设计的精髓

15.2 向上类型转换

让我们回到第14章的问题,我传递了一个Wind类的对象引用到tune()函数里,但得到的确实调用了基类Instrument的play()方法,这显然不是我想要的。

//: C15:Instrument2.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Inheritance & upcasting
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Eflat }; // Etc.

class Instrument {
public:
  void play(note) const {
    cout << "Instrument::play" << endl;
  }
};

// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
  // Redefine interface function:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
};

void tune(Instrument& i) {
  // ...
  i.play(middleC);
}

int main() {
  Wind flute;
  tune(flute); // Upcasting
} ///:~

15.3 问题

上述代码最终运行的合理性我们在上一章中已经解释过了,但这却并不符合我们的预期。因为我们知道这个对象实际上是Wind而不是Instrument,我们是希望函数tune()最终调用派生类的版本的。

15.3.1 函数调用捆绑

把函数体与函数调用相联系称为捆绑(binding)。当捆绑在程序运行之前由编译器和连接器完成时,这称为早捆绑(early binding)。c语言中只有一种函数调用方式,那就是早捆绑。
上面函数中的问题就是由早捆绑引起的,因为编译器在只有Instrument地址时并不知道要调用的正确函数。
解决方法是晚捆绑,这意味着捆绑根据对象的类型,发生在运行时,所以又称为动态捆绑。因此在编译时,需要有一种机制来通知编译器对某个函数进行晚捆绑。

15.4 虚函数

为了引起晚捆绑,C++要求在基类中声明这个函数时使用virtual关键字,晚捆绑只对virtual函数起作用,而且只在使用含有virtual函数的基类的地址时发生。仅仅在声明函数时使用关键字virtual,定义时并不需要,如果一个函数在基类中被声明为virtual,那么在所有的派生类中它都是virtual的。现在我们可以重新写代码了,解决上面的问题。

//: C15:Instrument3.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Late binding with the virtual keyword
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.

class Instrument {
public:
  virtual void play(note) const {
    cout << "Instrument::play" << endl;
  }
};

// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
  // Override interface function:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
};

void tune(Instrument& i) {
  // ...
  i.play(middleC);
}

int main() {
  Wind flute;
  tune(flute); // Upcasting
} ///:~

这次结果不同了,tune()函数调用了派生类Wind的play()版本

15.4.1 扩展性

通过将play()在基类中定义为virtual,不用改变tune()函数就可以在系统中随意增加新函数。只与基类接口通信,这是一种良好的OOP设计风格。这样的程序是可扩展的,因为可以通过从公共基类继承新数据类型而增加新功能。

//: C15:Instrument4.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Extensibility in OOP
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.

class Instrument {
public:
  virtual void play(note) const {
    cout << "Instrument::play" << endl;
  }
  virtual char* what() const {
    return "Instrument";
  }
  // Assume this will modify the object:
  virtual void adjust(int) {}
};

class Wind : public Instrument {
public:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
  char* what() const { return "Wind"; }
  void adjust(int) {}
};

class Percussion : public Instrument {
public:
  void play(note) const {
    cout << "Percussion::play" << endl;
  }
  char* what() const { return "Percussion"; }
  void adjust(int) {}
};

class Stringed : public Instrument {
public:
  void play(note) const {
    cout << "Stringed::play" << endl;
  }
  char* what() const { return "Stringed"; }
  void adjust(int) {}
};

class Brass : public Wind {
public:
  void play(note) const {
    cout << "Brass::play" << endl;
  }
  char* what() const { return "Brass"; }
};

class Woodwind : public Wind {
public:
  void play(note) const {
    cout << "Woodwind::play" << endl;
  }
  char* what() const { return "Woodwind"; }
};

// Identical function from before:
void tune(Instrument& i) {
  // ...
  i.play(middleC);
}

// New function:
void f(Instrument& i) { 
    i.play(CSharp);
    i.adjust(1);
}

// Upcasting during array initialization:
Instrument* A[] = {
  new Wind,
  new Percussion,
  new Stringed,
  new Brass,
};

int main() {
  Wind flute;
  Percussion drum;
  Stringed violin;
  Brass flugelhorn;
  Woodwind recorder;
  tune(flute);
  tune(drum);
  tune(violin);
  tune(flugelhorn);
  tune(recorder);
  f(flugelhorn);
} ///:~

Brass类继承了Wind类,它仍然有一个void play(note)的重写版本,而且它也仍然是虚函数,它也仍然是一个Instrument类。因此在调用tune(flugelhorn);时会调用Brass类中的play()版本。
同时也注意到,Brass类中并没有定义adjust()方法,却还是能正确地调用。这种情况,编译器会自动地调用继承层次中“最近”的定义——编译器保证对于虚函数总是有某种定义,所以绝不会出现最终调用不与函数体捆绑的情况。也就是说,Brass类的虚函数表中的adjust()方式是其上一个继承层次,也就是Wind类中的版本。(马上将谈到虚函数表)

15.5 C++如何实现晚捆绑

当通过创建虚函数(即使用关键字virtual)来告诉编译器要实现晚捆绑,编译器安装必要的晚捆绑机制。典型的编译器对每个包含虚函数的类创建一个表,称为VTABLE,俗称虚函数表。在VTABLE中,编译器放置特定的虚函数的地址。在每个带有虚函数的类中,编译器秘密地放置一个指针,称为vpointer,指向这个对象的VTABLE。当通过基类指针做虚函数调用时,编译器静态地插入能取得这个VPTR并在VTABLE表中查找函数地址的代码,这样就能调用正确的函数并引起晚捆绑。

15.5.5.1 存放类型信息

可以看到,在任何类中不存在显式的类型信息。那么c++如何通过虚函数机制来存放类型信息的呢?看下面

//: C15:Sizes.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Object sizes with/without virtual functions
#include <iostream>
using namespace std;

class NoVirtual {
  int a;
public:
  void x() const {}
  int i() const { return 1; }
};

class OneVirtual {
  int a;
public:
  virtual void x() const {}
  int i() const { return 1; }
};

class TwoVirtuals {
  int a;
public:
  virtual void x() const {}
  virtual int i() const { return 1; }
};

int main() {
  cout << "int: " << sizeof(int) << endl;
  cout << "NoVirtual: "
       << sizeof(NoVirtual) << endl;
  cout << "void* : " << sizeof(void*) << endl;
  cout << "OneVirtual: "
       << sizeof(OneVirtual) << endl;
  cout << "TwoVirtuals: "
       << sizeof(TwoVirtuals) << endl;
} ///:~

NoVirutal类的大小就是一个int类型的大小,OneVirtual类型比它多出一个void指针的长度,而TwoVirtual跟OneVirtual一样。这说明:如果有一个或多个虚函数,编译器都只在这个结构里插入一个单个指针。
如果一个类是空的,对它求大小会怎么样?大小会是0吗?使用上面这个NoVirtual的类,把int a去掉,再用sizeof()去求大小,会得到结果为1。如果没有数据成员,C++编译器会强制这个对象是非零长度,因为每个对象必须有一个相互区别的地址,这就是原因。否则当你寻址到某个地址时,发现这里存在两个长度为0的类的对象,那么编译器该执行哪个类的操作呢?

15.5.2 虚函数功能图示

每当创建一个包含有虚函数的类或包含从虚函数的类派生一个类时,编译器就为这个类创建一个唯一的VTABLE。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用这个基类的虚函数地址。然后VPTR被放置在这个类中,并且它指向这个对象的VTABLE的起始地址。
以Brass类的对象调用并不存在与该类中的方法adjust()为例,Instrument指向这个对象的起始地址,对于所有的Instrument对象或由Instrument派生的对象,它们的VPTR都在对象的相同位置(通常是在对象的开头),所以编译器能够取出这个VPTR。VPTR指向的VTABLE的起始地址,而所有的VTABLE具有相同的顺序,不管何种类型的对象。只有如此,VPTR才能知道应该去调用哪个函数。下面将会看到,VPTR是在运行时去确定实际函数的地址的。
虚函数表示意图

15.5.3 撩开面纱

Bruce Eckel大师给了一段汇编代码,i.adjust(1);的汇编如下:

push 1
push si
mov bx, word ptr[si]
call word ptr[bx+4]
add sp, 4

函数调用时,首先将参数从右向左进栈,因此先将1压栈;对于c++,有一个this指针,它指向了这个对象的首地址,必须要使用它才能知道该调用什么函数,因此它也将被压栈(当然类的static成员函数不需要this指针)。对于大多数编译器,VPTR就在this指针的位置,因此mov bx, word ptr[si]就把VPTR加载到了bx寄存器里,在Bruce大师的内存模式下,每个指针占用两个字节长度,而adjust又在第2个位置(下标从0开始算),因此call word ptr[bx+4]就调用了adjust()方法。

15.5.4 安装vpointer

总之,VPTR和VTABLE精确地保证了多态调用的逻辑正确性。同时也要看到,在初始化VPTR之前,绝对不能调用虚函数。在Instrument的例子中,编译器创建了一个默认构造函数,它做了初始化VPTR的工作。

15.5.5 对象是不同的

向上类型转换仅处理地址
向上类型转换仅处理地址
向上类型转换仅处理地址
重要的事情说3遍。正是因为地址不具有类型信息,所以才能引起基类指针的多态调用。而如果编译器有一个它知道确切类型的对象,那么任何函数都不再使用晚捆绑。

//: C15:Early.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Early binding & virtual functions
#include <iostream>
#include <string>
using namespace std;

class Pet {
public:
  virtual string speak() const { return ""; }
};

class Dog : public Pet {
public:
  string speak() const { return "Bark!"; }
};

int main() {
  Dog ralph;
  Pet* p1 = &ralph;
  Pet& p2 = ralph;
  Pet p3;
  // Late binding for both:
  cout << "p1->speak() = " << p1->speak() <<endl;
  cout << "p2.speak() = " << p2.speak() << endl;
  // Early binding (probably):
  cout << "p3.speak() = " << p3.speak() << endl;
} ///:~

p1->speak()和p2.speak()使用地址来操作对象,就意味着信息不完整,p1和p2可能表示Pet的地址,也可能表示其派生类的对象的地址,所以必须用虚函数调用。然而p3是一个确切的Pet类型,编译器就可以做早捆绑,直接调用基类的方法。

15.6 为什么需要虚函数

虚函数提供了如此方便的特性,以帮助我们组织代码,那为什么不直接默认使用呢?原因是显而易见的:虚函数调用并不是对于绝对地址的一个简单的CALL,而是需要两条以上的复杂的汇编指令。这既需要代码空间,又需要执行时间。简而言之:效率。但是这确实是一个好的特性,当确定程序的运行效率瓶颈不在虚函数时,便可以放心地使用这个特性。

15.7 抽象基类和纯虚函数

在设计时,常常希望基类仅仅作为其派生类的一个借口。仅想对基类进行向上类型转换,使用它的接口,而不希望用户实际地创建一个基类的对象。这可以使用纯虚函数,来使基类成为抽象类。在声明函数前加上virtual,后面加上=0。不能生成一个抽象类对象!!!当继承一个抽象类时,必须实现所有的纯虚函数,否则继承出的类也将是一个抽象类。建立公共接口的惟一原因是他能对每个不同子类有不同的表示,将所有具体实现类的操作抽象出来,减少耦合性。
uml
声明纯虚函数的语法virtual void f()=0;
这样做等于告诉编译器在VTABLE中为函数保留一个位置,但在这个特定的位置中不放地址。这就是不能创建创建一个抽象类对象的原因:因为VTABLE是不完整的,编译器不知道函数在什么位置,因此它不允许这么做。
注意,纯虚函数禁止对抽象类的函数以传值方式调用。这是防止对象切片的一种方法。通过抽象类,可以保证在向上类型转换期间总是使用指针或引用。

15.7.1 纯虚定义

在基类中,对纯虚函数的提供定义是可能的。

//: C15:PureVirtualDefinitions.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Pure virtual base definitions
#include <iostream>
using namespace std;

class Pet {
public:
  virtual void speak() const = 0;
  virtual void eat() const = 0;
  // Inline pure virtual definitions illegal:
  //!  virtual void sleep() const = 0 {}
};

// OK, not defined inline
void Pet::eat() const {
  cout << "Pet::eat()" << endl;
}

void Pet::speak() const { 
  cout << "Pet::speak()" << endl;
}

class Dog : public Pet {
public:
  // Use the common Pet code:
  void speak() const { Pet::speak(); }
  void eat() const { Pet::eat(); }
};

int main() {
  Dog simba;  // Richard's dog
  simba.speak();
  simba.eat();
} ///:~

纯虚函数不能定义为内联的,也就是说不能写在类中。在类中只能给出声明,定义需要类外部进行。
Pet的VTABLE仍然是空的,但是其派生类实现了这两个接口。

15.8 继承和VTABLE

当实现继承和重定义一些虚函数时,编译器对新类创建一个新VTABLE,并且插入新函数的地址,对于没有重新定义的函数使用基类函数的地址。无论如何,对于可被创建的对象,在VTABLE中总有一个函数地址的全集。
但是在派生类中继承或增加新的虚函数时会发生什么?

//: C15:AddingVirtuals.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Adding virtuals in derivation
#include <iostream>
#include <string>
using namespace std;

class Pet {
  string pname;
public:
  Pet(const string& petName) : pname(petName) {}
  virtual string name() const { return pname; }
  virtual string speak() const { return ""; }
};

class Dog : public Pet {
  string name;
public:
  Dog(const string& petName) : Pet(petName) {}
  // New virtual function in the Dog class:
  virtual string sit() const {
    return Pet::name() + " sits";
  }
  string speak() const { // Override
    return Pet::name() + " says 'Bark!'";
  }
};

int main() {
  Pet* p[] = {new Pet("generic"),new Dog("bob")};
  cout << "p[0]->speak() = "
       << p[0]->speak() << endl;
  cout << "p[1]->speak() = "
       << p[1]->speak() << endl;
//! cout << "p[1]->sit() = "
//!      << p[1]->sit() << endl; // Illegal
} ///:~

新类Dog中有一个新的虚函数virtual string sit() const;但是在main函数中,却将Dog类对象的指针放到Pet类的指针数组中去,这在以前看来是准备要进行多态调用。但是编译器却不会让p[1]->sit()编译通过,因为作为Pet类指针的p[1]只知道Pet类中定义的虚函数,以及Dog类中重写的基类中的虚函数,但并不知道Dog类中新定义的虚函数。这里我们知道p[1] 是由Dog*向上类型转换来的,因此也有足够的理由将其转换会去,这样写((Dog*)p[1])->sit();是完全可以的,但大部分情况是不知道的。
运行时类型辨认有助于我们解决这个问题,英文术语叫RTTI。RTTI是有关向下类型转换基类指针到派生类的问题,直觉上这是不安全的,因为编译器没法凭空知道该基类指针是哪种派生类的指针。RTTI的问题放到后面讨论。

15.8.1 对象切片

当多态地处理对象时,传地址与传值有明显的不同。基类地址的大小和派生类地址的大小,在同一台计算机里肯定是相同的,但是传递对象就不同了。看下面的例子:

//: C15:ObjectSlicing.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
#include <iostream>
#include <string>
using namespace std;

class Pet {
  string pname;
public:
  Pet(const string& name) : pname(name) {}
  virtual string name() const { return pname; }
  virtual string description() const {
    return "This is " + pname;
  }
};

class Dog : public Pet {
  string favoriteActivity;
public:
  Dog(const string& name, const string& activity)
    : Pet(name), favoriteActivity(activity) {}
  string description() const {
    return Pet::name() + " likes to " +
      favoriteActivity;
  }
};

void describe(Pet x) { // Slices the object
  cout << x.description() << endl;
}

int main() {
  Pet p("Alfred");
  Dog d("Fluffy", "sleep");
  describe(p);
  describe(d);
} ///:~

describe(Pet x);接受一个Pet对象作为参数,但我们并没有明确地为Pet类创建复制构造函数,因此这是编译器自动创建的。调用describe(p);时,因为p也是Pet类对象,因此,调用复制构造函数,在函数中的生存期内,临时创建了一个Pet对象x,由x调用了它的方法description(),当退出函数时,x的析构函数被调用,x对象被销毁。整个过程中p都没有被操作(仅仅被复制了)。
但是下面一句describe(b);,传了一个更大的对象进来,它里面也包含Pet的部分,但是还有其他成员。这种情况下,编译器会把不属于Pet的部分丢弃掉,只拷贝这个对象的Pet部分,切除这个对象的派生部分,当然也会调用Pet的复制构造函数。
因此两次调用都使用了基类的description()版本,对象在被切片了!变成了了一个Pet对象。
这种隐藏的向上类型转换动作要小心一个问题:基类的对象不能有纯虚函数,否则编译器不会允许我们这么做的,因为无法为一个抽象类创建对象

15.9 重载和重新定义

这又涉及到名字隐藏的问题。看下面的代码:

//: C15:NameHiding2.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Virtual functions restrict overloading
#include <iostream>
#include <string>
using namespace std;

class Base {
public:
  virtual int f() const { 
    cout << "Base::f()\n"; 
    return 1; 
  }
  virtual void f(string) const {}
  virtual void g() const {}
};

class Derived1 : public Base {
public:
  void g() const {}
};

class Derived2 : public Base {
public:
  // Overriding a virtual function:
  int f() const { 
    cout << "Derived2::f()\n"; 
    return 2;
  }
};

class Derived3 : public Base {
public:
  // Cannot change return type:
  //! void f() const{ cout << "Derived3::f()\n";}
};

class Derived4 : public Base {
public:
  // Change argument list:
  int f(int) const { 
    cout << "Derived4::f()\n"; 
    return 4; 
  }
};

int main() {
  string s("hello");
  Derived1 d1;
  int x = d1.f();
  d1.f(s);
  Derived2 d2;
  x = d2.f();
//!  d2.f(s); // string version hidden
  Derived4 d4;
  x = d4.f(1);
//!  x = d4.f(); // f() version hidden
//!  d4.f(s); // string version hidden
  Base& br = d4; // Upcast
//!  br.f(1); // Derived version unavailable
  br.f(); // Base version available
  br.f(s); // Base version abailable
} ///:~
  • 在Derived3中,编译器不允许我们改变重新定义过的函数的返回值(如果f()不是虚函数,则是允许的)。
  • 如果重新定义了一个重载成员函数(这里说的是修改参数列表),则在派生类中的其他重载函数将会被隐藏。但是如果把d4向上类型转换为Base,则只有基类版本是可行的,派生类版本不可行。
    (待续)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值