面试八股-c++
- threadlocal
- volatile:当对象的值可能在程序的控制之外被改变时,将对象声明为volatile,比如程序内包含一个由系统时钟定时更新的变量时。volatile本质上是阻止编译器优化,让程序永远从内存读取这个对象,不会把他加载到寄存器中。(参考C++ Primer第5版)
static关键字的作用
- 全局静态变量
- 将全局变量声明为static可以使得全局变量只在当前文件内有效;
- 实际上,对于全局变量,static修改了标识符的链接属性,由默认的external变为internal
- C++11的匿名命名空间代替了这个功能;
- 静态存储区
- 如果全局变量不加static,它也是静态的,只不过作用域是全局的,可以通过extern引入
- 局部静态变量
- 在局部作用域中声明的static变量,例如函数中的static变量;
- 局部静态变量从第一次执行定义时生效;
- 即使函数结束执行,该变量也不会被销毁,只有程序结束后才销毁该变量;
- 静态存储区
- 类的静态成员变量
- 属于类,不属于某个对象;
- 使用
class::static_member的方式调用; - 模板的静态成员属于每个模板实例;
- 派生类的对象能访问基类的静态成员;
- 类的静态成员函数
- 静态成员函数只能操作类的静态成员变量;
- 不包含this指针,因此也不能声明为const;(静态成员函数不属于任何对象,因此也不包含this指针,没有this指针,自然也就不能指向const对象);
- (静态函数)
- 对于被static修饰的普通函数,其只能在定义它的源文件中使用,不能在其他源文件中被引用。
C++和C的区别
- C++是面向对象的语言,而C是面向过程的结构化编程语言
- C++具有封装、继承和多态三种特性
- C++相比C,增加多许多类型安全的功能,比如强制类型转换
- C++支持泛型编程,比如模板类、函数模板等
C++内存布局
- 代码区
- Data区,已初始化的全局变量区
- BSS区,未初始化的全局变量区,会在程序启动后自动清零,对于double类型。
- 堆区
- 栈区
各种指针
- 悬垂指针/空悬指针
指针指向的内存已经被释放了,指针本身还指向被释放的内存。 - 野指针
未初始化的指针(没有被初始化为nullptr),随即指向一块内存。 - 空指针
nullptr、NULL、0 - 内存泄漏
内存还没释放,指针弄没了。
inline
- 早期的作用:inline可以看作是给编译器的建议,(1)编译器会根据函数的规模等指标决定是否把函数在调用处展开,例如函数是否被调用很多次、函数内部是否有for、switch、while等复杂的控制逻辑、函数是否递归等。(2)即使不加inline,在o3编译时,编译器有时也会自动将小函数内联,因此,使用inline时首先考虑的并不应该是效率,而是其他东西。
- 现在的作用:能让一个函数定义在头文件里
const的作用
- (最基本地)将变量定义为const,用作常量。
- 函数参数定义成const引用,避免拷贝(const用来防止函数内部修改这个参数)。
- 类的const成员函数:这样类的const对象就能调用这个成员函数了,(原理是给this指针加上了底层const,否则的话,const对象的指针不能作为参数传递给隐含的this参数,因为this默认是没有底层const属性的,无法指向一个常量对象。)
- 关于
const_cast,const_cast不是用来转换常量的,而是用来去掉本来不是常量的变量的底层const属性。 - const更多是给人看的,不是给编译器看的。
C++在执行main函数之前都做什么
- 加载动态库
- 初始化全局变量和static变量(可以利用这个初始化的过程在构造函数中执行一些代码)
vector
- vector底层是什么数据结构
连续的线性空间 - vector的内存分配策略
- STL内存分配没有使用malloc/free,而是使用的是std::Allocator(实际上Allocator类就是对malloc和free做了一层封装),对象初始化使用'placement new'语法。
- 预留空间,空间满了就扩为原来的两倍。
- 扩容时,是分配一块新的连续内存,原有的元素移动或拷贝到新的内存中,频繁扩容时会存在性能损失。
c++11中的新特性
我们通常把c++11之后的c++称为现代c++,以下是一些常见新特性
- 语法
- 列表初始化
- 可以使用
initializer_list<T>让一个类拥有列表初始化的能力
- 可以使用
- 范围for语句
- using,新的类型别名声明,代替typedef
- 尾置返回类型,
auto func(int) -> int(*)[10];
- 列表初始化
- 类型、关键字
- long long类型
- nullptr常量
- auto类型
- constexpr
- 是一种在编译器求值的常量表达式(常量表达式,值为常量并且在编译过程就能得到计算结果)
- 可以定义constexpr变量,constexpr函数,字面值常量类(需要constexpr构造函数)
- decltype
- 作用是推断一个表达式的类型,并可以将返回值作为类型声明
- 和auto的区别是,当你不想要表达式的值,只想要表达式的类型时,可以使用decltype
- 类型转换
- static_cast、dynamic_cast、reinterpret_cast
- 面向对象
- =default、=delete
- explicit,防止隐式类型转换
- 虚函数的override
- final阻止继承
- move语义和右值引用
- lambda表达式
- bind,函数适配器
- 内存管理
- 智能指针
- stl
- unordered_map、unordered_set
重要的特性
- 规范了初始化
- 直接初始化(祖传C特性)
- 值初始化
类的值初始化和直接初始化是一样的;但是内置类型的值初始化和直接初始化是不同的,内置类型值初始化一定有一个值,但是内置类型的直接初始化还是老一套,全局的一定有值,非全局的必须得指定一个值才会直接初始化。 - 列表初始化
涉及到标准库initializer_list类型 - 类内初始化
- 规范了类型转换(这个貌似不是C++11的新特性?)
static_cast:通常意义上的类型转换const_cast:改变对象的底层const(例如一个const指针或const引用),不是用来将一个常量变成非常量dynamic_cast:将一个基类的指针转换成其派生类的指针(派生类中必须有虚函数)
这属于C++的运行时运行时类型识别的特性,例如有时我们想通过基类的指针去执行一个派生类的普通函数(不是虚函数)。reinterpret_cast:在位这一级别重新解释对象(其实就是把一个指针转换成另一个指针,但是非常灵活,甚至可以把一个整型值的地址拿出来转换成一个函数指针)。- 右值引用、移动构造和移动赋值运算符
- 标准库
move函数:强行获得一个非右值对象的右值引用。 - 规范了对类的默认行为的控制
=default=delete- 智能指针:
shared_ptr,unique_ptr,weak_ptr - 可调用对象:
- 函数指针;
- Lambda表达式;
- 函数对象(实现了函数调用运算符的对象)
- 标准库:
- 函数适配器
bind:把一个可调用对象转换为另一个可调用对象 function类型:给一个函数调用形式参数,接受相同调用形式的可调用对象。- 标准库自定义的一系列可调用对象(算数、关系、逻辑)
- 函数适配器
- hash容器:
unordered_map、unordered_set explicit:强制显式类型转换- 类的类型转换运算符:例如流对象通常能做条件表达式,就是因为流对象被转换成了
bool类型。 explicit有个例外就是如果表达式被用在条件里,隐式的转换还是会执行。
新语法
- Lambda表达式
- 列表初始化
- 范围for语句
- auto
- 类型
using声明:代替typedef explicitoverride、finalthread_local变量- 函数的尾置返回类型:
auto func(int t) -> int (*)[10]; - 类的委托构造函数:使用类内其他构造函数帮助完成自己的初始化过程
其他特性
tuple模板:类似pair,但是tuple的模板参数是可变的- C++11新的标准库:正则表达式库、随机数库、多线程库
std::thrad以及thread_local变量。thread_local变量是线程内共享的变量。 - 两种新的顺序容器:
array、forward_list - 容器的
emplace成员函数:原地构造一个新对象,而不是把一个对象作为参数传递进去。 long long类型nullptr常量constexpr变量和函数:编译器计算结果auto类型:编译期推断变量类型decltype类型:编译期推断decltype的参数的类型,并将decltype用于变量声明和定义- 通过
cbegin()和cend()获得常量迭代器 - 数组现在也可以使用
begin()、end()系列函数获取指针了 allocator类可以用来分配未初始化的内存(和new相对),还可以在未分配的内存上构造和析构对象,很灵活(但是这两个函数已经被C++20移除了,不知道出于什么原因)sizeof现在可以利用作用域运算符获取类成员大小了namespace
类型转换
- static_cast,常规的类型转换
- const_cast,改变对象的底层const
- dynamic_cast,用于父子类指针和引用之间的转换,属于一种多态特性
- reinterpret_cast,不会改变变量本身的值,而是在字节层面上把变量解释为另外一个类型
内存管理
- malloc、free
- new、delete
- operator new、operator delete
- placement new
智能指针
- shared_ptr
- make_shared,构造对象并创建智能指针,最安全
- 利用普通指针初始化shared_ptr,不安全
- 混用普通指针和shared_ptr会导致普通指针在用户不想释放的时候被shared_ptr释放
- 混用两个智能指针会导致两个智能指针单独计算引用计数,引发问题
- 通过拷贝shared_ptr增加引用计数
- reset,引用计数-1
- unique_ptr,不支持拷贝和赋值
- weak_ptr,shared_ptr的弱引用,不会影响引用计数。不能直接访问数据,必须先使用lock方法获得shared_ptr,解决shared_ptr相互引用造成的资源不释放问题(属于一种内存泄漏)
- RAII
allocator
- stl中用来内存管理的类
- stl容易一般都有一个模板参数Allocator,因为是个模板参数而不是函数参数,所以可以用自定义的allocator代替std::Allocator
- allocator负责内存的分配和释放,对象的创建和销毁
面向对象
- 拷贝控制
- 会默认生成的函数:默认构造、拷贝构造、拷贝赋值、析构函数
- 初始化顺序
- 优先构造基类对象(按派生顺序构造)
- 然后构造成员对象(按声明顺序构造)
- 成员能不能是引用类型?
- 能,但是
- 构造函数形参必须也是引用类型(否则永远只能获得一个局部变量的引用)
- 必须在构造函数的初始化列表里初始化
- 能,但是
- 访问权限,public、private、protected
- protected表示成员可以被派生类访问,但不公共可见
- private对派生类不可见
- 继承权限
- 控制派生类的用户(包括派生类的派生类和类外部)对基类的访问权限,而不是控制派生类本身对基类的访问权限
- public,保留原来的权限
- private,public和protected变为private
- protected,public变为protected
- 虚继承/虚基类
- 解决由多重继承引起的多基类实例问题
- 在继承类名前加上virtual
- 多态性
- 虚函数
- 虚函数又叫动态多态
- 虚析构函数的必要性
- 使用基类指针指向派生类时,如果基类的析构函数不是虚函数,会导致无法调用派生类的析构函数
- 而虚构造会产生一个悖论,当对象没有实例化时,没有虚表指针,是无法调用虚构造函数的
- 类A的对象a保存一个虚函数指针,虚函数指针指向类A的虚函数表,虚函数表保存了虚函数指针,指向的是实际调用函数地址,不同类的虚表中可能回复用同一个函数(类的继承导致的)
- 纯虚函数/抽象类
- 抽象类不能实例化
- 纯虚函数也不能实现,
virtual int func1() = 0;
- 能否在构造函数和析构函数中调用虚函数?
- 可以,但是此时多态特性是失效的
- 重载
- 重载又叫静态多态,对调用函数的推断发生在编译器
- 重载实现原理,命名倾轧技术,在函数名上带上参数信息
- 普通函数重载、成员函数重载、重写、运算符重载、函数对象几种
- 仿函数(functor)是指函数对象
- 函数对象
- lambda表达式,
[capture] (param) mutable noexcept/throw() -> return type {}; - bind函数适配器
- 虚函数
- 名字查找
- 作用域
- 命名空间
- 友元
泛型
- 模板的实例化发生在编译期
- 模板实例化是指从函数模板/类模板生成一个真正的函数/类的过程
- 显式模板实参
- 模板实参推断
stl
- stl中包括:容器、算法、迭代器、适配器、内存分配器allocator
- vector
- unordered_map、unordered_set
- 适配器
- 容器适配器,stack,queue
- 迭代器适配器,插入迭代器、移动迭代器、流迭代器、反向迭代器
- 函数适配器,bind
vector和array的区别
C++ STL 中 array 和 vector 的主要区别如下:
存储方式: - std::array 是定长数组,大小在编译期确定,内存在线程栈上分配。 - std::vector 是动态数组,大小可变,内存通常在堆上分配。
大小变化: - array 大小固定,不能增删元素。 - vector 支持动态增删元素(如 push_back、pop_back)。
性能: - array 没有动态分配,访问速度快,适合小型、固定长度数据。 - vector 需要动态分配和扩容,适合大小不确定的数据。
用法场景: - array 适合长度已知且不会变化的场景。 - vector 适合长度不确定或需要频繁增删元素的场景。
vector和deque的区别
C++ STL 中 deque 和 vector 的主要区别如下:
底层结构
- vector:底层是连续的线性内存(动态数组),所有元素在内存中是连续存放的。
- deque:底层是分段的线性内存(通常是指针数组,每个指针指向一块连续的小内存块),整体上表现为双端队列,内存不一定连续。
插入/删除效率
- vector:支持尾部插入/删除(push_back/pop_back)效率高,头部或中间插入/删除效率低(需要移动大量元素)。
- deque:支持头部和尾部的高效插入/删除(push_front/push_back),中间插入/删除效率与vector类似。
随机访问
两者都支持随机访问(operator[]),但vector的访问速度略快,因为内存连续。
内存分配
- vector:扩容慢,扩容时需要整体搬迁数据到新内存。
- deque:扩容时只需分配新的小块内存,不需要整体搬迁已有元素。
适用场景
- vector:适合只在尾部插入/删除、需要高效随机访问的场景。
- deque:适合需要在头尾两端频繁插入/删除的场景。
隐式类型转换
- 内置类型的隐式类型转换
- 构造函数
- 类型转换运算符
其他
- extern "C"
- 导入c函数的关键字,它告诉编译器这段代码按c编译
- threadlocal
- 线程局部变量
- 要配合C++ thread标准库一起使用
- atomic
- 可以对基本类型封装原子操作
- mutable
- 可以把一个成员声明为可变的,操作成员的函数仍然能受到const保护
C++20
- concept
- 和模板类型推断有关,可以用来约束模板实参的类型,
concept concept-name = expression
- 和模板类型推断有关,可以用来约束模板实参的类型,
- modules
- coroutine
- 无栈协程,无栈协程比有栈协程轻量
- 无栈协程本质上是一种可以暂停和恢复的函数
- 协程内部的三个关键字,co_await、co_yield、co_return
- co_await和co_yield可以挂起协程
- co_return返回
socket开发
- 系统调用都是线程安全的,系统调用都是原子操作
- socket,域 + socket类型 + 协议
- IPv4域 + SOCK_STREAM 默认是tcp
- IPv4域 + SOCK_DGRAM 默认是udp
- tcp
- 服务端,socket -> bind -> listen -> accept
- 客户端,socket -> bind -> connect
- 数据传输,send,recv(read,write)
- 关闭socket,close
- 多线程读写需要给整个数据加锁,不能给单次发送加锁,单次发送可能会出现短读短写问题,因此多线程写socket还是会相互阻塞,没有意义
- udp
- socket,bind
- sendto,recvfrom
- close
- 可以多线程同时读写同一个socket
boost
boost库的定位是实验性质的库,里面的东西可能会在未来进入C++标准,但是在C++11之后,很多boost中的特性已经进入了C++,很多人开始禁止使用boost。
grpc + brotobuf
- grpc底层是tcp epoll模型
- 上层的通信协议是http + protobuf
函数调用约定
stdcall、cdecl、fastcall三者的区别
stdcall、cdecl、fastcall三种函数调用约定的区别如下:
- cdecl(C Declaration)
- 参数从右到左入栈。
- 由调用者负责清理堆栈。
- 支持可变参数(如printf)。
-
是C默认的调用约定。
-
stdcall(Standard Call)
- 参数从右到左入栈。
- 由被调用者(函数本身)负责清理堆栈。
-
是C++标准调用约定。
-
fastcall
- 前两个参数通过寄存器(如ECX、EDX)传递,其余参数从右到左入栈。
- 由被调用者负责清理堆栈。
- 速度较快,减少了对栈的操作。
总结: - cdecl:调用者清理栈,支持可变参数,默认约定。 - stdcall:被调用者清理栈,不支持可变参数,常用于WinAPI。 - fastcall:部分参数用寄存器传递,被调用者清理栈,效率
“清栈”指的是清理入栈参数
以上几种函数调用约定是传统x86架构下的调用约定,x64架构下,windows和unix平台的函数调用约定有所不同。
函数是如何调用的
单个函数调用操作所使用的栈部分被称为栈帧(stack frame)结构
存放所有函数调用链的内存区域称为“栈”,栈上的每个函数调用成为“帧”
栈的地址是从高地址向低地址增长的,栈顶的地址最低
这里涉及到两个指针,“栈指针(esp)”和“帧指针(ebp)”,栈指针是栈上的一个全局指针,始终指向栈顶,帧指针是指向当前函数栈底的指针
┌──────────────────────────────┐
│ ...(上一个函数的栈帧) │
├──────────────────────────────┤
│ 参数n(最右边的参数) │ ← esp(刚进入函数时)
│ ... │
│ 参数2 │
│ 参数1(最左边的参数) │
├──────────────────────────────┤
│ 返回地址(ret address) │ ← call指令压入
├──────────────────────────────┤
│ 上一个ebp(栈帧指针) │ ← push ebp
├──────────────────────────────┤
│ 局部变量 │ ← sub esp, size
│ ... │
└──────────────────────────────┘
函数调用和返回的过程主要依赖于栈(调用栈)来管理参数、返回地址和局部变量。以常见的 x86 平台为例,过程如下:
- 调用前准备
- 调用者先把当前上下文(即占用的寄存器)入栈保存
- 调用者将函数参数按调用约定(如从右到左)依次压入栈中。
-
调用者执行
call指令,call会将下一条指令的地址(返回地址)压入栈,然后跳转到被调用函数的入口。 -
进入函数
- 被调用函数通常会保存当前的栈帧指针(即上一个函数的帧指针),然后将栈顶指针esp设置为自己的帧指针ebp,并为局部变量分配空间。
- 此时,栈上依次存放:参数、返回地址、上一个栈帧指针、局部变量。
-
这里有一个要点,新的ebp保存的是其实是被调用函数的栈底位置,而栈底位置存放的是上一个函数的ebp,这就构成了栈帧的链式结构,函数调用可以通过ebp一级一级向上回溯
-
函数执行
- 函数体内可以通过栈帧指针访问参数和局部变量。
-
为了能全局访问到参数,被调用函数不会将参数pop出来,而是通过帧指针访问函数参数
-
函数返回
- 函数将返回值放到约定的寄存器。
- 恢复上一个栈帧指针。
-
执行
ret指令,从栈顶弹出返回地址,跳转回调用者。 -
调用者收尾
- 调用者根据调用约定,自己或被调用者负责清理参数。
- 恢复调用前的上下文。
- 继续执行后续代码。
局部变量的入栈顺序
局部变量的入栈顺序是否和源码中的声明顺序一致,取决于是否有栈溢出保护机制,如果有栈溢出保护机制,局部变量在源码中的声明顺序和入栈顺序是相反的,即后声明的变量先入栈,这种情况下,先声明的变量位于栈上的低地址,后声明的变量位于栈上的高地址(栈地址是从高到低增长的)
为啥函数栈被写坏了,函数返回时被崩溃
有一种场景是,函数栈写超了,由于写入时是往高地址写入的,而上一个函数的帧地址和返回地址恰好在函数局部数据内存区域的高地址处,函数栈写超了后会破坏掉上一个函数的帧地址和返回地址,导致函数返回时崩溃。
关于寄存器
x64架构有16个通用整型寄存器,分别是rax、rbx、rcx、rdx、rsi、rdi、r8、r9、r10、r11、r12、r13、r14、r15,其中rax一般用来保存函数的返回值
有8个浮点寄存器,分别是xmm0、xmm1、xmm2、xmm3、xmm4、xmm5、xmm6、xmm7
除此之外,还有栈指针寄存器rsp(指向进程栈顶)和帧指针寄存器rbp(指向当前栈帧基地址),指令寄存器rip(指向当前执行的指令地址)
windows和unix平台的函数调用约定有所不同,windows平台使用的是微软的x64调用约定,unix平台使用的是System V AMD64 调用约定。
windows x64调用约定: - 前四个整数参数通过寄存器rcx、rdx、r8、r9传递 - 前八个浮点参数通过寄存器xmm0到xmm7传递
System V AMD64调用约定: - 前六个整数参数通过寄存器rdi、rsi、rdx、 rcx、r8、r9传递 - 前八个浮点参数通过寄存器xmm0到xmm7传递
顶层const和底层const的写法
//底层const
const int *a = 10;
int const *b = 10;
//顶层const
int * const c = 10; //指针本身是常量,不能改变指向
注意,底层const写在*的左边,顶层const写在*的右边。
C/C++程序的编译过程
C/C++ 程序的编译过程一般分为四个主要阶段:预处理、编译、汇编、链接。
- 预处理(Preprocessing)
- 处理
#include、#define、#ifdef等预处理指令,展开头文件、宏、条件编译,去除注释。 -
生成扩展名为
.i的预处理文件。 -
编译(Compilation)
- 将预处理后的代码翻译为汇编代码,进行语法分析、语义分析和优化。
-
生成扩展名为
.s的汇编文件。 -
汇编(Assembly)
- 将汇编代码翻译为机器指令,生成目标文件(object file),扩展名为
.o或.obj。 -
每个源文件对应一个目标文件。
-
链接(Linking)
- 将多个目标文件和库文件合并,解决符号引用,分配内存地址,生成可执行文件或库文件(如
.exe、.so、.dll)。 - 由链接器(如
ld)完成。
gdb常用命令
gcc编译的时候,要带上-g参数,表示编译时生成调试信息,这样才能在gdb中调试。
GDB(GNU Debugger)是Linux下常用的C/C++调试工具,常用命令如下:
gdb ./a.out启动gdb并加载可执行文件file [文件名]加载指定的可执行文件kill终止当前调试的程序run [参数]启动程序并传递参数break [file:]位置设置断点(如break main、break 23)info breakpoints查看所有断点delete [编号]删除断点disable/enable [编号]禁用/启用断点next/n单步执行(不进入函数)step/s单步执行(进入函数)continue/c继续运行直到下一个断点finish运行到当前函数返回print [变量/表达式]打印变量或表达式的值display [变量]每次停下时自动显示变量值set var x=5修改变量值backtrace/bt打印调用栈info locals查看当前函数的所有局部变量info args查看当前函数的参数list [位置]查看源码quit/q退出gdb
其他问题
memset如何实现
借助simd指令,并行执行