前言:
该文章只针对面试时面试官提问如何回答的更全更好,看此文章不能学会相应语言,也没有知识点相关代码。如果知识点本身不会,背诵此文章可能能让你找到一份工作,但不能让你持续的干下去。还是需要自身精通对应知识点。
该文章适合有C++基础的朋友阅读,主要是针对本科应届生。收录了二十道近几年非常经典的C++面试题。C语言部分参考我之前发的博文。
工作几年的朋友面试时很少会遇到这些问题,更多问的是之前工作的具体项目,以及专项知识点的深度,不会再有这些基础问题。
一、C和C++有什么区别?C语言如何实现面向对象编程?
C和C++的主要区别在于C++是C的扩展,C++是完全兼容C语言的,且C++更加适合于面向对象的思想,而C语言更时候面向过程编程思想。
C语言想实现面向对象通常可以通过结构体和函数指针来模拟类的方法。
二、如何理解面向对象的核心思想(封装、继承、多态的好处)
1. 封装:封装是将数据和操作数据的函数绑定在一起的过程,形成一个称为“对象”的整体。封装的好处是可以隐藏对象的内部实现细节,只提供有限的接口供外部访问,这样可以提高代码的安全性和可维护性。
2.继承:继承是从已有的类派生出新的类的过程,新的类会继承原有类的属性和方法。继承的好处是可以实现代码的重用,提高代码的效率。
3.多态:多态是指同一个接口可以有多种不同的实现方式。多态的好处是可以提高代码的灵活性和可扩展性。
三、如何理解高内聚低耦合的思想
高内聚低耦合是一种编程思想(或者说规范),它就要求开发人员在面向对象编程的过程中尽可能做到:类的内部功能关联可以很复杂,但对外接口尽量单一;且类的封装中应该尽可能减少对外部对象的依赖,一个类只实现某类或某个方法。
高内聚低耦合的优势
- 增强了模块代码的复用性,方便移植单个模块相关文件到其他工程,而不需要做过多的修改工作;
- 高内聚低耦合使得项目中模块与模块之间的关系尽可能的简单直接,这给项目的调试、分工、维护带来的好处是巨大的;
降低耦合度的方法
- 少使用类的继承,多用接口隐藏实现的细节;
- 界面类与业务逻辑类应当做到分离,尽量避免复杂的依赖关系;
- 遵循一个定义只在一个地方出现,并且少使用全局变量;
- 如果模块间必须存在耦合,应当考虑用设计模式加以优化,实在不能优化也要尽可能做到单向依赖。
- 尽量不用“硬编码”的方式写程序,同时也尽量避免直接用 SQL 语句操作数据库
增强内聚度方法
- 模块只对外暴露最小限度的接口,类属性和方法的访问权限能用 private 就不要用 public;
- 模块的功能化分尽可能的单一,过度依赖的两个类应该合并为一个类;
- 模块内部的修改,不得影响其他模块,如果有影响就要想办法解决;
四、C和C++中struct和class有什么区别
在C语言中,struct默认为公有权限,且没有权限修饰符,只能通过特殊技巧模拟私有成员,此外不能定义成员函数,但可以使用函数指针。
C++中,struct默认为共有权限,其它功能与class一致,可以使用权限修饰符,可以定义成员函数。
C++中,class默认为私有权限。
五、new/delete与malloc/free的区别
它们都是动态管理内存的入口,负责申请与释放堆区资源
- malloc与free是标准库函数,而new/delete是C++中的运算符
- malloc需要手动计算申请空间的大小,返回void指针。而new是自动计算类型大小,返回对应类型的指针。
- malloc只负责申请内存资源,而new会负责初始化,针对类对象还会自动调用构造函数。
- free只负责释放内存资源,而delete会负责清理资源,针对类对象会自动调用析构函数
- new和delete可以进行运算符重载。
- 释放数组时,delete后需要额外添加中括号
- new还可以用于定位内存【极其少见】
六、内联函数与宏函数的区别
宏函数是在预处理阶段进行的代码替换。
内联函数在编译器阶段是直接复制“镶嵌”到主函数中去的,就是将内联函数的代码直接放在内联函数的位置上,所以没有指令跳转,指令按顺序执行。内联函数运行速度比常规函数稍快,但代价是需要占用更多的内存。
内联函数是真正的函数。
七、引用与指针有什么区别?
引用的本质是指针常量,引用可以做到的事都可以使用指针做,但引用更安全,更简洁。而指针更灵活,更通用。在使用时有以下区别
1. 语法:
- 引用使用&符号声明,指针使用*符号声明。
- 引用在声明时必须初始化,而指针可以先声明后赋值。
2. 操作:
- 引用在声明后不能再引用其他对象,指针可以在运行时指向不同的对象。
- 引用不需要解引用操作符*,指针需要通过*来访问所指向的对象。
3. 空值:
- 引用不能指向空值,指针可以指向空值(nullptr)。
4. 传递方式:
- 通过引用传递参数时,会直接操作原始数据,而指针传递参数时需要通过指针操作符*来访问原始数据。
5. 大小:
- 引用在内存中通常被实现为指针,但引用本身不占用额外的内存空间,而指针需要额外的内存空间来存储地址。
八、const关键字的作用
const关键字用于声明常量,表示该变量的数值在程序执行期间不能被修改。
在C语言中的用处参考 C语言常见面试题 其中第三章、第五章、第九章
const在C++中可以用来修饰成员变量,被修饰的成员变量只能在初始化列表中进行初始化,无法被修改。
const还可以用来修饰成员函数,主要目的是防止成员函数修改成员变量的值,但可以修改静态成员变量,只能调用const修饰的成员函数或静态成员函数,const修饰成员函数本质上修饰的是this指针,所以const修饰的成员函数可以和同名非const成员函数构造函数重载。
九、static关键字的作用
在C语言中的用处参考 C语言常见面试题 其中第十章
在C++中static可用于修饰成员变量,被修饰的成员变量需要在类内声明,类外初始化。并包含以下特点:
- 被修饰的成员变量属于类不属于具体的对象。
- 静态成员变量的内存在程序开始时分配,程序运行结束时释放内存。
- 静态成员变量对于所有该类的成员是共享的,并在对象创建之前就已经产生了。
- 静态成员必须进行初始化,否则会在linker(链接)步骤时报错
- 使用静态成员不需要定义出对象,可以通过类名+域操作符访问
static修饰成员函数有以下特点:
- 与修饰静态成员变量一样,可以通过类名 + 域操作符访问
- 与类关联,不与对象关联,不能使用this指针
- 不能访问非静态成员,只能访问静态成员
何时使用static关键字?(大多面试会深入问具体使用位置)
1)一切不需要实例化(创建对象)就可以确定行为方式的函数都应该设计为静态的。例如:圆类中计算面积和周长的函数;简单工厂中创建对象的函数。
2)通过其它类型对象转换成自己类型对象一般使用静态成员函数。例如:Qt中图片类型转换函数;不同类型字符串格式转换函数。
3)单例的设计。
十、拷贝构造函数调用的时机以及深拷贝与浅拷贝的区别
调用时机(不考虑存在移动构造的情况):
- 主动创建对象时,用一个对象去初始化另一个对象的时候
- 一个对象以值传递的形式传入函数体
- 一个对象以值的形式从函数返回
注意:针对上述第三种情况,C++标准允许一种(编译器)实现省略创建一个只是为了初始化另一个同类型对象的临时对象。指定这个参数(-fno-elide-constructors)将关闭这种优化。(优化方式是建立一个对象引用绑定到返回的优化,可以省略两次调用拷贝构造函数)
深拷贝与浅拷贝的区别:
默认的拷贝构造函数为浅拷贝。针对指针对象,只拷贝指针存储的地址。而深拷贝会开辟新的内存,拷贝内存里的数据。
十一、菱形继承会引发哪些问题?如何解决菱形继承问题
引发问题
如图类B与类C继承类A,而类D多继承与类B与类C,此时会引发数据冗余与二义性的问题。
数据冗余:在创建 D 类的对象时,类A 的构造函数将会调用两次,相当于创建两个类A对象
二义性:A类中成员变量,可以通过B和C去访问,此时会存在两个同种含义的变量
解决方案
- 尽量选择避免这种设计,本身这种设计较为繁琐且对性能也有所影响。
- 使用虚继承,在类B与类C继承类A时使用虚继承即可解决这种问题
注意:在虚继承的设计中,如果类B或类C在初始化列表中初始化了类A,此时类B与类C的初始化列表中针对类A的初始化会失效,会调用类A的无参构造函数,若想人为选择类A的构造函数,需要在类D的初始化列表中去选择类A的构造函数初始化
十二、什么是多态,如何实现多态,请举例说明
多态指同种行为,不同的对象有不同的表现。
在C++中多态分为静态多态(编译多态)与动态多态(运行多态)两种,实现静态多态可以通过函数重载或泛型编程,实现动态多态需要用到继承与虚函数。
函数重载指在同一作用域,相同的函数名,不同的参数列表构成重载函数,在调用函数时,编译器会根据传参类型选择要调用的函数。
泛型编程需要创建对应的模板,针对特殊类型也可以对模板进行显式实例化。
动态多态的实现的基础是子类重新实现父类的虚函数,调用时为父类指针指向子类对象,通过该指针调用虚函数。
十三、一般推荐把父类的析构函数设置成虚函数,为什么?
根据语法规则,将父类的析构函数设置成虚函数,那么子类的析构函数会自动变为虚函数。
当父类指针指向子类对象的时候,如果析构函数不是虚函数,则不会发生动态多态(运行时多态),而导致只会调用父类的析构函数,只会释放掉对象中父类的资源,此时子类对象资源没有释放,从而导致内存泄漏。
扩展:动态多态的实现原理由虚函数表实现,一旦类中引入了虚函数,在程序编译期间会创建虚函数表,表中每一项数据都是虚函数的入口地址,为了将对象与虚函数表关联起来,编译器会在对象中会增加一个指针成员用于存储虚函数表的位置,基类的指针指向派生类对象时就是通过虚函数表的指针来找到实际应该调用的函数。
基类与派生类都维护自己的虚函数表,虚函数表位于只读数据段(.rodata),如果派生类重写基类的虚函数,则虚函数表存储的是派生类的函数的地址,没有重写的虚函数则保存的是基类的虚函数表
回答问题时建议带上虚函数表原理,可以大幅提升面试成功率
十四、简述覆盖(重写/覆写)、重载、隐藏的概念
覆盖(override)
派生类重新实现基类的虚函数,要求函数名、参数、返回值都必须相同,基类中该函数必须要有virtual关键字修饰,重写函数的权限访问限定符可以不同,通过父类指针或引用指向子类对象,再去调用该函数。主要目的是用于实现动态多态。原理是使用虚函数表
重载(overload)
同一作用域中的函数名相同,参数不同的多个函数间构成重载。主要目的是提高的易用性,减少函数名数量,同时提高程序的可读性,原理是编译器会将重载函数设置成不同的函数名,根据参数类型与个数进行匹配
扩展问题:在 C++ 程序中调用被 C 编译器编译后的函数,为什么要加 extern “C”?
C++语言支持函数重载,C 语言不支持函数重载。函数被 C++编译后在库中的名字与C语言的不同。假设某个函数的原型为:void foo(int x, int y);
该函数被C编译器编译后在库中的名字为 _foo,而C++编译器则会产生像_foo_int_int 之类的名字。
C++提供了 C 连接交换指定符号 extern“C”来解决名字匹配问题。
隐藏(hiding)
父类与子类有同名函数,调用的时候总是调用子类的函数,此时父类成员函数被隐藏。主要目的是一般子类继承过来的函数不适合子类,或者需要扩展 ,则需要重写父类的函数。两个函数的返回值和参数可以相同也可以不同。
十五、什么是泛型编程?简要说明C++模板,其用法和目的分别是什么
泛型编程指编写不依赖具体数据类型的程序,目的是将程序尽可能通用,将算法从数据结构中抽象出来,成为通用算法。
C++模板是一种泛型编程的工具,允许程序员编写通用的类或函数,以便在不同数据类型下进行重复使用。模板的主要目的是实现代码重用和提高代码的灵活性。
用法:
函数模板:通过函数模板可以编写通用的函数,支持多种数据类型。
template <typename T>
T add(T a, T b) {
return a + b;
}
类模板:通过类模板可以编写通用的类,支持多种数据类型。
template <typename T>
class Pair {
public:
T first, second;
Pair(T a, T b) : first(a), second(b) {}
};
十六、STL中vector与list的区别
vector与list都属于STL中的序列式容器(顺序容器),元素以严格的线性形式组织起来,每个元素都有固定位置。
vector本质是动态数组,随机存取任何元素都能在常数事件完成,在尾端增删元素性能高。增加数据时大致按以下流程:
建立空间->填充数据->重建更大空间->复制原空间数据->删除原空间->添加新数据
list本质是双向循环链表,在任何位置增删元素都能在常数时间完成,但随机访问偏慢。
主要区别在于vector支持下标操作,而list不支持,vector多用于存储已知长度(模糊范围也可)且经常随机访问的数据,list主要用于存储长度未知且经常增删数据而少量随机访问。
十七、简述什么是迭代器?有什么作用?迭代器失效是什么意思?
迭代器是一种检查容器内元素并遍历元素的数据类型,迭代器的核心作用为使算法独立于容器类型。标准库为每一种标准容器定义了一种迭代器类型,而极少数容器支持下标操作访问容器元素。
由于一些对容器的操作如删除元素或移动元素会修改容器的内在状态,会使原本指向被移动元素的迭代器失效,也可能使其他迭代器失效。使用无效的迭代器是没有意义的,可能会导致和使用空指针相同的问题,所以使用迭代器时,需要特别留意哪些操作会使迭代器失效,使用无效迭代器会导致严重的运行错误。
十八、什么是智能指针?简述智能指针实现原理
所谓智能指针就是智能/自动化的管理指针所指向的动态资源的释放。它是一个类模板,有类似指针的功能,对*和->运算符进行了重载。
实现原理:
智能指针用一个类描述,这个类中有一个指针成员(一个引用计数成员),构造函数中初始化指针成员指向对象(初始化引用计数成员的值为1),析构函数中删除指针成员指向的对象(将引用计数的值自减,如果减到0的时候,删除指针成员指向的对象)
下列代码为shared_ptr的基础功能实现,只实现了最基本的功能。
#ifndef MYSHAREDPTR_H
#define MYSHAREDPTR_H
#include <iostream>
#include <string>
using namespace std;
template <typename T>
class MySharedPtr
{
private:
T* ptr;
long *countRef;
protected:
void release(void)
{
(*countRef) --;
if(*countRef == 0) {
if(ptr != NULL) {
delete ptr;
}
delete countRef;
}
}
public:
explicit MySharedPtr(T* _ptr = NULL) : ptr(_ptr), countRef(new long(1))
{
;
}
MySharedPtr(const MySharedPtr<T> &other) : ptr(other.ptr), countRef(other.countRef)
{
(*countRef) ++;
}
~MySharedPtr()
{
release();
}
MySharedPtr<T> operator =(const MySharedPtr<T> &other)
{
if(this != &other) {
release();
ptr = other.ptr;
countRef = other.countRef;
(*countRef) ++;
}
}
T& operator *(void)
{
return *ptr;
}
T* operator ->(void)
{
return ptr;
}
T* getPtr(void)
{
return ptr;
}
int getCounRef(void)
{
return *countRef;
}
};
#endif // MYSHAREDPTR_H
扩展:智能指针并非线程安全,如果想让他线程安全可以选择继承该类,重写对应功能,增加互斥锁使之线程安全。另外shared_ptr在相互引用的情况下会出现故障,从而需要引入weak_ptr协助shared_ptr工作
十九、什么是单例?如何实现单例?
该类型只有一个对象,不能再额外创建一个新的对象
实现步骤:
- 将构造函数访问权限设置成 private 或者 protected ,不允许类的外部创建对象
- 用一个静态成员指针指向创建的对象
- 提供一个静态成员函数,获取这个对象的首地址
饿汉式与懒汉式:饿汉式单例在第2步时直接new出该对象,懒汉式单例在第2步时令指针等于NULL,只有在第一次用到类实例的时候才会去实例化
懒汉式线程不安全问题:两个线程同时调用获取单例的静态函数时可能造成内存泄漏,故需要在该函数中增加互斥锁,但是在已创建此对象时再加锁会影响程序性能,所以在加锁前需要再次判断是否已经创建出对象。
如何自动释放单例:可以使用智能指针管理对象的内存,将智能指针定义在静态区即可;也可以在单例中创建一个内部类,内部类的析构函数中释放单例,内部类对象创建在静态区,那么在程序结束时则自动释放掉单例。
注意:其它常考的设计模式还有工厂、策略模式、代理模式、适配器模式、观察者模式、组合模式,建议学习其原理,并能绘制UML类图
二十、C++11有什么新特性,简述你所知道的新特性
- nullptr指针:用于代替NULL,解决部分编译将NULL直接解释成0,导致函数重载引发的调错函数问题
- 列表初始化:统一初始化的语法格式,可以使用 { } 对所有类型进行初始化
- 别名声明using:可用于代替typedef给类型取别名
- 类型推断:auto关键字与decltype类型指示符。用于定义变量时不确定类型的情况,让编译器推断
- 右值:右值引用用于解决左值引用无法接收右值的情况,另外引入了万能引用与完美转发
- 移动构造函数:通过右值实现,目的是解决拷贝构造浪费过多资源的问题
- 委托构造函数:通过初始化列表委托其它构造函数帮我进行初始化
- 范围for循环:主要用于各种容器的遍历
- 拉姆达表达式(匿名函数):可以直接在需要调用函数的位置定义短小精悍的函数,不需要预定义好函数,使代码更加紧凑,结构层次更加明显、可读性更好
- 线程操作由于在C++11中相对比较难用,建议使用C++14的线程处理
注:特性不止这些,只列举了相对常见部分,加粗的部分尤为重要
结语
编写该文章目的主要为想从事相关工作的同学找到一份好的工作,以上题目在面试中经常出现,如果有在外面试的朋友发现有更常见更经典的题目也可以私信告知,后续也会更新到博客当中。
如果有朋友想系统的学习嵌入式相关知识,从事相关的行业,可以私信我,有一些经典的电子档书籍资料和开源网课学习链接。