一、文章来由
Bill又写文章来由了哇~~早就想好好搞清这个问题了,这是c++领域里面比较难搞定的一块知识点,而且最近在看设计模式,里面有涉及这块,之前学过的不用容易玩忘记,于是就干脆研究透一点,也好碰到、用到的时候不心慌~于是有了这篇文章。
二、从编译时和运行时说起
2.1 编译时:
顾名思义就是正在编译的时候。就是编译器帮你把源代码翻译成机器能识别的代码。(当然只是一般意义上这么说,实际上可能只是翻译成某个中间状态的语言。比如Java只有JVM识别的字节码,C#中只有CLR能识别的MSIL)
编译时就是简单的作一些翻译工作。如果发现错误编译器就告诉你。比如点击微软的VS点下build,如果下面有errors或者warning信息,那都是编译器检查出来的。所谓这时的错误就叫编译时错误,这个过程中做类型检查叫编译时类型检查,或静态类型检查(所谓静态嘛就是没把真把代码放内存中运行起来,而只是把代码当作文本来扫描下)。所以说编译时还分配内存肯定是错误的。
2.2 运行时:
所谓运行时就是代码跑起来了,被装载到内存中去了。(你的代码保存在磁盘上没装入内存之前是死的,静态的;只有跑到内存中才变成活的)。而运行时类型检查就与前面讲的编译时类型检查(或者静态类型检查)不一样。不是简单的扫描代码。而是在内存中做些操作,做些判断。
例如,在C++中:
int arr[] = {1,2,3};
int result = arr[4];
cout<
上面的代码错误很明显—数组越界了。但用编译器编译,是不会报错的。可见编译器其实还是挺笨的,然后开始Debug,可能会报错(也可能不报错,本人用的vs2012 debug模式就没有)。但实际上运行时做数组的越界检查不是C++里面支持的特性,这里你dubug是VS中的一些工具给你做的检查。你如果点运行时选的是release而不是debug的话会发现一切正常运行,但得到的结果不确定的(因为不知道arr[4]所指的内存里存放的是什么)。
2.3 c++多态在编译时和运行时【底层机制,很重要】:
那C++为什么不在运行时做数组越界检查呢?
这应该主要是考虑到性能问题吧,C++设计之初为了达到与C差不多的效率,就尽量不会在运行时多做些额外的检查,因为这样无疑会降低性能的,但有些地方却是必须得做运行时类型检查,比如多态,不在运行时做类型检查就无法确定类型。
举个简单例子,假如有父类Father,继承自Father的子类Son,这两个类中都有虚函数Fun
Father fa;
Son so;
fa = so;
fa.Fun(); //在编译时,实际上是把Fun当作Father类中的Fun看待
但在运行时实际上这里的Fun是调用的Son中的函数Fun,所以不做运行时类型检查是无法确定的
2.4 编译时多态和运行时多态
关于编译时和运行时,多态还有一个问题很重要—编译时多态和运行时多态
多态性是面向对象程序设计的重要特征之一。所谓多态性是指当不同的对象收到相同的消息时,产生不同的动作。C++的多态性具体体现在运行和编译两个方面,在程序运行时的多态性通过继承虚函数来体现,而在程序编译时多态性体现在函数和运算符的重载上。
C++支持两种多态性:
编译时多态:程序运行前发生的事件 —— 函数重载、运算符重载 —— 静态绑定
运行时多态:程序运行时发生的事件 —— 虚函数机制 —— 动态绑定
三、多态性小览
上面说了这么多,才开始重头戏。
3.1 多态定义
定义:多态性可以简单地概括为“一个接口,多种方法”,程序在运行时才决定调用的函数,它是面向对象编程领域的核心概念。多态(polymorphism),字面意思多种形状。
C++多态性是通过虚函数来实现的,虚函数允许子类重新定义成员函数,而子类重新定义父类的做法称为覆盖(override),或者称为重写。
这里我觉得要补充,重写的话可以有两种,直接重写成员函数和重写虚函数,只有重写了虚函数的才能算作是体现了C++多态性。
而重载则是允许有多个同名的函数,而这些函数的参数列表不同,允许参数个数不同,参数类型不同,或者两者都不同。编译器会根据这些函数的不同列表,将同名的函数的名称做修饰,从而生成一些不同名称的预处理函数,来实现同名函数调用时的重载问题。
3.2 重载和重写
上面的定义,有了一个定性的认识,C++多态性是通过重写了虚函数实现的,有必要看看重载和重写。
其实这两个概念对于我们来说肯定并不陌生,但是有很多细节的地方容易被忽略。
方法重载:
- 对于面向对象而言,必须在同一个类里面
- 方法名相同
- 参数类型不同 如:public void test ( int i , int j ){} 和 public void test ( int i , float j ){}
- 参数数目不同 如:public void test ( int i ){} 和 public void test ( int i , int j ){}
- 和方法的返回值无关 如:public int test( int i ){} 和 public void test( int i , int j) {} 也属于方法重载
方法重写是指重写父类的方法
1.类必须继承了父类才可以重写父类的方法;
2.必须和父类的方法的返回值,参数列表和方法名一样才算重写!(这点很重要)
3.3 早绑定与晚绑定
多态与非多态的实质区别就是函数地址是早绑定还是晚绑定。如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并生产代码,是静态的,地址就是早绑定的。而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就是晚绑定。
那么多态的作用是什么呢,封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。也就是说,不论传递过来的究竟是那个类的对象,函数都能够通过同一个接口调用到适应各自对象的实现方法。
最常见的用法就是声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。如果没有使用虚函数的话,即没有利用C++多态性,则利用基类指针调用相应的函数的时候,将总被限制在基类函数本身,而无法调用到子类中被重写过的函数。因为没有多态性,函数调用的地址将是一定的,而固定的地址将始终调用到同一个函数,这就无法实现一个接口,多种方法的目的了。
3.4 代码分析
3.4.1 例1
#include
using namespace std;
class A
{
public:
void foo()
{
printf("A类中:1\n");
}
virtual void fun()
{
printf("A类中:2\n");
}
};
class B : public A
{
public:
void foo()
{
printf("B类中:3\n");
}
void fun()
{
printf("B类中:4\n");
}
};
int main(void)
{
A a;
B b;
A *p = &a;
p->foo();
p->fun();
cout<
p = &b;
p->foo();
p->fun();
cout<
B *ptr = (B *)&a;
ptr->foo();
ptr->fun();
return 0;
}
运行出来的结果如图:
分析:
第一个p->foo()和p->fun()都容易理解。
第二个输出结果是1、4。p->foo()和p->fun()是基类指针指向子类对象,完全体现了多态的用法,p->foo()由于指针是个基类指针,指向是一个固定偏移量的函数,因此此时指向的就只能是基类的foo()函数的代码了,因此输出的结果还是1。
而p->fun()指针是基类指针,指向的fun是一个虚函数,由于每个虚函数都有一个虚函数列表,此时p调用fun()并不是直接调用函数,而是通过虚函数列表找到相应的函数的地址,因此根据指向的对象不同,函数地址也将不同,这里将找到对应的子类的fun()函数的地址,因此输出的结果也会是子类的结果4。
还有一个很变态的东东
B *ptr = (B *)&a;
ptr->foo();
ptr->fun();
一个用子类的指针去指向一个强制转换为子类地址的基类对象。结果,这两句调用的输出结果是3,2。
并不是很理解这种用法,从原理上来解释,由于B是子类指针,虽然被赋予了基类对象地址,但是ptr->foo()在调用的时候,由于地址偏移量固定,偏移量是子类对象的偏移量,于是即使在指向了一个基类对象的情况下,还是调用到了子类的函数,虽然可能从始到终都没有子类对象的实例化出现。
所以这种情况只看指针类型。
而ptr->fun()的调用,可能还是因为C++多态性的原因,由于指向的是一个基类对象,通过虚函数列表的引用,找到了基类中fun()函数的地址,因此调用了基类的函数。
所以这种情况只看绑定对象类型。
由此可见多态性的强大,可以适应各种变化,不论指针是基类的还是子类的,都能找到正确的实现方法。
3.4.2 例2
#include
using namespace std;
class Base
{
public:
virtual void f1(float x)
{
cout<<"Base::f1(float)"< }
virtual void f2(float x)
{
cout<<"Base::f2(float)"< }
void f3(float x)
{
cout<<"Base::f3(float)"< }
};
class Derived : public Base
{
public:
virtual void f1(float x)
{
cout<<"Derived::f1(float)"<