C/C++教程

C++常见面试题总结

本文主要是介绍C++常见面试题总结,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

C++常见面试题总结2---面向对象

    • 面向对象和面向过程
    • 封装,实现代码模块化
    • 继承,实现代码复用
      • 继承方式比较
      • 继承和组合的区别?
      • 单继承和多继承的虚函数表结构
      • 多重继承时会出现什么问题?如何解决?
      • 如何让类不能被继承?
    • 多态
    • 虚函数和纯虚函数
      • 虚函数
      • 纯虚函数
      • 抽象类
      • 接口
      • 虚函数和纯虚函数的区别
      • 构造函数一般不定义为虚函数的原因
      • 析构函数一般定义为虚函数的原因

面向对象和面向过程

面向过程编程: 一种以执行程序操作的过程或函数为中心编写软件的方法。程序的数据通常存储在变量中,与这些过程是分开的。所以必须将变量传递给需要使用它们的函数。缺点:随着程序变得越来越复杂,程序数据与运行代码的分离可能会导致问题。例如,程序的规范经常会发生变化,从而需要更改数据的格式或数据结构的设计。当数据结构发生变化时,对数据进行操作的代码也必须更改为接受新的格式。查找需要更改的所有代码会为程序员带来额外的工作,并增加了使代码出现错误的机会。

面向对象编程: 以创建和使用对象为中心。一个对象(Object)就是一个软件实体,它将数据和程序在一个单元中组合起来。对象的数据项,也称为其属性,存储在成员变量中。对象执行的过程被称为其成员函数。将对象的数据和过程绑定在一起则被称为封装。

面向对象编程将数据成员和成员函数封装到一个类中,并声明数据成员和成员函数的访问级别(public、private、protected),以便控制类对象对数据成员和函数的访问,对数据成员起到一定的保护作用。而且在类的对象调用成员函数时,只需知道成员函数的名、参数列表以及返回值类型即可,无需了解其函数的实现原理。当类内部的数据成员或者成员函数发生改变时,不影响类外部的代码。

面向对象的三大特性:

  • 封装:将具体的实现过程和数据封装成一个函数,只能通过接口进行访问,降低耦合性。
  • 继承:子类继承父类的特征和行为,子类有父类的非 private 方法或成员变量,子类可以对父类的方法进行重写,增强了类之间的耦合性,但是当父类中的成员变量、成员函数或者类本身被 final 关键字修饰时,修饰的类不能继承,修饰的成员不能重写或修改。
  • 多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。

封装,实现代码模块化

隐藏对象的属性和实现细节,仅对外公开接口和对象进行交互,将数据和操作数据的方法进行有机结合。封装的目的:增强安全性和简化编程,使用者不必了解具体的实现细节,而只是通过外部接口及特定的访问权限来使用类的成员(函数是封装的一种形式)。保护或防止代码/数据被无意破坏。

  • 保护数据成员,不让类以外的程序直接访问或修改,只能通过提供的公共的接口访问,即数据封装。
  • 方法的细节对用户是隐藏的,只要接口不变,内容的修改不会影响到外部的调用者,即方法封装。

封装解决的问题: 一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。

访问权限限定符:

  • public(共有),public成员可以在类外直接访问。
  • protected(保护),protected成员在类外(可以看成是私有的)不能够访问。
  • private(私有),private成员在类外不能够访问。

继承,实现代码复用

继承可以使得子类具有父类的各种属性和方法,而不需要再次编写相同的代码。在子类继承父类的同时,可以重新定义某些属性,并重新某些方法,即覆盖父类的原有属性和方法,使其获得与父类不同的功能。

目的: 实现代码复用,是实现多态的必要条件;保持原有类特性的基础上进行扩展,增加新功能。

继承解决的问题: 继承最大的一个好处就是代码复用。还可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。

继承方式比较

在这里插入图片描述

继承和组合的区别?

类继承和对象组合是复用的两种最常用的技术。

继承: 白箱复用,继承是Is a 的关系,如Student继承Person,则说明Student is a Person。
优点: 容易进行新的实现;易于修改或扩展那些被复用的实现。
缺点: 基类内部细节对子类可见,在一定程度上破坏了封装性;子类和父类高度耦合,修改父类的代码,会直接影响到子类。继承层次过深过复杂,就会导致代码可读性、可维护性变差。

组合: 黑箱复用,组合是has-a关系,也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
优点: 封装性好,当前对象只能通过被包含对象的接口来对其进行访问,被包含对象的内部细节对外是不可见;当前对象与包含的对象是一个低耦合关系,如果修改被包含对象的类中代码不需要修改当前对象类的代码,代码维护性好;当前对象可以在运行时动态的绑定所包含的对象。
缺点: 容易产生过多的对象;为了能组合多个对象,必须仔细定义接口。

组合比继承更具灵活性和稳定性,所以在设计的时候优先使用组合。只有当下列条件满足时才考虑使用继承:

  • 子类是一种特殊的类型,而不只是父类的一个角色;
  • 子类的实例不需要变成另一个类的对象;
  • 子类扩展,而不是覆盖或者使父类的功能失效。

单继承和多继承的虚函数表结构

编译器处理虚函数表:

  • 编译器将虚函数表的指针放在类的实例对象的内存空间中,该对象调用该类的虚函数时,通过指针找到虚函数表,根据虚函数表中存放的虚函数的地址找到对应的虚函数。
  • 如果派生类没有重新定义基类的虚函数 A,则派生类的虚函数表中保存的是基类的虚函数 A 的地址,即基类和派生类的虚函数 A 的地址是一样的。
  • 如果派生类重写了基类的某个虚函数B,则派生的虚函数表中保存的是重写后的虚函数B的地址,即虚函数B有两个版本,分别存放在基类和派生类的虚函数表中。
  • 如果派生类重新定义了新的虚函数C,派生类的虚函数表保存新的虚函数 C 的地址。

(1)单继承无虚函数覆盖的情况:
基类的虚函数表:
在这里插入图片描述
派生类的虚函数表:
在这里插入图片描述
(2)单继承有虚函数覆盖的情况:

派生类的虚函数表:
在这里插入图片描述
(3)多继承无虚函数覆盖的情况:
class Derive : public Base1, public Base2, public Base3
派生类的虚函数表:(基类的顺序和声明的顺序一致)
在这里插入图片描述
(4)多继承有虚函数覆盖的情况:
派生类的虚函数表:
在这里插入图片描述

多重继承时会出现什么问题?如何解决?

多继承: 是指从多个直接基类中产生派生类。
多重继承容易出现的问题: 命名冲突和数据冗余问题,菱形继承。
对于派生类中继承的的成员变量 var1 ,从继承关系来看,实际上保存了两份,一份是来自基类 Base2,一份来自基类 Base3。因此,出现了命名冲突。

解决方法 1:声明出现冲突的成员变量来源于哪个类
void set_var1(int tmp) { Base2::var1 = tmp; } // 这里声明成员变量来源于类 Base2,当然也可以声明来源于类 Base3。

解决方法 2:虚继承
使用虚继承的目的:保证存在命名冲突的成员变量在派生类中只保留一份,即使间接基类中的成员在派生类中只保留一份。在菱形继承关系中,间接基类称为虚基类,直接基类和间接基类之间的继承关系称为虚继承。
实现方式:在继承方式前面加上 virtual 关键字。

如何让类不能被继承?

解决方法一: 借助 final 关键字,用该关键字修饰的类不能被继承。

class Base final
{
};

class Derive: public Base{ // error: cannot derive from 'final' base 'Base' in derived type 'Derive'

};

解决方法二: 借助友元、虚继承和私有构造函数来实现。

template <typename T>
class Base{
    friend T;
private:
    Base(){
        cout << "base" << endl;
    }
    ~Base(){}
};

class B:virtual public Base<B>{   //一定注意 必须是虚继承
public:
    B(){
        cout << "B" << endl;
    }
};

class C:public B{
public:
    C(){}   // error: 'Base<T>::Base() [with T = B]' is private within this context
};

具体原因:虽然 Base 类构造函数和析构函数被声明为私有 private,在 B 类中,由于 B 是 Base 的友元,因此可以访问 Base 类构造函数,从而正常创建 B 类的对象;

B 类继承 Base 类采用虚继承的方式,创建 C 类的对象时,C 类的构造函数要负责 Base 类的构造,但是 Base 类的构造函数私有化了,C 类没有权限访问。因此,无法创建 C 类的对象, B 类是不能被继承的类。

注意:在继承体系中,友元关系不能被继承,虽然 C 类继承了 B 类,B 类是 Base 类的友元,但是 C 类和 Base 类没有友元关系。

**这里采用虚继承的原因是:**直接由最低层次的派生类构造函数初始化虚基类。这是因为在菱形继承中,可能会存在对虚基类的多次初始化问题,为了避免出现该问题,在采用虚继承的时候,直接由最低层次的派生类构造函数直接负责虚基类 类的构造。如果不加virtual的话,在构造函数的顺序中,每个类只负责自己的直接基类的初始化,所以还是可以生成对象的。加上了virtual之后,C直接负责Base类的构造,但是Base类的构造函数和析构函数都是private,C无法访问,所以不能生成对象。

多态

可以简单概括为“一个接口,多种方法”,即用的是同一个接口,但是效果各不相同,多态有两种形式的多态,一种是静态多态,一种是动态多态。

多态解决的问题: 多态特性能提高代码的可扩展性和复用性。多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的if-else 语句等等。

C++多态分为:编译时多态性(静态多态)和运行时多态性(动态多态)。

  • 静态多态:是编译器在编译期间完成的,编译器会根据实参类型来选择调用合适的函数,如果有合适的函数可以调用就调,没有的话就会发出警告或者报错。通过函数重载或泛型编程实现。
  • 动态多态:是在程序运行时根据基类的引用(指针)指向的对象来确定具体该调用哪一个类的虚函数。通过虚函数实现。

编译时多态和运行时多态的区别:

时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。

动态多态的条件:

  • 基类中必须包含虚函数,且派生类一定要对基类中的虚函数进行重写。
  • 通过基类对象的指针或者引用调用虚函数。

重载、重写、隐藏

  • 重载: 是指同一可访问区内被声明几个具有不同参数列(参数的类型、个数、顺序)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
  • 隐藏: 是指派生类的函数屏蔽了与其同名的基类函数,主要只要同名函数,不管参数列表是否相同,基类函数都会被隐藏。若是想调用基类中的同名函数,可以加上类型名指明ex.Base::fun(1, 0.01);,这样就可以调用基类中的同名函数。
  • 重写(覆盖): 是指派生类中存在重新定义的函数。函数名、参数列表、返回值类型都必须同基类中被重写的函数一致,只有函数体不同。派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有 virtual 修饰。

重写和重载的区别:

  • 范围区别:对于类中函数的重载或者重写而言,重载发生在同一个类的内部,重写发生在不同的类之间(子类和父类之间)。
  • 参数区别:重载的函数需要与原函数有相同的函数名、不同的参数列表,不关注函数的返回值类型;重写的函数的函数名、参数列表和返回值类型都需要和原函数相同,父类中被重写的函数需要有 virtual 修饰。
  • virtual 关键字:重写的函数基类中必须有 virtual关键字的修饰,重载的函数可以有 virtual 关键字的修饰也可以没有。

隐藏和重写,重载的区别:

  • 范围区别:隐藏与重载范围不同,隐藏发生在不同类中。
  • 参数区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同;当参数不同时,无论基类中的函数是否被 virtual 修饰,基类函数都是被隐藏,而不是重写。

C语言能实现运行时多态吗?

C实现多态就仿照C++,具体实现要用到 结构体(虚函数表就是一个元素为虚函数指针的结构体) + 函数指针(记录函数名对应的函数地址)。这样处理存在一个缺陷就是:父子各自的函数指针之间指向的不是类似C++中维护的虚函数表而是一块物理内存,如果模拟的函数过多,就会难以维护。

虚函数和纯虚函数

虚函数

定义一个函数为虚函数,不代表函数为不被实现的函数。定义它为虚函数是为了允许用基类的指针来调用子类的这个函数。虚函数必须实现,如果不实现,编译器将报错。虚函数声明如下:

virtual ReturnType FunctionName(Parameter); 

虚函数带来的好处就是: 可以定义一个基类的指针, 其指向一个继承类, 当通过基类的指针去调用函数时, 可以在运行时决定该调用基类的函数还是继承类的函数。虚函数是实现运行时的多态(动态绑定)/接口函数的基础。

虚函数在c++中的实现机制: 用虚(函数)表和虚(表)指针。编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,称为虚表指针(vptr),这种数组成为虚函数表(virtual function table, vtbl)。虚函数表和类绑定,虚表指针和对象绑定。即类的不同的对象的虚函数表是一样的,但是每个对象都有自己的虚表指针,来指向类的虚函数表。 虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数。

虚函数表相关知识点:

  • 虚函数表存放的内容:类的虚函数的地址。
  • 虚函数表建立的时间:编译阶段,即程序的编译过程中会将虚函数的地址放在虚函数表中。
  • 虚表指针保存的位置:虚表指针存放在对象的内存空间中最前面的位置,这是为了保证正确取到虚函数的偏移量。

那些函数不能定义为虚函数?

  • 友元函数,它不是类的成员函数;
  • 全局函数;
  • 静态成员函数,它没有this指针;
  • 构造函数,拷贝构造函数,以及赋值运算符重载(可以但是一般不建议作为虚函数)。

纯虚函数

定义一个函数为纯虚函数,才代表函数没有被实现(即没有定义)。定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的派生类必须重写这个函数以实现多态。纯虚函数声明如下:

virtual void function()=0;

引入纯虚函数的目的: 为了安全,因为避免任何需要明确但是因为不小心而导致的未知的结果,提醒子类去做应做的实现;为了效率,不是程序执行的效率,而是为了编码的效率。
纯虚函数的意义: 让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的默认实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口,没有具体的实现方法。继承纯虚函数的派生类,如果没有完全实现基类纯虚函数,该派生类依然是抽象类,不能实例化对象。

在虚函数和纯虚函数的定义中不能有static标识符。 原因:被static修饰的函数在编译时候要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。

抽象类

包含纯虚函数的类是抽象类,抽象类不能定义实例,只能创建它的派生类的实例,但可以声明指向实现该抽象类的具体类的指针或引用。抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。
(1)抽象类的定义:称带有纯虚函数的类为抽象类。
(2)抽象类的作用:为派生类提供基类,定义了一个接口,派生类将具体实现在其基类中作为接口的操作。
(3)使用抽象类时注意:a、抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。b、抽象类是不能定义对象的。c、抽象类不能用作参数类型、函数返回类型或显式转换的类型。

接口

如果一个类里面只有纯虚函数,没有其他成员函数和数据成员,就是接口类。好的接口定义应该是具有专一功能性的,而不是多功能的,否则造成接口污染。如果一个类只是为实现了这个接口的中一个功能,但是却不得不去实现接口中的其他方法,就叫接口污染。

接口有如下特性:

接口除了可以包含方法之外,还可以包含属性、索引器、事件,而且这些成员都被定义为公有的。除此之外,不能包含任何其他的成员,例如:常量、域、构造函数、析构函数、静态成员。一个类可以继承多个接口和类(包括抽象类)。

接口是引用类型的,类似于类,和抽象类的相似之处有三点:

  • 不能实例化;
  • 包含未实现的方法声明;
  • 派生类必须实现未实现的方法,抽象类是抽象方法,接口则是所有成员(不仅是方法包括其他成员)。

抽象类和接口的区别:

  • 接口和抽象类的概念不一样。接口是对动作的抽象,抽象类是对根源的抽象。抽象类表示的是,这个对象是什么。接口表示的是,这个对象能做什么。
  • 抽象类可以有普通成员变量、静态方法、析构/构造函数,接口中不能有。
  • 继承类对于两者所涉及方法的实现是不同的。继承类对于抽象类所定义的抽象方法,可以不用重写,也就是说,可以延用抽象类的方法;而对于接口类所定义的方法或者属性来说,在继承类中必须要给出相应的方法和属性实现。
  • 接口可以用于支持回调,而继承并不具备这个特点。
  • 抽象类不能被密封,一个类一次可以实现若干个接口,但是只能扩展一个(抽象类)父类。
  • 如果抽象类实现接口,则可以把接口中方法映射到抽象类中作为抽象方法而不必实现,而在抽象类的子类中实现接口中方法。

既然有抽象类,为什么要用接口呢?

接口带来的最大好处就是避免了多继承带来的复杂性和低效性,并且同时可以提供多重继承的好处。接口和抽象类都可以体现多态性,但是抽象类对事物进行抽象,更多的是为了继承,为了扩展,为了实现代码的重用,子类和父类之间体现的是is-a关系;接口则更多的体现一种行为约束,一种规则,一旦实现了这个接口,就要给出这个接口中所有方法的具体实现,也就是说实现类对于接口中所有的方法都是有意义的。

在设计类的时候,首先考虑用接口抽象出类的特性,当你发现某些方法可以复用的时候,可以使用抽象类来复用代码。简单说,接口用于抽象事物的特性,抽象类用于代码复用。

虚函数和纯虚函数的区别

  • 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类;而只含有虚函数的类不能被称为抽象类。
  • 使用方式不同:虚函数可以被直接使用,也可以被子类重载以后以多态的形式调用;而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类只有声明而没有定义。
  • 虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,以多态的形式被调用,目的是提供一个统一的接口。
  • 对于虚函数来说,父类和子类都有各自的版本。以多态方式调用的时候动态绑定。纯虚函数只有子类的版本。
  • 对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;
  • 析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象。
  • 友元不是成员函数,只有成员函数才可以是虚拟的,因此友元不能是虚拟函数。但可以通过让友元函数调用虚拟成员函数来解决友元的虚拟问题。

构造函数一般不定义为虚函数的原因

  • 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
  • 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用,构造函数是在创建对象时自动调用的。
  • 从实现上考虑:虚函数表是在创建对象之后有的,因此不能定义成虚函数。
  • 从类型上考虑:在创建对象时需要明确其类型。

析构函数一般定义为虚函数的原因

析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。

这篇关于C++常见面试题总结的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!