C++多态

多态

多态(Polymorphism)按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同的实现方式即为多态。引用Charlie Calverts对多态的描述——多态性是允许你将父对象设置成为一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作(摘自“Delphi4 编程技术内幕”)。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。多态性在Object Pascal和C++中都是通过虚函数实现的。

多态的实现原理以及多态的理解

多态的实现效果
– 多态:同样的调用语句有多种不同的表现形态;

多态实现的三个条件
– 有继承、有virtual重写、有父类指针(引用)指向子类对象。

多态的C++实现
– virtual关键字,告诉编译器这个函数要支持多态;不要根据指针类型判断如何调用;而是要根据指针所指向的实际对象类型来判断如何调用

多态的重要意义
– 设计模式的基础。

实现多态的理论基础
– 函数指针做函数参数,动态联编PK静态联编。根据实际的对象类型来判断重写函数的调用。

C++中多态的实现原理
当类中声明虚函数时,编译器会在类中生成一个虚函数表虚函数表是一个存储类成员函数指针的数据结构虚函数表是由编译器自动生成与维护的virtual成员函数会被编译器放入虚函数表中存在虚函数时,每个对象中都有一个指向虚函数表的指针(vptr指针)

形式

多态指同一个实体同时具有多种形式。它是面向对象程序设计(OOP)的一个重要特征。如果一个语言只支持类而不支持多态,只能说明它是基于对象的,而不是面向对象的。C++中的多态性具体体现在运行和编译两个方面。运行时多态是动态多态,其具体引用的对象在运行时才能确定。编译时多态是静态多态,在编译时就可以确定对象使用的形式。

多态: 同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。在运行时,可以通过指向基类的指针,来调用实现派生类中的方法。“一个接口,多种方法”,程序在运行时才决定调用的函数.
C++中,实现多态有以下方法:虚函数,抽象类,覆盖,模板(重载和多态无关)。C++多态性是通过虚函数来实现的,虚函数允许子类重新定义成员函数,而子类重新定义父类的做法称为覆盖(override),或者称为重写。
OC中的多态:不同对象对同一消息的不同响应.子类可以重写父类的方法

作用

把不同的子类对象都当作父类来看,可以屏蔽不同子类对象之间的差异,写出通用的代码,做出通用的编程,以适应需求的不断变化。

赋值之后,父类型的引用就可以根据当前赋值给它的子对象的特性以不同的方式运作。也就是说,父亲的行为像儿子,而不是儿子的行为像父亲。

举个例子:从一个基类中派生,响应一个虚命令,产生不同的结果。
比如从某个基类派生出多个子类,其基类有一个虚方法Tdoit,然后其子类也有这个方法,但行为不同,然后这些子类对象中的任何一个可以赋给其基类对象的引用,或者说将子对象地址赋给基类指针,这样其基类的对象就可以执行不同的操作了。实际上你是在通过其基类的引用来访问其子类对象的,你要做的就是一个赋值操作。

使用继承的结果就是当创建了一个类的家族,在认识这个类的家族时,就是把子类的对象当作基类的对象,这种认识又叫作upcasting(向上转型)。这样认识的重要性在于:我们可以只针对基类写出一段程序,但它可以适应于这个类的家族,因为编译器会自动找出合适的对象来执行操作。这种现象又称为多态性。而实现多态性的手段又叫称动态绑定(dynamic binding)。
简单的说,建立一个父类对象的引用,它所指对象可以是这个父类的对象,也可以是它的子类的对象。java中当子类拥有和父类同样的函数,当通过这个父类对象的引用调用这个函数的时候,调用到的是子类中的函数。

多态与非多态的实质区别就是函数地址是早绑定还是晚绑定。如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并生产代码,是静态的,就是说地址是早绑定的。而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就属于晚绑定。

最常见的用法就是声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。如果没有使用虚函数的话,即没有利用C++多态性,则利用基类指针调用相应的函数的时候,将总被限制在基类函数本身,而无法调用到子类中被重写过的函数。

虚函数与多态(C++)

虚函数是C++中用于实现多态(polymorphism)的机制。核心理念就是通过基类访问派生类定义的函数。假设我们有下面的类层次:

class A
{
public:
    virtual void foo() { cout << "A::foo() is called" << endl;}
};
class B: public A
{
public:
    virtual void foo() { cout << "B::foo() is called" << endl;}
};

那么,在使用的时候,我们可以:

A * a = new B();
a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!

这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。

虚函数只能借助于指针或者引用来达到多态的效果,如果是下面这样的代码,则虽然是虚函数,但它不是多态的:

class A
{
public:
    virtual void foo();
};
class B: public A
{
    virtual void foo();
};
void bar()
{
    A a;
    a.foo(); // A::foo()被调用
}

1.1 多态

在了解了虚函数的意思之后,再考虑什么是多态就很容易了。仍然针对上面的类层次,但是使用的方法变的复杂了一些:

void bar(A *a)
{
    a->foo(); // 被调用的是A::foo() 还是B::foo()?
}

因为foo()是个虚函数,所以在bar这个函数中,只根据这段代码,无从确定这里被调用的是A::foo()还是B::foo(),但是可以肯定的说:如果a指向的是A类的实例,则A::foo()被调用,如果a指向的是B类的实例,则B::foo()被调用。

这种同一代码可以产生不同效果的特点,被称为“多态”。

1.2 多态有什么用?

  多态这么神奇,但是能用来做什么呢?这个命题我难以用一两句话概括,一般的C++教程(或者其它面向对象语言的教程)都用一个画图的例子来展示多态的用途,我就不再重复这个例子了,如果你不知道这个例子,随便找本书应该都有介绍。我试图从一个抽象的角度描述一下,回头再结合那个画图的例子,也许你就更容易理解。

在面向对象的编程中,首先会针对数据进行抽象(确定基类)和继承(确定派生类),构成类层次。这个类层次的使用者在使用它们的时候,如果仍然在需要基类的时候写针对基类的代码,在需要派生类的时候写针对派生类的代码,就等于类层次完全暴露在使用者面前。如果这个类层次有任何的改变(增加了新类),都需要使用者“知道”(针对新类写代码)。这样就增加了类层次与其使用者之间的耦合,有人把这种情况列为程序中的“bad smell”之一。

多态可以使程序员脱离这种窘境。再回头看看1.1中的例子,bar()作为A-B这个类层次的使用者,它并不知道这个类层次中有多少个类,每个类都叫什么,但是一样可以很好的工作,当有一个C类从A类派生出来后,bar()也不需要“知道”(修改)。这完全归功于多态–编译器针对虚函数产生了可以在运行时刻确定被调用函数的代码。

1.3 如何“动态联编”

  编译器是如何针对虚函数产生可以再运行时刻确定被调用函数的代码呢?也就是说,虚函数实际上是如何被编译器处理的呢?Lippman在深度探索C++对象模型[1]中的不同章节讲到了几种方式,这里把“标准的”方式简单介绍一下。

我所说的“标准”方式,也就是所谓的“VTABLE”机制。编译器发现一个类中有被声明为virtual的函数,就会为其搞一个虚函数表,也就是 VTABLE。VTABLE实际上是一个函数指针的数组,每个虚函数占用这个数组的一个slot。一个类只有一个VTABLE,不管它有多少个实例。派生类有自己的VTABLE,但是派生类的VTABLE与基类的VTABLE有相同的函数排列顺序,同名的虚函数被放在两个数组的相同位置上。在创建类实例的时候,编译器还会在每个实例的内存布局中增加一个vptr字段,该字段指向本类的VTABLE。通过这些手段,编译器在看到一个虚函数调用的时候,就会将这个调用改写,针对1.1中的例子:

void bar(A * a)
{
    a->foo();
}
会被改写为:
void bar(A * a)
{
    (a->vptr[1])();
}

因为派生类和基类的foo()函数具有相同的VTABLE索引,而他们的vptr又指向不同的VTABLE,因此通过这样的方法可以在运行时刻决定调用哪个foo()函数。

虽然实际情况远非这么简单,但是基本原理大致如此。

1.4 overload和override

虚函数总是在派生类中被改写,这种改写被称为“override”。我经常混淆“overload”和“override”这两个单词。但是随着各类C++的书越来越多,后来的程序员也许不会再犯我犯过的错误了。但是我打算澄清一下:

override是指派生类重写基类的虚函数,就象我们前面B类中重写了A类中的foo()函数。重写的函数必须有一致的参数表和返回值(C++标准允许返回值不同的情况,这个我会在“语法”部分简单介绍,但是很少编译器支持这个feature)。这个单词好象一直没有什么合适的中文词汇来对应,有人译为“覆盖”,还贴切一些。
overload约定成俗的被翻译为“重载”。是指编写一个与已有函数同名但是参数表不同的函数。例如一个函数即可以接受整型数作为参数,也可以接受浮点数作为参数。

二. 虚函数的语法

  虚函数的标志是“virtual”关键字。

2.1 使用virtual关键字

考虑下面的类层次:
class A
{
public:
    virtual void foo();
};

class B: public A
{
public:
    void foo(); // 没有virtual关键字!
};

class C: public B // 从B继承,不是从A继承!
{
public:
    void foo(); // 也没有virtual关键字!
};

这种情况下,B::foo()是虚函数,C::foo()也同样是虚函数。因此,可以说,基类声明的虚函数,在派生类中也是虚函数,即使不再使用virtual关键字。

2.2 纯虚函数

  如下声明表示一个函数为纯虚函数:

class A
{
public:
    virtual void foo()=0; // =0标志一个虚函数为纯虚函数
};

一个函数声明为纯虚后,纯虚函数的意思是:我是一个抽象类!不要把我实例化!纯虚函数用来规范派生类的行为,实际上就是所谓的“接口”。它告诉使用者,我的派生类都会有这个函数。

2.3 虚析构函数

  析构函数也可以是虚的,甚至是纯虚的。例如:

class A
{
public:
virtual ~A()=0; // 纯虚析构函数
};

当一个类打算被用作其它类的基类时,它的析构函数必须是虚的。考虑下面的例子:

class A
{
public:
    A() { ptra_ = new char[10];}
    ~A() { delete[] ptra_;} // 非虚析构函数
private:
    char * ptra_;
};
class B: public A
{
public:
    B() { ptrb_ = new char[20];}
    ~B() { delete[] ptrb_;}
private:
    char * ptrb_;
};
void foo()
{
    A * a = new B;
    delete a;
}

在这个例子中,程序也许不会象你想象的那样运行,在执行delete a的时候,实际上只有A::~A()被调用了,而B类的析构函数并没有被调用!这是否有点儿可怕?

如果将上面A::~A()改为virtual,就可以保证B::~B()也在delete a的时候被调用了。因此基类的析构函数都必须是virtual的。

纯虚的析构函数并没有什么作用,是虚的就够了。通常只有在希望将一个类变成抽象类(不能实例化的类),而这个类又没有合适的函数可以被纯虚化的时候,可以使用纯虚的析构函数来达到目的。

2.4 虚构造函数?

  构造函数不能是虚的。

三. 虚函数使用技巧

3.1 private的虚函数

考虑下面的例子:

class A
{
public:
    void foo() { bar();}
private:
    virtual void bar() { ...}
};
class B: public A
{
private:
    virtual void bar() { ...}
};

在这个例子中,虽然bar()在A类中是private的,但是仍然可以出现在派生类中,并仍然可以与public或者protected的虚函数一样产生多态的效果。并不会因为它是private的,就发生A::foo()不能访问B::bar()的情况,也不会发生B::bar()对A::bar ()的override不起作用的情况。

这种写法的语意是:A告诉B,你最好override我的bar()函数,但是你不要管它如何使用,也不要自己调用这个函数。

3.2 构造函数和析构函数中的虚函数调用

一个类的虚函数在它自己的构造函数和析构函数中被调用的时候,它们就变成普通函数了,不“虚”了。也就是说不能在构造函数和析构函数中让自己“多态”。例如:

class A
{
public:
    A() { foo();} // 在这里,无论如何都是A::foo()被调用!
    ~A() { foo();} // 同上
    virtual void foo();
};
class B: public A
{
public:
    virtual void foo();
};
void bar()
{
    A * a = new B;
    delete a;
}

如果你希望delete a的时候,会导致B::foo()被调用,那么你就错了。同样,在new B的时候,A的构造函数被调用,但是在A的构造函数中,被调用的是A::foo()而不是B::foo()。

3.4 什么时候使用虚函数

在你设计一个基类的时候,如果发现一个函数需要在派生类里有不同的表现,那么它就应该是虚的。从设计的角度讲,出现在基类中的虚函数是接口,出现在派生类中的虚函数是接口的具体实现。通过这样的方法,就可以将对象的行为抽象化。

以设计模式[2]中Factory Method模式为例,Creator的factoryMethod()就是虚函数,派生类override这个函数后,产生不同的Product类,产生的Product类被基类的AnOperation()函数使用。基类的AnOperation()函数针对Product类进行操作,当然 Product类一定也有多态(虚函数)。

另外一个例子就是集合操作,假设你有一个以A类为基类的类层次,又用了一个std:: vector来保存这个类层次中不同类的实例指针,那么你一定希望在对这个集合中的类进行操作的时候,不要把每个指针再cast回到它原来的类型(派生类),而是希望对他们进行同样的操作。那么就应该将这个“一样的操作”声明为virtual。

现实中,远不只我举的这两个例子,但是大的原则都是我前面说到的“如果发现一个函数需要在派生类里有不同的表现,那么它就应该是虚的”。这句话也可以反过来说:“如果你发现基类提供了虚函数,那么你最好override它”。

附:C++中的虚函数和纯虚函数用法

1.虚函数和纯虚函数可以定义在同一个类(class)中,含有纯虚函数的类被称为抽象类(abstract class),而只含有虚函数的类(class)不能被称为抽象类(abstract class)。

2.虚函数可以被直接使用,也可以被子类(sub class)重载以后以多态的形式调用,而纯虚函数必须在子类(sub class)中实现该函数才可以使用,因为纯虚函数在基类(base class)只有声明而没有定义。

3.虚函数和纯虚函数都可以在子类(sub class)中被重载,以多态的形式被调用。

4.虚函数和纯虚函数通常存在于抽象基类(abstract base class -ABC)之中,被继承的子类重载,目的是提供一个统一的接口。

5.虚函数的定义形式:virtual {method body} ;纯虚函数的定义形式:virtual { } = 0; 在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时候要求前期bind,然而虚函数却是动态绑定(run-time bind),而且被两者修饰的函数生命周期(life recycle)也不一样。

6.如果一个类中含有纯虚函数,那么任何试图对该类进行实例化的语句都将导致错误的产生,因为抽象基类(ABC)是不能被直接调用的。必须被子类继承重载以后,根据要求调用其子类的方法。

以下为一个简单的虚函数和纯虚函数的使用演示,目的是抛砖引玉!

//father class
class Virtualbase
{
public:
    virtual void Demon()= 0; //prue virtual function
    virtual void Base() {cout<<"this is farther class"<};
};
//sub class
class SubVirtual :public Virtualbase
{
public:
    void Demon() { cout<<" this is SubVirtual!"<<endl;}

void Base() {cout<<"this is subclass Base"<<endl;}
};

void main()
{
    Virtualbase* inst = new SubVirtual(); //multstate pointer
    inst->Demon();
    inst->Base();
    // inst = new Virtualbase();
    // inst->Base()
    return ;
}


虚函数是在类中被声明为virtual的成员函数,当编译器看到通过指针或引用调用此类函数时,对其执行延迟绑定,即通过指针(或引用)指向的类的类型信息来决定该函数是哪个类的。通常此类指针或引用都声明为基类的,它可以指向基类或派生类的对象。多态指同一个方法根据其所属的不同对象可以有不同的行为。

早绑定指编译器在编译期间即知道对象的具体类型并确定此对象调用成员函数的确切地址;而晚绑定是根据指针所指对象的类型信息得到类的虚函数表指针进而确定调用成员函数的确切地址。

编译器对每个包含虚函数的类创建一个表(称为vtable)。在vtable中,编译器放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密地置一指针,称为vpointer(缩写为vptr),指向这个对象的vtable。通过基类指针做虚函数调用时(也就是做多态调用时),编译器静态地插入取得这个vptr,并vtable表中查找函数地址的代码,这样就能调用正确的函数使晚捆绑发生。为每个类设置vtable、初始化vptr、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数,这个对象的合适的函数就能被调用,哪怕在编译器还不知道这个对象的特定类型的情况下。

在任何类中不存在显示的类型信息,可对象中必须存放类信息,否则类型不可能在运行时建立。那这个类信息是什么呢?我们来看下面几个类:

class no_virtual
{
public:
    void fun1() const{}
    int  fun2() const { return a; }
private:
    int a;
}

class one_virtual
{
public:
    virtual void fun1() const{}
    int  fun2() const { return a; }
private:
    int a;
}

class two_virtual
{
public:
    virtual void fun1() const{}
    virtual int  fun2() const { return a; }
private:
    int a;
}

以上三个类中:
no_virtual没有虚函数,sizeof(no_virtual)=4,类no_virtual的长度就是其成员变量整型a的长度;
one_virtual有一个虚函数,sizeof(one_virtual)=8;
two_virtual有两个虚函数,sizeof(two_virtual)=8; 有一个虚函数和两个虚函数的类的长度没有区别,其实它们的长度就是no_virtual的长度加一个void指针的长度,它反映出,如果有一个或多个虚函数,编译器在这个结构中插入一个指针( vptr)。在one_virtual 和two_virtual之间没有区别。这是因为vptr指向一个存放地址的表,只需要一个指针,因为所有虚函数地址都包含在这个表中。这个VPTR就可以看作类的类型信息。

那我们来看看编译器是怎么建立VPTR指向的这个虚函数表的。先看下面两个类:

class base
{
public:
    void bfun(){}
    virtual void vfun1(){}
    virtual int vfun2(){}
private:
    int a;
}
class derived : public base
{
public:
    void dfun(){}
    virtual void vfun1(){}
    virtual int vfun3(){}
private:
    int b;
}
两个类VPTR指向的虚函数表(VTABLE)分别如下:
base类
        ——————
VPTR——> |&base::vfun1 |
        ——————
        |&base::vfun2 |
        ——————

derived类
        ———————
VPTR——> |&derived::vfun1 |
        ———————
        |&base::vfun2    |
        ———————
        |&derived::vfun3 |
        ———————

每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个VTABLE,如上图所示。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在derived的VTABLE中,vfun2的入口就是这种情况。)然后编译器在这个类中放置VPTR。当使用简单继承时,对于每个对象只有一个VPTR。VPTR必须被初始化为指向相应的VTABLE,这在构造函数中发生。

一旦VPTR被初始化为指向相应的VTABLE,对象就”知道”它自己是什么类型。但只有当虚函数被调用时这种自我认知才有用。

VPTR常常位于对象的开头,编译器能很容易地取到VPTR的值,从而确定VTABLE的位置。VPTR总指向VTABLE的开始地址,所有基类和它的子类的虚函数地址(子类自己定义的虚函数除外)在VTABLE中存储的位置总是相同的,如上面base类和derived类的VTABLE中vfun1和vfun2的地址总是按相同的顺序存储。编译器知道vfun1位于VPTR处,vfun2位于VPTR+1处,因此在用基类指针调用虚函数时,编译器首先获取指针指向对象的类型信息(VPTR),然后就去调用虚函数。如一个base类指针pBase指向了一个derived对象,那pBase->vfun2()被编译器翻译为 VPTR+1 的调用,因为虚函数vfun2的地址在VTABLE中位于索引为1的位置上。同理,pBase->vfun3()被编译器翻译为 VPTR+2的调用。这就是所谓的晚绑定。

重载、重写、重定义

函数重载
必须在同一个类中进行
子类无法重载父类的函数,父类同名函数将被名称覆盖
重载是在编译期间根据参数类型和个数决定函数调用
函数重写
必须发生于父类与子类之间
并且父类与子类中的函数必须有完全相同的原型
使用virtual声明之后能够产生多态(如果不使用virtual,那叫重定义)
多态是在运行期间根据具体对象的类型决定函数调用

多态的实现2

在C++中,我们把表现多态的一系列成员函数设置为虚函数。虚函数可能在编译阶段并没有被发现需要调用,但它还是整装待发,随时准备接受指针或引用的“召唤”。设置虚函数的方法为:在成员函数的声明最前面加上保留字virtual。注意,不能把virtual加到成员函数的定义之前,否则会导致编译错误。

说明1:
通过虚函数表指针VPTR调用重写函数是在程序运行时进行的,因此需要通过寻址操作才能确定真正应该调用的函数。而普通成员函数是在编译时就确定了调用的函数。在效率上,虚函数的效率要低很多。
说明2:
出于效率考虑,没有必要将所有成员函数都声明为虚函数

为什么要定义虚析构函数

在父类中声明虚析构函数的原因
通过父类指针,把所有的子类析构函数都执行一遍。。。

C++纯虚函数

一、定义
纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”
virtual void funtion()=0
二、引入原因
1、为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
2、在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
三、相似概念
1、多态性
指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性。
a、编译时多态性:通过重载函数实现
b、运行时多态性:通过虚函数实现。
2、虚函数
虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态覆盖(Override)
3、抽象类
包含纯虚函数的类称为抽象类。由于抽象类包含了没有定义的纯虚函数,所以不能定义抽象类的对象。

发表评论

电子邮件地址不会被公开。 必填项已用*标注