C/C++面试:面向对象的三大特性是哪些

1060 篇文章 293 订阅

面向对象的三大特性是哪些

(1)封装:

  • 将客观事务封装成抽象的类,而类可以都自己的数据和方法暴露给可信的类或者对象,对不可信的类或者对象进行信息隐藏
  • 通过这种⽅式,对象对内部数据提供了不同级别的保护,以防⽌程序中⽆关的部分意外的改变或错误的使⽤了对象的私有部分。

(2) 继承:

  • 可以使用现有类的所有功能,并且无需重新编写原来的类就可以对功能进行扩展
  • 继承概念的实现⽅式有两类:
    • 实现继承:实现继承是指直接使⽤基类的属性和⽅法⽽⽆需额外编码的能⼒。
    • 接⼝继承:接⼝继承是指仅使⽤属性和⽅法的名称、但是⼦类必需提供实现的能⼒。

(3)多态:

  • 就是向不同的对象发送同⼀个消息,不同对象在接收时会产⽣不同的⾏为(即⽅法)。即⼀个接⼝,可以实现多种⽅法。
  • 多态与⾮多态的实质区别就是函数地址是早绑定还是晚绑定的。如果函数的调⽤,在编译器编译期间就可以确定函数的调⽤地址,并产⽣代码,则是静态的,即地址早绑定。⽽如果函数调⽤的地址不能在编译器期间确定,需要在运⾏时才确定,这就属于晚绑定。

什么是类的继承(is-a)

类和类之间的关系

  • has-A包含关系:用来描述一个类由多个部件类构成,实现has-A关系用类的成员属性,即一个类的成员属性是另一个已经定义好的类
  • use-A,一个类使用另一个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数的方式来实现
  • is-A:继承关系,关系具有传递性

继承相关概念

  • 所谓的继承就是一个类继承了另一个类的属性和方法,这个新的类包含了上一个类的属性和方法,被称为子类或者派生类,被继承的类被称为父类或者基类

继承的特点

  • 子类拥有父类的属性和方法
  • 子类可以拥有父类没有的属性和方法
  • 子类对象可以当做父类对象使用

什么是组合(has-a)

  • 一个类里面的数据成员是另一个类的对象,即内嵌其他类的对象作为自己的成员
  • 创建组合类的对象:⾸先创建各个内嵌对象,难点在于构造函数的设计。创建对象时既要对基本类型的成员进⾏初始化,⼜要对内嵌对象进⾏初始化。
  • 建组合类对象,构造函数的执⾏顺序:先调⽤内嵌对象的构造函数,然后按照内嵌对象成员在组合类中的定义顺序,与组合类构造函数的初始化列表顺序⽆关。然后执⾏组合类构造函数的函数体,析构函数调⽤顺序相反。

类如何实现只能静态分配和只能动态分配

  • 只能静态分配:把new、delete运算符重置为private属性
  • 只能动态分配:把构造、析构函数设置为protected

建立类的对象有两种方式:

  • 静态建立:静态建立一个类对象,就是由编译器为对象在栈空间中分配内存
  • 动态建立,A *p = new A():动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象
  • 只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上。可以将new运算符设置为private

多态的实现

多态其实一般就是指继承+虚函数实现的动态,对于重载来说,实际上基于的原理是,编译器为函数⽣成符号表时的不同规则,重载只是⼀种语⾔特性,与多态⽆关,与⾯向对象也⽆关,但这⼜是 C++中增加的新规则,所以也算属于 C++,所以如果⾮要重载算是多态的⼀种,那就可以说:多态可以分为静态多态和动态多态。

  • 静态多态其实就是重载,因为静态多态是指在编译时期就决定了调⽤哪个函数,根据参数列表来决定;
  • 动态多态是指通过⼦类重写⽗类的虚函数来实现的,因为是在运⾏期间决定调⽤的函数,所以称为动态多态,

⼀般情况下我们不区分这两个时所说的多态就是指动态多态。

动态多态的实现与虚函数表,虚函数指针相关。

静态绑定 VS 动态绑定

说起静态绑定和动态绑定,我们首先要知道静态类型和动态类型,静态类型就是它在程序被声明时所采用的类型,在编译期间确定,动态类型则是指“目前所指对象的实际类型”,在运行期间确定。

  • 静态绑定,又叫做“早绑定”,绑定的是静态类型,所对应的函数或者属性依赖于对象的静态类型,发生在编译期间
  • 动态绑定,又叫做“玩绑定”,绑定的是动态类型,所对应的函数或者属性依赖于对象的静态类型,发生在运行期间

比如说,virtual函数是动态绑定的,非virtual函数是静态绑定的,缺省参数值也是静态绑定的。这里注意我们不应该重新定义继承而来的缺省参数,因为即使我们重新定义了,也不会其中。因为一个基类的指针指向一个派生类对象,在派生类的对象中针对虚函数的参数缺省值进行了重定义,但是缺省参数值是静态绑定的,静态绑定绑定的是静态类型相关的内容,所以对出现一种派生类的虚函数实现方式结合了基类的缺省参数值的调用效果,这个与所期望的效果不同。

静态联编和动态联编

在C++中,联编是指一个计算机程序的不同部分彼此关联的过程。按照联编所进行的阶段不同,可以分为静态联编和动态联编

  • 静态联编是指联编⼯作在编译阶段完成的,这种联编过程是在程序运⾏之前完成的,⼜称为早期联编。要实现静态联编,在编译阶段就必须确定程序中的操作调⽤(如函数调⽤)与执⾏该操作代码间的关系,确定这种关系称为束定,在编译时的束定称为静态束定。静态联编对函数的选择是基于指向对象的指针或者引⽤的类型。其优点是效率⾼,但灵活性差。
  • 动态联编是指联编在程序运⾏时动态地进⾏,根据当时的情况来确定调⽤哪个同名函数,实际上是在运⾏时虚函数的实现。这种联编⼜称为晚期联编,或动态束定。动态联编对成员函数的选择是基于对象的类型,针对不同的对象类型将做出不同的编译结果。

C++中⼀般情况下的联编是静态联编,但是当涉及到多态性和虚函数时应该使⽤动态联编。动态联编的优点是灵活性强,但效率低。动态联编规定,只能通过指向基类的指针或基类对象的引⽤来调⽤虚函数,其格式为:指向基类的指针变量名->虚函数名(实参表)或基类对象的引⽤名.虚函数名(实参表)

实现动态联编三个条件:

  • 必须把动态联编的⾏为定义为类的虚函数;
  • 类之间应满⾜⼦类型关系,通常表现为⼀个类从另⼀个类公有派⽣⽽来;
  • 必须先使⽤基类指针指向⼦类型的对象,然后直接或间接使⽤基类指针调⽤虚函数;

虚函数实现原理

  • ⾸先我们来说⼀下,C++中多态的表象,在基类的函数前加上 virtual 关键字,在派⽣类中᯿写该函数,运⾏时将会根据对象的实际类型来调⽤相应的函数。如果对象类型是派⽣类,就调⽤派⽣类的函数,如果是基类,就调⽤基类的函数。
  • 实际上,当一个类中包含虚函数时,编译器会为该类生成一个虚函数表,保存该类中的虚函数的地址。同样,派生类继承基类,派生类中自然一定有虚函数,所以编译器也会为派生类生成自己的虚函数表。当我们定义一个派生类对象时,编译器检测该类型有虚函数,所以为这个派生类对象生成一个虚函数指针,指向该类型的虚函数表,这个虚函数指针的初始化是在构造函数中完成的。
  • 后续如果有⼀个基类类型的指针,指向派⽣类,那么当调⽤虚函数时,就会根据所指真正对象的虚函数表指针去寻找虚函数的地址,也就可以调⽤派⽣类的虚函数表中的虚函数以此实现多态。
  • 补充:如果基类中没有定义成virtual,那么进⾏ Base B; Derived D; Base *p = D; p->function(); 这种情况下调⽤的则是 Base 中的 function()。因为基类和派生类中都没有虚函数的定义,那么编译器就会认为不要留给动态多态的机会,就事先进行函数地址的绑定(早绑定),具体过程就是,定义了一个派生类对象,首先要构造基类的空间,然后构造派生类的自身内容,形成一个派生类对象,那么在进行类型转换时,直接截取基类的部分的内容,编译器认为类型就是基类,那么(函数符号表[不同于虚函数表的另⼀个表]中)绑定的函数地址也就是基类中函数的地址,所以执⾏的是基类的函数。

编译器处理虚函数表应该如何处理

对于派生类来说,编译器建立虚函数表的过程其实一共是三个步骤:

  • 拷贝基类的虚函数表,如果是多继承,就拷贝每个有虚函数基类的虚函数表
  • 当然还有一个基类的虚函数表和派生类自身的虚函数表共⽤了⼀个虚函数表,也称为某个基类为派生类的主记录
  • 查看派生类中是否有重写基类中的虚函数,如果有,就替换成已经重写的虚函数地址;查看派生类是否有自身的虚函数,如果有,就追加自身的虚函数到自身的虚函数表中

Derived *pd = new D(); B *pb = pd; C *pc = pd; 其中 pb,pd,pc 的指针位置是不同的,要注意的是派⽣类的⾃身的内容要追加在主基类的内存块后。
在这里插入图片描述

哪些函数不能是虚函数

  • 构造函数:构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个镀锡有一个虚表指针,虚表指针在构造函数中初始化
  • 内联函数:内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数
  • 静态函数:静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义
  • 友元函数:友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法
  • 普通函数:普通函数不属于类的成员函数,不具有继承特性,因此普通函数不能是虚函数

为什么基类的构造函数不能定义为虚函数

虚函数的调用依赖于虚函数表,而指向虚函数的指针vprt需要在构造函数中进行初始化,所以无法调用定义为虚函数的构造指针

  • 虚函数调用只需要知道“部分的”信息,即只需要知道函数接口,不需要知道对象的具体类型。但是,我们要创建一个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该定义成虚函数
  • ⽽且从⽬前编译器实现虚函数进⾏多态的⽅式来看,虚函数的调⽤是通过实例化之后对象的虚函数表指针来找到虚函数的地址进⾏调⽤的,如果说构函数是虚的,那么虚函数表指针则是不存在的,⽆法找到对应的虚函数表来调⽤虚函数,那么这个调⽤实际上也是违反了先实例化后调⽤的准则。

为什么基类的析构函数需要定义为虚函数?

  • 为了实现动态绑定,基类指针指向派生类对象
  • 一个基类的指针指向一个派生类的对象,如果基类的析构函数没有定义成虚函数,那么在对象销毁时,编译器根据指针类型就会认为当前对象的类型是基类,就会调用基类的析构函数,只能销毁派生类对象中的部分数据
  • 所以必须将析构函数定义为虚函数,从而在对象销毁时,调用派生类的析构函数,从而避免内存泄露

构造函数或析构函数中调用虚函数会怎样

实际上是不应该在构造函数或者析构函数中调用虚函数的,因为这样的调用其实并不会带来所想要的效果。

举个例子,有一个动物的基类,基类中定义了一个动物本身行为的虚函数action_type(),在基类的构造函数中调用了这个虚函数。

派⽣类中重写了这个虚函数,我们期望着根据对象的真实类型不同,⽽调⽤各⾃实现的虚函数,但实际上当我们创建⼀个派⽣类对象时,⾸先会创建派⽣类的基类部分,执⾏基类的构造函数,此时,派⽣类的⾃身部分还没有被初始化,对于这种还没有初始化的东⻄,C++选择当它们还不存在作为⼀种安全的⽅法。

也就是说构造派生类的基类部分是,编译器会认为这就是一个基类类型的对象,然后调用基类类型中的虚函数实现,并没有按照我们想要的方式进行,即对象在派生类构造函数执行前并不会成为一个派生类对象。

在析构函数中也是同理,派生类执行了析构函数后,派生类自身成员呈现未定义状态,那么在执行基类的析构函数中是不可能调用派生类重写的方法。所以,我们不应该在构造函数或者析构函数中调用虚函数。

构造函数和析构函数能抛出异常吗?

  • 从语法的角度来说,构造函数可以抛出异常,但是从逻辑和风险控制的角度来说,尽量不要抛出异常,否则可能导致内存泄露
  • 析构函数不可以抛出异常,如果析构函数抛出异常,那异常点之后的程序,比如释放内存等操作,就不会被执行,从而导致内存泄露;而且当异常发生时,C++通常会调用对象的析构函数来释放资源,如果此时析构函数也抛出异常,即前一个异常未处理又出现了新的异常,从而导致程序崩溃的问题

构造函数的执⾏顺序?析构函数的执⾏顺序?

构造函数顺序:

  • 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序
  • 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,⽽不是它们出现在成员初始化表中的顺序。
  • 派生类构造函数

析构函数顺序:

  • 和构造函数相反

构造函数的执⾏算法:

  • 在派生类构造函数中,所有的虚函数以及上一层基类的构造函数被调用
  • 对象的vptr被初始化
  • 如果有成员初始化列表,将在构造函数体内扩展开来,这必须在 vptr 被设定之后才做;
  • 执⾏程序员所提供的代码;

构造函数的扩展过程:

  • 记录在成员初始化列表中的数据成员初始化操作会被放在构造函数的函数体内,并与成员的声明顺序为顺序;
  • 如果⼀个成员并没有出现在成员初始化列表中,但它有⼀个默认构造函数,那么默认构造函数必须被调⽤;
  • 如果 class 有虚表,那么它必须被设定初值;
  • 所有上⼀层的基类构造函数必须被调⽤;
  • 所有虚基类的构造函数必须被调⽤

析构函数被扩展的过程:

  • 析构函数函数体被执⾏;
  • 如果 class 拥有成员类对象,⽽后者拥有析构函数,那么它们会以其声明顺序的相反顺序被调⽤;
  • 如果对象有⼀个 vptr,现在被重新定义
  • 如果有任何直接的上⼀层⾮虚基类拥有析构函数,则它们会以声明顺序被调⽤;
  • 如果任何虚基类拥有析构函数

析构函数怎么起作用的

构造函数只是起初始化值的作⽤,但实例化⼀个对象的时候,可以通过实例去传递参数,从主函数传递到其他的函数⾥⾯,这样就使其他的函数⾥⾯有值了。规则,只要你⼀实例化对象,系统⾃动会调⽤⼀个构造函数,就是你不写,编译器也⾃动调⽤⼀次。

析构函数与构造函数的作⽤相反,⽤于撤销对象的⼀些特殊任务处理,可以是释放对象分配的内存空间;特点:析构函数与构造函数同名,但该函数前⾯加~。

析构函数没有参数,也没有返回值,而且不能重载,在一个类中只能有一个析构函数。当销毁对象时,编译器自动调用析构函数。每⼀个类必须有⼀个析构函数,⽤户可以⾃定义析构函数,也可以是编译器⾃动⽣成默认的析构函数。

动态绑定是如何实现的

  • 当编译器发现类中有虚函数时,会创建一张虚函数表,把虚函数的函数入口地址放到虚函数表中,并且在对象中增加一个指针vptr,用于指向类的虚函数表
  • 当派生类覆盖基类的虚函数时,会将虚函数表中对应的指针进行替换,从而调用派生类中覆盖后的虚函数,从而实现动态绑定

动态多态有什么作用?有哪些必要条件?

动态动态的作用

  • 隐藏实现细节,使得代码模块化,提高代码的可复用性
  • 接口重用,使得派生类的功能可以被基类的指针/引用所调用,即向后兼容,提高代码的可扩展性和可维护性

必要条件

  • 必须有继承
  • 必须有虚函数覆盖
  • 必须有基类指针/引用指向子类对象

纯虚函数有什么作用?怎么实现?数 (应⽤于接⼝继承和实现继承)

  • 定义纯虚函数是为了定义接口,起到规范的作用,想要继承这个类就必须覆盖该函数
  • 实现方式是在虚函数声明的结尾加上=0即可

对于纯虚函数来说,我们其实是可以给它提供实现代码的,但是由于抽象类不能实例化,调⽤这个实现的唯⼀⽅式是在派⽣类对象中指出其 class 名称来调⽤。

虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表是怎么维护的

  • 虚函数表示针对类的,类的所有对象共享这个类的虚函数表

  • 每个对象内部都保存着一个指向该类虚函数表的指针vptr,

  • 每个对象的vptr存放地址都不同,但是都指向同一张虚函数表

  • 具体可以参考这里

如果类A是一个空类,那么sizeof(A)的值为多少?

结果为1,因为编译器需要区分这个空类的不同实例,分配一个字节,可以使得这个空类的不同实例拥有独一无二的地址

class A{}; sizeof(A) = 1; //空类在实例化时得到⼀个独⼀⽆⼆的地址,所以为 1.
class A{virtual Fun(){} }; sizeof(A) = 4(32bit)/8(64bit) //当 C++ 类中有虚函数的时候,会有⼀个指向虚函数表的指针(vptr)
class A{static int a; }; sizeof(A) = 1;
class A{int a; }; sizeof(A) = 4;
class A{static int a; int b; }; sizeof(A) = 4;

覆盖和重载有什么区别

  • 覆盖指的是派生类中重新定义的函数,其函数名、参数列表、返回类型与父类完全相同,只是函数体存在区别;覆盖只发生在类的成员函数中
  • 重载指的是两个函数具有相同的函数名、不同的参数列表、不关心返回值;当调用函数时,根据传递的参数列表来判断调用的是哪个函数;重载可以是类的成员函数;也可以是普通函数

RTTI是什么?其原理是什么?

RTTI即运行时类型识别,其功能由两个运算符实现:

  • typeid运算符,用于返回表达式的类型,可以通过基类的指针获取派生类的数据类型
  • dynamic_cast运算符,具有类型检查的功能,用于将基类的指针或引用安全地转换成派生类的指针或引用。
  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值