C++学习笔记 —— 虚函数

一、虚函数实现多态

1.1 多态公有继承

假如希望同一个方法在派生类和基类中的行为是不同的,即同一个方法的行为随上下文而异,这种行为称为多台——具有多种形态。

有两种重要的机制可用于实现多太公有继承:

  • 在派生类中重新定义基类的方法。
  • 使用虚方法。

注意:如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样程序将根据对象类型而不是引用或指针的类型来选择方法版本。为基类声明一个虚析构函数也是一种惯例。

现有父类Brass和派生类BrassPlus

class Brass
{
private:
    ...
public:
    Brass(const std::string & s = "Nullbody", long an = -1, double bal = 0.0);
    ...
    virtual void ViewAcct() const; 
    ...
};
// 继承父类Brass
class BrassPlus : public Brass
{
private:
    ...
public:
    BrassPlus(const std::string & s = "Nullbody", long an = -1, double bal = 0.0, double ml =500, double r = 0.11125);
    ...
    virtual void ViewAcct() const;
    ... 
};

1.2 通过对象调用

由对象确定使用哪一种方法。

Brass dom("Dominic Banker", 11224, 4183.45);
BrassPlus dot("Dorothy Banker", 12118, 2592.00);
dom.ViewAcct();  // 调用Brass::ViewAcct()
dot.ViewAcct();  // 调用BrassPlus::ViewAcct()

1.3 通过引用或指针调用

如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法。

1.3.1 使用关键字virtual

如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法。
引用的类型为Brass,但b2_ref引用的是一个BrassPlus对象,所以使用的是BrassPlus::ViewAcct()。使用Brass指针代替引用时,行为将与此类似。

类方法:virtual void ViewAcct() const;

Brass dom("Dominic Banker", 11224, 4183.45);
BrassPlus dot("Dorothy Banker", 12118, 2592.00);
Brass & b1_ref =dom;
Brass & b2_ref =dot;
b1_ref.ViewAcct();  // 调用Brass::ViewAcct()
b2_ref.ViewAcct();  // 调用BrassPlus::ViewAcct()

1.3.2 没有使用关键字virtual

如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法。
引用变量的类型为Brass,所以选择了Brass::ViewAcct()。使用Brass指针代替引用时,行为将与此类似。

类方法:void ViewAcct() const;

Brass dom("Dominic Banker", 11224, 4183.45);
BrassPlus dot("Dorothy Banker", 12118, 2592.00);
Brass & b1_ref =dom;
Brass & b2_ref =dot;
b1_ref.ViewAcct();  // 调用Brass::ViewAcct()
b2_ref.ViewAcct();  // 调用Brass::ViewAcct()

1.4 实现多态性

假设要同时管理Brass和BrassPlus账户,如果能使用同一个数组来保存Brass和BrassPlus对象,将很有帮助,但这是不可能的。数组中所有元素的类型必须相同,而Brass和BrassPlus是不同的类型。

然而,可以创建指向Brass的指针数组。这样,每个元素的类型都相同,但由于使用的是公有继承模型,因此Brass指针既可以指向Brass对象,也可以指向BrassPlus对象。因此,可以使用一个数组来表示多种类型的对象。这就是多态性。

...
...

int main()
{
    ...
    Brass * p_clients[4];
    for (int i = 0; i < 4; i++)
    {
        while (cin >> kind && (kind != '1' && kind != '2'))
            cout << "Enter either 1 or 2: ";
        if (kind == '1')
            p_clients[i] = new Brass(temp, tempnum, tempbal);
        else
        {
            ...
            p_clients[i] = new BrassPlus(temp, tempnum, tempbal, tmax, trate);
        }
        ...
    }
    ...
    for (int i = 0; i < 4; i++)
    {
         p_clients[i]->ViewAcct();
    }
}

如果数组成员指向的是Brass对象,则调用Brass::ViewAcct();如果指向的是BrassPlus对象,则调用BrassPlus::ViewAcct()。如果Brass::ViewAcct();未被声明为虚的,则在任何情况下都将调用Brass::ViewAcct()。

二、虚函数实现动态联编

2.1 动态联编

如上面程序所示,如果使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding)

2.2 向上强制转换

通常,C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型:

double x = 2.5;
int * pi = &x;  // 不允许,不匹配的指针类型
long & rl = x;  // 不允许,不匹配的引用类型

然而,指向基类的引用或指针可以引用派生类对象,而不必进行显式类型转换。如下:

BrassPlus dilly("Annie Dill", 493222, 2000);
Brass * pb = &dilly;  // ok
Brass & rb = dilly;   // ok

将派生类引用或指针转换为基类引用或指针被称为向上强制转换(upcasting),这使公有继承不需要进行显式类型转换。该规则是is-a关系的一部分。BrassPlus对象都是Brass对象,因为它继承了Brass对象所有的数据成员和成员函数。所以,可以对Brass对象执行的操作,都适用于BrassPlus对象。

2.3 向下强制转换

相反的过程,将基类指针或者引用转换为派生类指针或引用——称为向下强制转换(downcasting)。如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。

2.4 虚成员函数和动态联编

对于使用基类引用或指针作为参数的函数调用,将进行向上转换。假定以下每个函数都调用虚方法ViewAcct():

void fr(Brass & rb);    // uses rb.ViewAcct()
void fb(Brass * pb);    // uses pb->ViewAcct()
void fv(Brass b);       // uses b.ViewAcct()
int main()
{
    Brass b("Billy Bee", 123422, 10000.0);
    BrassPlus bp("Betty Beep", 232313, 12345.0);
    fr(b);    // uses Brass::ViewAcct()
    fr(bp);   // uses BrassPlus::ViewAcct()
    fp(b);    // uses Brass::ViewAcct()
    fp(bp);   // uses BrassPlus::ViewAcct()
    fv(b);    // uses Brass::ViewAcct()
    fv(bp);   // uses Brass::ViewAcct()
    ...
}

按值传递导致只将BrassPlus对象的Brass部分传递给函数fv()。但随引用和指针发生的隐式向上转换导致函数fr()和fp()分别为Brass对象和BrassPlus对象使用Brass::ViewAcct()和BrassPlus::ViewAcct()。

三、使用虚函数代价

使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间
    (给每个对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针);
  • 对于每个类,编译器都创建一个虚函数地址表(数组)
    (上述隐藏的指针成员指向虚函数表);
  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。

四、有关虚函数注意事项

要点:

  • 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有的派生类(包括从派生类派生出来的类)中是虚的。
  • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为动态联编或晚期联编。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。
  • 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的类方法声明为虚的。

4.1 构造函数

构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数。然后,派生类的构造函数将使用基类的一个构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类构造函数声明为虚的没什么意义。

4.2 析构函数

析构函数应当是虚函数,除非类不用做基类。例如,假设Employee是基类,Singer是派生类,并添加一个char *成员,该成员指向由new分配的内存。当Singer对象过期时,必须调用~Singer()析构函数来释放内存。

Employee * pe = new Singer;  // 向上转换
...
delete pe;  // 此时调用~Employee()还是~Singer()?

如果使用默认的静态联编,delete语句将调用~Employee()析构函数。这将释放由Singer对象中的Employee部分指向的内存,但不会释放新的类成员指向的内存。

但如果析构函数是虚的,则将先调用~Singer()析构函数释放由Singer组件指向的内存,然后调用~Employee()析构函数来释放由Employee组件指向的内存。

因此,使用虚析构函数可以确保正确的析构函数序列被调用。

通常应给基类提供一个虚析构函数,即使它并不需要析构函数。

virtual ~BaseClass() { }

4.3 友元

友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数。如果由于这个原因引起了设计问题,可以通过让友元函数使用虚成员函数来解决。

4.4 没有重新定义

如果派生类没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本时隐藏的。

4.5 重新定义将隐藏方法

重新定义继承的方法并不是重载。如果重新定义派生类中的函数,将不只是使用相同的函数参数列表覆盖基类声明,无论参数列表是否相同,该操作将隐藏所有的同名基类方法。

class Dwelling
{
public:
    virtual void showperks(int a) const;
    ...
};
class Hovel : public Dwelling
{
public:
    virtual void showperks() const;
    ...
};
Hovel trump;
trump.showperks();    // 可用
trump.showperks(5);   // 被隐藏不可用

这引出两条经验规则:
第一,如果重新定义继承的方法,应确保与原来的原型完全相同,但如果返回类型是基类引用或指针,则可用修改为指向派生类的引用或指针。这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类型的变化而变化:

class Dwelling
{
public:
// 基类方法
    virtual Dwelling & build(int n);
    ...
};
class Hovel : public Dwelling
{
public:
// a derived method with a covariant return type
     virtual Hovel & build(int n);    // same function signature
    ...
};

注意,这种例外只适用于返回值,而不适用于参数。

第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。

class Dwelling
{
public:
// 三个重载的showperks()函数
    virtual void showperks(int a) const;
    virtual void showperks(double b) const;
    virtual void showperks() const;
    ...
};
class Hovel : public Dwelling
{
public:
// 三个重新定义的showperks()函数
    virtual void showperks(int a) const;
    virtual void showperks(double b) const;
    virtual void showperks() const;    
    ...
};

如果只重新定义一个版本,则另外两个版本将被隐藏,派生类对象将无法使用它们。
注意,如果不需要修改,则新定义可只调用基类版本:

void Hovel::showperks() const {Dwelling::showperks();}

• 由 Leung 写于 2018 年 9 月 19 日

• 参考:C++ Primer Plus(第6版)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,204评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,091评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,548评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,657评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,689评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,554评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,302评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,216评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,661评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,851评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,977评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,697评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,306评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,898评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,019评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,138评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,927评论 2 355

推荐阅读更多精彩内容

  • 前言 把《C++ Primer》[https://book.douban.com/subject/25708312...
    尤汐Yogy阅读 9,518评论 1 51
  • 3. 类设计者工具 3.1 拷贝控制 五种函数拷贝构造函数拷贝赋值运算符移动构造函数移动赋值运算符析构函数拷贝和移...
    王侦阅读 1,804评论 0 1
  • 1.C和C++的区别?C++的特性?面向对象编程的好处? 答:c++在c的基础上增添类,C是一个结构化语言,它的重...
    杰伦哎呦哎呦阅读 9,518评论 0 45
  • 亲爱的李先生: 你好。 已经不止一次这样称呼你了,虽然我们在一起的时候,你并不知道这样礼貌的称呼。 “李先生”这个...
    陈星然阅读 412评论 2 5
  • 请原谅作为一个母亲的冲动! 这天,我还是习以为常地去大门口等待。紧接着同班里的同学身影渐渐离开学校,而我还是没看到...
    岁月的足迹阅读 412评论 0 1