面试之CPP基础知识-4


#什么是内存泄露,如何检测与避免

内存泄露

用new/malloc申请了内存,但是没有用delete/free释放内存。而其他程序就无法再申请该块空间

避免内存泄露的几种方式

  • 使用shared_ptr管理对象。
  • 将基类的析构函数声明为虚函数
  • 对象数组的释放用delete []
  • 有new就有delete,有malloc就有free,保证它们一定成对出现

检测工具

  • Linux下可以使用Valgrind工具
  • Windows下可以使用CRT库

#对象复用的了解,零拷贝的了解

对象复用

对象复用其本质是一种设计模式:Flyweight享元模式。

通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。

零拷贝

零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。

零拷贝技术可以减少数据拷贝和共享总线操作的次数。

在C++中,vector的一个成员函数**emplace_back()**很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造,效率更高。举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <vector>
#include <string>
#include <iostream>
using namespace std;

struct Person
{
    string name;
    int age;
    //初始构造函数
    Person(string p_name, int p_age): name(std::move(p_name)), age(p_age)
    {
         cout << "I have been constructed" <<endl;
    }
     //拷贝构造函数
     Person(const Person& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been copy constructed" <<endl;
    }
     //转移构造函数
     Person(Person&& other): name(std::move(other.name)), age(other.age)
    {
         cout << "I have been moved"<<endl;
    }
};

int main()
{
    vector<Person> e;
    cout << "emplace_back:" <<endl;
    e.emplace_back("Jane", 23); //不用构造类对象

    vector<Person> p;
    cout << "push_back:"<<endl;
    p.push_back(Person("Mike",36));
    return 0;
}
//输出结果:
//emplace_back:
//I have been constructed
//push_back:
//I have been constructed
//I am being moved.

#介绍面向对象的三大特性,并且举例说明

三大特性:封装 继承 多态

(1)继承

让派生类获得基类的属性和方法。

它可以使用现有类的所有功能,并在类中对这些功能进行扩展

例如,将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉、走路等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法

(2)封装

封装,也就是把客观事物封装成抽象的类,并且给类的属性和方法设置访问权限,例如:将公共的数据或方法使用public修饰,而不希望被访问的数据或方法采用private修饰。

(3)多态

同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为**(重载实现编译时多态,虚函数实现运行时多态)** 。

允许将子类类型的指针赋值给父类类型的指针

实现多态有二种方式:覆盖(override),重载(overload)。

覆盖:是指子类重新定义父类的虚函数的做法。

重载:是指允许存在多个同名函数,而这些函数的参数类型不同或者个数不同。

#C++的四种强制转换

MSDN . 强制转换运算符

static_castdynamic_castconst_castreinterpret_cast
一般用于数值转换用于指针和引用删除const特性指针间转换
不执行运行时类型检查执行检查删除volatile特性整数类型转指针
类指针可以向上转换向上/下转换不能用于函数指针指针转整数类型
不安全转换失败返回null不安全

#C++函数调用的压栈过程

跳过。

从代码入手,解释这个过程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

int f(int n) 
{
	cout << n << endl;
	return n;
}

void func(int param1, int param2)
{
	int var1 = param1;
	int var2 = param2;
	printf("var1=%d,var2=%d", f(var1), f(var2));//如果将printf换为cout进行输出,输出结果则刚好相反
}

int main(int argc, char* argv[])
{
	func(1, 2);
	return 0;
}
//输出结果
//2
//1
//var1=1,var2=2

当函数从入口函数main函数开始执行时,编译器会将我们操作系统的运行状态,main函数的返回地址、main的参数、mian函数中的变量、进行依次压栈;

当main函数开始调用func()函数时,编译器此时会将main函数的运行状态进行压栈,再将func()函数的返回地址、func()函数的参数从右到左、func()定义变量依次压栈;

当func()调用f()的时候,编译器此时会将func()函数的运行状态进行压栈,再将f()的返回地址、函数的参数从右到左、定义变量依次压栈。

从代码的输出结果可以看出,函数f(var1)、f(var2)依次入栈,而后先执行f(var2),再执行f(var1),最后打印整个字符串,将栈中的变量依次弹出,最后主函数返回。

#coredump 遇到过吗?怎么调试这个错误?

coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,~~这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。~~对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。

使用gdb命令对core文件进行调试

gdb <可执行文件路径> <core文件路径>

gdb 调试准备

#说说移动构造函数

移动构造函数使右值对象拥有的资源无需复制即可移动到左值中。可以搭配std::move将变量转为右值。

#C++中将临时变量作为返回值时的处理过程

  • 临时变量在函数调用过程中是先被压到程序进程的栈中
  • 当函数退出时,临时变量出栈,销毁临时变量,但是临时变量占用的内存空间没有被清空,可以被分配给其他变量
  • 所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了

所以建议不再函数中返回局部变量。 硬要返回用new个局部指针并外部接受后再delete、声明为static、用全局变量(推荐)

#如何获得结构成员相对于结构开头的字节偏移量

使用<stddef.h>头文件中的,offsetof宏

#静态类型和动态类型,静态绑定和动态绑定的介绍

  • 静态类型:对象在声明时采用的类型,在编译期确定;

  • 动态类型:通常是指一个指针或引用,具体所指对象的类型,是在运行期决定的;

  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;

  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

  • 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

#引用是否能实现动态绑定,为什么可以实现?

可以。因为引用在运行期确定其指向的对象,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。

注意只能调用虚函数。举个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class Base 
{
public:
	virtual void  fun()
	{
		cout << "base :: fun()" << endl;
	}
};

class Son : public Base
{
public:
	virtual void  fun()
	{
		cout << "son :: fun()" << endl;
	}
	void func()
	{
		cout << "son :: not virtual function" <<endl;
	}
};

int main()
{
	Son s;
	Base& b = s; // 基类类型引用绑定已经存在的Son对象,引用必须初始化
	s.fun(); //son::fun()
	b.fun(); //son :: fun()
	return 0;
}

需要说明的是虚函数才具有动态绑定,上面代码中,Son类中还有一个非虚函数func(),这在b对象中是无法调用的,如果使用基类指针来指向子类也是一样的。

#全局变量和局部变量有什么区别?

生命周期不同:全局变量的生命周期跟主程序一样;局部变量的生命周期超出大括号就不存在了;

全局变量保存在内存的全局存储区中,占用静态的存储单元;局部变量保存在栈中,只有在所在函数被调用时才动态地为变量分配存储单元。

#指针加减计算要注意什么?

指针加减本质是对其所指地址的移动,移动的步长跟指针的类型大小一致

遇到指针的计算,需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度,建议都转成10进制计算,计算结果除以类型长度取得结果

#怎样判断两个浮点数是否相等?

两个浮点数比较只能通过相减取绝对值后与预先设定的精度比较。浮点数与0的比较也应该注意。这是计算机浮点数表示法精度导致的问题。 https://blog.csdn.net/bitekong/article/details/119933898

#方法调用的原理(栈,汇编)

pass

  1. 机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈称为帧栈;帧栈可以认为是程序栈的一段,它有两个端点,一个标识起始地址,一个标识着结束地址,两个指针结束地址指针esp,开始地址指针ebp;

  2. 由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;每一个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了。

  3. 过程实现

① 备份原来的帧指针,调整当前的栈帧指针到栈指针位置;

② 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存;

③ 使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等。

④ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了

⑤ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。

⑥ 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理。

⑦ 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。

⑧ 弹出返回地址,跳出当前过程,继续执行调用者的代码。

  1. 过程调用和返回指令

① call指令

② leave指令

③ ret指令

#C++中的指针参数传递和引用参数传递有什么区别?底层原理你知道吗?

指针传递是将参数的内存地址传递给函数,需要使用*操作符对指针所指向的数据进行修改,从而改变实参的值。

引用传递是将参数的别名传递给函数,可以直接使用该别名来修改实参的数据。


  1. 指针参数传递本质上是值传递,它所传递的是一个地址值。

值传递过程中,被调函数的形参作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。

值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

  1. 引用参数传递过程中,被调函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。

被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。

因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

  1. 引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。

而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。

  1. 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。

指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。

符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。

https://zhuanlan.zhihu.com/p/130464385

#类如何实现只能静态分配和只能动态分配

如何定义一个只能在堆上(栈上)生成对象的类?

只能在堆上(动态分配)

方法:将析构函数设置为私有

原因:C++ 是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。

只能在栈上(静态分配)

方法:将 new 和 delete 重载为私有

原因:在堆上生成对象,其过程分为两阶段:第一阶段,使用 new 在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将 new 操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象。

#如果想将某个类用作基类,为什么该类必须定义而非声明?

派生类为了使用从基类继承而来的成员,必须知道他们的具体实现,而定义可以提供这些成员的具体实现。

来自GPT3.5。

#继承机制中对象之间如何转换?指针和引用之间如何转换?

  • 向上类型转换
    • 将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。
    • 内存里面:基类对象相当于只是换了个地址。换成了派生类对象中存储基类成员的内存地址。但是派生类对象中有的,基类没有的那些变量仍然留在了内存中。(保留了再次从基类转换成派生类的可能性)
  • 向下类型转换
    • 将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,所以需要使用dynamic_cast进行向下类型转换。(一般来说先向上转换后,才能使用向下转换,将基类重新变为派生类,执行派生类的额外方法)
    • 内存里面:相当于把基类起始地址赋值给派生类对象,因为基类有的成员,派生类中也一定有。

https://blog.csdn.net/baidu_35679960/article/details/80821222