C++ 三大特性:封装、继承、多态性
要讲 封装、继承、多态就必须从面向对象说起
开发一个软件是为了解决某些问题,这些问题所涉及的业务范围称为该软件的问题域。面向对象的编程语言将客观事物看作具有属性和行为(或服务)的对象,通过抽象找出同一类对象的共同属性(静态特征)和行为(动态特性),形成类。通过类的继承、与多态可以很方便的实现代码重用,大大缩短了开发周期,并使得软件统一。
与之相对的是结构化设计思路,结构化设计的思路主要是:自顶向下、逐步求精;其程序结构是按功能划分为若干个基本模块,这些模块形成一个树状结构;各模块之间的关系尽可能简单在功能上相对独立;每一模块内部均是由顺序,选择和循环3种基本结构组成;其模块化实现的具体办法是使用子程序。
而面向对象的方法:首先,他讲数据及对数据的操作方法放在一起,作为一个相互依存、不可分离的整体——对象。对同类型对象抽象 出其共性,形成类。类中的大多数数据,只能用本类的方法进行处理。类通过一个简单的外部接口与外界发生关系,对象与对象之间通过消息进行通信。
面向对象方法中的对象,是系统中用来描述客观事物的一个实体,它是用来构成系统的一个基本单位。对象由一组属性和一组行为构成。
C++面向对象有三大特性:
一、封装
封装是面向对象的一个重要原则,就是把对象的属性和服务合成一个独立的系统单位,并尽可能隐藏面向对象的细节;
简单来说封装就是把类中一些属性隐藏起来,让外界不能直接访问只能通过某些特定的方式才能访问。封装的目的是增强安全性和简化编程,使用者不必了解具体的实现细节,而只是通过外部接口以及特定的访问权限来使用类的成员。
例如,下例中时一个动物类,我们把数据和功能封装起来。
1 class Animal 2 { 3 public: //这就是公共成员,外部的接口 4 void SetAnimalName(string strname); 5 void ShowAnimalName(); 6 private: //这是私有成员,外部是无法访问到的 7 string m_strName; 8 };
二、继承
继承是面向对象技术能够提高软件开发效率的重要原因之一;其定义是 :特殊类的对象拥有其一般类的全部属性与服务,称作特殊类对一般类的继承。
三、多态
多态是指在一般类中定义的属性和行为,被特殊类继承后,可以具有不同的数据类型或变现出不同的行为。即允许将子类类型的指针赋值给父类类型的指针,多态性在C++中是通过虚函数实现的。虚函数就是允许被其子类重新定义的成员函数。而子类重新定义父类虚函数的做法,称为“覆盖”,或者称为“重写”。子类重写父类中虚函数时,即使不用virtual声明,该重载函数也是虚函数。
(以下内容为转载)
在程序设计领域,一个广泛认可的定义是“一种将不同的特殊行为和单个泛化记号相关联的能力”。和纯粹的面向对象程序设计语言不同,C++中的多态有着更广泛的含义。除了常见的通过类继承和虚函数机制生效于运行期的动态多态(dynamic polymorphism)外,带变量的宏,模板,函数重载,运算符重载,拷贝构造等也允许将不同的特殊行为和单个泛化记号相关联,由于这种关联处理于编译期而非运行期,因此被称为静态多态(static polymorphism)。
1、 函数重载与缺省参数
(1)函数重载的实现原理
假设,我们现在想要写一个函数(如Exp01),它即可以计算整型数据又可以计算浮点数,那样我们就得写两个求和函数,对于更复杂的情况,我们可能需要写更多的函数,但是这个函数名该怎么起呢?它们本身实现的功能都差不多,只是针对不同的参数:
int sum_int(int nNum1, int nNum2)
{
return nNum1 + nNum2;
}
double sum_float(float nNum1, float nNum2)
{
return nNum1 + nNum2;
}
C++中为了简化,就引入了函数重载的概念,大致要求如下:
1、 重载的函数必须有相同的函数名
2、 重载的函数不可以拥有相同的参数
2、 运算符重载
运算符重载也是C++多态性的基本体现,在我们日常的编码过程中,我们经常进行+、—、*、/等操作。在C++中,要想让我们定义的类对象也支持这些操作,以简化我们的代码。这就用到了运算符重载。
比如,我们要让一个日期对象减去另一个日期对象以便得到他们之间的时间差。再如:我们要让一个字符串通过“+”来连接另一个字符串……
要想实现运算符重载,我们一般用到operator关键字,具体用法如下:
返回值 operator 运算符(参数列表)
{
// code
}
例如:
CMyString Operator +(CMyString & csStr)
{
int nTmpLen = strlen(msString.GetData());
if (m_nSpace <= m_nLen+nTmpLen)
{
char *tmpp = new char[m_nLen+nTmpLen+sizeof(char)*2];
strcpy(tmpp, m_szBuffer);
strcat(tmpp, msString.GetData());
delete[] m_szBuffer;
m_szBuffer = tmpp;
}
}
这样,我们的函数就可以写成:
int sum (int nNum1, int nNum2)
{
return nNum1 + nNum2;
}
double sum (float nNum1, float nNum2)
{
return nNum1 + nNum2;
}
到现在,我们可以考虑一下,它们既然拥有相同的函数名,那他们怎么区分各个函数的呢?
那就是通过C++名字改编(C++名字粉碎),,对于重载的多个函数来说,其函数名都是一样的,为了加以区分,在编译连接时,C++会按照自己的规则篡改函数名字,这一过程为"名字改编".有的书中也称为"名字粉碎".不同的C++编译器会采用不同的规则进行名字改编,例如以上的重载函数在VC6.0下可能会被重命sum_int@@YAHHH@Z和sum_float@@YAMMM@Z这样方便连接器在链接时正常的识别和找到正确的函数。
(2)缺省参数
无论是Win系统下的API,还是Linux下的很多系统库,它们的好多的函数存在许多参数,而且大部分都是NULL,倘若我们有个函数大部分的时候,某个参数都是固定值,仅有的时候需要改变一下,而我们每次调用它时都要很费劲的输入参数岂不是很痛苦?C++提供了一个给参数加默认参数的功能,例如:
double sum (float nNum1, float nNum2 = 10);
我们调用时,默认情况下,我们只需要给它第一个参数传递参数即可,但是使用这个功能时需要注意一些事项,以免出现莫名其妙的错误,下面我简单的列举一下大家了解就好。
A、 默认参数只要写在函数声明中即可。
B、 默认参数应尽量靠近函数参数列表的最右边,以防止二义性。比如
double sum (float nNum2 = 10,float nNum1);
这样的函数声明,我们调用时:sum(15);程序就有可能无法匹配正确的函数而出现编译错误。
3.宏多态
带变量的宏可以实现一种初级形式的静态多态:
// macro_poly.cpp
#include <iostream>
#include <string>
// 定义泛化记号:宏ADD
#define ADD(A, B) (A) + (B);
int main()
{
int i1(1), i2(2);
std::string s1("Hello, "), s2("world!");
int i = ADD(i1, i2); // 两个整数相加
std::string s = ADD(s1, s2); // 两个字符串“相加”
std::cout << "i = " << i << "\n";
std::cout << "s = " << s << "\n";
}
当程序被编译时,表达式ADD(i1, i2)和ADD(s1, s2)分别被替换为两个整数相加和两个字符串相加的具体表达式。整数相加体现为求和,而字符串相加则体现为连接(注:string.h库已经重载了“+”)。程序的输出结果符合直觉:
1 + 2 = 3
Hello, + world! = Hello, world!
4.类中的早期绑定
先看以下的代码:
#include<iostream> using namespace std; class animal { public: void sleep(){ cout<<"animal sleep"<<endl; } void breathe(){ cout<<"animal breathe"<<endl; } }; class fish:public animal { public: void breathe(){ cout<<"fish bubble"<<endl; } }; int main() { fish fh; animal *pAnimal=&fh; pAnimal->breathe(); }
答案是输出:animal breathe
从编译的角度
C++编译器在编译的时候,要确定每个对象调用的函数的地址,这称为早期绑定(early binding),当我们将fish类的对象fh的地址赋给pAn时,C++编译器进行了类型转换,此时C++编译器认为变量pAn保存的就是animal对象的地址。当在main()函数中执行pAn->breathe()时,调用的当然就是animal对象的breathe函数。
内存模型的角度
对于简单的继承关系,其子类内存布局,是先有基类数据成员,然后再是子类的数据成员,当然后面讲的复杂情况,本规律不一定成立。
我们构造fish类的对象时,首先要调用animal类的构造函数去构造animal类的对象,然后才调用fish类的构造函数完成自身部分的构造,从而拼接出一个完整的fish对象。当我们将fish类的对象转换为animal类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是图中的“animal的对象所占内存”。那么当我们利用类型转换后的对象指针去调用它的方法时,当然也就是调用它所在的内存中的方法。因此,输出animal breathe,也就顺理成章了。
前面输出的结果是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用迟绑定(late binding)技术。当编译器使用迟绑定时,就会在运行时再去确定对象的类型以及正确的调用函数。而要让编译器采用迟绑定,就要在基类中声明函数时使用virtual关键字(注意,这是必须的,很多学员就是因为没有使用虚函数而写出很多错误的例子),这样的函数我们称为虚函数。一旦某个函数在基类中声明为virtual,那么在所有的派生类中该函数都是virtual,而不需要再显式地声明为virtual。
动态多态性
下面我们将上面一段代码进行部分修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
#include<iostream> using namespace std; class animal { public : void sleep(){ cout<< "animal sleep" <<endl; } virtual void breathe(){ cout<< "animal breathe" <<endl; } }; class fish: public animal { public : void breathe(){ cout<< "fish bubble" <<endl; } }; int main() { fish fh; animal *pAnimal=&fh; pAnimal->breathe(); } |
运行结果:fish bubble
编译器为每个类的对象提供一个虚表指针,这个指针指向对象所属类的虚表。在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向所属类的虚表,从而在调用虚函数时,就能够找到正确的函数。由于pAn实际指向的对象类型是fish,因此vptr指向的fish类的vtable,当调用pAn->breathe()时,根据虚表中的函数地址找到的就是fish类的breathe()函数。正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。那么虚表指针在什么时候,或者说在什么地方初始化呢?
答案是在构造函数中进行虚表的创建和虚表指针的初始化。还记得构造函数的调用顺序吗,在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表。当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。
当fish类的fh对象构造完毕后,其内部的虚表指针也就被初始化为指向fish类的虚表。在类型转换后,调用pAn->breathe(),由于pAn实际指向的是fish类的对象,该对象内部的虚表指针指向的是fish类的虚表,因此最终调用的是fish类的breathe()函数。
下面详细的介绍内存的分布
基类的内存分布情况
对于无虚函数的类A:
class A
{
void g(){.....}
};
则sizeof(A)=1;
如果改为如下:
class A
{
public:
virtual void f()
{
......
}
void g(){.....}
}
则sizeof(A)=4! 这是因为在类A中存在virtual function,为了实现多态,每个含有virtual function的类中都隐式包含着一个静态虚指针vfptr指向该类的静态虚表vtable, vtable中的表项指向类中的每个virtual function的入口地址
例如 我们declare 一个A类型的object :
A c;
A d;
则编译后其内存分布如下:
从 vfptr所指向的vtable可以看出,每个virtual function都占有一个entry,例如本例中的f函数。而g函数因为不是virtual类型,故不在vtable的表项之内。说明:vtab属于类成员静态pointer,而vfptr属于对象pointer
继承类的内存分布状况
假设代码如下:
public B:public A
{
public :
int f() //override virtual function
{
return 3;
}
};
则
A c;
A d;
B e;
编译后,其内存分布如下:
从中我们可以看出,B类型的对象e有一个vfptr指向vtable address:0x00400030 ,而A类型的对象c和d共同指向类的vtable address:0x00400050a
动态绑定过程的实现
我们说多态是在程序进行动态绑定得以实现的,而不是编译时就确定对象的调用方法的静态绑定。
其过程如下:
程序运行到动态绑定时,通过基类的指针所指向的对象类型,通过vfptr找到其所指向的vtable,然后调用其相应的方法,即可实现多态。
例如:
A c;
B e;
A *pc=&e; //设置breakpoint,运行到此处
pc=&c;
此时内存中各指针状况如下:
可以看出,此时pc指向类B的虚表地址,从而调用对象e的方法。继续运行,当运行至pc=&c时候,此时pc的vptr值为0x00420050,即指向类A的vtable地址,从而调用c的方法。
对于虚函数调用来说,每一个对象内部都有一个虚表指针,该虚表指针被初始化为本类的虚表。所以在程序中,不管你的对象类型如何转换,但该对象内部的虚表指针是固定的,所以呢,才能实现动态的对象函数调用,这就是C++多态性实现的原理。
需要注意的几点
总结(基类有虚函数):
1、每一个类都有虚表。
2、虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现。如果基类3个虚函数,那么基类的虚表中就有三项(虚函数地址),派生类也会有虚表,至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。
3、派生类的虚表中虚函数地址的排列顺序和基类的虚表中虚函数地址排列顺序相同。
下面想将虚函数和纯虚函数做个比较
虚函数
引入原因:为了方便使用多态特性,我们常常需要在基类中定义虚函数。
纯虚函数
引入原因:为了实现多态性,纯虚函数有点像java中的接口,自己不去实现过程,让继承他的子类去实现。
在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。 这时我们就将动物类定义成抽象类,也就是包含纯虚函数的类
纯虚函数就是基类只定义了函数体,没有实现过程定义方法如下
virtual void Eat() = 0; 直接=0 不要 在cpp中定义就可以了
虚函数和纯虚函数的区别
1虚函数中的函数是实现的哪怕是空实现,它的作用是这个函数在子类里面可以被重载,运行时动态绑定实现动态
纯虚函数是个接口,是个函数声明,在基类中不实现,要等到子类中去实现
2 虚函数在子类里可以不重载,但是虚函数必须在子类里去实现。
类的多继承
一个类可以从多个基类中派生,也就是说:一个类可以同时拥有多个类的特性,是的,他有多个基类。这样的继承结构叫作“多继承”,最典型的例子就是沙发-床了:
SleepSofa类继承自Bed和Sofa两个类,因此,SleepSofa类拥有这两个类的特性,但在实际编码中会存在如下几个问题。
a) SleepSofa类该如何定义?
Class SleepSofa : public Bed, public Sofa
{
….
}
构造顺序为:Bed sofa sleepsofa (也就是书写的顺序)
b) Bed和Sofa类中都有Weight属性页都有GetWeight和SetWeight方法,在SleepSofa类中使用这些属性和方法时,如何确定调用的是哪个类的成员?
可以使用完全限定名的方式,比如:
Sleepsofa objsofa;
Objsofa.Bed::SetWeight(); // 给方法加上一个作用域,问题就解决了。
虚继承
倘若,我们定义一个SleepSofa对象,让我们分析一下它的构造过程:它会构造Bed类和Sofa类,但Bed类和Sofa类都有一个父类,因此Furniture类被构造了两次,这是不合理的,因此,我们引入了虚继承的概念。
class Furniture{……};
class Bed : virtual public Furniture{……}; // 这里我们使用虚继承
class Sofa : virtual public Furniture{……};// 这里我们使用虚继承
class sleepSofa : public Bed, public Sofa {……};
这样,Furniture类就之构造一次了……
总结下继承情况中子类对象的内存结构:
单继承情况下子类实例的内存结构
(1)一般继承(无虚函数覆盖)
假设有如下所示的一个继承关系:
(2)一般继承(有虚函数覆盖)
在这个类的设计中,假设只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子
class A
{
public:
A(){m_A = 0;}
virtual fun1(){};
int m_A;
};
class B:public A
{
public:
B(){m_B = 1;}
virtual fun1(){};
virtual fun2(){};
int m_B;
};
int main(int argc, char* argv[])
{
B* pB = new B;
return 0;
}
则在VC6.0下的内存分配图:
在该图中,子类只有一个虚函数表,与以上的两种情况向符合。
多继承情况下子类实例的内存结构(非虚继承)
(1)多重继承(无虚函数覆盖)
假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数:
对于子类实例中的虚函数表,是下面这个样子:
(2)多重继承(有虚函数覆盖)
下图中,我们在子类中覆盖了父类的f()函数。
下面是对于子类实例中的虚函数表的图:
在多继承(非虚继承)情况下,对应于以下例程序:
#include <stdio.h>
class A
{
public:
A(){m_A = 1;};
~A(){};
virtual int funA(){printf("in funA\r\n"); return 0;};
int m_A;
};
class B
{
public:
B(){m_B = 2;};
~B(){};
virtual int funB(){printf("in funB\r\n"); return 0;};
int m_B;
};
class C
{
public:
C(){m_C = 3;};
~C(){};
virtual int funC(){printf("in funC\r\n"); return 0;};
int m_C;
};
class D:public A,public B,public C
{
public:
D(){m_D = 4;};
~D(){};
virtual int funD(){printf("in funD\r\n"); return 0;};
int m_D;
};
从该图中可以看出,此时子类中确实有三个来自于父类的虚表。
多继承情况下子类实例的内存结构(存在虚继承)
在虚继承下,Der通过共享虚基类SuperBase来避免二义性,在Base1,Base2中分别保存虚基类指针,Der继承Base1,Base2,包含Base1, Base2的虚基类指针,并指向同一块内存区,这样Der便可以间接存取虚基类的成员,如下图所示:
class SuperBase
{
public:
int m_nValue;
void Fun(){cout<<"SuperBase1"<<endl;}
virtual ~SuperBase(){}
};
class Base1: virtual public SuperBase
{
public:
virtual ~ Base1(){}
};
class Base2: virtual public SuperBase
{
public:
virtual ~ Base2(){}
};
class Der:public Base1, public Base2
{
public:
virtual ~ Der(){}
};
void main()
{
cout<<sizeof(SuperBase)<<sizeof(Base1)<<sizeof(Base2)<<sizeof(Der)<<endl;
}
1) GCC中结果为8, 12, 12, 16
解析:sizeof(SuperBase) = sizeof(int) + 虚函数表指针
sizeof(Base1) = sizeof(Base2) = sizeof(int) + 虚函数指针 + 虚基类指针
sizeof(Der) = sizeof(int) + Base1中虚基类指针 + Base2虚基类指针 + 虚函数指针
GCC共享虚函数表指针,也就是说父类如果已经有虚函数表指针,那么子类中共享父类的虚函数表指针空间,不在占用额外的空间,这一点与VC不同,VC在虚继承情况下,不共享父类虚函数表指针,详见如下。
2)VC中结果为:8, 16, 16, 24
解析:sizeof(SuperBase) = sizeof(int) + 虚函数表指针
sizeof(Base1) = sizeof(Base2) = sizeof(int) + SuperBase虚函数指针 + 虚基类指针 + 自身虚函数指针
sizeof(Der) = sizeof(int) + Base1中虚基类指针 + Base2中虚基类指针 + Base1虚函数指针 + Base2虚函数指针 + 自身虚函数指针
如果去掉虚继承,结果将和GCC结果一样,A,B,C都是8,D为16,原因就是VC的编译器对于非虚继承,父类和子类是共享虚函数表指针的。
(1) 部分虚继承的情况下子类实例的内存结构:
#include "stdafx.h"
class A
{
public:
A(){m_A = 0;};
virtual funA(){};
int m_A;
};
class B
{
public:
B(){m_B = 1;};
virtual funB(){};
int m_B;
};
class C
{
public:
C(){m_C = 2;};
virtual funC(){};
int m_C;
};
class D:virtual public A,public B,public C
{
public:
D(){m_D = 3;};
virtual funD(){};
int m_D;
};
int main(int argc, char* argv[])
{
D* pD = new D;
return 0;
}
(2)全部虚继承的情况下,子类实例的内存结构
class A
{
public:
A(){m_A = 0;}
virtual funA(){};
int m_A;
};
class B
{
public:
B(){m_B = 1;}
virtual funB(){};
int m_B;
};
class C:virtual public A,virtual public B
{
public:
C(){m_C = 2;}
virtual funC(){};
int m_C;
};
int main(int argc, char* argv[])
{
C* pC = new C;
return 0;
}
(3) 菱形结构继承关系下子类实例的内存结构
class A
{
public:
A(){m_A = 0;}
virtual funA(){};
int m_A;
};
class B :virtual public A
{
public:
B(){m_B = 1;}
virtual funB(){};
int m_B;
};
class C :virtual public A
{
public:
C(){m_C = 2;}
virtual funC(){};
int m_C;
};
class D: public B, public C
{
public:
D(){m_D = 3;}
virtual funD(){};
int m_D;
};
int main(int argc, char* argv[])
{
D* pD = new D;
return 0;
}
对于子类虚表的个数和设置,貌似虚继承与非虚继承的差别不是很大。
参考:
http://blog.csdn.net/chen_yi_long/article/details/8662822
http://blog.csdn.net/zyq0335/article/details/7657465
http://haoel.blog.51cto.com/313033/124595/
http://blog.csdn.net/xsh_123321/article/details/5956289
https://www.cnblogs.com/kunhu/p/3631285.html
面向对象的软件工程是面向对象方法在软件工程领域的全面应用。他包括面向对象的分析(OOA)、面向对象的设计(OOD)、面向对象的编程(OOP)、面向对象的测试(OOT)和面向对象的软件维护(OOSM)等主要内容。