23、必考 | 常见C++面试题知识点总结
1. 结构体和共同体的区别。
定义:
结构体struct:把不同类型的数据组合成一个整体,自定义类型。
共同体union:使几个不同类型的变量共同占用一段内存。
地址:
struct和union都有内存对齐,结构体的内存布局依赖于CPU、操作系统、编译器及编译时的对齐选项。
关于内存对齐,先让我们看四个重要的基本概念:
1.数据类型自身的对齐值:
对于char型数据,其自身对齐值为1,对于short型为2,对于int,float,double类型,其自身对齐值为4,单位字节。
2.结构体或者类的自身对齐值:其成员中自身对齐值最大的那个值。
3.指定对齐值:#pragma pack(n),n=1,2,4,8,16改变系统的对齐系数
4.数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中小的那个值。
常见数据类型及其长度:
注意long int和int一样是4byte,long double和double一样是8byte。(关于long double,ANSI C标准规定了double变量存储为 IEEE 64 位(8 个字节)浮点数值,但并未规定long double的确切精度。所以对于不同平台可能有不同的实现。有的是8字节,有的是10字节,有的是12字节或16字节。)
在标准c++中,int的定义长度要依靠你的机器的字长,也就是说,如果你的机器是32位的,int的长度为32位,如果你的机器是64位的,那么int的标准长度就是64位。经测试,在64位操作系统下,int的长度还是32位的。
从上面的一段文字中,我们可以看出,首先根据结构体内部成员的自身对齐值得到结构体的自身对齐值(内部成员最大的长度),如果没有修改系统设定的默认补齐长度4的话,取较小的进行内存补齐。
结构体struct:不同之处,stuct里每个成员都有自己独立的地址。sizeof(struct)是内存对齐后所有成员长度的加和。
共同体union:当共同体中存入新的数据后,原有的成员就失去了作用,新的数据被写到union的地址中。sizeof(union)是最长的数据成员的长度。
总结: struct和union都是由多个不同的数据类型成员组成, 但在任何同一时刻, union中只存放了一个被选中的成员, 而struct的所有成员都存在。在struct中,各成员都占有自己的内存空间,它们是同时存在的。一个struct变量的总长度等于所有成员长度之和。在Union中,所有成员不能同时占用它的内存空间,它们不能同时存在。Union变量的长度等于最长的成员的长度。对于union的不同成员赋值, 将会对其它成员重写, 原来成员的值就不存在了, 而对于struct的不同成员赋值是互不影响的。
2.static 和const分别怎么用,类里面static和const可以同时修饰成员函数吗。
static的作用:
对变量:
1.局部变量:
在局部变量之前加上关键字static,局部变量就被定义成为一个局部静态变量。
1)内存中的位置:静态存储区
2)初始化:局部的静态变量只能被初始化一次,且C中不可以用变量对其初始化,而C++可以用变量对其初始化。(详见:http://www.cnblogs.com/novice-dxx/p/7094690.html)
3)作用域:作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域随之结束。
注:当static用来修饰局部变量的时候,它就改变了局部变量的存储位置(从原来的栈中存放改为静态存储区)及其生命周期(局部静态变量在离开作用域之后,并没有被销毁,而是仍然驻留在内存当中,直到程序结束,只不过我们不能再对他进行访问),但未改变其作用域。
2.全局变量
在全局变量之前加上关键字static,全局变量就被定义成为一个全局静态变量。
1)内存中的位置:静态存储区(静态存储区在整个程序运行期间都存在)
2)初始化:未经初始化的全局静态变量会被程序自动初始化为0(自动对象的值是任意的,除非他被显示初始化)
3)作用域:全局静态变量在声明他的文件之外是不可见的。准确地讲从定义之处开始到文件结尾。
注:static修饰全局变量,并未改变其存储位置及生命周期,而是改变了其作用域,使当前文件外的源文件无法访问该变量,好处如下:(1)不会被其他文件所访问,修改(2)其他文件中可以使用相同名字的变量,不会发生冲突。**对全局函数也是有隐藏作用。**而普通全局变量只要定义了,任何地方都能使用,使用前需要声明所有的.c文件,只能定义一次普通全局变量,但是可以声明多次(外部链接)。注意:全局变量的作用域是全局范围,但是在某个文件中使用时,必须先声明。
对类中的:
1.成员变量
用static修饰类的数据成员实际使其成为类的全局变量,会被类的所有对象共享,包括派生类的对象。因此,static成员必须在类外进行初始化(*初始化格式: int base::var=10;)*,而不能在构造函数内进行初始化,不过也可以用const修饰static数据成员在类内初始化 。
特点:
-
- 不要试图在头文件中定义(初始化)静态数据成员。在大多数的情况下,这样做会引起重复定义这样的错误。即使加上#ifndef #define #endif或者#pragma once也不行。
- 静态数据成员可以成为成员函数的可选参数,而普通数据成员则不可以。
- 静态数据成员的类型可以是所属类的类型,而普通数据成员则不可以。普通数据成员的只能声明为 所属类类型的指针或引用。
2.成员函数
-
- 用static修饰成员函数,使这个类只存在这一份函数,所有对象共享该函数,不含this指针。
- 静态成员是可以独立访问的,也就是说,无须创建任何对象实例就可以访问。base::func(5,3);当static成员函数在类外定义时不需要加static修饰符。
- 在静态成员函数的实现中不能直接引用类中说明的非静态成员,可以引用类中说明的静态成员。因为静态成员函数不含this指针。
不可以同时用const和static修饰成员函数。
C++编译器在实现const的成员函数的时候为了确保该函数不能修改类的实例的状态,会在函数中添加一个隐式的参数const this*。但当一个成员为static的时候,该函数是没有this指针的。也就是说此时const的用法和static是冲突的。
我们也可以这样理解:两者的语意是矛盾的。static的作用是表示该函数只作用在类型的静态变量上,与类的实例没有关系;而const的作用是确保函数不能修改类的实例的状态,与类型的静态变量没有关系。因此不能同时用它们。
const的作用:
1.限定变量为不可修改。
2.限定成员函数不可以修改任何数据成员。
3.const与指针:
const char *p 表示 指向的内容不能改变。
char * const p,就是将P声明为常指针,它的地址不能改变,是固定的,但是它的内容可以改变。
3.指针和引用的区别,引用可以用常指针实现吗。
本质上的区别是,指针是一个新的变量,只是这个变量存储的是另一个变量的地址,我们通过访问这个地址来修改变量。
而引用只是一个别名,还是变量本身。对引用进行的任何操作就是对变量本身进行操作,因此以达到修改变量的目的。
(1)指针:指针是一个变量,只不过这个变量存储的是一个地址,指向内存的一个存储单元;而引用跟原来的变量实质上是同一个东西,只不过是原变量的一个别名而已。如:
int a=1;int *p=&a;
int a=1;int &b=a;
上面定义了一个整形变量和一个指针变量p,该指针变量指向a的存储单元,即p的值是a存储单元的地址。
而下面2句定义了一个整形变量a和这个整形a的引用b,事实上a和b是同一个东西,在内存占有同一个存储单元。
(2)可以有const指针,但是没有const引用(const引用可读不可改,与绑定对象是否为const无关)
(3)指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)
(4)指针的值可以为空,但是引用的值不能为NULL,并且引用在定义的时候必须初始化;
(5)指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。
(6)"sizeof引用"得到的是所指向的变量(对象)的大小,而"sizeof指针"得到的是指针本身的大小;
(7)指针和引用的自增(++)运算意义不一样;
指针传参的时候,还是值传递,试图修改传进来的指针的值是不可以的。只能修改地址所保存变量的值。
引用传参的时候,传进来的就是变量本身,因此可以被修改。
4.什么是多态,多态有什么用途。
- 定义:“一个接口,多种方法”,程序在运行时才决定调用的函数。
- 实现:C++多态性主要是通过虚函数实现的,虚函数允许子类重写override(注意和overload的区别,overload是重载,是允许同名函数的表现,这些函数参数列表/类型不同)。
多态与非多态的实质区别就是函数地址是早绑定还是晚绑定。如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并生产代码,是静态的,就是说地址是早绑定的。而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就属于晚绑定。
3.目的:**接口重用。**封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。
4.用法:声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。
补充一下关于重载、重写、隐藏(总是不记得)的区别:
Overload(重载):在C++程序中,可以将语义、功能相似的几个函数用同一个名字表示,但参数或返回值不同(包括类型、顺序不同),即函数重载。
(1)相同的范围(在同一个类中);
(2)函数名字相同;
(3)参数不同;
(4)virtual 关键字可有可无。
Override(重写):是指派生类函数覆盖基类函数,特征是:
(1)不同的范围(分别位于派生类与基类);
(2)函数名字相同;
(3)参数相同;
(4)基类函数必须有virtual 关键字。注:重写基类虚函数的时候,会自动转换这个函数为virtual函数,不管有没有加virtual,因此重写的时候不加virtual也是可以的,不过为了易读性,还是加上比较好。
Overwrite(隐藏):隐藏,是指派生类的函数屏蔽了与其同名的基类函数,规则如下:
(1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。
(2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。
补充一下虚函数表:
多态是由虚函数实现的,而虚函数主要是通过**虚函数表(V-Table)**来实现的。
如果一个类中包含虚函数(virtual修饰的函数),那么这个类就会包含一张虚函数表,虚函数表存储的每一项是一个虚函数的地址。如下图:
这个类的每一个对象都会包含一个虚指针(虚指针存在于对象实例地址的最前面,保证虚函数表有最高的性能),这个虚指针指向虚函数表。
注:对象不包含虚函数表,只有虚指针,类才包含虚函数表,派生类会生成一个兼容基类的虚函数表。
- 原始基类的虚函数表
下图是原始基类的对象,可以看到虚指针在地址的最前面,指向基类的虚函数表(假设基类定义了3个虚函数)
- 单继承时的虚函数(无重写基类虚函数)
假设现在派生类继承基类,并且重新定义了3个虚函数,派生类会自己产生一个兼容基类虚函数表的属于自己的虚函数表。
Derive class 继承了 Base class 中的三个虚函数,准确的说,是该函数实体的地址被拷贝到 Derive类的虚函数表,派生类新增的虚函数置于虚函数表的后面,并按声明顺序存放。
- 单继承时的虚函数(重写基类虚函数)
现在派生类重写基类的x函数,可以看到这个派生类构建自己的虚函数表的时候,修改了base::x()这一项,指向了自己的虚函数。
- 多重继承时的虚函数(Derived ::public Base1,public Base2)
这个派生类多重继承了两个基类base1,base2,因此它有两个虚函数表。
它的对象会有多个虚指针(据说和编译器相关),指向不同的虚函数表。
多重继承时指针的调整:
Derive b;
Base1* ptr1 = &b; // 指向 b 的初始地址
Base2* ptr2 = &b; // 指向 b 的第二个子对象
因为 Base1 是第一个基类,所以 ptr1 指向的是 Derive 对象的起始地址,不需要调整指针(偏移)。
因为 Base2 是第二个基类,所以必须对指针进行调整,即加上一个 offset,让 ptr2 指向 Base2 子对象。
当然,上述过程是由编译器完成的。
Base1* b1 = (Base1*)ptr2;
b1->y(); // 输出 Base2::y()
Base2* b2 = (Base2*)ptr1;
b2->y(); // 输出 Base1::y()
其实,通过某个类型的指针访问某个成员时,编译器只是根据类型的定义查找这个成员所在偏移量,用这个偏移量获取成员。由于 ptr2 本来就指向 Base2 子对象的起始地址,所以b1->y()
调用到的是Base2::y()
,而 ptr1 本来就指向 Base1 子对象的起始地址(即 Derive对象的起始地址),所以b2->y()
调用到的是Base1::y()
。
- 虚继承时的虚函数表
虚继承的引入把对象的模型变得十分复杂,除了每个基类(MyClassA和MyClassB)和公共基类(MyClass)的虚函数表指针需要记录外,每个虚拟继承了MyClass的父类还需要记录一个虚基类表vbtable的指针vbptr。MyClassC的对象模型如图4所示。
虚基类表每项记录了被继承的虚基类子对象相对于虚基类表指针的偏移量。比如MyClassA的虚基类表第二项记录值为24,正是MyClass::vfptr相对于MyClassA::vbptr的偏移量,同理MyClassB的虚基类表第二项记录值12也正是MyClass::vfptr相对于MyClassA::vbptr的偏移量。(虚函数与虚继承深入探讨)
对象模型探讨:
1.没有继承情况,vptr存放在对象的开始位置,以下是Base1的内存布局
m_iData :100
2.单继承的情况下,对象只有一个vptr,它存放在对象的开始位置,派生类子对象在父类子对象的最后面,以下是D1的内存布局
B1::vptr : 4294800 |
B2::vptr : 4294776 |
D::m_iData :300 |
\4. 虚拟继承情况下,虚父类子对象会放在派生类子对象之后,派生类子对象的第一个位置存放着一个vptr,虚拟子类子对象也会保存一个vptr,以下是VD1的内存布局
B1::vptr :4294864 |
VD1::vptr : 4294944 |
VD1::m_iData : 200 |
VD2::Unknown : 4294952 |
VD::m_iData : 500 |
B1::m_iData : 100 |
\5. 棱形继承的情况下,非虚基类子对象在派生类子对象前面,并按照声明顺序排列,虚基类子对象在派生类子对象后面
VD2::vptr : 4 294932 |
VD2::m_iData : 300 |
B1::vptr : 4294920 |
B1::m_iData : 100 |
补充一下纯虚函数:
- 定义: 在很多情况下,基类本身生成对象是不合情理的。为了解决这个问题,方便使用类的多态性,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;)纯虚函数不能再在基类中实现,编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。
- 特点:
1,当想在基类中抽象出一个方法,且该基类只做能被继承,而不能被实例化;(避免类被实例化且在编译时候被发现,可以采用此方法)
2,这个方法必须在派生类(derived class)中被实现;
- 目的:使派生类仅仅只是继承函数的接口。
补充一下纯虚函数:
-
定义:称带有纯虚函数的类为抽象类。
-
作用:为一个继承体系提供一个公共的根,为派生类提供操作接口的通用语义。
-
特点:1.抽象类只能作为基类来使用,而继承了抽象类的派生类如果没有实现纯虚函数,而只是继承纯虚函数,那么该类仍旧是一个抽象类,如果实现了纯虚函数,就不再是抽象类。
2.抽象类不可以定义对象。
补充一下多重继承和虚继承:
多重继承:
定义:派生类继承多个基类,派生类为每个基类(显式或隐式地)指定了访问级别——public
、protected
或 private
。
class Panda : public Bear, public Endangered {
}
构造:
-
- 派生类的对象包含每个基类的基类子对象。
- 派生类构造函数初始化所有基类(多重继承中若没有显式调用某个基类的构造函数,则编译器会调用该基类默认构造函数),派生类只能初始化自己的基类,并不需要考虑基类的基类怎么初始化。
- 多重继承时,基类构造函数按照基类构造函数在类派生列表中的出现次序调用。
析构:总是按构造函数运行的逆序调用析构函数。(基类的析构函数最好写成virtual,否则再子类对象销毁的时候,无法销毁子类对象部分资源。)假定所有根基类都将它们的析构函数适当定义为虚函数,那么,无论通过哪种指针类型删除对象,虚析构函数的处理都是一致的。
拷贝构造/赋值:如果要为派生类编写拷贝构造函数,则需要为调用基类相应拷贝构造函数并为其传递参数,否则只会拷贝派生类部分。
深拷贝与浅拷贝:
浅拷贝:默认的复制构造函数只是完成了对象之间的位拷贝,也就是把对象里的值完全复制给另一个对象,如A=B。这时,如果B中有一个成员变量指针已经申请了内存,那A中的那个成员变量也指向同一块内存。 这就出现了问题:当B把内存释放了(如:析构),这时A内的指针就是野指针了,出现运行错误。
深拷贝:自定义复制构造函数需要注意,对象之间发生复制,资源重新分配,即A有5个空间,B也应该有5个空间,而不是指向A的5个空间。
虚继承与虚基类:
定义:在多重继承下,一个基类可以在派生层次中出现多次。(派生类对象中可能出现多个基类对象)在 C++ 中,通过使用虚继承解决这类问题。虚继承是一种机制,类通过虚继承指出它希望共享其虚基类的状态。在虚继承下,对给定虚基类,无论该类在派生层次中作为虚基类出现多少次,只继承一个共享的基类子对象。共享的基类子对象称为虚基类。
用法:istream
和 ostream
类对它们的基类进行虚继承。通过使基类成为虚基类,istream
和 ostream
指定,如果其他类(如 iostream
同时继承它们两个,则派生类中只出现它们的公共基类ios的一个副本。通过在派生列表中包含关键字 virtual
设置虚基类:
class istream : public virtual ios { ... };
class ostream : virtual public ios { ... };
// iostream inherits only one copy of its ios base class
class iostream: public istream, public ostream { ... };
5.各个排序算法的时间复杂度和稳定性,快排的原理。
- 插入排序
每次将一个待排序的数据,跟前面已经有序的序列的数字一一比较找到自己合适的位置,插入到序列中,直到全部数据插入完成。
- 希尔排序
先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。由于希尔排序是对相隔若干距离的数据进行直接插入排序,因此可以形象的称希尔排序为“跳着插”
- 冒泡排序
通过交换使相邻的两个数变成小数在前大数在后,这样每次遍历后,最大的数就“沉”到最后面了。重复N次即可以使数组有序。
冒泡排序改进1:在某次遍历中如果没有数据交换,说明整个数组已经有序。因此通过设置标志位来记录此次遍历有无数据交换就可以判断是否要继续循环。
冒泡排序改进2:记录某次遍历时最后发生数据交换的位置,这个位置之后的数据显然已经有序了。因此通过记录最后发生数据交换的位置就可以确定下次循环的范围了。
- 快速排序
“挖坑填数+分治法”,首先令i =L; j = R; 将a[i]挖出形成第一个坑,称a[i]为基准数。然后j--由后向前找比基准数小的数,找到后挖出此数填入前一个坑a[i]中,再i++由前向后找比基准数大的数,找到后也挖出此数填到前一个坑a[j]中。重复进行这种“挖坑填数”直到i==j。再将基准数填入a[i]中,这样i之前的数都比基准数小,i之后的数都比基准数大。因此将数组分成二部分再分别重复上述步骤就完成了排序。
- 选择排序
数组分成有序区和无序区,初始时整个数组都是无序区,然后每次从无序区选一个最小的元素直接放到有序区的最后,直到整个数组变有序区。
- 堆排序
堆的插入就是——每次插入都是将新数据放在数组最后,而从这个新数据的父结点到根结点必定是一个有序的数列,因此只要将这个新数据插入到这个有序数列中即可。
堆的删除就是——堆的删除就是将最后一个数据的值
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
【C/C++面试必考必会】专栏,直击面试核心,精选C/C++及相关技术栈中面试官最爱的必考点!从基础语法到高级特性,从内存管理到多线程编程,再到算法与数据结构深度剖析,一网打尽。助你快速构建知识体系,轻松应对技术挑战。希望专栏能让你在面试中脱颖而出,成为技术岗的抢手人才。