这里汇总一些C/C++开发岗的常见面试八股题,都属于比较基础、偏理论性的题目。换句话说,如果这些题目答不上来,可能会给面试官留下的基础不好的印象,尤其是科班生哈。
废话不多说,直接开始。
一、C/C++篇
1. 基础中的基础篇
简述C和C++的区别
难度:⭐ 被考频率:⭐⭐⭐
如果你面试的是C++开发岗,有些面试官会把这个问题作为第一个问题。C和C++的区别细究起来的话太多了,因此,我们只要回答它们最大的,也是最主要的区别就好了。如果面试官想知道其余细节的话会继续追问的。
C和C++的区别如下:
思想上:C是面向过程的,它的主要特点是函数。编程思想是将问题分解成不同的步骤,并调用函数来依次实现这些步骤。
C++是面向对象的,它的主要特点是类和对象。编程思想是将数据和数据操作封装成不同的类,通过创建这些类的对象并调用对象的成员函数来实现对数据模型的操作。应用上:C的应用更偏底层,常常用于嵌入式开发、驱动开发等直接与硬件交互的领域。
C++由于它优秀的面向对象机制,在大型应用程序的开发方面也表现出色。C++是对C的扩充。除了添加了面向对象机制以外,C++还添加的机制常用到的有:类模板、异常处理、运算符重载、标准模板库(STL)、命名空间(namespace)、流(stream)等等。
C程序从源程序到二进制机器代码的过程和gcc指令
难度:⭐⭐ 被考频率:⭐⭐⭐
举例:gcc编译源代码hello.c的过程。
1. 预处理(Preprocessing):gcc -E hello.c -o hello.i
处理所有预编译指令,即源代码文件中以“#”开头的指令,具体为:
a. 展开宏定义#define。
b. 处理条件编译指令#ifdef。
c. 处理文件包含指令#include。
d. 删除注释"//"和"/* */"。
e. 添加行号和文件标识,便于编译器在编译阶段产生错误和警告提示时能够显示行号。
2. 编译(Compilation):gcc -S hello.i -o hello.s
对预处理后的文件进行词法分析、语法分析、语义分析、中间代码的生成和优化,最后得到汇编代码文件。
a. 词法分析(Lexical analysis):词法分析器会从左到右逐个字符读入源程序,按照词法规则将源代码分割成一个个单词(Token),检查词法错误,并输出二元组<单词类别,单词属性>方便后续编译过程的引用。
b. 语法分析(Syntax analysis):识别由词法分析器输出的单词符号序列,构造一棵语法树。语法树指出了词法单元流的语法结构,可以判断是否符合语法规范。常见的语法分析方法分为自下而上和自上而下两大方法。
c. 语义分析(Semantic analysis):使用语法树和单词符号表中的信息,进一步检查源程序是否满足语言定义的语义约束。这一步分析的时静态语义,也就是在编译期间能分析的语义,而动态语义指在运行期间才能确定的语义。
d. 中间代码生成:根据语义分析的输出,生成类机器语言的中间代码,如三地址代码。
e. 中间代码优化:改进中间代码的质量。
f. 目标代码生成:将中间代码映射成目标语言(汇编语言)。
3. 汇编(Assembly):gcc -c hello.s -o hello.o
汇编器将汇编代码翻译成可执行的机器码。每一条汇编语句对应一条机器指令,因此汇编器的工作就是根据汇编指令和机器指令的对照表一一翻译过来即可。
4. 链接(Linking):gcc hello.o -o hello
链接器将多个汇编后得到的机器码文件链接,从而生成一个可执行程序。分为静态链接和动态链接两种方式。
a. 静态链接:在链接可执行文件时,链接器会找到所有被用到的源代码,将它们复制并组合起来形成一个可执行文件。优点是由于可执行文件中已经具备了执行程序需要的全部内容,因此执行时运行速度较快。缺点是如果有多个可执行程序需要引用同一个目标文件,每个可执行程序都会复制一份该文件的副本,造成空间浪费;另外,如果某个目标文件被修改了,则所有引用该文件的可执行程序都需要重新编译。
b. 动态链接:程序被拆分成独立的部分存储,只有运行时才链接在一起形成完整的程序。优点是即使多个程序依赖同一个动态链接库,也不需要将这个库复制多份,而是所有程序共享这个库;此外在程序更新时只更新被修改的库,不需要重新链接所有程序。缺点是因为将链接过程推迟到程序运行时期,所以会对程序性能产生损失。
C程序的内存管理
难度:⭐⭐ 被考到频率:⭐⭐⭐⭐
C或C++语言编写的程序在处理机上运行,通常被分成五段:
栈区:存放函数中声明的局部变量、函数的形参和返回值。地址空间“向下减少”。
堆区:保存动态分配的内存区域,可由程序员向操作系统申请和自行释放。
静态区(全局区):存储全局变量和静态变量。静态区内存程序开始时创建,直到程序运行结束后才会被释放。
常量区:保存常量,在程序运行期间不能被修改的量,如字符串常量"abcd"。
代码区:存放程序代码,二进制机器指令形式,只读。
补充:一个由C语言程序编译得到的可执行二进制文件,从硬盘上被加载到内存空间上运行的过程,内存的分配和管理机制。
一个C语言编译的可执行二进制文件,通常被分为三段:代码段(text)、数据段(data)、堆栈段(BSS)。
装载到内存上被分为五段:栈区(stack)、堆区(heap)、未初始化变量(BSS)、初始化变量(data)、代码段(text)。
可执行文件中,text段存放二进制程序;data段存放静态初始化的数据,即赋初值的全局变量和static变量;BSS段存放未初始化的数据,即没有赋初值的全局变量和static变量,在可执行文件中BSS段并不占用实际空间,而是只记载该区大小的数值,程序载入时菜实际分配空间,且该控件由系统初始化为零。
应用程序加载到内存空间时,操作系统根据可执行文件中header中的内容,为text段、data段、为BSS段分配相应的内存空间,将文件中
data段和text段内容拷贝到内存中,将BSS段初始化为零,同时为堆区和栈区分配空间并维护。
堆和栈的区别
难度:⭐⭐ 被考到频率:⭐⭐⭐⭐⭐
堆 | 栈 |
---|---|
用户自己申请 | 系统分配 |
由不连续的内存块构成的链表,大小可以调整 | 连续空间,大小固定 |
速度慢,会产生碎片 | 速度快,不会产生碎片 |
向高地址增长 | 向低地址增长 |
由库函数(malloc/realloc/free、new/delete)提供服务 | 由系统提供服务 |
什么叫内存泄漏
难度:⭐⭐ 被考到频率:⭐⭐
内存泄漏,通常指堆内存泄漏,程序员向系统申请任意大小的堆内存块,使用完毕必须进行显式的内存释放,否则该内存不能再次被使用。即使用malloc、realloc、new申请的空间,必须使用free、delete进行释放。
什么叫作用域
2. C++特性篇
C++的三大特征
难度:⭐ 被考到频率:⭐⭐
继承、多态、封装。
继承:一个对象可以继承另一类对象的特征和能力。目的是避免公用代码的重复开发,减少代码和数据冗余。
多态:为不同数据类型的实体提供统一的接口,程序运行时,相同的消息可能会送给多个不同的类别之对象,而系统可依据对象所属类别,引发对应类别的方法,而有不同的行为
封装:把客观的事物抽象成一个类,就是将数据和方法打包在一起,加以权限的区分,达到保护并安全使用数据的目的。
静态多态和动态多态
难度:⭐⭐ 被考到频率:⭐⭐
多态分为静态多态和动态多态。
静态多态:在编译期间决定程序的执行过程。包括函数重载和泛型编程,泛型编程包括函数模板和类模板。
动态多态:在程序运行时根据被引用对象的实际类型判断调用哪个方法。包括虚函数。
C++中重载、重写(覆盖)和隐藏的区别
难度:⭐⭐ 被考到频率:⭐⭐
- 重载(overload):同一作用域存在的名称相同、参数类型或参数数目不同的函数。在函数调用时根据不同的参数来决定具体调用哪个函数。
class A {
...
void fun(int);
void fun(double, double);
...
}
- 重写(覆盖)(override)
派生类中的函数覆盖基类中的同名函数,要求两个函数具有相同的参数个数、参数类型和返回值类型,且基类中的函数必须是虚函数。重写指的是重写基类函数中的函数体。
class A { //父类
public:
virtual int fun(int a) {...}
}
class B : public A { //子类
public:
virtual int fun(int a) override {...} //重写,加override可以确保是重写父类的函数
}
- 隐藏(hide)
在某些情况下,派生类中的函数屏蔽了基类中的同名函数的现象。如果想调用基类的函数必须加作用域限定符。具体情况有:
(1)派生类和基类中具有同名函数,两个函数参数列表相同,且基类的函数没有被声明称虚函数。
class A { //父类
public:
void fun(int a) {...}
};
class B : public A { //子类
public:
void fun(int a) {...} //隐藏父类的fun函数
};
int main() {
B b;
b.fun(2); //调用B中的fun函数
b.A::fun(2); //调用A中fun函数
return 0;
}
(2)派生类和基类中具有同名函数,两个函数参数列表不同,无论基类的函数是不是虚函数都会被隐藏。
class A { //父类
public:
void fun(int a) {...}
};
class B : public A { //子类
public:
void fun(char* a) {...} //隐藏父类的fun函数
};
int main() {
B b;
b.fun(2); //报错,调用B中的fun函数,但参数类型出错
b.A::fun(2); //调用A中fun函数
return 0;
}
虚函数
难度:⭐⭐ 被考到频率:⭐⭐⭐
基类和派生类中可以出现名字相同、参数个数和参数类型都相同的函数,直接调用时编译器会让派生类函数覆盖基类函数,或者通过添加作用域限定符来调用基类函数。因此,
下面的例子中,基类和派生类中具有同名函数display()。
#include <iostream>
using namespace std;
//定义基类
class Point {
protected:
float x, y;
public:
Point(float x = 0, float y = 0); //构造函数
void display();
};
Point::Point(float a, float b) : x(a), y(b) {}
void Point::display() {
cout << "[x, y] = [" << x << ", " << y << "]" << endl;
}
//定义派生类,继承自父类Point
class Circle : public Point {
private:
float radius;
public:
Circle(float x = 0, float y = 0, float r = 0); //构造函数
void display(); //与基类中同名的函数
};
Circle::Circle(float a, float b, float r) : Point(a, b), radius(r) {}
void Circle::display() {
cout << "[x, y] = [" << x << ", " << y << "]; r = " << radius << endl;
return;
}
int main() {
Point p(1, 1); //定义一个基类对象
Circle c(5, 5, 2.5); //定义一个派生类对象
Point* pt; //定义一个基类指针
pt = &p; //指针指向基类对象
pt->display(); //调用同名函数
pt = &c; //指针指向派生类对象
pt->display(); //调用同名函数
return 0;
}
输出结果。
[x, y] = [1, 1]
[x, y] = [5, 5]
分析输出结果可以发现,第一行调用的基类的成员函数,第二行虽然指针指向了派生类,但由于指针的类型时基类的,调用的依旧是基类的成员函数。如果我们希望指针指向什么类的对象,就调用该类的成员函数,则需要将基类的display函数声明为虚函数。
修改代码,其他地方不修改,只是在基类Point中的声明display函数时添加关键字virtual,如下。
//定义基类
class Point {
protected:
float x, y;
public:
Point(float x = 0, float y = 0); //构造函数
virtual void display();
};
... ...
运行结果如下。
[x, y] = [1, 1]
[x, y] = [5, 5]; r = 2.5
在设定虚函数之前,基类指针本应该指向基类对象,如果指向派生类对象,则必须进行指针类型转换,将派生类的指针转换成基类指针,因此通过该指针也只能调用派生类对象中的基类部分。
设定虚函数之后,派生类的基类部分取代了原本基类中的虚函数,因此即使基类指针指向派生类对象,也会调用派生类中的成员函数。
虚函数的原理
难度:⭐⭐⭐ 被考到频率:⭐⭐
实现原理:虚函数表+虚表指针
虚函数表(vtbl):是一个数组,每个元素都用来存储虚函数的地址。每个类有一个自己的虚函数表,由于类中虚函数的个数可以在编译时期就能确定,因此虚函数表的大小在编译时期就可以确定。虚函数表是全局共享的,存储在全局数据区,在编译时完成构造,全局可用。
虚表指针(vptr):编译器为每个类对象添加一个隐藏成员,隐藏成员保存了一个指向虚函数表的指针,
未完待续……