目录:
C/C++面向对象编程之封装
C/C++面向对象编程之继承
C/C++面向对象编程之多态
1、类型转换
先回忆一下C语言中经常使用数据类型的转换。
数据类型转换的前提是,编译器知道如何对数据进行取舍。例如:
int a = 10.9;
printf("%d\n", a);
输出结果为 10,编译器会将小数部分直接丢掉(不是四舍五入)。再如:
float b = 10;
printf("%f\n", b);
输出结果为 10.000000,编译器会自动添加小数部分。
类其实也是一种数据类型,也可以发生数据类型转换。
将派生类对象赋值给基类对象时,会舍弃派生类新增的成员,也就是“大材小用”,如下图所示:
这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。理由很简单,基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象之间也不能赋值。
看个例子:
#include <iostream>
using namespace std;
//基类
class A{
public:
A(int a);
public:
void display();
public:
int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
cout<<"Class A: m_a="<<m_a<<endl;
}
//派生类
class B: public A{
public:
B(int a, int b);
public:
void display();
public:
int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}
int main(){
A a(10);
B b(66, 99);
//赋值前
a.display();
b.display();
cout<<"--------------"<<endl;
//赋值后
a = b;
a.display();
b.display();
return 0;
}
运行结果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99
本例中 A 是基类, B 是派生类,a、b 分别是它们的对象,由于派生类 B 包含了从基类 A 继承来的成员,因此可以将派生类对象 b 赋值给基类对象 a。通过运行结果也可以发现,赋值后 a 所包含的成员变量的值已经发生了变化。
赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。
看到这里可能会产生一个疑问,派生类对象b赋值给基类对象a,基类对象a就可以访问派生类对象b的同名变量很好理解,那么为什么访问派生类的同名函数a.display()调用的是基类的函数,而不是派生类的同名函数?
我在C/C++面相对象编程之封装中,把对象如何调用成员函数有过一段解释,下边再回忆一下C++是如何调用成员函数的:
我们知道成员函数是存放在代码区,供所有对象共享。实际上C++的编译代码的过程中,把成员函数最终编译成与对象无关的全局函数,如果函数体中没有成员变量,那问题就很简单,不用对函数做任何处理,直接调用即可。
如果成员函数中使用到了成员变量该怎么办呢?成员变量的作用域不是全局,不经任何处理就无法在函数内部访问。
C++规定,编译成员函数时要额外添加一个参数,把当前对象的指针传递进去,通过指针来访问成员变量。
例如上边例子中:
void A::display(){
cout<<"Class A: m_a="<<m_a<<endl;
}
被编译后就变成了类似于下边的函数:
void new_function_name(A* const p){
//通过指针p来访问a
cout<<"Class A: m_a="<<p->m_a<<endl;
}
使用 a.display();调用函数时,也会被编译成类似下面的形式:
new_function_name(&a);
这样通过传递对象指针就完成了成员函数和成员变量的关联。这与我们从表面上看到的刚好相反,通过对象调用成员函数时,不是通过对象找函数,而是通过函数找对象。
这样就解释了为什么派生类对象 b 赋值给基类对象 a后,a.display()调用的是基类的函数,而不是派生类的同名函数,即对象调用成员函数,只与对象所在的类型有关。
再把上边的例子改成指针看一下效果:
#include <iostream>
using namespace std;
//基类
class A{
public:
A(int a);
public:
void display();
public:
int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
cout<<"Class A: m_a="<<m_a<<endl;
}
//派生类
class B: public A{
public:
B(int a, int b);
public:
void display();
public:
int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}
int main(){
A *a = new A(10);
B *b = new B(66, 99);
//赋值前
a->display();
b->display();
cout<<"--------------"<<endl;
//赋值后
a = b;
a->display();
b->display();
return 0;
}
运行结果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99
本例将派生类指针赋值给基类指针。与对象变量之间的赋值不同的是,对象指针之间的赋值并没有拷贝对象的成员,也没有修改对象本身的数据,仅仅是改变了指针的指向的地址,指针指向的数据类型并不会改变。运行结果和上边的例子还是一样。
编译器虽然通过指针的指向来访问成员变量,但是却不通过指针的指向来访问成员函数:编译器通过指针的类型来访问成员函数。
概括起来说就是:编译器通过指针来访问成员变量,指针指向哪个对象就使用哪个对象的数据;编译器通过指针的类型来访问成员函数,指针属于哪个类的类型就使用哪个类的函数。
2、为什么引入多态和虚函数
我们直观上认为,如果指针指向了派生类对象,那么就应该使用派生类的成员变量和成员函数,这符合人们的思维习惯。但是通过上边类型转换的例子,我们会发现通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。为了消除这种尴尬,让基类指针能够访问派生类的成员函数,C++ 增加了虚函数(Virtual Function)。使用虚函数非常简单,只需要在函数声明前面增加 virtual 关键字。
把上边的例子再改一下:
#include <iostream>
using namespace std;
//基类
class A{
public:
A(int a);
public:
virtual void display();//声明为虚函数
public:
int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
cout<<"Class A: m_a="<<m_a<<endl;
}
//派生类
class B: public A{
public:
B(int a, int b);
public:
virtual void display();//声明为虚函数
public:
int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}
int main(){
A *a = new A(10);
B *b = new B(66, 99);
//赋值前
a->display();
b->display();
cout<<"--------------"<<endl;
//赋值后
a = b;
a->display();
b->display();
return 0;
}
运行结果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class B: m_a=66, m_b=99
Class B: m_a=66, m_b=99
和前面的例子相比,本例仅仅是在 display() 函数声明前加了一个virtual关键字,将成员函数声明为了虚函数(Virtual Function),把派生类的指针变量b,赋值给基类指针变量a后,基类指针a就可以访问派生类的成员函数了。
有了虚函数,基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量),指向派生类对象时就使用派生类的成员。换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。
C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。
3、编译器如何实现多态和虚函数
在类型转换一节强调,通过指针调用普通的成员函数时会根据指针的类型(通过哪个类定义的指针)来判断调用哪个类的成员函数,为什么加了虚函数后,就可以通过指针指向的对象找到对应的虚函数?
编译器之所以能通过指针指向的对象找到虚函数,是因为在创建对象时额外地增加了虚函数表。
如果一个类包含了虚函数,那么在创建该类的对象时就会额外地增加一个数组,数组中的每一个元素都是虚函数的入口地址。不过数组和对象是分开存储的,为了将对象和数组关联起来,编译器还要在对象中安插一个指针,指向数组的起始位置。这里的数组就是虚函数表(Virtual function table),简写为vtable。
看个例子:
#include <iostream>
#include <string>
using namespace std;
//People类
class People{
public:
People(string name, int age);
public:
virtual void display();
virtual void eating();
protected:
string m_name;
int m_age;
};
People::People(string name, int age): m_name(name), m_age(age){ }
void People::display(){
cout<<"Class People:"<<m_name<<"今年"<<m_age<<"岁了。"<<endl;
}
void People::eating(){
cout<<"Class People:我正在吃饭,请不要跟我说话..."<<endl;
}
//Student类
class Student: public People{
public:
Student(string name, int age, float score);
public:
virtual void display();
virtual void examing();
protected:
float m_score;
};
Student::Student(string name, int age, float score):
People(name, age), m_score(score){ }
void Student::display(){
cout<<"Class Student:"<<m_name<<"今年"<<m_age<<"岁了,考了"<<m_score<<"分。"<<endl;
}
void Student::examing(){
cout<<"Class Student:"<<m_name<<"正在考试,请不要打扰T啊!"<<endl;
}
//Senior类
class Senior: public Student{
public:
Senior(string name, int age, float score, bool hasJob);
public:
virtual void display();
virtual void partying();
private:
bool m_hasJob;
};
Senior::Senior(string name, int age, float score, bool hasJob):
Student(name, age, score), m_hasJob(hasJob){ }
void Senior::display(){
if(m_hasJob){
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,并且顺利找到了工作,Ta今年"<<m_age<<"岁。"<<endl;
}else{
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,不过找工作不顺利,Ta今年"<<m_age<<"岁。"<<endl;
}
}
void Senior::partying(){
cout<<"Class Senior:快毕业了,大家都在吃散伙饭..."<<endl;
}
int main(){
People *p = new People("赵红", 29);
p -> display();
p = new Student("王刚", 16, 84.5);
p -> display();
p = new Senior("李智", 22, 92.0, true);
p -> display();
return 0;
}
运行结果:
Class People:赵红今年29岁了。
Class Student:王刚今年16岁了,考了84.5分。
Class Senior:李智以92的成绩从大学毕业了,并且顺利找到了工作,Ta今年22岁。
各个类的对象内存模型如下所示:
图中左半部分是对象占用的内存,右半部分是虚函数表 vtable。在对象的开头位置有一个指针 vfptr,指向虚函数表,并且这个指针始终位于对象的开头位置。
仔细观察虚函数表,可以发现基类的虚函数在 vtable 中的索引(下标)是固定的,不会随着继承层次的增加而改变,派生类新增的虚函数放在 vtable 的最后。如果派生类有同名的虚函数遮蔽(覆盖)了基类的虚函数,那么将使用派生类的虚函数替换基类的虚函数,这样具有遮蔽关系的虚函数在 vtable 中只会出现一次。
当通过指针调用虚函数时,先根据指针找到 vfptr,再根据 vfptr 找到虚函数的入口地址。以虚函数 display() 为例,它在 vtable 中的索引为 0,通过 p 调用时:
p -> display();
编译器内部会发生类似下面的转换:
( *( *(p+0) + 0 ) )(p);
下面我们一步一步来分析这个表达式:
- 0是 vfptr 在对象中的偏移,p+0是 vfptr 的地址;
- (p+0)是 vfptr 的值,而 vfptr 是指向 vtable 的指针,所以(p+0)也就是 vtable 的地址;
- display() 在 vtable 中的索引(下标)是 0,所以( *(p+0) + 0 )也就是 display() 的地址;
- 知道了 display() 的地址,( *( *(p+0) + 0 ) )§也就是对 display() 的调用了,这里的 p 就是传递的实参,它会赋值给 this 指针。
可以看到,转换后的表达式是固定的,只要调用 display() 函数,不管它是哪个类的,都会使用这个表达式。换句话说,编译器不管 p 指向哪里,一律转换为相同的表达式。转换后的表达式没有用到与 p 的类型有关的信息,只要知道 p 的指向就可以调用函数。这跟名字编码(Name Mangling)算法有着本质上的区别(非虚函数编译器还是通过名字编码算法对函数重新命名)。
再来看一下 eating() 函数,它在 vtable 中的索引为 1,通过 p 调用时:
p -> eating();
编译器内部会发生类似下面的转换:
( *( *(p+0) + 1 ) )(p);
对于不同的虚函数,仅仅改变索引(下标)即可。
- 必须存在继承关系;
- 继承关系中必须有同名的虚函数,并且它们是覆盖关系(函数原型相同)。
- 存在基类的指针,通过该指针调用虚函数。
4、多态的思想
“多态(polymorphism)”指的是同一名字的事物可以完成不同的功能。多态可以分为编译时的多态和运行时的多态。前者主要是指函数的重载(包括运算符的重载)、对重载函数的调用,在编译时就能根据实参确定应该调用哪个函数,因此叫编译时的多态;而后者则和继承、虚函数等概念有关。
在现实生活中由未来的事情兼容以前的事情很常见,而虚函数实现的多态的牛逼之处在于可以由现在的事情兼容未来的事情。
多态可以实现把不同的子类对象都当作父类来看,可以屏蔽不同子类对象之间的差异,写出通用的代码,做出通用的编程,以适应需求的不断变化。我们可以只针对基类写出一段程序,但它可以适应于这个类的家族,因为编译器会自动找出合适的对象来执行操作。
使用多态可以解决项目中紧偶合的问题,提高程序的可扩展性。
5、C语言用实现多态的思想
所谓多态,其实就是“多种形态”。C++中虚函数的主要作用就是实现多态。简单说父类的指针调用重写的虚函数,当父类指针指向父类对象
时调用的是父类的虚函数,指向子类对象时调用的是子类的虚函数。
在C语言中,可以利用“结构在内存中的布局与结构的声明具有一致的顺序”这一事实实现继承,再结合C语言的函数指针和类型转换可以实现类似的多态功能。C语言的基类定义一个函数指针,把派生类对象指针强制转换成基类对象指针,以达到基类的对象指针可以调用到派生类对象的回调函数,实现多态的目的。
看个例子:
#include <iostream>
using namespace std;
//用一个函数指针
typedef void(*FUN)();
//父类
struct AA
{
FUN fun;
};
//子类
struct BB
{
AA a;
};
void FunA()
{
printf("AA::fun\n");
}
void FunB()
{
printf("BB::fun\n");
}
int main(){
AA a;
BB b;
a.fun = FunA; //父类对象设置父类回调函数
b.a.fun = FunB; //子类对象设置子类回调函数
AA* p = &a; //定义一个父类指针指向父类对象
p->fun(); //调用父类的fun函数
p = (AA*)&b; //父类指针指向子类对象
p->fun(); //调用子类的fun函数
return 0;
}
输出:
AA::fun
BB::fun
指针的使用非常灵活,C语言结构体做类型转换也可以把基类对象指针强制转换成派生类对象指针来使用,使基类指针具有访问派生类任意成员的功能(C++的多态只能访问派生类的虚函数)。
结合RT-Thread设备框架部分的例子:
#include <iostream>
//基类
struct rt_device{
//抽象出所有设备共同的属性和行为
char *name;
void(*init)(struct rt_device *device);
void(*open)(struct rt_device *device,int oflag);
void(*close)(struct rt_device *device);
void(*read)(struct rt_device *device,int pos,void *buffer, int size);
void(*write)(struct rt_device *device,int pos, const void *buffer, int size);
void(*control)(struct rt_device *device, int cmd, void *args);
};
//rt_pin继承rt_device
struct rt_pin{
struct rt_device parent;
char *pin_status;
};
//rt_serial继承rt_device
struct rt_serial{
struct rt_device parent;
char *baud_rate;
};
//pin回调函数,以初始化函数为例
void pin_init(struct rt_device *device)
{
struct rt_pin *pin = NULL;
pin = (struct rt_pin *)device;//类型转换
pin->pin_status="OUT";
printf("%s_pin_status=%s\n",pin->parent.name,pin->pin_status);
}
//serial回调函数,以初始化函数为例
void serial_init(struct rt_device *device)
{
struct rt_serial *serial = NULL;
serial = (struct rt_serial *)device;//类型转换
serial->baud_rate="115200";
printf("%s_baud_rate=%s\n",serial->parent.name,serial->baud_rate);
}
//设置统一接口
void device_init(struct rt_device *device)
{
device->init(device);
/* ..添加其他内容..*/
}
void device_read(struct rt_device *device,int pos,void *buffer, int size)
{
device->read(device,pos,buffer,size);
/* ..添加其他内容..*/
}
void device_write(struct rt_device *device,int pos, const void *buffer, int size)
{
device->write(device,pos,buffer,size);
/* ..添加其他内容..*/
}
int main(){
struct rt_device *device = NULL;
struct rt_pin *pin =(struct rt_pin *)malloc(sizeof(struct rt_pin));//创建pin设备
struct rt_serial *serial =(struct rt_serial *)malloc(sizeof(struct rt_serial));//创建serial设备
pin->parent.init = pin_init;//设置pin设备的回调函数
/*可以继续设置其他回调函数*/
pin->parent.name = "GPIOA";//设置pin接口
device_init(&(pin->parent));//调用初始化接口
serial->parent.init = serial_init;//设置serial设备的回调函数
/*可以继续设置其他回调函数*/
serial->parent.name = "UART1";//设置串口波特率
device_init(&(serial->parent));//调用初始化接口
return 0;
}
一个附加问题,说明类和结构体继承的差异,对比C和C++的两段代码:
#include <iostream>
using namespace std;
//基类A
class A{
public:
int m_a;
};
//基类B
class B{
public:
int m_b;
};
//派生类 C
class C: public A,public B{
public:
int m_c;
};
int main(){
A *pa=new A;
B *pb=new B;
C *pc=new C;
pa->m_a =1;
pb->m_b =2;
pc->m_a =3;
pc->m_b =3;
pc->m_c =3;
cout<<"pb->m_b="<<pb->m_b<<endl;
pa=pc;//类型转换
pb=pc;//类型转换
cout<<"pb->m_b="<<pb->m_b<<endl;
cout<<"pa="<<pa<<endl;
cout<<"pb="<<pb<<endl;
cout<<"pc="<<pc<<endl;
return 0;
}
在VC6.0的编译环境下,输出结果为:
pb->m_b=2
pb->m_b=3
pa=007318F0
pb=007318F4
pc=007318F0
注意到:指针pb的值和pa、pc并不一样
看下派生类C的对象内存模型:
内存地址 | C类对象 | 对象指针 |
---|---|---|
0x007318F0 | m_a | <-pa、pc |
0x007318F4 | m_b | <-pb |
0x007318F8 | m_c |
再看下一段代码:
#include <iostream>
//基类
struct A{
int x;
};
//基类
struct B{
char y;
};
//多继承
struct C{
struct A parent1;
struct B parent2;
int z;
};
int main(){
struct A *a=(struct A *)malloc(sizeof(struct A));
struct B *b=(struct B *)malloc(sizeof(struct B));
struct C *c=(struct C *)malloc(sizeof(struct C));
a->x = 1;
b->y = 2;
c->parent1.x=3;
c->parent2.y=3;
c->z = 3;
printf("b->y=%x\n",b->y);
a=(struct A *)c;//类型转换
b=(struct B *)c;//类型转换
printf("b->y=%x\n",b->y);
printf("a=%x\n",a);
printf("b=%x\n",b);
printf("c=%x\n",c);
return 0;
}
在VC6.0的编译环境下,输出结果为:
b->y=2
b->y=3
a=751790
b=751790
c=751790
注意到:指针abc的值一样
看下结构体C的变量内存模型:
内存地址 | 结构体C | 指针变量 |
---|---|---|
0x00751790 | m_a | <- a、b、c |
0x00751794 | m_b | |
0x00751798 | m_c |
这也是结构体指针变量和对象指针变量不一样的一个地方,对象的指针必须要求指向对象的起始位置(这可能和虚函数有关),而结构体变量指针没有这个要求。
参考资料:http://c.biancheng.net/cplus/
联系作者:
欢迎关注本人公众号: