您的位置:首页 > 编程语言 > C语言/C++

【C++研发面试笔记】2. 多态性

2016-10-01 22:27 295 查看

【C++研发面试笔记】2. 多态性

2.1 多态性来源

多态性指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。

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

C++支持两种多态性:编译时多态性,运行时多态性。

a. 编译时多态性:通过函数重载、重写、模板来实现。

b. 运行时多态性:通过虚函数继承实现。对于虚函数来说,父类和子类都有各自的版本。由多态方式调用的时候动态绑定。

重载、重写与屏蔽的区别

重载:在相同作用域内,函数名称相同,参数或常量性不同的相关函数称为重载。

重写:派生类重写基类中同名同参数同返回值的函数(通常是虚函数,这是推荐的做法)。

屏蔽:一个内部作用域(派生类,嵌套类或名字空间)内提供一个同名但不同参数或不同常量性的函数,使得外围作用域的同名函数在内部作用域不可见,编译器在进行名字查找时将在内部作用域找到该名字从而停止去外围作用域查找,因而屏蔽外围作用域的同名函数。尽量不要屏蔽外围作用域(包括继承而来的)名字。屏蔽所带来的隐晦难以理解。

2.2 继承、接口与组合

2.2.1 派生类的3种继承方式

(1)公有继承方式:基类成员对其对象的可见性与一般类及其对象的可见性相同,公有成员可见,其他成员不可见。这里保护成员与私有成员相同。

(2)私有继承方式:基类成员对其对象的可见性与一般类及其对象的可见性相同,公有成员可见,其他成员不可见。基类成员对派生类的可见性对派生类来说,基类的公有成员和保护成员可见,基类的公有成员和保护成员都作为派生类的私有成员,并且不能被这个派生类的子类所访问;基类的私有成员不可见,派生类不可访问基类中的私有成员。尽量避免 private 继承,因为从基类继承而来的所有接口均为私有的,外部不可访问。

(3)保护继承方式:这种继承方式与私有继承方式的情况相同,两者的区别仅在于对派生类的成员而言,基类成员对其对象的可见性与一般类及其对象的可见性相同,公有成员可见,其他成员不可见。

(4)虚拟继承是多重继承中特有的概念。虚拟基类是为了解决多重继承而出现的,如类D继承自类B和类C,而类B和类C都继承自类A,此时类D中会继承两次A。为了节省内存空间,可以将B、C对A的继承定义为虚拟继承,而A成了虚拟基类,此时只需要继承一次。

2.2.2 继承的实质

(1)覆盖

构造函数从最初始的基类开始构造的,所以对于某个子类具体实现,其也会默认构造全体父系类。派生类的内存大小=派生类本身数据变量+父类大小+虚函数表指针+指向父类指针。各个父类的同名变量不会形成覆盖,都是单独的变量。

覆盖函数的就近调用,如果父类存在相关接口则优先调用父类接口(此时操作的是父类实例),如果父类也不存在相关接口则调用祖父辈接口。

析构函数也是从子类开始向上析构的。假设有如下情况,带非虚析构函数的基类指针 pb 指向一个派生类对象d,而派生类在其析构函数中释放了一些资源,如果我们 delete pb;那么派生类对象的析构函数就不会被调用,从而导致资源泄漏发生。因此,应该声明基类的析构函数为虚函数。

(2)重写

在继承中有三类函数:纯虚函数、虚函数、非虚函数。

非虚函数:当一个成员函数为非虚函数时,它在派生类中的行为就不应该不同。实际上,非虚成员函数表明了一种特殊性上的不变性,因为它表示的是不会改变的行为。所以,声明非虚函数的目的在于,使派生类强制继承函数的接口和实现。

纯虚函数:声明纯虚函数的目的是让派生类来继承函数接口而不是实现,而实现交由派生类来完成,派生类也必须重写该接口(如果要实例的话)。

虚函数:让派生类来继承函数接口和实现,而派生类可以重写该接口,但也可以调用基类的默认实现。

2.2.3 继承与组合间的区别:

面向对象编程讲究的是代码复用,继承和组合都是代码复用的有效方法。组合是将其他类的对象作为成员使用,继承是子类可以使用父类的成员方法。

继承在继承结构中,父类的内部细节对于子类是可见的。简单易用,使用语法关键字即可轻易实现。易于修改或扩展那些父类被子类复用的实现。编译阶段静态决定了层次结构,不能在运行期间进行改变。破坏了封装性,由于“白盒”复用,父类的内部细节对于子类而言通常是可见的。子类与父类之间紧密耦合,子类依赖于父类的实现,子类缺乏独立性。当父类的实现更改时,子类也不得不会随之更改。

组合是通过对现有的对象进行拼装(组合)产生新的、更复杂的功能。因为在对象之间,各自的内部细节是不可见的,所以我们也说这种方式的代码复用是“黑盒式代码复用”。通过获取指向其它的具有相同类型的对象引用,可以在运行期间动态地定义(对象的)组合。不破坏封装,整体类与局部类之间松耦合,彼此相对独立。

继承体现的是一种专门化的概念而组合则是一种组装的概念。除非用到向上转型,不然优先考虑组合。

2.2.4 继承与接口的区别:

首先来说接口也是一种继承方式,所谓接口实际上指的只有声明,对应的是只有纯虚函数的抽象类,在C++中并没有关于接口的关键字(这点同Java是不一样的)。

2.3 模板与多态

模板(泛型)是一种对类型进行参数化的工具,通常有两种形式:函数模板和类模板。函数模板针对仅参数类型不同的函数。类模板针对仅数据成员和成员函数类型不同的类。使用模板的目的就是能够让程序员编写与类型无关的代码。

注意:模板的声明或定义只能在全局,命名空间或类范围内进行。即不能在局部范围,函数内进行,比如不能在main函数中声明或定义一个模板。

2.3.1函数模板通式

// 模板函数定义
template < class 形参名 ... > 返回类型 函数名(参数列表)
{函数体}
// 比如定义交换函数
template <class T> void swap(T& a, T& b){};


其中template和class是关键字,class可以用typename关键字代替,在这里typename 和class没区别,<>括号中的参数叫模板形参,模板形参和函数形参很相像,模板形参不能为空。

2.3.2类模板通式

template < class 形参名... >  class 类名
{ ... };


类模板和函数模板都是以template开始后接模板形参列表组成,模板形参不能为空,一但声明了类模板就可以用类模板的形参名声明类中的成员变量和成员函数,即可以在类中使用内置类型的地方都可以使用模板形参名来声明。比如

template < class T> class A{
public:
T a; T b;
T hy(T c, T &d);
};


在类A中声明了两个类型为T的成员变量a和b,还声明了一个返回类型为T带两个参数类型为T的函数hy。

类模板对象的创建:比如一个模板类A,则使用类模板创建对象的方法为
A<int> m;
在类A后面跟上一个<>尖括号并在里面填上相应的类型,这样的话类A中凡是用到模板形参的地方都会被int 所代替。当类模板有两个模板形参时创建对象的方法为
A<int, double> m;
类型之间用逗号隔开。

在类模板外部定义成员函数的方法为:

template<模板形参列表> 返回类型 类名<模板形参名>::函数名(参数列表){函数体};
template < class T1,class T2> void A< T1,T2 >::h(){}


2.3.3 非类型形参

1、非类型模板形参:模板的非类型形参也就是内置类型形参,如
template<class T, int a> class B{};
其中int a就是非类型的模板形参。

2、 非类型形参在模板定义的内部是常量值,也就是说非类型形参在模板的内部是常量。

3、非类型模板的形参只能是整型、指针和引用,像
double,String, String **
这样的类型是不允许的。但是
double &,double *
对象的引用或指针是正确的。

2.3.4 操作符重载

运算符重载是对已有的运算符赋予多重含义,使同一个运算符作用于不同类型的数据导致不同类型的行为。

运算符重载的实质是函数重载。在实现过程中,首先把指定的运算表达式转化为对运算符函数的调用,运算对象转化为运算符函数的实参,然后根据实参的类型来确定需要调用达标函数。

不能重载的运算符只有五个,它们是:成员运算符“.”、指针运算符“*”、作用域运算符“::”、“sizeof”、条件运算符“?:”。

运算符重载形式有两种,重载为类的成员函数和重载为类的友元函数。

运算符重载为类的成员函数的一般语法形式为:

函数类型 operator 运算符(形参表)
{
函数体;
}


运算符重载为类的友元函数的一般语法形式为:

friend 函数类型 operator 运算符(形参表)
{
函数体;
}


2.3.5 拷贝构造函数

拷贝构造函数嘛,当然就是拷贝和构造了,是一种特殊的构造函数,它由编译器调用来完成一些基于同一类的其他对象的构建及初始化。代码结构如下:

Class X{
public:
  X();
  X(const X&);//拷贝构造函数
}


如果在类中没有显式地声明一个拷贝构造函数,那么,编译器将会自动生成一个默认的拷贝构造函数,该构造函数完成对象之间的浅拷贝自定义拷贝构造函数是一种良好的编程风格,它可以阻止编译器形成默认的拷贝构造函数,提高源码效率。

深拷贝和浅拷贝可以简单理解为:如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,只是定义了新的指针,就是浅拷贝。

2.3.6 C语言的泛型处理

在C语言中可以通过函数指针来实现部分泛型



2.4 动态绑定和静态绑定

1、静态对象:对象在声明时采用的类型。是在编译期确定的。

2、动态对象:指对象当前的类型,是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。关于对象的静态类型和动态类型,看一个示例:

class B
{
}
class C : public B
{
}
class D : public B
{
}
D* pD = new D();//pD的静态类型是它声明的类型D*,动态类型也是D*
B* pB = pD;//pB的静态类型是它声明的类型B*,动态类型是pB所指向的对象pD的类型D*
C* pC = new C();
pB = pC;//pB的动态类型是可以更改的,现在它的动态类型是C*


3、静态绑定:绑定的是对象的静态类型,某特性(比如函数)依赖于对象的静态类型,发生在编译期。

4、动态绑定:绑定的是对象的动态类型,某特性(比如函数)依赖于对象的动态类型,发生在运行期。

class B
{
void DoSomething();
virtual void vfun();
}
class C : public B
{
void DoSomething();//首先说明一下,这个子类重新定义了父类的no-virtual函数,这是一个不好的设计,会导致名称遮掩;这里只是为了说明动态绑定和静态绑定才这样使用。
virtual void vfun();
}
class D : public B
{
void DoSomething();
virtual void vfun();
}
D* pD = new D();
B* pB = pD;


在这里,虽然pD和pB都指向同一个对象,但pD->DoSomething()和pB->DoSomething()并不是调用同一个函数。因为函数DoSomething是一个no-virtual函数,它是静态绑定的,也就是编译器会在编译期根据对象的静态类型来选择函数。pD的静态类型是D*,那么编译器在处理pD->DoSomething()的时候会将它指向D::DoSomething()。同理,pB的静态类型是B*,那pB->DoSomething()调用的就是B::DoSomething()。

而pD->vfun()和pB->vfun()调用的是同一个函数。因为vfun是一个虚函数,它动态绑定的,也就是说它绑定的是对象的动态类型,pB和pD虽然静态类型不同,但是他们同时指向一个对象,他们的动态类型是相同的,都是D*,所以,他们的调用的是同一个函数:D::vfun()。

上面都是针对对象指针的情况,对于引用(reference)的情况同样适用。

指针和引用的动态类型和静态类型可能会不一致,但是对象的动态类型和静态类型是一致的。D D; D.DoSomething()和D.vfun()永远调用的都是D::DoSomething()和D::vfun()。

总结一句话,只有虚函数才使用的是动态绑定,其他的全部是静态绑定。

特别需要注意的地方

当缺省参数和虚函数一起出现的时候情况有点复杂,因为虚函数是动态绑定的,而为了执行效率,缺省参数是静态绑定的。 所以绝不重新定义继承而来的缺省参数。

2.5 多继承的对象结构

已知:

class ClassC : public ClassA,public ClassB


下面这张图说明多继承下的对象结构:



这篇博文是个人的学习笔记,内容许多来源于网络(包括CSDN、博客园及百度百科等),博主主要做了微不足道的整理工作。由于在做笔记的时候没有注明来源,所以如果有作者看到上述文字中有自己的原创内容,请私信本人修改或注明来源,非常感谢>_<
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息