1、内存管理
1.内存模型
栈:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。
堆:就是那些由new分配的内存块,其释放由程序员控制(一个new对应一个delete)。
自由存储区:堆是操作系统维护的一块内存,自由存储区是C++中通过new和delete动态分配和释放对象的抽象概念,和堆比较像,但不等价。
常量存储区:存储常量,不允许修改。
代码区:存放函数体的二进制代码。
2.内存泄漏
内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况;比如,new申请资源后没有delete,子类继承父类时,父类析构函数不是虚函数。
检测:一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。
解决办法:智能指针
3.内存对齐
什么是内存对齐?
在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。
为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
为什么要内存对齐?
需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的一个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。
而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
2、C++基础语法
1.在main执行之前和之后执行的代码
main函数执行之前,主要就是初始化系统相关资源:
-
设置栈指针
-
初始化静态static变量和global全局变量,即.data段的内容
-
将未初始化部分的全局变量初始化,short、int、long等为0,bool为false,指针为NULL等
-
全局对象初始化,在main之前调用构造函数,这是可能会执行前的一些代码
-
将main函数的参数argc、argv等传递给main函数,然后允许main函数
main函数执行之后:
-
全局对象的析构函数执行
-
可以使用atexit注册一个函数,它会在main之后执行
2.简述C++从代码到可执行二进制文件的过程
一个C++程序从源代码到可执行程序有四个过程:预处理,编译,汇编,链接。
-
预编译:这个过程主要的处理操作如下:
(1) 将所有的#define删除,并且展开所有的宏定义
(2) 处理所有的条件预编译指令,如#if、#ifdef
(3) 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。
(4) 过滤所有的注释
(5) 添加行号和文件名标识。
-
编译:这个过程主要的处理操作如下:
(1) 词法分析:将源代码的字符序列分割成一系列的记号。
(2) 语法分析:对记号进行语法分析,产生语法树。
(3) 语义分析:判断表达式是否有意义。
(4) 代码优化:
(5) 目标代码生成:生成汇编代码。
(6) 目标代码优化:
-
汇编:这个过程主要是将汇编代码转变成机器可以执行的指令。
-
链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接。
静态链接:是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。每当库函数的代码修改了,都需要重新进行编译链接形成可执行程序。但是运行速度快。
动态链接:是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。更新方便,但是每次都需要进行链接,性能会有一定损耗。
3.引用和指针的区别
指针:指针相当于一个变量,但是他和不同变量不一样,它存放的是其他变量在内存中的地址。指针名指向了内存的首地址。在32位平台下,无论指针类型是什么,sizeof p = 4,在64位平台下,sizeof p 都是8。指针在初始化后可以改变指向。
引用:引用是原来的变量实质上是一个东西,是原来变量的别名,引用只能有一级,引用不能为NULL而且在定义时必须初始化,在初始化之后不可再改变,sizeof引用得到的是引用所指向变量的大小。
在汇编层面,一些编译器把引用当成指针操作,因此引用也会占用空间,是否占用空间,应该结合编译器分析。
当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以。
在传递函数参数时,什么时候用指针,什么时候用引用?
-
需要返回函数局部变量的内存时候用指针,使用指针传参需要开辟内存,用完要记得释放指针内存,不然会内存泄漏,而返回局部变量的引用是没有意义的。
-
对栈空间大小敏感的时候(比如递归)使用引用。使用引用传递不需要创建临时变量,开销要更小。
-
类对象作为参数传递时使用引用,这是C++类对象传递的标准方式。
4.指针数组、数组指针、函数声明和函数指针
int *p[10]; // 指针数组,数组内有10个元素,每个元素都是指向int类型的指针int (*p)[10]; // 数组指针,是一个指针,指向一个int类型的数组int*p(int a); // 函数声明,返回值是int*类型的,参数是int类型int (*p)(int a); // 函数指针,指向一个返回值为int,参数为int的函数
5.说说new和malloc的区别,以及各自底层实现原理
-
new是操作符,而malloc是函数。
-
new在调用的时候先分配内存,在调用构造函数,释放的时候调用析构函数;而malloc没有构造函数和析构函数。
-
malloc需要给定申请内存的大小,返回的指针需要强转;new会调用构造函数,不用指定内存的大小,返回指针不用强转。
-
new可以被重载;malloc不行
-
new分配内存更直接和安全。
-
new发生错误抛出异常,malloc返回null。
malloc底层实现:当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用mmap()。malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。
new底层实现:关键字new在调用构造函数的时候实际上进行了如下的几个步骤:
-
首先调用operator new的标准库函数,分配足够大的原始为类型化的内存,创建一个新的对象
-
将构造函数的作用域赋值给这个新的对象(因此this指向了这个新的对象)
-
执行构造函数中的代码(为这个新对象添加属性)
-
返回新对象
6.说说内联函数和宏函数的区别
-
宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。
-
宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率
-
宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等
inline函数一般用于比较小的,频繁调用的函数,最好定义在头文件,而不仅仅是声明,因为编译器在处理inline函数时,需要在调用点内联展开该函数,所以仅需要声明函数是不够的。
7.说说const和define的区别
const用于定义常量;而define用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:
-
const生效于编译的阶段;define生效于预处理阶段。
-
const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接的操作数,并不会存放在内存中。
-
const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。
8.C++中const和static关键字的作用
static
-
定义全局静态变量和局部静态变量:在变量前面加上static关键字。初始化的静态变量会在数据段分配内存,未初始化的静态变量会在BSS段分配内存。直到程序结束,静态变量始终会维持前值。只不过全局静态变量和局部静态变量的作用域不一样;
-
定义静态函数:在函数返回类型前加上static关键字,函数即被定义为静态函数。静态函数只能在本源文件中使用;
-
在变量类型前加上static关键字,变量即被定义为静态变量。静态变量只能在本源文件中使用;
-
在c++中,static关键字可以用于定义类中的静态成员变量:使用静态数据成员,它既可以被当成全局变量那样去存储,但又被隐藏在类的内部。类中的static静态数据成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间。
-
在c++中,static关键字可以用于定义类中的静态成员函数:与静态成员变量类似,类里面同样可以定义静态成员函数。只需要在函数前加上关键字static即可。如静态成员函数也是类的一部分,而不是对象的一部分。所有这些对象的静态数据成员都共享这一块静态存储空间,不具有this指针。静态成员函数在类内定义,必须在类外初始化。不能访问类对象的非static成员变量和非static成员函数。
const
-
cosnt常量在定义时必须初始化,之后无法更改;
-
const形参可以接受const和非const类型的实参;
-
const成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数,不同类对其const数据成员可以不同,所以不能再类中声明初始化。
-
const成员函数:const对象不可以调用非const成员函数,非const对象都可以调用;不可以改变非mutable数据的值。
9.final和override关键字
override
当在父类中使用了虚函数时候,可能需要在某个子类中对这个虚函数进行重写:如果不使用override将虚函数的名字写错了,这时候override的作用就出来了,它指定了子类的这个虚函数是重写的父类的,如果名字错误的话,编译器是不会通过的。
class A{virtual void foo();};class B : public A{virtual void f00(); // 新增的函数virtual void foo() override;};
final
当不希望某个类被继承时,或不希望某个虚函数被重写时,可以在类名和虚函数后面添加final关键字,添加final关键字后被继承或重写时,编译器会报错。
10.volatile、mutable和explicit关键字
1、volatile
volotile关键字是一种类型修饰符,用它声明的类型变量表示可能被某些编译器未知的因素修改。因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。多线程应用中被几个任务共享的变量应该加 volatile;
2、mutable
mutable是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const的函数里面修改一些跟类状态无关的数据成员,那么这个数据成员就应该被mutalbe来修饰。
3、explicit
它用来修饰只有一个参数的类构造函数,以表明该构造函数是显式的,而非隐式的。当使用explicit修饰构造函数时,它将禁止类对象之间的隐式转换,以及禁止隐式调用拷贝构造函数。
class Foo {public:Foo(int x) { /* ... */ }};void bar(Foo f) {// ...}int main() {bar(42); // 隐式转换,调用Foo(42)}// 在这里,`bar`函数需要一个`Foo`对象,但传入了一个`int`,编译器会隐式调用`Foo(int)`构造函数。如果这不符合预期,加上`explicit`class Foo {public:explicit Foo(int x) { /* ... */ }};void bar(Foo f) {// ...}int main() {bar(42); // 错误:不能隐式转换int到Foobar(Foo(42)); // 正确:显式转换}
11.说说什么是野指针和悬空指针,怎么产生的,如何避免?
-
概念:野指针就是没有被初始化的指针,悬空指针就是最初指向的内存已经被释放的一种指针。
-
产生原因:释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。这些我们都要注意避免。
-
避免办法:
(1)初始化置NULL
(2)申请内存后判空
(3)指针释放后置NULL
(4)使用智能指针
12.C++的顶层const和底层const
-
顶层const:指的是const修饰的变量本身是一个常量,无法修改。
-
底层const:指的是const修饰的变量所指向的对象是一个常量,指的是所知变量。
int i = 10;const int ii = 11; // 顶层const: 对象 ii 本身是常量const int *a = &i; // 底层const: 指针对象 a 本身不是常量,而所存地址指向的对象是常量int const *b = &i; // 底层const: 与上一条语句写法不同,但都是表示指向常量的指针,即指针常量int* const c = &i; // 顶层const: 指针对象 c 本身是常量,即常量指针const int* const d = &i; // 左侧const是底层const,右侧const是顶层constconst int& e = i; // 底层const: 引用所存地址指向的对象是常量
-
常量指针:即指针本身是常量,表示指针存储的地址不可被修改
-
指针常量:即指向常量的指针,表示其所指向的对象不可被修改
int i = 10;int* const a = &i; //顶层 const => 常量指针const int* b = &i; //底层 const => 指针常量const int* const c = &i; //指向常量对象的常量指针,既有底层 const 又有 顶层 const
13.如何用代码判断大小端存储?
大端存储:字数据的高字节存储在低地址中。
小端存储:字数据的低字节存储在低地址中。
在socket编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输。
#include <iostream>using namespace std;int main(){int a = 0x1234;//由于int和char的长度不同,借助int型转换成char型,只会留下低地址的部分char c = (char)(a);if (c == 0x12)cout << "big endian" << endl;else if(c == 0x34)cout << "little endian" << endl;}
14.malloc、realloc、calloc的区别
void *malloc(unsigned int num_size);int *p = malloc(20*sizeof(int));// 省去了人为空间计算,malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的void *calloc(size_t n, size_t size);int *p = calloc(20, sizeof(int));// 给动态分配的空间分配额外的空间,用于扩充容量void realloc(void *p, size_t new_size);
15.C++函数调用的压栈过程
当函数从入口函数main函数开始执行时,编译器会将我们操作系统的运行状态,main函数的返回地址、main的参数,main函数中的变量、进行依次压栈。当main函数开始调用func函数时,编译器会将main函数的运行状态进行压栈,再将func函数的返回地址,func函数的参数从右到左,func定义变量依次压栈;当func函数调用f函数时,编译器此时会将func函数的运行状态进行压栈,再将返回地址,f函数的参数从右到左,f函数定义变量依次压栈,先执行f后,在执行func最后执行main。
16.全局变量和局部变量有什么区别?
生命周期不同:全局变量随主程序创建而创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在。
使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。
操作系统和编译器通过内存分配的未知可以区分两者:全局变量分配在全局数据端并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面。
17.C++中struct和class的区别
相同点:
-
两者都拥有成员函数、公有和私有部分
-
任何可以使用class完成的工作,同样可以使用struct完成
不同点
-
两者中如果不对成员指定公私有,struct默认是公有的,class默认是私有的
-
class默认private继承,而struct默认是public继承
3、面向对象
1.面向对象的三大特性
面向对象的三大特性是封装、继承、多态。
-
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行 交互。
-
多态:用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。实现多态,有二种方式,重写,重载。
-
继承:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。三种继承方式:
继承方式 | private继承 | protected继承 | public继承 |
---|---|---|---|
基类的private成员 | 不可见 | 不可见 | 不可见 |
基类的protected成员 | 变为private成员 | 仍为protected成员 | 仍为protected成员 |
基类的public成员 | 变为private成员 | 变为protected成员 | 仍为public成员仍为public成员 |
2.C++中的重载、重写的区别
重载:重载是指在同一范围定义中的同名成员函数才存在重载函数。主要特点是函数名相同,参数类型和数目所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。
重写:重写是指在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数,运行时根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
-
用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。
-
存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
-
多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性。
-
重写用虚函数来实现,结合动态绑定。
-
纯虚函数是虚函数再加上 = 0。
-
抽象类是指包括至少一个纯虚函数的类。
纯虚函数:virtual void fun()=0。即抽象类必须在子类实现这个函数,即先有名称,没有内容,在派生类实现内容。
重载与重写的区别:
-
重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
-
重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
-
重写关系中,调用方法根据对象类决定,重载根据调用时实参表与形参表的对应关系来选择函数体。
3.说说C++中构造函数有几种,分别有什么作用?
C++中的构造函数可以分为四类:默认构造函数,初始化构造函数,拷贝构造函数,移动构造函数。
1、默认构造函数和初始化构造函数。在定义类的对象的时候,完成对象的初始化工作。
class Student{public://默认构造函数Student(){num=1001;age=18; }//初始化构造函数Student(int n,int a):num(n),age(a){}private:int num;int age;};int main(){//用默认构造函数初始化对象S1Student s1;//用初始化构造函数初始化对象S2Student s2(1002,18);return 0;}
有了有参数的构造函数,编译器就不提供默认的构造函数了。
2、拷贝构造函数
#include "stdafx.h"#include "iostream.h"class Test{int i;int *p;public:Test(int ai,int value){i = ai;p = new int(value);}~Test(){delete p;}Test(const Test& t){this->i = t.i;this->p = new int(*t.p);}};//复制构造函数用于复制本类的对象int main(int argc, char* argv[]){Test t1(1,2);Test t2(t1);//将对象t1复制给t2。注意复制和赋值的概念不同return 0;}
赋值构造函数默认实现的是值拷贝(浅拷贝)3、移动构造函数用于将其他类型的变量,隐式转换为本类对象。下面的代码,将int的r转换为Student类型的对象,对象的age为r,num = 1004Student(int r){int num=1004;int age= r;}
4.浅拷贝和深拷贝的区别
浅拷贝
浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。
深拷贝
深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。
5.如果有一个空类,它会默认添加哪些函数?
-
无参的构造函数
-
拷贝构造函数
-
=运算符重载函数
-
析构函数
6.如何阻止一个类被实例化?
-
将类定义为抽象基类或者将构造函数私有化
-
不允许类外部创建类对象,只能在类内部创建对象
7.如何禁止程序自动生成拷贝构造函数?
-
为了组织编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们可以把它们设置为private,防止被调用。
-
类的成员函数和firend友元函数还是可以调用private函数,如果这个private函数只声明不定义,则会产生一个连接错误,所以我们定义一个基类,在基类中将拷贝构造函数和拷贝赋值函数设置成private,那么派生类中编译器中将不会自动生成这两个函数,且由于在基类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作。
8.C++中为什么拷贝构造函数必须传引用不能传值?
假设拷贝构造函数是按值传递参数的话,会发生什么情况呢?比如,如果有一个类MyClass,拷贝构造函数写成MyClass(MyClass other),那么在调用这个构造函数的时候,就需要传递一个实参的副本。也就是说,当传入一个对象时,需要调用拷贝构造函数来创建参数other,而这个参数本身又是一个传值参数,这就会导致无限递归调用拷贝构造函数。
在C++标准中,明确指出拷贝构造函数的参数必须是引用类型,否则会导致编译错误。这是因为编译器检测到这种情况,会阻止用户这样定义,避免运行时的问题。
9.类成员初始化方式?构造函数的执行顺序?为什么用成员初始化列表会快一些?
-
赋值初始化,通过在函数体内进行赋值初始化;列表初始化,在冒号后使用初始化列表进行初始化。这两种方式的主要区别在于:对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据的赋值表达式,那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。
-
一个派生类构造函数的执行顺序如下:虚拟基类的构造函数(多个虚拟基类则按照继承的的顺序执行构造函数);基类的构造函数(多个普通基类也按照继承的顺序执行构造函数);类类型的成员对象的构造函数(按照成员对象在类中的定义顺序);派生类自己的构造函数。
-
方法一是在构造函数中做赋值的操作,而方法二是做纯粹的初始化操作。我们都知道,C++的赋值是会产生临时对象的。临时对象的出现会降低程序的效率。
10.简述下向上转型和向下转型
-
子类转换为父类:向上转型,使用dynamic_cast(expression),这种转换相对来说安全不会有数据的丢失。
-
父类转换为子类:向下转型,可以使用强制类型转换,这种转换是不安全的,会导致数据的丢失,原因是父类的指针或者引用的内存中可能不包含子类的成员的内存。
11.简述一下C++中的多态
由于派生类重写基类方法,然后用基类引用或指针指向派生类对象,调用方法时候会根据所指对象的实际类型来调用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象是基类,就调用基类的函数,这就是多态。
#include <iostream>using namespace std;class Base{public:virtual void fun(){cout << " Base::func()" <<endl;}};class Son1 : public Base{public:virtual void fun() override{cout << " Son1::func()" <<endl;}};class Son2 : public Base{};int main(){Base* base = new Son1;base->fun();base = new Son2;base->fun();delete base;base = NULL;return 0;}// 运行结果// Son1::func()// Base::func()
子类1继承并重写了基类的函数,子类2继承基类但没有重新基类的函数,从结果分析子类 体现了多态性,那么为什么会出现多态性,其底层的原理是什么?
这里需要引出虚表和虚基表指针的概念。
虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表。
虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针。
实现多态的过程:
-
编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址
-
编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属的虚表。在构造时,根据对象的类型区初始化虚表指针,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数。
-
所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数。此时,编译器只看到了父类,并为父类对象初始化虚表指针,令他指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表。
-
派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
-
当派生类对基类的虚函数没有重写时,派生类的虚表指针指针指向的相当于基表指针,此时调用的是基类的虚函数,当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表,调用这个函数。
12.静态绑定和动态绑定
静态绑定: 静态绑定是在编译时确定调用的函数或方法,它是通过函数或方法的名称、参数数量、类型和顺序来匹配确定的。对于非虚拟函数和静态成员函数,默认情况下都是静态绑定。例如,在以下代码中:
class Base {public:void display() {std::cout << "Base class" << std::endl;}};class Derived : public Base {public:void display() {std::cout << "Derived class" << std::endl;}};int main() {Base baseObj;Derived derivedObj;baseObj.display(); // 静态绑定,输出 "Base class"derivedObj.display(); // 静态绑定,输出 "Derived class"}
动态绑定: 动态绑定是指在运行时确定调用的函数或方法,它是通过虚拟函数和指针/引用来实现的。虚拟函数是在基类中声明为虚拟的成员函数,在派生类中进行重写。通过使用基类的指针或引用调用虚拟函数时,实际调用的是派生类中重写的函数。例如,在以下代码中:
class Base {public:virtual void display() {std::cout << "Base class" << std::endl;}};class Derived : public Base {public:void display() {std::cout << "Derived class" << std::endl;}};int main() {Base* basePtr;Derived derivedObj;basePtr = &derivedObj;basePtr->display(); // 动态绑定,输出 "Derived class"}
13.基类的虚函数表存放在内存的什么区?虚表指针vptr的初始化时间?
C++中的虚函数表位于只读数据段,也就是C++内存模型中的常量区;而虚函数位于代码段,也就是C++内存模型的代码区。
由于虚表指针和虚函数密不可分,对于有虚函数或继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面。
14.构造函数为什么不能为虚函数?析构函数为什么要虚函数?
构造函数为什么不能为虚函数
-
从存储空间角度:虚函数对应一个指向vtable,这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable 中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)
-
从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
-
从实现上看,vbtl 在构造函数调用后才建立,因而构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。
析构函数为什么要虚函数?
虚析构:将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。如果基类的析构函数不是虚函数,在特定情况下会导致派生来无法被析构。
-
用派生类类型指针绑定派生类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
-
用基类类型指针绑定派生类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构基类,不会析构派生类对象,从而造成内存泄漏。为什么会出现这种现象呢,个人认为析构的时候如果没有虚函数的动态绑定功能,就只根据指针的类型来进行的,而不是根据指针绑定的对象来进行,所以只是调用了基类的析构函数;如果基类的析构函数是虚函数,则析构的时候就要根据指针绑定的对象来调用对应的析构函数了。
C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。
15.请你回答一下 C++ 类内可以定义引用数据成员吗?
c++类内可以定义引用成员变量,但要遵循以下三个规则:
-
不能用默认构造函数初始化,必须提供构造函数来初始化引用成员变量。否则会造成引用未初始化错误。
-
构造函数的形参也必须是引用类型。
-
不能在构造函数里初始化,必须在初始化列表中进行初始化。
16.哪些函数不能是虚函数?
-
构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化。
-
内联函数:内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
-
静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
-
友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
-
普通函数,普通函数不属于类的成员函数,不具有继承性,因此普通函数没有虚函数。
17.说说 C++ 中什么是菱形继承问题,如何解决
/**Animal类对应于图表的类A**/class Animal { /* ... */ }; // 基类{int weight;public:int getWeight() { return weight;};};class Tiger : public Animal { /* ... */ };class Lion : public Animal { /* ... */ }class Liger : public Tiger, public Lion { /* ... */ }int main( ){Liger lg ;/*编译错误,下面的代码不会被任何C++编译器通过 */int weight = lg.getWeight(); }
在我们的继承结构中,我们可以看出Tiger和Lion类都继承自Animal基类。所以问题是:因为Liger多重继承了Tiger和Lion类,因此Liger类会有两份Animal类的成员(数据和方法),Liger对象"lg"会包含Animal基类的两个子对象。
所以,你会问Liger对象有两个Animal基类的子对象会出现什么问题?再看看上面的代码-调用"lg.getWeight()"将会导致一个编译错误。这是因为编译器并不知道是调用Tiger类的getWeight()还是调用Lion类的getWeight()。所以,调用getWeight方法是不明确的,因此不能通过编译。
我们给出了菱形继承问题的解释,但是现在我们要给出一个菱形继承问题的解决方案。如果Lion类和Tiger类在分别继承Animal类时都用virtual来标注,对于每一个Liger对象,C++会保证只有一个Animal类的子对象会被创建。看看下面的代码:
class Tiger : virtual public Animal { /* ... */ };class Lion : virtual public Animal { /* ... */ }
18.说说什么是虚继承,解决什么问题,如何实现?
虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这将存在两个问题:其一,浪费存储空间;第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。虚继承可以解决多种继承前面提到的两个问题。
虚继承底层实现原理与编译器相关,一般通过虚基类指针和虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。
实际上,vbptr指的是虚基类表指针,该指针指向了一个虚基类表,虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。
19.说说C++中虚函数与纯虚函数的区别
-
虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而只含有虚函数的类不能被称为抽象类。
-
虚函数可以被直接使用,也可以被子类重载以后,以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类有声明而没有定义。
-
虚函数和纯虚函数都可以在子类中被重载,以多态的形式被调用。
-
虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,目的是提供一个统一的接口。
-
虚函数的定义形式:
virtual{}
;纯虚函数的定义形式:virtual { } = 0
;在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。