c++面试常见问题总结

近来在面试的过程,发现面试官在c++方面总是喜欢问及的一些相关问题总结,当时没怎么答出来,或者是答的不怎么全面,故而查询相关资料总结下。(后面实际工作会进行实时更新信息)

<一>c++虚函数方面
虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表指针被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

那么虚函数表的指针存放在哪里呢?

上述已经描述过了,存放在具体的实例对象中,通过虚函数指针来操控虚函数表,在进行多态的时候,需要用到,根据具体的实例对象就能确定所要调用的时哪一个具体类的具体方法。都是通过虚函数表来完成相应的操作的。在虚函数表中存放的是具体实例类重写的父类的虚函数方法地址。当调用时,根据具体的实例即可访问到具体方法。综上所述虚函数可以概括为以下几点:

a>虚函数表父类子类都有一个,并且子类根据继承将从父类将继承来的虚函数进行覆盖,通过基类指针指向派生类对象调用方法时,根据派生类对象类型调用其方法。(采用的是动态联编方式,在运行时实现)。

b>虚函数表好比是一个类似数组的东西,在类对象中存储vptr(虚函数指针),并指向虚函数表,因为其不属于方法,也不属于代码,所以不能存放在代码段,虚函数表存放在全局区。

c>虚函数表中存储的是虚函数的方法的地址,其大小在编译节点就已经确定好了,根据的继承关系,就能确定好虚函数表的大小。所以不用动态的分配内存,不在堆中。

d>虚函数表父子类各一份,并且子类覆盖父类继承来的虚函数。虚函数表指针放在对象内存开头的四个字节。如下可获取出来:

typedef void (*PFUN)();B b; PFUN* ptr = (PFUN*)(int*)*(int*)(&b);

e>通过父类指针指向子类对象,在调用的时候通过动态联编实现多态方法的调用。

f>析构函数也定义成虚析构函数,为了避免继承过程,在释放资源的时候,避免只时调用了基类的析构,从而导致了派生类的析构没有被调用,从而造成资源泄漏的影响。

g>构造函数不能被定义为虚函数,因为虚函数通过虚函数指针调用的,创建对象需要调用构造函数,此时对象还没创建完成,虚函数指针不存在。另外调用虚函数就须要通过 vtable来实现,但是此刻对象还没有实例化,也就是内存空间还没有,无法找到虚指针,所以构造函数不能是虚函数

<二>有关c++类占用内存的多少计算
在c++中一个空类所占内存的大小为1字节,例如:

class A{}; 计算其占用内存大小:sizeof(A) = 1;为什么一个空类的大小占用1字节呢?是因为类的实例化实质上就是在内存中分配一块独一无二地址,这样保证了实例是唯一存在的。所以,给空类分配一个字节,就相当于给实例分配了一个地址。如果不隐含包含一个字节的话,就不能进行实例化。当该类作为基类被继承的时候,系统会优化该类成为0字节,这个被称为空白基类最优化过程。

以下几种类型的类占用内存字节的大小,在32位系统下:

class A{}; sizeof(A) = 1;该大小上述已经具体化介绍。

//只包含普通成员函数的类,成员函数不占用内存空间

class B{

public:

B(){}

~B(){}

};

sizeof(B) = 1;

//包含普通成员变量的类,根据变量实际大小计算占用内存大小

class C{

public:

C(){}

~C(){}

private:

int c;

};

sizeof(C) = 4 ;

//包含虚函数的类,包含虚函数指针,虚函数指针变量本身占用内存大小

class D{

public:

D(){}

virtual ~D(){}

private:

int d;

};

sizeof(D) = 8; (64位机器,一共是16字节,指针变量&整型变量都是8字节)

//包含继承关系的类

class E:public D{

public:

E(){}

~E(){}

private:

int e;

};

sizeof(E) = 8;

从以上A到E几个实例来解释该类所占用内存大小的原因。

A类,是一个空类,因为空类也可以进行实例化,实例化就需要系统分配一块唯一地址的内存,所有系统隐含添加一个字节大小。

B类,虽然包含有构造函数和析构函数,但是在类中,成员函数是不占用内存的,另外该类并无成员变量,所以占用的内存大小和仍旧是1字节。同样是需要实例化所需要的。

C类,包含成员变量,系统给成员变量按照实际类型来分配具体内存大小的。例如,一个int型变量,占用4字节,所有sizeof(C) = 4.

D类,存在虚函数的类都有一个一维的虚函数表也也称为虚表,虚表里存放的就是虚函数的地址,因此,虚表是属于类的。这样的类对象的前四个字节是一个指向虚表的指针,类内部必须得保存这个虚表的起始指针。在32位的系统分配给虚表指针的大小为4个字节,得到类D的大小为4,所以在算上成员变量d所占用的字节数,sizeof(D) = 8;

E类,继承了D类,E类和D类共享一个虚函数指针,在E类中自身的一个成员变量e,加上继承D中的成员d,sizeof(E) = 8。(共享一个虚函数指针,所以在E类中不计数)

综上所述:

空类:占用一个字节大小,因为每一个类都需要实例化时分配一块独一无二的内存空间。

类内部:普通成员变量根据各自类型占用相应的内存大小,但是static成员变量以及静态方法均不占用类内存大小,其存放在全局区域。子类继承父类,则将父类的成员变量计算进入子类占用的大小。非虚的成员函数不占用内存大小,但是虚函数,需要维护一张虚函数表存放相应的虚函数地址,所以虚函数指针在类内部,指针占用相应的内存大小,并且继承之后,父子类共享此虚指针。(32位指针4字节,64位指针,8字节字节)

<三>c++中的虚继承的作用?
在c++中需要通一个继承是一个特性,常使用的都是一些继承虚函数,为了实现多态的过程。但是往往存在一种情况,为了提高代码的复用性,有一个基类,其自身有很多方法,是很多子类都能使用,所以往往就让子类直接将该类继承过来使用,避免了子类自己在实现一边,避免造成大量的代码冗余,维护也不方便。但是,如果大量的子类都继承同一个基类,在大量子类中在派生一个共同的子类,会出现一些问题的,比如从不同途径继承来的同名的数据成员在内存中有不同的拷贝造成数据不一致问题,占用内存大小。所以,使用虚继承实现将共同基类设置为虚基类。这时从不同的路径继承过来的同名数据成员在内存中就只有一个拷贝,同一个函数名也只有一个映射,从而避免调用该方法时出现数据二义性以及节省相应内存空间。

虚继承的原理?

虚继承的原理过程是通过虚基类指针和虚基类表来实现,一个虚基类指针占用四个字节的大小,虚基类表不占用类存储空间大小,在虚基类表中存储的是虚基类相对于派生类的偏移量,这样就根据偏移量找到虚基类成员。如果虚继承的类被继承,该派生类同样有一份虚基类指针的拷贝。这样就能保证虚基类中在子类中存在一份拷贝。避免有多分拷贝造成二义性。

语法:

class 派生类: virtual 基类1,virtual 基类2,...,virtual 基类n

{

...//派生类成员声明

};

如图所示:

 

构造函数的和析构函数的执行顺序

首先执行虚基类的构造函数,多个虚基类的构造函数按照被继承的顺序构造;

执行基类的构造函数,多个基类的构造函数按照被继承的顺序构造;

执行成员对象的构造函数,多个成员对象的构造函数按照申明的顺序构造;

执行派生类自己的构造函数;

析构以与构造相反的顺序执行;

注:

从虚基类直接或间接派生的派生类中的构造函数的成员初始化列表中都要列出对虚基类构造函数的调用。但只有用于建立对象时,在最后派生类的构造函数处调用虚基类的构造函数,而该派生类的所有基类中列出的对虚基类的构造函数的调用在执行中被忽略,从而保证对虚基类子对象只初始化一次。(在最派生类的成员初始化类表中,中间的初始化列表成员会被忽略,从而保证对虚基类子对象初始化一次)。

在一个成员初始化列表中同时出现对虚基类和非虚基类构造函数的调用时,虚基类的构造函数先于非虚基类的构造函数执行。

例如:如下代码所示:

一个派生类继承多个基类,多个基类中包含重复名称的方法。(普通继承)

class A{

public:

A(){cout<<"A is been called"<<endl;}

void fun(){cout<<"A fun been called"<<endl;}

};

class B{

public:

B(){cout<<"B is been called"<<endl;}

void fun(){cout<<"B fun been called"<<endl;}

};

class C:public A,public B{

public:

C(){cout<<"C is been called"<<endl;}

};

此种方法会导致同名方法存在多分拷贝,导致调用时会产生二义性,只能使用该种方法调用,如下:

int main(void){

C c;

//c.fun()错误,会产生二义性

c.A::fun();

c.B::fun();

return 0;

}

2,继承一个多层的类

class F{

public:

F(){cout<<"F been called"<<end;}

void gun(){cout<<"F gun been called"<<endl;}

};

class S1:public F{

public:

S1(){cout<<"S1 been called"<<endl;}

};

class S2:public F{

public:

S2(){cout<<"S2 been called"<<endl;}

};

class Son:public S1,public S2{

public:

Son(){cout<<"Son been called"<<endl;}

};

int main(){

Son son;

//son.gun();有二义性,因为该方法在S1和S2中各有一份备份

son.S1::gun();

son.S2::gun();

return 0;

}

虚继承就避免了这样的二义性,也节省了空间

(1)

class F{

public:

F(){cout<<"F been called"<<end;}

void gun(){cout<<"F gun been called"<<endl;}

};

class S1:public virtual F{

public:

S1(){cout<<"S1 been called"<<endl;}

};

class S2:public virtual F{

public:

S2(){cout<<"S2 been called"<<endl;}

};

class Son:public S1,public S2{

public:

Son(){cout<<"Son been called"<<endl;}

};

int main(){

Son son;

son.gun();//也可使用先前的方法调用

return 0;

}

<四>为什么构造函数不能写成虚函数,析构函数需要写成虚函数?以及什么情况下,子类的析构不会被调用?
因为虚函数的是通过虚函数指针操控虚函数表来实现的,且虚函数指针存放在实例对象的头部位置,在创建一个实例对象时,需要调用对应的构造函数,此刻对象还未生成,是不能调用虚函数的,故而构造函数不能为虚函数。(如果设置了构造函数为虚函数,编译时会报错)。

对于析构函数,是实例对象将要释放资源,需要调用调用析构函数,在继承关系中,为了防止在释放对象的时候调用析构函数,只调用了基类的析构而没有对派生类的析构进行调用,所以,将基类的析构函数进行虚化,从而保证基类和派生类的析构函数都被调用,确保释放其所占用的资源。(调用析构顺序,虚析构函数,调用基类析构的时候,子类对象已经全部销毁)

当基类指针指向子类对象,但是子类析构函数不是虚函数,当调用析构函数的时候,子类的析构函数是不会被调用的。

<五>volatile作用
访问寄存器要比访问内存要块,因此CPU会优先访问该数据在寄存器中的存储结果,但是内存中的数据可能已经发生了改变,而寄存器中还保留着原来的结果。为了避免这种情况的发生将该变量声明为volatile,告诉CPU每次都从内存去读取数据。

注:一个参数可以即是const又是volatile的吗?可以,一个例子是只读状态寄存器,是volatile是因为它可能被意想不到的被改变,是const告诉程序不应该试图去修改他。

<六>析构函数能抛出异常吗
答案:肯定是不能。
C++标准指明析构函数不能、也不应该抛出异常。C++异常处理模型最大的特点和优势就是对C++中的面向对象提供了最强大的无缝支持。那么如果对象在运行期间出现了异常,C++异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象(也即对象超出了它原来的作用域),并释放对象原来所分配的资源, 这就需要调用这些对象的析构函数来完成释放资源的任务,所以从这个意义上说,析构函数已经变成了异常处理的一部分。如果在析构函数中抛出了异常,可能会导致析构函数没有完全执行,从而导致某些对象所占用的资源没有被回收掉,从而导致资源泄漏。

<七>避免在基类的构造函数和析构函数中调用虚函数?
例如:

class A{

public:
A(){
fun();
}
virtual void fun(){
cout<<"Afun"<<endl;
}
};

class B:public A{

public:
virtual void fun(){
cout<<"Bfun"<<endl;
}
};

int main(){

B b;

return 0;
}


如上所示,调用结果是输出Afun,这是因为在创建对象b的时候,因为B继承A,在进行构造函数的调用的时候,优先调用基类的构造函数,此时的派生类对象尚未完成初始化,此刻虚函数指针还未完成初始化,不能够去检索对应的虚函数表,所以此时进行构造调用的时候为基类的方法。同样析构中的虚函数,等价于调用本类中的方法,从而失去虚函数的效果多态性,无实际存在的意义,(本身析构的调用顺序跟构造相反)。

构造函数的调用顺序是从基类到派生类,逐层构造。在构造的过程中,vptr被指向本层的vtable。而虚函数的行为依赖于vptr。因此,在本层构造函数中,编译器无法获知派生类的任何信息,因此无法形成正确的vtable访问。

<.八>c++中const的用法介绍
在c++模型中,const关键字通常使用的是防止变量对象意外的改变的功能,即只读变量,在c++中const可以用来修饰的对象有变量,函数返回值,函数参数,以及函数本身,下面介逐一介绍其方式;

const修饰变量:通常是用来定义个常量的属性,表示在该变量在使用范围内是一个常量,不能进行修改的,如果强制修改的话,会出现错误。例如:const int a = 10;

const修饰函数参数:在函数的参数传递中有三种类型的传递方式,值传递,指针传递,引用传递,只有值传递的时候会出现临时变量的产生,其余两种的传递都是相当于调用所传的对象的本身。在这三种传递方式中,需要注意的是,在和const结合使用的时候的一些注意事项:

函数参数为传入参数,不管是指针传递和引用传递,为了防止意外更改该参数,加上const修饰,可以起到保护作用。例如:void fun(const int *p) or void fun(const int & x).

函数参数为传出参数,此时的参数为输出参数,不能使用const进行修饰,否则,此刻该参数将失去输出参数的功能。例如:void fun( char* in,char * out ) or void fun(char* in,char* & out).

const修饰函数的返回值:

当函数的返回值为指针,使用const修饰,表明函数的返回的指针指向的内容是不可变,但是指针本身的指向是可以改变,并且,返回的指针必须使用一个对应的const修饰的指针进行接收,

例如:const char* GetString(), const char* ptr = GetString()

当函数的返回值为值传递的方式,使用const没有价值的,因为返回的是一个临时对象。

如果函数返回值是引用,需要格外注意的是,根据实际情况来区分是要获取该对象的一份拷贝,还是该对象的一个别名使用。要根据实际情况做判断。通常参数返回引用为了使用在类赋值函数中使用,用于链式表达式的调用,a=b=c;具体可参考string类的赋值函数。

const修饰函数:

在类中,任何不会修改成员的函数都应该定义成const成员函数,如果在const成员函数中修改了成员,会出现错误。通过此种类型提高程序的健壮性。有关const成员函数的几个特性:

const对象只能访问const成员函数,非const对象两者都可访问;

const对象的成员是不可修改,但是通过指针维护的对象是可以修改的

const成员函数不可以修改对象成员,不管对象是否具有const属性,在编译是以是否修改成员为依据,进行检查

如果一个成员被mutable修饰,那个任何方式都可以修改该成员,即便是const成员函数,也可以修改他

例如:

#include <iostream>
#include <string.h>
using namespace std;
//const修饰函数的返回值
const char* getString(char* str){

char *p = str;
return p;

}

void GetInfo(const char* src, char* dst){

memcpy(dst,src,strlen(src));

}

class A{

public:
A(int a,int b,int c):a(a),b(b),c(c){
}
~A(){}


//非const方法,都可以修改
void fun(int a,int b,int c){

cout<<"修改之前"<<endl;
cout<<"this->a "<<this->a<<endl;
cout<<"this->b "<<this->b<<endl;
cout<<"this->c "<<this->c<<endl;
//this->a = a; //const成员不可更改
this->b = b;
this->c = c;
cout<<"修改之后"<<endl;
cout<<"this->a "<<this->a<<endl;
cout<<"this->b "<<this->b<<endl;
cout<<"this->c "<<this->c<<endl;
}

//const成员方法
void gun(int a,int b,int c) const{
cout<<"修改之前"<<endl;
cout<<"this->a "<<this->a<<endl;
cout<<"this->b "<<this->b<<endl;
cout<<"this->c "<<this->c<<endl;
//cosnt成员不可以修改成员
//this->a = a; //本身是cosnt成员,不能修改
//this->b = b; //普通成员,但是在const函数中不可修改
this->c = c; //使用了mutable修饰,任何地方丢可修改
cout<<"修改之后"<<endl;
cout<<"this->a "<<this->a<<endl;
cout<<"this->b "<<this->b<<endl;
cout<<"this->c "<<this->c<<endl;

}
private:
const int a;
int b;
mutable int c;

};


int main(int argc, char const *argv[]) {

//此刻返回的指针的内容是不可更改的
const char* p = getString("liux");
cout<<"p = "<<p<<endl;
char c[12] = {"0"};
char *dst = c;
GetInfo(p,dst);
cout<<"dst = "<<dst<<endl;

A* a = new A(1,2,3);
a->fun(7,8,9);
a->gun(10,11,12);

const A *a1= new A(11,22,33);
//a1->fun(0,0,0);//const对象只能访问const成员
a1->gun(21,31,41);

delete a;

return 0;
}

<九>static关键字修饰总结
static关键字修饰变量,表示该变量为一个静态变量,并且全局共享(在全局作用于中定义,并且变量作用范围仅限于定义改变量的文件中)

static 关键字在函数体中修饰变量,表示该变量只进行一次初始化,之后每次调用进入该函数,该变量的值仍旧是上一次调用时的值,如果更能该了个变量的值,就保持了该值,拥有一种持久化保存的性质。而函数体中普通的变量生命周期在函数返回时会被销毁,之后从新调用的时候才会重新定义。

static在类中修饰数据成员,表示该变量不属类中的某个实例,所有的实例共享此变量,即该成员隶属类所有,修改变量需要在类外进行初始化,实际使用中,尽量避免在.h文件初始化该变量,容易造成重复定义。除此之外,类的静态变量可以被类的const成员方法合法更改,见如下代码演示:

static 修饰的类成员方法的地址,可以直接使用普通的函数指针来存储,而普通的成员方法的地址必须使用类成员函数指针来存储,另外,静态方法只能访问静态成员,详情将如下代码所示:

注:static修饰的函数在内存中只有一份,而普通函数在每个调用者中都维持一份拷贝。

以上几种总结方式示例代码如下:

#include <iostream>
using namespace std;


class A{

public:
A(){}
~A(){}

void gun(int m){
cout<<"gun修改前: "<<val<<endl;
val = m;
cout<<"gun修改前: "<<val<<endl;
}
//const成员函数可以更改静态成员变量的值
void fun(int m) const{
cout<<"fun修改前: "<<val<<endl;
val = m;
cout<<"fun修改前: "<<val<<endl;
}

//静态成员可以作为静态成员方法的默认参数,普通方法不允许
static void hun(int m = val){
cout<<"hun() = "<<m<<endl;
/*
静态方法只能访问静态成员,不能访问非静态成员,否则会报错,如下
cout<<" hun() nal = "<<nal<<endl;
*/
}

//静态方法和普通方法的函数指针
static void s_fun(){ cout<<"++++++++++++++"<<endl;}
void f_fun(){cout<<"******************"<<endl;}

//函数内使用static修饰变量让其持久化
void last(){
static int lt = 888;
cout<<"lt value is "<<lt--<<endl;

}

private:
static int val;
int nal;

};

//在类外初始化静态成员变量,注尽量在.cpp文件中初始化静态成员变量,不然头文件的相互包含很容易引起定义冲突的错误
//该出是为了实现方便,都在.cpp文件中
int A::val = 100;

int main(){
A* a = new A();
//通过const成员变量修改静态成员变量
//a->fun(222);

//使用静态成员变量作为静态方法的默认参数
a->hun();

/**
静态成员函数的地址可以使用普通的函数指针来存储,而普通成员函数的地址
必须使用成员函数指针来存储,如下所示:
**/
void (*s_ptr)() = &A::s_fun;
void (A::*f_ptr)() = &A::f_fun;
f_ptr = &A::f_fun;
s_ptr(); //通过普通的函数指针可以直接调用类中的静态方法
(a->*f_ptr)(); //通过类成员函数指针调用类中的成员方法,利用对象调用成员方式调用函数指针成员方法
//============================================
/*
以下写法错误,涉及到运算符的优先级,如下几种写法,因括号优先级最高,编译是报错,正确写法附上所示
a->*f_ptr(); or (a->*f_ptr()());报错:must use ‘.*’ or ‘->*’ to call pointer-to-member function in ‘f_ptr (...)’, e.g. ‘(... ->* f_ptr) (...)’
a->(*f_ptr)();报错:invalid use of unary ‘*’ on pointer to member
*/
//============================================


//函数内使用static修饰变量持久化
int i;
for(i = 0;i < 5;++i){
a->last();
}
return 0;
}

<十>explcit关键字
该关键字用来修饰单参或者除了第一个参数其余都是取胜参数的构造函数,避免构造函数进行隐式转化。

例如:

class A{

public:

explcit A(int i){n = i;}

void print(){cout<<"n = "<<n<<endl;}

private:

int n;

};

int main(){

A a(1);

a.print();

//此时因为构造函数被explcit修饰,不能隐士转化,如下不能使用此方法调用,否则会报错。

//A b = 2;

// b.print();

return 0;

}

<十一>流操作符重载为什么返回引用
在程序中,流操作符>>和<<经常连续使用。因此这两个操作符的返回值应该是一个仍旧支持这两个操作符的流引用。其他的数据类型都无法做到这一点。所以返回引用仍旧保持相应的操作。
注意:除了在赋值操作符和流操作符之外的其他的一些操作符中,如+、-、*、/等却千万不能返回引用。因为这四个操作符的对象都是右值,因此,它们必须构造一个对象作为返回值。这样其自身在使用这些运算符操作室,作为左值从而将右值计算进来。

<十二>c++的初始化列表在什么情况使用。
有三种情景需要使用初始化列表:

1>当类中存在类数据成员(包含继承的情景),需要带类数据成员的构造函数来进行数据的初始化。

2>类中有const修饰的类数据成员或普通数据成员,需要使用初始化列表(因为const修饰的成员是只读性,不能初始化作为左值来进行赋值操作,例如,不能在构造函数值使用=进行赋值)。

3>子类需要初始化父类的私有成员,通过初始化列表显示(且只能显示)调用父类的构造函数进行初始化

<十三>c++默认无参构造跟拷贝构避坑
如图:

 

使用中,结合实际情况,显示自己实现对应的拷贝构造函数,避免上图中的错误理论。


————————————————
原文链接:https://blog.csdn.net/qq_26105397/article/details/80585613

 

posted @ 2023-06-23 17:16  imxiangzi  阅读(100)  评论(0编辑  收藏  举报