C/C++面试题分享「虚函数、多态、内存管理与软件调试篇」
🍓个人主页:个人主页
🍒系列专栏:C/C++基础与进阶
🔥 推荐一款模拟面试、刷题神器,从基础到大厂面试题👉点击跳转刷题网站进行注册学习
目录
1、C++中的多态是什么?
2、C++类中的虚函数是如何调用的?
3、构造函数能不能是virtual虚函数?
4、为什么一般情况下类的析构函数都要声明为virtual?哪种情况下不需要声明为虚函数?如何显式地声明一个不能被继承的类?
4.1、为什么一般情况下类的析构函数都要声明为virtual?
4.2、哪种情况下不需要声明为虚函数?
4.3、如何显式地声明一个不能被继承的类?
5、malloc和free,与new和delete有什么不一样?
6、什么是内存泄露?可能的原因有哪些?
7、从内存分区来看,C/C++内存越界一般包括哪几类内存越界?内存越界一般可能是什么操作触发的?
8、C/C++程序出异常时,调试程序的常用方法有哪些?
9、引发C/C++程序出异常的常见原因有哪些?大概地讲几个。
10、排查软件异常的常用方法有哪些?
之前有分享总结过一些C/C++笔试题,今天我们继续来讲述这方面内容。本文将讲述一些与C++多态、虚函数、内存管理及软件调试相关的笔试题,以供参考。
C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/category_11397492.html
1、C++中的多态是什么?
广义上的多态,包括编译时多态和运行时多态,编译时多态(静态多态)主要包含函数重载与模板,运行时多态(动态多态)是由继承及虚函数实现的动态绑定。
狭义上的多态,指的是运行时的动态绑定,将子类的对象赋值给父类的指针,调用虚函数时根据父类中的实际类型去确定要调用的函数。多态是C++中的核心概念,C++代码中导出都在使用。设计模式中的简单工厂模式是最典型的使用案例。
多态的示例代码如下所示:
// 基类(父类)class Base{public: Base() {}; virtual ~Base() {}; virtual void Func1() { cout << "call Base::Func1" << endl; };};// 派生类(子类)class Derived : public Base{public: Derived() {}; virtual ~Derived() {}; virtual void Func1() { cout << "call Derived::Func1" <Func1();
2、C++类中的虚函数是如何调用的?
通过C++类对象去调用C++类的虚函数过程为:先获取C++对象中虚函数表指针中的值(虚函数表指针变量的地址,就是C++对象的首地址),就是虚函数表的首地址,然后根据要调用目标虚函数在虚函数表中的位置偏移,找到虚函数表中存放目标虚函数的地址,该内存地址中存放就是目标虚函数的地址(虚函数的代码段地址),然后直接去call就可以了。
要看虚函数调用的完整过程,可以去查看汇编代码,汇编代码能直观地反映出整个过程。下面以如下的示例代码来讲述虚函数调用的完整过程。
// 定义类class CContact : public IContactPtr{ CContact(); ~CContact(); // ...... // 类中的虚函数func1 virtual func1(); // ......}; // 获取IContactPtr类指针IContactPtr* GetContactPtr(){ // 此处return一个子类CContact对象地址} // 通过类指针调用类的虚函数func1GetContactPtr()->func1();
如上所示,我们定义了一个继承于接口类IContactPtr的子类CContact,然后调用GetContactPtr接口获取一个父类的指针,指针中存放的是子类对象的地址,然后去调用虚函数func1。调用虚函数的func1的汇编代码如下:
先call函数 GetContactPtr获取CContact业务类指针,call完成后,CContact业务类指针
保存到eax寄存器中,下面来详细解释接下来的几句汇编代码:
(1)mov edx, [eax]
eax寄存器中存放的C++类对象首地址,该类对象首地址就是类中成员变量虚函数表指针变量的地址(虚函数表指针变量在内存排列上位于C++对象的首位),所以对eax取址得到的就是虚函数表指针变量中存放的虚函数表的首地址,将虚函数表的首地址存到edx寄存器中(第一次寻址)。
(2)mov ecx, [ebp+var_8]
将[ebp+var_8]内存中保存的C++类对象首地址再给到ecx寄存器中,是为了调用虚函数(C++类的成员函数IsExistLocalArchFile)传递类的this指针的,C++的汇编代码中是通过ecx寄存器传递this指针的。
(3)mov eax, [edx+140h]
目标虚函数在虚函数表中的偏移是140h,所以edx+140h就是目标虚函数在虚函数表中的内存地址,对edx+140h取址(即[edx+140h])得到的就是虚函数表中存放的目标虚函数的首地址(虚函数代码段的地址),然后将虚函数的首地址放置到eax寄存器中,接下来直接去call eax,就是去调用虚函数了。(第二次寻址)
3、构造函数能不能是virtual虚函数?
构造函数不能是虚函数!虚函数的调用要到虚函数表中找到函数地址去call的,而虚函数表的地址是存放在类对象中的虚函数表指针成员中的。
需要先执行构造函数去构造类对象后才能给类对象中的虚函数表指针成员赋值的。所以,没有调用构造函数,就不会有类对象,就不会有虚函数表指针存在,就不能去调用虚函数,所以,析构函数不能为虚函数。
这个问题提的比较有意思,我曾经问过一些工作多年的同事,他们都没回答上来,其实归根结底还是对虚函数的调用机制不太了解导致的。
4、为什么一般情况下类的析构函数都要声明为virtual?哪种情况下不需要声明为虚函数?如何显式地声明一个不能被继承的类?
4.1、为什么一般情况下类的析构函数都要声明为virtual?
一般情况下,我们会将基类的析构函数都声明为virtual虚函数,如下所示:
class CShape{public: CShape(); virtual ~CShape(); // 析构函数声明为virtual函数};
将被继承的类或可能被继承的类设置成virtual虚函数,这个与多态的场景有关。在多态中,将子类的对象地址赋值给父类的指针,在delete该父类指针时,因为父类指针中存放的是子类对象的地址,所以需要调用子类的析构函数去将子类对象析构掉。
如果父类的析构函数不声明为virtual的,那么delete父类指针时,会直接去调用父类的析构函数,没有调用子类的析构函数,导致子类对象没有被析构,导致内存泄漏。如果子类对象的析构函数中需要去delete其他内存资源或者释放其他资源,会导致更多的资源泄漏。
为啥父类的析构被声明为virtual后,在执行delete存放子类对象的父类指针时就会调用子类的析构函数呢?因为父类析构函数被设置为虚函数后,因为多态,子类中的虚函数表中父类的析构函数会被子类的析构函数替换掉,这样才能保证delete时调用到子类的析构函数。
比如如下的代码,定义了一个父类Base和子类Derived,定义了一个父类Base指针,将一个子类的对象赋给了它,然后去delete父类的指针:
// 基类class Base{public: Base() {}; virtual ~Base() { cout << "call Base::~Base" << endl; }; virtual void Func1() { cout << "call Base::Func1" << endl; };};// 派生类class Derived : public Base{public: Derived() {}; virtual ~Derived() { cout << "call Derived::~Derived" << endl; }; virtual void Func1() { cout << "call Derived::Func1" << endl; };};// 示例代码Base* pBase = new Derived;// ...delete pBase;
如果父类Base的析构不声明为virtual的,则执行delete pBase;时就会直接调用父类的析构函数~Base(),不会调用子类的析构函数~Derived()。
所以,如果一个类可能被继承,则需要将其析构函数定义为virtual虚函数,从而保证在动态多态的情况下子类的析构函数能被调用到。
4.2、哪种情况下不需要声明为虚函数?
不是所有的类都需要将析构函数设置为virtual虚函数的,因为类中包含虚函数,编译器会自动在类中增加一个虚函数表指针成员变量,还要维护虚函数表,这是有一定开销的。如果明确某个类不会被继承,则其析构函数不需要声明为virtual。
4.3、如何显式地声明一个不能被继承的类?
我们可以使用C++11中引入的final关键字去声明一个类不能被继承,即在定义类时设置final标记,比如:
class ClassA final{ /* ... */};
设置final标记后,该类就不能被继承了。如果被继承了,编译时会报错。
5、malloc和free,与new和delete有什么不一样?
malloc/free是C/C++语言中的标准库函数, new/delete是C++中的运算符。它们都可用于申请动态内存和释放动态内存。
malloc仅仅只能申请指定大小的内存,这对于创建C++类对象是不够的。对于C++对象,在创建时需要自动执行构造函数,在销毁时需要自动执行析构函数,这些只有new和delete才能做到。即new操作不仅能申请C++对象所需要的动态内存空间,还会触发构造函数的调用,delete操作会触发析构函数的调用,并将动态申请的内存释放掉。
因为new和delete是运算符,所以可以去重载这两个运算符,比如如下的代码:
class GdiplusBase{public: void (operator delete)(void* in_pVoid) { DllExports::GdipFree(in_pVoid); } void* (operator new)(size_t in_size) { return DllExports::GdipAlloc(in_size); } void (operator delete[])(void* in_pVoid) { DllExports::GdipFree(in_pVoid); } void* (operator new[])(size_t in_size) { return DllExports::GdipAlloc(in_size); }};
6、什么是内存泄露?可能的原因有哪些?
动态申请的内存在使用完成后没有释放,就会导致内存泄漏。有内存泄漏的代码块如果被频繁地执行,则会导致越来越多的内存被消耗掉,直到进程的虚拟内存空间被耗尽(一个进程的虚拟内存是有上限的,比如一个32位进程,系统给其分配的虚拟内存只有4GB),就会报Out of memory内存耗尽的异常。
如果内存泄漏的不多,没有将进程的内存耗尽,则在进程退出时操作系统会将泄漏的内存释放掉。
产生内存泄漏的原因可能有:
1)通过new/malloc申请的动态内存,在使用完后没有用delete/free去释放。
2)使用new动态申请的数组内存,只调用delete去释放,没有调用delete[]。比如如下所示:int* p = new int[10]; // 动态地申请了一段数组// ...if ( p != NULL ){ delete p; // 此处应该使用delete[] p;}
3)在多态调用中,没有将父类的析构函数设置为virtual。
在多态虚函数调用中,没有将父类的析构函数设置为virtual导致通过delete父类指针去释放子类对象时没有调用到子类的析构函数,导致子类对象没有析构(子类的内存没有释放),如果子类的析构函中有释放其他的内存资源(大家习惯在类的析构函数中去释放资源),会因为子类的析构函数没被调用到,导致更多的内存泄漏。这种情况下的内存泄漏比较隐蔽,特别是对于新手,很难察觉。
示例代码如下,定义了一个父类Base和子类Derived,定义了一个父类Base指针,将一个子类的对象赋给了它,然后去delete父类的指针:// 父类class Base{public: Base() {}; virtual ~Base() { cout << "call Base::~Base" << endl; }; virtual void Func1() { cout << "call Base::Func1" << endl; };};// 子类class Derived : public Base{public: Derived() {}; virtual ~Derived() { cout << "call Derived::~Derived" << endl; }; virtual void Func1() { cout << "call Derived::Func1" << endl; };};// 示例代码Base* pBase = new Derived;// ...delete pBase;
如果父类Base的析构不声明为virtual的,则执行delete pBase;时就会直接调用父类的析构函数~Base(),不会调用子类的析构函数~Derived()。
4)代码中抛出异常,导致delete内存的代码被跳过去了。
C++代码中使用throw抛出异常,在寻找异常处理代码时会自动进行栈展开,栈展开时会自动释放函数中的栈内存,但动态申请的堆内存是不会自动释放的。因为抛出了异常,可能导致delete堆内存的代码被跳过去了,从而产生了内存泄漏。当然这种内存泄漏是比较少见,只是遇到时要注意一下。
7、从内存分区来看,C/C++内存越界一般包括哪几类内存越界?内存越界一般可能是什么操作触发的?
C/C++中大部分软件异常都是内存相关问题引起的。存放数据的内存一般有栈内存区、堆内存区和全局内存区,所以一般内存越界包括栈内存越界、堆内存越界和全局内存越界。
触发内存越界的场景主要有两种,一种是执行memcpy时有越界,一种是遍历数组时有越界(数组下标超过数组的实际大小)。
8、C/C++程序出异常时,调试程序的常用方法有哪些?
调试C/C++程序的常用方法有:
1)使用IDE直接进行Debug调试和Release调试(单步调试、断点调试、添加数据断点等);
2)将IDE附加到进程上调试;
3)使用Windbg/gdb等调试器进行动态调试;
4)调用OutputDebugString将日志打印输出到调试器输出窗口中;
5)将日志输出到文件中的调试等。
9、引发C/C++程序出异常的常见原因有哪些?大概地讲几个。
引发程序出异常的常见原因有:(面试时讲出其中的若干种就可以了,不需要全部都说出来)
1)变量未初始化;
2)空指针与野指针;
3)内存越界;
4)线程栈溢出;
5)内存访问违例;
6)内存泄漏;
7)死循环;
8)死锁;
9)线程句柄泄露;
10)堆内存被破坏;
11)格式化符与待格式化的变量不一致;
12)LastError值被覆盖;
13)库与库之间不匹配;
14)第三库注入时第三方库发生异常。
关于引发C/C++程序出异常的常见原因详细分析,可以参见我之前写的长篇分析文章:
【C++进阶】C++软件异常的常见原因分析与总结(实战经验分享)https://blog.csdn.net/chenlycly/article/details/124996473
10、排查软件异常的常用方法有哪些?
排查软件异常的常用方法有:
1)直接调试(Debug调试、Release调试和附加到进程上调试);
2)输出打印日志排查;
3)历史版本比对法(找到最开始出问题的那个版本,查看svn上的相关时间点的提交记录,对修改的代码进行甄别);
4)代码分块注释;
5)使用Windbg/gdb静态分析dump文件(Windows系统用Windbg、Linux系统用gdb);
6)使用Windbg/gdb动态调试目标进程;
7)使用AddressSanitizer等专用内存分析工具进行分析。
关于排查C/C++程序异常的常见方法的详细说明,可以参见我之前写的长篇分析文章:
排查C++软件异常的常见思路与方法(实战经验总结)https://blog.csdn.net/chenlycly/article/details/120629327