C++ Primer 学习笔记33_面向对象编程(4)--虚函数与多态(一):多态、派生类重定义、虚函数的访问、 . 和->的区别、虚析构函数、object slicing与虚函数

C++ Primer学习笔记33_面向对象编程(4)--虚函数与多态(一):多态、派生类重定义、虚函数的访问、 . 和->的区别、虚析构函数、object slicing与虚函数

一、多态

多态可以简单地概括为“一个接口,多种方法”,前面讲过的重载就是一种简单的多态,一个函数名(调用接口)对应着几个不同的函数原型(方法)。

更通俗的说,多态行是指同一个操作作用于不同的对象就会产生不同的响应。或者说,多态性是指发出同样的消息被不同类型的对象接收时有可能导致完全不同的行为。

多态行分为静态多态性和动态多态性,其中函数重载和运算符重载属于静态多态性,虚函数属于动态多态性。

C++是依靠虚函数来实现动态多态性的。

多态的实现:

函数重载

运算符重载

模板

虚函数

1、静态联编(绑定)与动态联编(绑定)

程序调用函数时,具体应使用哪个代码块是由编译器决定的。

(1)静态联编(绑定)

绑定过程出现在编译阶段,在编译期就已确定要调用的函数。

(2)动态联编(绑定)

绑定过程工作在程序运行时执行,在程序运行时才确定将要调用的函数。

C++通过虚函数来实现动态联编(绑定)。

二、虚函数

虚函数的概念:在基类中的成员函数前加上一个关键字 virtual

如果一个函数在基类中被声明为虚函数,则他在所有派生类中都是虚函数。即使省略了virtual关键字,也仍然是虚函数。

虚函数的定义:

virtual 函数类型 函数名称(参数列表);

只有通过基类指针或引用调用虚函数才能引发动态绑定,包括通过基类指针的反引用调用虚函数,因为反引用一个指针将返回所指对象的引用。

虚函数不能声明为静态

常见的不能声明为虚函数的有:普通函数(非成员函数)、静态成员函数、构造函数、友元函数,而内联成员函数、赋值操作符重载函数即使声明为虚函数也无意义。

1、派生类重定义

派生类中可根据需要对虚函数进行重定义,重定义的格式有一定的要求:

(1)与基类的虚函数有相同的参数个数;

(2)与基类的虚函数有相同的参数类型;

(3)与基类的虚函数有相同的返回类型:或者与基类虚函数相同,或者都返回指针(或引用),并且派生类虚函数所返回的指针(或引用)类型是基类中被替换的虚函数所返回的指针(或引用)类型的子类型(派生类型)。

2、虚函数的访问

(1)通过对象访问:和普通函数一样,虚函数可以通过对象名来调用,此时编译器采用的时静态联编。通过对象名访问虚函数时,调用哪个类的函数取决于定义对象的类型。对象类型是基类时,就调用基类的函数;对象类型是子类时,就调用子类的函数。

(2)通过指针访问非虚函数:使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数,而不是根据指针指向的对象类型。

(3)通过指针访问虚函数:使用指针访问虚函数时,编译器根据指针所指向对象的类型决定要调用哪个函数(动态联编),而与指针本身的类型无关。

(4)通过引用访问虚函数:与使用指针访问虚函数类似,但代码的安全性高(详细看书),可以将引用理解成一种“受限制的指针”。

总结:C++中的函数调用默认不是用动态绑定。要触动动态绑定,需满足两个条件:

(1)第一,只有指定为虚函数的成员函数才能进行动态绑定。

(2)第二,必须通过基类类型的引用或指针进行函数调用。

【例1】调用一成员函数时,使用动态联编的情况是()

A、通过对象调用一虚函数

B、通过指针或引用调用一虚函数

C、通过对象调用静态函数

D、通过指针或引用调用一静态函数

解答:B

【例2】下述代码的输出结果是什么?

#include <iostream>
using namespace std;

class base
{
public:
    virtual void disp() {cout << "hello, base1" << endl;}
    void disp2() {cout << "hello, base2" << endl;}
};
class child1: public base
{
public:
    void disp() {cout << "hello, child1" << endl;}
    void disp2() {cout << "hello, child2" << endl;}
};

int main()
{
    base* base = NULL;
    child1 obj_child1;
    base = &obj_child1;
    base->disp();
    base->disp2();
    return 0;
}

解答:输出:

hello, child1

hello, child2

通过指针访问非虚函数:使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数,而不是根据指针指向的对象类型。

通过指针访问虚函数:使用指针访问虚函数时,编译器根据指针所指向对象的类型决定要调用哪个函数(动态联编),而与指针本身的类型无关。

【C++中类的方法的调用 . 和->的区别】

->是用在指针类型的类实例的,所以对于指针类型的类实例使用->是没问题的。对象则用.操作符。

【例3】构造函数为什么不能是虚函数?

解答:假设有如下代码:

#include <iostream>
using namespace std;

class A
{
public:
    A() {cout << "A()" <<endl;}
};
class B: public A
{
public:
    B(): A() {cout << "B()" <<endl;}
};

int main()
{
    B b;
    B* pb = &b;
}

则构造B类对象时:

(1)根据继承的性质,构造函数执行顺序是:

A()B()

(2)根据虚函数的性质,如果A的构造函数为虚函数,且B类也给出了构造函数,则应该只执行B类的构造函数,不再执行A类的构造函数。这样A就不构造了。

(3)这样(1)和(2)就发生了矛盾。

另外,virtual函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用virtual函数来实现你想完成的动作。

【例4】那些函数不能为虚函数?

解答:常见的不能声明为虚函数的有:普通函数(非成员函数)、静态成员函数、构造函数、友元函数,而内联成员函数、赋值操作符重载函数即使声明为虚函数也无意义。

(1)为什么C++不支持普通函数为虚函数?

普通函数(非成员函数)只能被overload(重载),不能被override(覆盖),声明为虚函数也没有什么意义,因此编译器会在编译时绑定函数。

(2)为什么C++不支持构造函数为虚函数?

上例已经给出答案。

(3)为什么C++不支持静态成员函数为虚函数?

静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,它不归某个具体对象所有,所以它没有要动态绑定的必要性。

(4)为什么C++不支持友元函数为虚函数?

因为C++不支持友元函数的继承,没有实现为虚函数的必要。

内联函数:内联函数是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后,对象能够准确地执行自己的动作,这是不可能统一的。即使虚函数被声明为内联函数,编译器遇到这种情况根本不会把这样的函数内联展开,而是当做普通函数来处理。

赋值运算符:虽然可以在基类中将成员函数operator= 定义为虚函数,但这样做没有意义。赋值操作符重载函数要求形参与类本身类型相同,故基类中的赋值操作符形参类型为基类类型,即使声明为虚函数,也不能作为子类的赋值操作符。

【例5】以下描述正确的是()

A、虚函数是可以内联的,可以减少函数调用的开销提高效率

B、类里面可以同时存在函数名和参数都一样的虚函数和静态函数

C、父类的析构函数是非虚的,但是子类的析构函数是虚的,delete子类对象指针会调用父类的析构函数

D、以上都不对

解答:C。C中即使子类的析构函数不是虚的,对子类对象指针调用析构函数,也会调用父类的析构函数。但若delete父类对象指针却不会调用子类的析构函数,因为父类的析构函数不是虚函数,不执行动态绑定。

【例6】以下代码的输出结果是()。

#include <iostream>
using namespace std;

class B
{
public:
    B()
    {
        cout << "B constructor, " << endl;
        s = "B";
    }
    void f() {cout << s << endl;}
private:
    string s;
};

class D: public B
{
public:
    D(): B()
    {
        cout << "D constructor, " << endl;
        s = "D";
    }
    void f() {cout << s << endl;}
private:
    string s;
};

int main()
{
    B* b = new D();
    b->f();
    ((D*)b)->f();
    delete b;
    return 0;
}

解答:输出结果是:

B constructor,

D constructor,

B

D

若在类B中的函数f前加上virtual关键字,则输出结果为

B constructor,

D constructor,

D

D

可见若函数不是虚函数,则不是动态绑定。

【例7】下列代码的输出结果是什么?

#include <iostream>
using namespace std;

class A
{
public:
    virtual void Fun(int number = 10)
    {
        cout << "A::Fun with number " << number << endl;
    }
};

class B: public A
{
public:
    virtual void Fun(int number = 20)
    {
        cout << "B::Fun with number " << number << endl;
    }
};

int main()
{
    B b;
    A &a = b;
    a.Fun();
}

运行结果:

B::Fun with number 10

解释:B::Fun with number 10。虚函数动态绑定到B,但缺省实参是编译时候确定的10,而非20.

三、几道c++面试题

1. 来看一道出错的题:

#include <iostream>
using namespace std;

class A
{
public:
    virtual void test()
    {
        cout<<"A::test()"<<endl;
    }
private:
    int i;
};

class B: public A
{
public:
    void test()
    {
        cout<<"B::test()"<<endl;
    }
private:
    int i;
};

void f(A* p, int len)
{
    for (int i = 0; i < len; i++)
    {
        p[i].test();
    }
}

int main(void)
{
    cout << sizeof(A) << endl;
    cout << sizeof(B) << endl;
    B b[3];
    f(b, 3);
    return 0;
}

运行结果:

8

12

B::test()

段错误 (核心已转储)

只会输出一次B::test() 然后就崩溃了,因为第二次按A的大小去跨越,找到的不是第二个B的首地址。

如果将B中的int i;一句注释掉,使得A和B大小都是8时,即程序可以正常运行。

而输出的是B::test() 是因为p[i] 可以看做指针的反引用,返回的是A对象的引用,故调用的是虚函数。

2. 再来看一道有些迷惑的题:

#include <iostream>
using namespace std;

class A
{
public:
    A()
    {
        Print();
    }
    virtual void Print()
    {
        cout << "A is constructed." << endl;
    }
};

class B: public A
{
public:
    B()
    {
        Print();
    }

    virtual void Print()
    {
        cout << "B is constructed." << endl;
    }
};

int main(void)
{
    A *pA = new B();
    delete pA;

    return 0;
}

运行结果:

A is constructed.

B is constructed.

解释:先后打印出两行:A is constructed.
B is constructed. 调用B的构造函数时,先会调用B的基类A的构造函数。然后在A的构造函数里调用Print。由于此时实例的类型B的部分还没有构造好,本质上它只是A的一个实例,它的虚函数表指针指向的是类型A的虚函数表。因此此时调用的Print是A::Print,而不是B::Print。接着调用类型B的构造函数,并调用Print。此时已经开始构造B,因此此时调用的Print是B::Print。

同样是调用虚拟函数Print,我们发现在类型A的构造函数中,调用的是A::Print,在B的构造函数中,调用的是B::Print。因此虚函数在构造函数中,已经失去了虚函数的动态绑定特性。

3. 在普通成员函数里调用虚函数?

#include <iostream>
using namespace std;

class Base
{
public:
    void print()
    {
        doPrint();
    }

private:
    virtual void doPrint()
    {
        cout << "Base::doPrint" << endl;
    }
};

class Derived : public Base
{
private:
    virtual void doPrint()
    {
        cout << "Derived::doPrint" << endl;
    }
};

int main()
{
    Base b;
    b.print();

    Derived d;
    d.print();
    return 0;
}

运行结果:

Base::doPrint

Derived::doPrint

解释:在print中调用doPrint时,doPrint()的写法和this->doPrint()是等价的,因此将根据实际的类型调用对应的doPrint。所以结果是分别调用的是Base::doPrint和Derived::doPrint2。

四、构造函数和析构函数中的虚函数

1、概念

构造派生类对象时,首先运行基类构造函数初始化对象的基类部分。在执行基类构造函数时,对象的派生类部分是未初始化的。实际上,此时对象还不是一个派生类对象。

撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆序撤销它的基类部分。

在这两种情况下,运行构造函数或析构函数时,对象都是不完整的。为了适应这种不完整,编译器将对象的类型视为在构造或析构期间发生了变化。在基类构造或析构函数中,将派生类对象当做基类类型对象对待。

【例子】以下哪些做法是不正确或是应该极力避免的()(多选)

A、构造函数声明为虚函数

B、派生关系中的基类析构函数声明为虚函数

C、构造函数调用虚函数

D、析构函数调用虚函数

解答:ACD。构造函数和析构函数是特殊的成员函数,在其中访问虚函数时,C++采用静态联编,即在构造函数或析构函数内,即使是使用“this->虚函数名”的形式来调用,编译器仍将其解释为静态联编的“本类名::虚函数名”,因而这样会与使用者的意图不符,应该尽量避免。

2、虚析构函数

何时需要虚析构函数?

当你可能通过基类指针删除派生类对象时

如果你打算允许其他人通过基类指针调用对象的析构函数(通过delete这样做是正常的),并且被析构的派生类对象是有重要的析构函数需要执行,就需要让基类的析构函数作为虚函数。

【例子】

#include <iostream>
using namespace std;

class Base
{
public:
    virtual void Fun1()
    {
        cout << "Base::Fun1 ..." << endl;
    }

    virtual void Fun2()
    {
        cout << "Base::Fun2 ..." << endl;
    }

    void Fun3()
    {
        cout << "Base::Fun3 ..." << endl;
    }

    Base()
    {
        cout << "Base ..." << endl;
    }
    // 如果一个类要做为多态基类,要将析构函数定义成虚函数
    virtual ~Base()
    {
        cout << "~Base ..." << endl;
    }
};

class Derived : public Base
{
public:
    /*virtual */
    void Fun1()
    {
        cout << "Derived::Fun1 ..." << endl;
    }

    /*virtual */
    void Fun2()
    {
        cout << "Derived::Fun2 ..." << endl;
    }

    void Fun3()
    {
        cout << "Derived::Fun3 ..." << endl;
    }
    Derived()
    {
        cout << "Derived ..." << endl;
    }
    /*  virtual*/
    ~Derived() //即使没有virtual修饰,也是虚函数
    {
        cout << "~Derived ..." << endl;
    }
};

int main(void)
{
    Base *p;
    p = new Derived;

    p->Fun1();
    delete p; //通过基类指针删除派生类对象
    return 0;
}

运行结果:

Base ...

Derived ...

Derived::Fun1 ...

~Derived ...

~Base ...

解释:即通过delete 基类指针删除了派生类对象(执行派生类析构函数),此时就好像delete 派生类指针 效果一样。如果基类析构函数没有声明为virtual,此时只会输出~Base。

五、object slicing与虚函数

首先看下图的继承体系:

#include <iostream>
using namespace std;

class CObject
{
public:
    virtual void Serialize()
    {
        cout << "CObject::Serialize ..." << endl;
    }
};

class CDocument : public CObject
{
public:
    int data1_;
    void func()
    {
        cout << "CDocument::func ..." << endl;
        Serialize();
    }
    virtual void Serialize()
    {
        cout << "CDocument::Serialize ..." << endl;
    }
    CDocument()
    {
        cout << "CDocument()" << endl;
    }
    ~CDocument()
    {
        cout << "~CDocument()" << endl;
    }
    CDocument(const CDocument &other)
    {
        data1_ = other.data1_;
        cout << "CDocument(const CDocument& other)" << endl;
    }
};

class CMyDoc : public CDocument
{
public:
    int data2_;
    virtual void Serialize()
    {
        cout << "CMyDoc::Serialize ..." << endl;
    }
};

int main(void)
{
    CMyDoc mydoc;
    CMyDoc *pmydoc = new CMyDoc;

    cout << "#1 testing" << endl;
    mydoc.func();

    cout << "#2 testing" << endl;
    ((CDocument *)(&mydoc))->func();

    cout << "#3 testing" << endl;
    pmydoc->func();

    cout << "#4 testing" << endl;
    ((CDocument)mydoc).func();      //mydoc对象强制转换为CDocument对象,向上转型
    // 将派生类对象转化为了基类对象
    //vptr 指向基类的虚函数表
    delete pmydoc;
    return 0;
}

运行结果:

CDocument()

CDocument()

#1 testing

CDocument::func ...

CMyDoc::Serialize ...

#2 testing

CDocument::func ...

CMyDoc::Serialize ...

#3 testing

CDocument::func ...

CMyDoc::Serialize ...

#4 testing

CDocument(const CDocument& other)

CDocument::func ...

CDocument::Serialize ...

~CDocument()

~CDocument()

~CDocument()

解释:由于Serialize是虚函数,根据this指针指向的真实对象,故前3个testing输出都是CMyDoc::Serialize ...但第4个testing中发生了Object Slicing,即对象切割,将CMyDoc对象转换成基类CDocument对象时,调用了CDocument类的拷贝构造函数,CMyDoc类的额外成员如data2_消失,成为完全一个CDocument对象,包括vptr
也指向基类的虚函数表,故输出的是CDocument::Serialize ...

此外还可以看到,调用了两次CDocument构造函数和一次CDocument 拷贝构造函数,CDocument析构函数被调用3次。

参考:

C++ primer 第四版

C++ primer 第五版

版权声明:本文为博主原创文章,未经博主允许不得转载。

时间: 2024-10-07 17:46:35

C++ Primer 学习笔记33_面向对象编程(4)--虚函数与多态(一):多态、派生类重定义、虚函数的访问、 . 和->的区别、虚析构函数、object slicing与虚函数的相关文章

C++ Primer 学习笔记_73_面向对象编程 --再谈文本查询示例

面向对象编程 --再谈文本查询示例 引言: 扩展第10.6节的文本查询应用程序,使我们的系统可以支持更复杂的查询. 为了说明问题,将用下面的简单小说来运行查询: Alice Emma has long flowing red hair. Her Daddy says when the wind blows through her hair, it looks almost alive, like a fiery bird in flight. A beautiful fiery bird, he

C++ Primer 学习笔记_74_面向对象编程 --再谈文本查询示例[续/习题]

面向对象编程 --再谈文本查询示例[续/习题] //P522 习题15.41 //1 in TextQuery.h #ifndef TEXTQUERY_H_INCLUDED #define TEXTQUERY_H_INCLUDED #include <iostream> #include <fstream> #include <sstream> #include <vector> #include <set> #include <map&g

C++ Primer 学习笔记_66_面向对象编程 --定义基类和派生类[续]

算法旨在用尽可能简单的思路解决问题,理解算法也应该是一个越看越简单的过程,当你看到算法里的一串概念,或者一大坨代码,第一感觉是复杂,此时不妨从例子入手,通过一个简单的例子,并编程实现,这个过程其实就可以理解清楚算法里的最重要的思想,之后扩展,对算法的引理或者更复杂的情况,对算法进行改进.最后,再考虑时间和空间复杂度的问题. 了解这个算法是源于在Network Alignment问题中,图论算法用得比较多,而对于alignment,特别是pairwise alignment, 又经常遇到maxim

C++ Primer 学习笔记_34_面向对象编程(5)--虚函数与多态(二):纯虚函数、抽象类、虚析构函数、动态创建对象

C++ Primer 学习笔记_34_面向对象编程(5)--虚函数与多态(二):纯虚函数.抽象类.虚析构函数.动态创建对象 一.纯虚函数 1.虚函数是实现多态性的前提 需要在基类中定义共同的接口 接口要定义为虚函数 2.如果基类的接口没办法实现怎么办? 如形状类Shape 解决方法 将这些接口定义为纯虚函数 3.在基类中不能给出有意义的虚函数定义,这时可以把它声明成纯虚函数,把它的定义留给派生类来做 4.定义纯虚函数: class <类名> { virtual <类型> <函

C++ Primer 学习笔记_65_面向对象编程 --概述、定义基类和派生类

面向对象编程 --概述.定义基类和派生类 引言: 面向对象编程基于的三个基本概念:数据抽象.继承和动态绑定. 在C++中,用类进行数据抽象,用类派生从一个类继承另一个:派生类继承基类的成员.动态绑定使编译器能够在运行时决定是使用基类中定义的函数还是派生类中定义的函数. 继承和动态绑定在两个方面简化了我们的程序:[继承]能够容易地定义与其他类相似但又不相同的新类,[派生]能够更容易地编写忽略这些相似类型之间区别的程序. 面向对象编程:概述 面向对象编程的关键思想是多态性(polymorphism)

C++ Primer 学习笔记_72_面向对象编程 --句柄类与继承[续]

面向对象编程 --句柄类与继承[续] 三.句柄的使用 使用Sales_item对象能够更easy地编写书店应用程序.代码将不必管理Item_base对象的指针,但仍然能够获得通过Sales_item对象进行的调用的虚行为. 1.比較两个Sales_item对象 在编写函数计算销售总数之前,须要定义比較Sales_item对象的方法.要用Sales_item作为关联容器的keyword,必须能够比較它们.关联容器默认使用keyword类型的小于操作符,可是假设给Sales_item定义小于操作符,

C++ Primer 学习笔记_67_面向对象编程 --转换与继承、复制控制与继承

面向对象编程 --转换与继承.复制控制与继承 I.转换与继承 引言: 由于每一个派生类对象都包括一个基类部分,因此能够像使用基类对象一样在派生类对象上执行操作. 对于指针/引用,能够将派生类对象的指针/引用转换为基类子对象的指针/引用. 基类类型对象既能够作为独立对象存在,也能够作为派生类对象的一部分而存在,因此,一个基类对象可能是也可能不是一个派生类对象的部分,因此,没有从基类引用(或基类指针)到派生类引用(或派生类指针)的(自己主动)转换. 关于对象类型,尽管一般能够使用派生类型的对象对基类

C++ Primer 学习笔记_69_面向对象编程 --继承情况下的类作用域

面向对象编程 --继承情况下的类作用域 引言: 在继承情况下,派生类的作用域嵌套在基类作用域中:如果不能在派生类作用域中确定名字,就在外围基类作用域中查找该名字的定义. 正是这种类作用域的层次嵌套使我们能够直接访问基类的成员,就好像这些成员是派生类成员一样: Bulk_item bulk; cout << bulk.book() << endl; 名字book的使用将这样确定[先派生->后基类]: 1)bulk是Bulk_item类对象,在Bulk_item类中查找,找不到名

C++ Primer 学习笔记_70_面向对象编程 --纯虚函数、容器与继承

面向对象编程 --纯虚函数.容器与继承 I.纯虚函数 在函数形参后面写上 =0 以指定纯虚函数: class Disc_item : public Item_base { public: double net_price(size_t) const = 0; //指定纯虚函数 }; 将函数定义为纯虚函数能够说明,该函数为后代类型提供了可以覆盖的接口,但是这个类的版本绝不会调用.重要的是,用户将不能创建Disc_item类型的对象. Disc_item discount; //Error Bulk