面试中常见的 C++ 问题汇总

以前秋招,整理过一版 C++ 问题汇总,这次社招,重新梳理一遍。

1 语法基础

C++ 和 C 有什么区别?

  • C++ 是面向对象的语言,而 C 是面向过程的语言

  • C++ 引入类的概念,而 C 中没有

  • C++ 引入引用的概念,而 C 中没有

  • C++ 引入函数重载的特性,而 C 中没有

  • C++ 引入 new/delete 运算符,取代 C 中的 malloc/free 库函数

相比于 C++98,C++11 有哪些新特性?

  • 引入 Lambda 表达式用于创建匿名函数

  • 引入自动类型推导 autodecltype

  • 引入 = default 生成默认构造函数,= delete 禁止使用拷贝构造函数

  • 引入 nullptr 关键字,用于解决 NULL 的二义性问题

  • 引入范围 for 循环

  • 引入列表初始化

  • 引入 shared_ptrunique_ptr 等智能指针

  • 引入右值引用,将引用绑定到右值,如临时对象或字面量

  • 引入线程库

structclass 有什么区别?

  • 成员的默认访问权限:struct 的成员默认为 public 权限,class 的成员默认为 private 权限

  • 默认继承权限:struct 的继承按照 public 处理,class 的继承按照 private 处理

对于一个频繁使用的短小函数,应该使用什么来实现?有什么优缺点?

应该使用 inline 内联函数,即编译器将 inline 内联函数内的代码替换到函数被调用的地方。

优点:

  • 在内联函数被调用的地方进行代码展开,省去函数调用的时间,从而提高程序运行效率

  • 相比于宏函数,内联函数在代码展开时,编译器会进行语法安全检查或数据类型转换,使用更加安全

缺点:

  • 代码膨胀,产生更多的开销

  • 如果内联函数内代码块的执行时间比调用时间长得多,那么效率的提升并没有那么大

  • 如果修改内联函数,那么所有调用该函数的代码文件都需要重新编译

  • 内联声明只是建议,是否内联由编译器决定,所以实际并不可控

#defineinline 有什么区别?

  • #define 宏函数在预处理阶段展开,而 inline 内联函数在编译阶段展开

  • #define 宏函数不检查参数类型,而 inline 内联函数检查参数类型,使用更加安全

const 关键字有什么作用?

  • 修饰变量时,表示该变量的值在其生命周期内只读,不能被改变

  • 修饰指针:int * const

  • 修饰指针所指向的对象:const int *

  • 修饰引用所绑定的对象:const int &

  • 修饰函数的引用形参时,可以保护实参不被函数修改

  • 修饰非静态成员变量时,不能在类定义处初始化,必须通过构造函数初始化列表进行初始化

  • 修饰静态成员变量时,不能在类内部初始化,一般在类外部进行初始化

  • 修饰成员函数时,表示该函数不应修改非静态成员,但并不可靠,因为指针所指对象可能会被修改

#defineconst 有什么区别?

  • 编译器处理方式不同:#define 宏是在预处理阶段展开,不能对宏定义进行调试,而 const 常量是在编译阶段使用

  • 类型和安全检查不同:#define 宏没有类型,不做任何类型检查,仅仅是代码展开,可能产生边际效应等错误,而 const 常量有具体类型,在编译阶段会执行类型检查

  • 存储方式不同:#define 宏仅仅是代码展开,在多个地方进行字符串替换,不会分配内存,存储于程序的代码段中,而 const 常量会分配内存,但只维持一份拷贝,存储于程序的数据段中

  • 定义域不同:#define 宏不受定义域限制,而 const 常量只在定义域内有效

explicit 关键字有什么作用?

可以用单个实参来调用的构造函数都定义了从形参类型到实参类型的隐式转换,这种转换往往都是非预期的,所以使用 explicit 关键字对构造函数进行修饰,从而避免由构造函数定义的隐式转换。

extern关键字有什么作用?

  • extern 修饰变量或函数时,表示变量或函数的定义在其他文件中,提示编译器在其他模块中寻找其定义

  • extern C 时,提示编译器在编译函数时按照 C 的规则去翻译相应的函数名,如果按照 C++ 的规则,函数名会被翻译得变得面目全非,因为 C++ 支持函数的重载。

static关键字有什么作用?

  • 修饰局部变量时,使得该变量在静态存储区分配内存;只能在首次函数调用中进行首次初始化,之后的函数调用不再进行初始化;其生命周期与程序相同,但其作用域为局部作用域,并不能一直被访问

  • 修饰全局变量时,使得该变量在静态存储区分配内存;在声明该变量的整个文件中都是可见的,而在文件外是不可见的

  • 修饰函数时,在声明该函数的整个文件中都是可见的,而在文件外是不可见的,从而可以在多人协作时避免同名的函数冲突

  • 修饰成员变量时,所有的对象都只维持一份拷贝,可以实现不同对象间的数据共享;不需要实例化对象即可访问;不能在类内部初始化,一般在类外部初始化,并且初始化时不加 static

  • 修饰成员函数时,该函数不接受 this 指针,只能访问类的静态成员;不需要实例化对象即可访问

sizeofstrlen 之间有什么区别?

  • sizeof 属于运算符,不是库函数,其结果在编译时期计算得到,因此不能用来得到动态分配的内存大小,而 strlen 属于库函数,其结果在运行期间计算得到

  • sizeof 参数可以是任何数据或数据类型,而 strlen 的参数只能是字符指针,且该指针指向结尾为 \0 的字符串

assert 有什么用处?

assert 是一种仅在 debug 版本中使用的宏函数,用于检查不该发生的情况,可以看作是一种在任何系统状态下都可以安全使用的无害测试手段;可以通过 #define NDEBUG 来关闭 assert,放在 <cassert> 头文件之前。

变量的声明和定义有什么区别?

  • 声明仅仅是把变量类型等信息提供给编译器,并不为其分配内存空间,而定义需要为变量分配内存空间

  • 变量可以在多处声明,如外部变量 extern,但只能在一处定义

指针和引用有什么区别?

  • 指针是一种对象,用来存放某个对象的地址,占用内存空间,而引用是一种别名,不占用内存空间

  • 指针可以声明为空,之后进行初始化,普通指针可以随时更换所指对象,而引用必须在声明的时候初始化,而且初始化后不可改变

  • 指针包含指向常量的指针和常量指针,而引用不包含常量引用,但包含对常量的引用

右值引用有什么作用?

右值引用的主要目的是为了实现转移语义和完美转发,消除两个对象交互时不必要的对象拷贝,也能够更加简洁明确地定义泛型函数。

类型转换分为哪几种?各自有什么样的特点?

  • static_cast:用于基本数据类型之间的转换、子类向父类的安全转换、void* 和其他类型指针之间的转换

  • const_cast:用于去除 constvolatile 属性

  • dynamic_cast:用于子类和父类之间的安全转换,可以实现向上向下转换,因为编译器默认向上转换总是安全的,而向下转换时,dynamic_cast 具有类型检查的功能;dynamic_cast 转换失败时,对于指针会返回目标类型的 nullptr,对于引用会返回 bad_cast 异常

  • reinterpret_cast:用于不同类型指针之间、不同类型引用之间、指针和能容纳指针的整数类型之间的转换

2 内存管理

常用的数据类型各占用多大的内存空间?

数据类型32位编译器64位编译器
bool11
char11
short (int)22
int44
unsigned (int)44
long48
long long88
float44
double88
pointer48

new/deletemalloc/free 之间有什么关系?

  • 相同点:对于内部数据类型来说,没有构造与析构的过程,所以两者是等价的,都可以用于申请动态内存和释放内存

  • 不同点:

    • new/delete 可以调用对象的构造函数和析构函数,属于运算符,在编译器权限之内,而 malloc/free 仅用于内存分配和释放,属于库函数,不在编译器权限之内

    • new 是类型安全的,而 malloc 返回的数据类型是 void *,所以要显式地进行类型转换

    • new 可以自动计算所需字节数,而 malloc 需要手动计算

    • new 申请内存失败时抛出 bad_malloc 异常,而 malloc 返回空指针

deletedelete [] 有什么区别?

  • 对于简单类型来说:使用 new 分配后,不管是数组数组还是非数组形式,两种方式都可以释放内存

  • 对于自定义类型来说:需要对于单个对象使用 delete,对于对象数组使用 delete [],逐个调用数组中对象的析构函数,从而释放所有内存;如果反过来使用,即对于单个对象使用 delete [],对于对象数组使用 delete,其行为是未定义的

所以,最恰当的方式就是如果用了 new,就用 delete;如果用了 new [],就用 delete []

内存泄漏的场景有哪些?

  • mallocfree 未成对出现,或 new/new []delete/delete [] 未成对出现

    • 在堆中创建对象分配内存,但未显式释放内存;比如,通过局部分配的内存,未在调用者函数体内释放:

      char* getMemory() {
      char *p = (char *)malloc(30);
      return p;
      }
      int main() {
      char *p = getMemory();
      return 0;
      }
    • 在构造函数中动态分配内存,但未在析构函数中正确释放内存

  • 未定义拷贝构造函数或未重载赋值运算符,从而造成两次释放相同内存的做法;比如,类中包含指针成员变量,在未定义拷贝构造函数或未重载赋值运算符的情况下,编译器会调用默认的拷贝构造函数或赋值运算符,以逐个成员拷贝的方式来复制指针成员变量,使得两个对象包含指向同一内存空间的指针,那么在释放第一个对象时,析构函数释放该指针指向的内存空间,在释放第二个对象时,析构函数就会释放同一内存空间,这样的行为是错误的

  • 没有将基类的析构函数定义为虚函数

内存的分配方式有几种?

  • 在栈上分配:在执行函数时,局部变量的内存都可以在栈上分配,函数结束时会自动释放;栈内存的分配运算内置于处理器的指令集中,效率很高,但分配的内存容量有限

  • 从堆上分配:由 new 分配和 delete 释放的内存块,也称为动态内存分配,使用者自行申请和释放内存,使用灵活

  • 从自由存储区分配:由 malloc 分配和 free 释放的内存块,与堆类似

  • 从常量存储区分配:特殊的存储区,存放的是常量,不可修改

  • 从全局/静态存储区分配:编译期间分配内存,整个程序运行期间都存在,如全局变量、静态变量等

堆和栈有什么区别?

  • 分配和管理方式不同:

    • 堆是动态分配的,其空间的分配和释放都由使用者控制

    • 栈是由编译器自动管理的,其分配方式有两种:

      1. 静态分配由编译器完成,比如局部变量的分配

      2. 动态分配由 alloca() 函数进行分配,但是会由编译器释放

  • 产生碎片不同:

    • 对堆来说,频繁使用 new/delete 或者 malloc/free 会造成内存空间的不连续,产生大量碎片,是程序效率降低

    • 对栈来说,不存在碎片问题,因为栈具有先进后出的特性

  • 生长方向不同:

    • 堆是向着内存地址增加的方向增长的,从内存的低地址向高地址方向增长

    • 栈是向着内存地址减小的方向增长的,从内存的高地址向低地址方向增长

  • 申请大小限制不同:

    • 栈顶和栈底是预设好的,大小固定

    • 堆是不连续的内存区域,其大小可以灵活调整

静态内存分配和动态内存分配有什么区别?

  • 静态内存分配是在编译时期完成的,不占用 CPU 资源;动态内存分配是在运行时期完成的,分配和释放需要占用 CPU 资源

  • 静态内存分配是在栈上分配的;动态内存分配是在堆上分配的

  • 静态内存分配不需要指针或引用类型的支持;动态内存分配需要

  • 静态内存分配是按计划分配的,在编译前确定内存块的大小;动态内存分配是按需要分配的

  • 静态内存分配是把内存的控制权交给了编译器;动态内存分配是把内存的控制权给了使用者

  • 静态内存分配的运行效率比动态内存分配高,动态内存分配不当可能造成内存泄漏

如何构造一个类,使得只能在堆上或只能在栈上分配内存?

  • 只能在堆上分配内存:将析构函数声明为 private

  • 只能在栈上生成对象:将 newdelete 重载为 private

浅拷贝和深拷贝有什么区别?

  • 浅拷贝只复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享一块内存

  • 深拷贝会创造一个相同的对象,新对象与原对象不共享内存,修改新对象不会影响原对象

什么是智能指针?智能指针有什么作用?分为哪几种?各自有什么样的特点?

智能指针是一个 RAII 类模型,用于动态分配内存,其设计思想是将基本类型指针封装为(模板)类对象指针,并在离开作用域时调用析构函数,使用 delete 删除指针所指向的内存空间。

智能指针的作用是,能够处理内存泄漏问题和空悬指针问题。

智能指针分为 auto_ptrunique_ptrshared_ptrweak_ptr 四种,各自的特点:

  • 对于 auto_ptr,实现独占式拥有的概念,同一时间只能有一个智能指针可以指向该对象;但 auto_ptr 在 C++11 中被摒弃,其主要问题在于:

    • 对象所有权的转移,比如在函数传参过程中,对象所有权不会返还,从而存在潜在的内存崩溃问题

    • 不能指向数组,也不能作为 STL 容器的成员

  • 对于 unique_ptr,实现独占式拥有的概念,同一时间只能有一个智能指针可以指向该对象,因为无法进行拷贝构造和拷贝赋值,但是可以进行移动构造和移动赋值

  • 对于 shared_ptr,实现共享式拥有的概念,即多个智能指针可以指向相同的对象,该对象及相关资源会在其所指对象不再使用之后,自动释放与对象相关的资源

  • 对于 weak_ptr,解决 shared_ptr 相互引用时,两个指针的引用计数永远不会下降为 0,从而导致死锁问题,因为 weak_ptr 是对对象的一种弱引用,可以绑定到 shared_ptr,但不会增加对象的引用计数

shared_ptr 是如何实现的?

  1. 构造函数中计数初始化为 1

  2. 拷贝构造函数中计数值加 1

  3. 赋值运算符中,左边的对象引用计数减 1,右边的对象引用计数加 1

  4. 析构函数中引用计数减 1

  5. 在赋值运算符和析构函数中,如果减 1 后为 0,则调用 delete 释放对象

3 面向对象

面向对象的三大特征是哪些?各自有什么样的特点?

  • 封装:将客观事物封装成抽象的类,而类可以把自己的数据和方法暴露给可信的类或者对象,对不可信的类或对象则进行信息隐藏

  • 继承:可以使用现有类的所有功能,并且无需重新编写原来的类即可对功能进行拓展

  • 多态:一个类实例的相同方法在不同情形下有不同的表现形式,使不同内部结构的对象可以共享相同的外部接口

多态的实现有哪几种?

多态分为静态多态和动态多态:

  • 静态多态是通过重载和模板技术实现的,在编译期间确定

  • 动态多态是通过虚函数和继承关系实现的,执行动态绑定,在运行期间确定

动态多态有什么作用?有哪些必要条件?

动态多态的作用:

  • 隐藏实现细节,使代码模块化,提高代码的可复用性

  • 接口重用,使派生类的功能可以被基类的指针/引用所调用,即向后兼容,提高代码的可扩充性和可维护性

动态多态的必要条件:

  • 需要有继承

  • 需要有虚函数覆盖

  • 需要有基类指针/引用指向子类对象

动态绑定是如何实现的?

当编译器发现类中有虚函数时,会创建一张虚函数表,把虚函数的函数入口地址放到虚函数表中,并且在对象中增加一个指针 vptr,用于指向类的虚函数表。当派生类覆盖基类的虚函数时,会将虚函数表中对应的指针进行替换,从而调用派生类中覆盖后的虚函数,从而实现动态绑定。

虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表是怎么维护的?

虚函数表是针对类的,类的所有对象共享这个类的虚函数表,每个对象内部都保存一个指向该类虚函数表的指针 vptr,每个对象的 vptr 的存放地址都不同,但都指向同一虚函数表。

为什么基类的构造函数不能定义为虚函数?

虚函数的调用依赖于虚函数表,而指向虚函数表的指针 vptr 需要在构造函数中进行初始化,所以无法调用定义为虚函数的构造函数。

为什么基类的析构函数需要定义为虚函数?

为了实现动态绑定,基类指针指向派生类对象,如果析构函数不是虚函数,那么在对象销毁时,就会调用基类的析构函数,只能销毁派生类对象中的部分数据,所以必须将析构函数定义为虚函数,从而在对象销毁时,调用派生类的析构函数,从而销毁派生类对象中的所有数据。

构造函数和析构函数能抛出异常吗?

  • 从语法的角度来说,构造函数可以抛出异常,但从逻辑和风险控制的角度来说,尽量不要抛出异常,否则可能导致内存泄漏

  • 析构函数不可以抛出异常,如果析构函数抛出异常,则异常点之后的程序,比如释放内存等操作,就不会被执行,从而造成内存泄露的问题;而且当异常发生时,C++ 通常会调用对象的析构函数来释放资源,如果此时析构函数也抛出异常,即前一个异常未处理又出现了新的异常,从而造成程序崩溃的问题

纯虚函数有什么作用?如何实现?

作用是实现一个接口,起到规范的作用,想要继承这个类就必须覆盖该函数。

实现方式是在虚函数声明的结尾加上 = 0 即可。

如何让一个类不能实例化?

将类定义为抽象类(也就是存在纯虚函数)或者将构造函数声明为 private

public 成员、protected 成员、private 成员有什么区别?

  • public 成员可以被本类及本类对象、友元类及友元类对象、子类及子类对象访问

  • protected 成员可以被本类、友元类、子类访问,不能被任何对象访问

  • private 成员只可以被本类访问

public 继承、protected 继承、private 继承有什么区别?

不管哪类继承,子类都可以访问父类的 public 成员、protected 成员,但是继承之后,成员属性会发生变化:

父类成员public 继承后子类成员protected 继承后子类成员private 继承后子类成员
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private无法继承无法继承无法继承

多继承存在什么问题?如何消除多继承中的二义性?

  • 增加程序的复杂度,使得程序的编写和维护比较困难,容易出错

  • 在继承时,基类之间或基类与派生类之间发生成员同名时,将出现对成员访问的不确定性,即同名二义性;消除同名二义性的方法:

    • 利用作用域运算符 ::,用于限定派生类使用的是哪个基类的成员

    • 在派生类中定义同名成员,覆盖基类中的相关成员

  • 当派生类从多个基类派生,而这些基类又从同一个基类派生,则在访问此共同基类的成员时,将产生另一种不确定性,即路径二义性;消除路径二义性的方法:

    • 消除同名二义性的两种方法都可以

    • 使用虚继承,使得不同路径继承来的同名成员在内存中只有一份拷贝

4 程序编译

程序编译的顺序是什么?

  1. 预处理:源代码经过预处理器,生成一个 .i 中间文件,这个阶段会把 #include 的头文件内容进行替换,并处理宏定义

  2. 编译:.i 中间文件生成 .s 汇编文件

  3. 汇编:.s 汇编文件经过汇编器生成 .obj 目标文件

  4. 链接:.obj 目标文件经过链接器,与 lib 静态链接库和 dll 动态链接库生成可执行文件

静态链接和动态链接有什么区别?

  • 静态链接是在编译链接时直接将需要的执行代码拷贝到调用处

    • 优点在于程序在发布时不需要依赖库,可以独立执行

    • 缺点在于程序的体积会相对较大,而且如果静态库更新之后,所有可执行文件需要重新链接

  • 动态链接是在编译时不直接拷贝执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定代码时,在共享执行内存中寻找已经加载的动态库可执行代码,实现运行时链接

    • 优点在于多个程序可以共享同一个动态库,节省资源

    • 缺点在于由于运行时加载,可能影响程序的前期执行性能