【C++】关于多态不得不说的那些事儿

Question List

[190706][c++]为什么64位系统中类的vptr指针地址与成员变量存储地址之间隔了一个不知道什么东西的地址?

Polymorphism

什么是多态?通俗来讲多态就是当我们通过指针或引用把派生类作为基类对象使用时,这个指针或引用具有多种形态;具体而言就是传递给基类对象的指针指的是什么形态,那么这个对象就是什么形态,这种多种形态的特性被称为多态
多态的实现依赖于两个基础:一个是向上造型Upcast,一个是动态绑定Dynamic Binding

Static Binding and Dynamic Binding

通常,我们将一个方法调用同一个方法主体关联起来称作绑定。绑定的对象有静态对象和动态对象之分,一般称代码在编译期就可确定的对象类型称为静态对象,将在程序运行时方能确定的对象类型称为动态对象。

  • Static Binding 静态绑定发生于程序编译期。它其实绑定了两个东西,一是函数调用与函数本身的关联,二是成员访问与变量内存地址间的关系。
  • Dynamic Binding 动态绑定发生于程序运行时。动态绑定则针对运行期产生的访问请求,只用到运行期的可用信息;在面向对象的代码中,动态绑定意味着决定哪个方法被调用或哪个属性被访问,将基于这个类本身而不基于访问范围,也就是说动态绑定执行期间(非编译期)判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。
Class Draw

接下来我们用画图的例子来了解静态绑定与动态绑定。

在上面的类图中,需要注意到基类Shape的析构函数是虚函数,关于这一点浙江大学翁恺老师的课上有说明,我们稍后再来具体谈谈。
用这个例子来描述多态会很清楚但其代码稍微有些复杂,我们只列举椭圆和圆的代码并对其其进行简写,暂时不去想函数是怎么实现的,而选择关心派生类与基类之间的继承关系与多态在其中扮演的角色。

#include <iostream>
using namespace std;

class Point{}; /*center point class.*/
class Shape
{
    /*The basic class: Shape class*/
public:
    Shape(){}
    virtual ~ Shape(){}
    void move(){ cout <<"Shape is moving..." << endl;}
    virtual void render(){cout << "Shape is drawing..." << endl; }
    virtual void resize(){cout <<"Shape is resizing..." << endl; }
protected:
    Point center;
}; 

class Ellipse: public Shape
{
   /* First derived class of Shape: Ellipse*/
public:   
    Ellipse(int maj, int minr){}
    virtual void render(){cout <<"Ellipse is drawing..." << endl; }
protected:
    int major_axis;
    int minor_axis;
};

class Circle: public Ellipse
{
    /*derived class of Ellipse.*/
public:
    Circle(int radius):Ellipse(radius,radius){}
    virtual void render(){cout << "Circle is drawing..." <<endl;}
};

void render(Shape* p)
{
    p->render();
}
Ellipse e(10,20);
Circle c(10);

e.render();
c.render();

render(&e);
render(&c);

Ellipse is drawing…
Circle is drawing…
Ellipse is drawing…
Circle is drawing…

上面的代码就是多态的直观表现,注意我们后来独立于各个类之外写的render函数中传递的是基类指针,这就是前面说的向上转型的概念,通过继承与C++面向对象的特性可以将子类对象当做父类对象来用;另外在调用render(&a)这段代码时,p指针指向的是Ellipse对象a,这也就是动态绑定所描述的在程序运行时再去判定p指向的类型并调用它具体类型的调用方法。

How to explain polymorphism

现在我们知道了多态是含有虚函数的基类指针所具备的多种形态,也知道这个多种形态是通过动态绑定机制来进行描述的。那么,在程序运行时,基类指针是如何判断动态绑定的对象的类型的呢?
借助于翁恺老师的“洛阳铲”教学,我们一步步探索,看看为基类添加虚函数后究竟发生了什么。

#include <iostream>
using namespace std;

// First we declare a class named A,
// and A only have one integer attribute,
// so if we use sizeof() function to 
// determine the size of A, we should got
// a 4 if we don't add virtual funtcion 
// for class A. But now we add a virtual
// function, is it still 4?

class A
{
public:
    A():i(10){}
    void seti(int value){ i = value;}
    virtual void f(){cout << "A::f()=" << i << endl;}
    int i;
};
// Let's make a test.
A a;
int* p = (int*)&a;

cout << sizeof(a) << endl;
cout << *p << endl;

16
1561612336

What??? 为什么会是16?
好吧,我表示有点疑惑。翁老师的课上用sizeof得到的结果是8,也就是int占4字节,另外一个因为添加虚函数而多出来的东西占4字节,我们知道这个东西应该是一个指针。这说的通对吧,但为什么到我这里变成了16嘞っ゚Д゚)っ
稍一回忆,翁老师在gcc hello.cpp命令后面加了个-m32表示生成32位机器的汇编代码,而我的电脑是64位系统。哦,懂了!!!ヽ( ̄▽ ̄)ノ
这里大家不妨思考一下,没懂的也没事,问题的答案被我放到了最后。

// we know that p is not the ptr
// witch allocate the address of
// i, we make a test of p++ to 
// watch where the next pointer  
// point.
A a0;
int* p0 = (int*)&a0;
cout << *p0 << endl;
p0++;p0++;
cout << *p0 << endl;

1561612336
10

到现在,我们知道了有了虚函数的类比普通的类在它成员变量的地址下面多了一个指针;这个指针就是vptr,它指向vtable。vtable里面存储的是这个类的所有虚函数的位置;分析上面的结果,我们发现对象a和b多出来的这个指针居然是一样的,也就是说也就是说这个vtable是这个类所共有的一个特性,而不是哪个所创建的具体的对象的。
Note: 这里有一个很令我困扰的点,就是这个p0指针。在32位系统里,它指的内存区域是连续的,p0指针+1就是A类成员变量int i所在的地址,但是在64位系统中不是,经过测试p0+2才是成员变量int i的地址,p0+1对应的空间不知道存了个什么东西,但根据在线IDE的编译结果,它似乎什么都没存,也就是说vptr到成员变量之间的指针是不连续的,这是什么原理呢?暂时把它放到悬疑列表中,等待后面有接触砖家的机会再去探讨,进行进一步的了解。

好了,接下来我们可以开始着手测试多态代码了。

//Now, we declare a class B,
//which inherited from class A.

class B: public A
{
public:
    B():j(20){}
    virtual void f(){ cout << "B::f()=" << j << endl;}
    int j;
}
//Make a object b with class B and 
//declare a pointer q to point the
//address of b.
B b;
int*q = (int*)&b;
b.f();

//Now, we got an object a of 
//class A, an object b of class
//B. And got two pointers p,q 
//which point the vptr of class A
//and class B.

//Let's check the pointers.
cout << *p <<endl;
cout << *q <<endl;

//What happened if we make a = b?
//Will a.f() print 40?
a = b;
cout << "We make a = b, then: " << endl;
a.f();

//What happened if we changed 
//the value i of b and do the
//same a = b operation?
b.seti(15);
a = b;
cout << "We do b.seti(15) and make a = b, then: " << endl;
a.f();

//Now, we declare a pointer A* which
//point the address of b. What contents 
//will pa->f() print?
A* pa = &b;
cout << "We make A* pa = &b, then pa->f(): " << endl;
pa->f();

//And next we change the pa to point
//the address of a and make *p=*q,
//what will happend?
cout << "We make pa = &a and *p=*q, then pa->f(): " << endl;
pa = &a;
*p = *q;
pa->f();

B::f()=20
1561612336
1534726352
We make a = b, then:
A::f()=10
We do b.seti(15) and make a = b, then:
A::f()=15
We make A* pa = &b, then pa->f():
B::f()=20
We make pa = &a and *p=*q, then pa->f():
B::f()=0

这里可能对最后输出的那个B:f()=0有些疑惑,它究竟输出了一个什么呢?
其实是这样的,我们执行*p=*q操作也就意味着将B的vptr地址赋值给了A的vptr,所以现在a里面的vptr指向B的vtable了。当我们再次通过pa->f()调用f()函数时它指向的其实是bf(),但是a里并没有j这个变量,所以它输出的是内存地址中i的下一个地址存放的内容,这里是0,当然在不同的情况下这个地址存放的东西肯定有所不同,相同的是一般我们很难确定它到底存了个什么(这里只是举例,写代码的时候千万不要这么写吼)。
还有一点是a=b这个语句,它就是个单纯的赋值,当我们修改了b中存储的i的值之后再将它赋值给a,可以很明显的看到a.f()输出的值发生了变化。

Why destructor must be virtual

最后,我们解释一下为什么基类Shape的析构函数必须是虚函数(摘自翁老师课程PPT)。
来看这样一段代码:

Shape* p = new Ellipse(10, 20);
/*do something.*/
delete p;

如果析构函数不是虚函数,那么当我们操作delete p时发生的是静态绑定(因为C++为了追求效率默认的是静态绑定),静态绑定意味着调用的是Shape的析构函数,Ellipse的析构函数就被丢掉了,而事实上我们需要调用的应该是Ellipse的析构函数。所以我们需要动态绑定,也就是只要定义的类里面有一个虚函数,就要将析构函数声明为虚函数,让delete p自己去找自己应该调用谁的析构函数。
还要注意一点,就是当类里面既有Overload又有Override时,要在派生类里重写所有的Overload函数避免Name Hidding的发生。

Name Hidding

Name Hidding是指当父类当中有Overload函数时,如果子类也出现了和父类当中同名的函数,那么子类当中就只有那个函数,而父类中的其他重载函数就都被隐藏掉了。只有C++这么干的,C++认为子类中的这个函数与父类中的那个同名函数无关,他们只是碰巧相同(其他语言就直接当做override了)。也就是说重载对于跨作用域是无效的,如(引自c++: 为什么需要名字隐藏机制(c++ Why name hiding)?):

using namespace std;
class B {
public:
  int f(int i) { cout << "f(int): "; return i+1; }
  // ...
};
class D : public B {
public:
  double f(double d) { cout << "f(double): "; return d+1.3; }
  // ...
};
int main()
{
  D* pd = new D;
  cout << pd->f(2) << '\n';
  cout << pd->f(2.3) << '\n';
}

这里D:f(double)隐藏了B::f(int)函数。如果要在D中能查找到基类的函数,从而实现重载,则可以使用using指示符:

//use using to make f(int) visible
using B::f; // make every f from B available

假若没有隐藏机制,改变基类实现将有可能影响到子类已经正常工作的代码出现未预料的行为,这不是我们希望看到的。

Answer of sizeof(a) is equal to 16

关于上面提到的sizeof(a)输出16的问题。首先我们知道int类型不管在32位系统还是64位系统中都占4个字节;而指针呢?在32位机器中,int*占4个字节,在64位系统中int*占8个字节。那我们用sizeof得到是不是应该是8+4=12啊,怎么会是16呢?答案是字节对齐,字节对齐是因为CPU按字长处理数据,其访问内存时总是将偶数地址放到总线上,这就要求编译器最好能够让放到总线上的地址总是对齐的,于是就出现了字节对齐。类与结构体的字节对齐值为成员变量中最大的那一个,于是A中的int类型变量对齐到int*的8字节,于是就出现了16这个数字。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值