前言
接续上篇C++面试题总结,本文选题原则:一,C++17及以前版本;二,针对上一篇补充STL、模板、并发与同步相关内容;三,STL内容太多,很难面面俱到,只总结相对来说可能更高频一点的内容,没总结到的内容,自己也不了解。
目录
- 可变参数函数的实现原理?
tuple的实现原理?tuple可以替代结构体吗?any可以替代void*吗?any采取了什么样的优化方案?使用场景有哪些?variant可以替代union吗?optional的使用场景有哪些?any和variant性能差异?sizeof(function)大小是多少?function的实现方式?- 零拷贝原则是什么?
forward和move的作用?- 有了
move,为什么还要引入forward? - 引用折叠规则是什么?万能引用是什么?
constexpr if和SFINEA比较?- type traits作用和实现方式?
- 模板的全特化和偏特化是什么?
- 可变参数模板有哪些应用场景?
- 模板的匹配规则是什么?
- CRTP是什么?
- 顺序容器和关联容器有什么区别?
- 容器的内存释放原则和拷贝策略?线程安全?
- 迭代器和指针的区别?
- 什么是迭代器失效?
++iterator和iterator++哪个效率高? string采用了什么优化手段?string::data()和string::c_str()的区别?string可被继承吗?array和vector的区别?vector内存分配策略?怎么提高效率?capacity()和size()有什么区别?vector::erase()和std::remove()的区别?vector中reserve()和resize()的区别?vector<bool>是数组吗?和bitset区别?vector<char>和string的区别?list和vector区别?list::size(),list::empty()时间复杂度?forward_list和list区别?deque数据结构是什么?- 适配器是什么?
- 关联容器有哪些?它们的区别是什么?
vector中at()和operator[]有什么区别?map中at()和operator[]有什么区别?map::operator[]和map::insert的区别?vector::swap()和vector::operator=的区别?push_back()和emplace_back()区别?clear()会释放内存吗?- 互斥锁和自旋锁区别?
- C++标准库提供了哪些解决死锁的方案?
- 线程和协程的区别?为什么引入协程?
- 什么是有栈协程和无栈协程?哪种更好?
- 条件变量的实现原理?进行条件判断的时候能用
if替代while吗? - 条件变量和信号量区别?
- 无锁编程的memory order有哪些?
- 为什么要在父线程执行
detach()或者join()? async、promise、packaged_task可互相替代吗?- 什么是无锁编程?
题目
(1) 可变参数函数的实现原理?
可变参数函数在编译期间,可以获取到实际参数数量和参数类型;可变参数函数运行的时候,会根据编译期间获取到的类型和大小信息,分配相应大小的栈空间,把参数按照从右到左的顺序依次压入当前函数的栈帧;当前栈帧的地址完全是可以获取到的,出于安全考虑,获取参数值前,需要通过va_start把参数值拷贝到va_list指向的堆空间,遍历va_list就可以获取到每个参数的值了。
(2) tuple的实现原理?
tuple通过可变参数模板、递归继承实现,继承链中的每个类都只有一个成员变量,且所有这些成员变量的名称相同。用户可在编译期通过get<N>()可以获取继承链中的某一个类,然后根据类名获取该类下的成员。
(3) tuple可以替代结构体吗?
不可以,tuple相比于结构体的优势是,不需要提前定义结构体结构,就能直接使用,非常方便。但是,第一,tuple拷贝效率往往更低,因为tuple有自定义拷贝、移动、赋值构造函数,,不是trivial类型,而结构体可以是trivial类型;第二,相比于结构体,tuple类型可读性非常差,不考虑模板编程,一般只用作函数返回值的场合,用于同时返回多个值。
(4) any可以替代void*吗?
可以,与void*相比,any优势在于:第一,其它类型被转换成void*后,类型信息会发生丢失,而any会存储类型信息,进行类型转化时更安全;第二,any析构时,会自动析构堆上的对象,而void*需要手动管理内存。劣势在于:第一,any内存占用更高;第二,进行类型转化,会进行类型检查,效率更低,但这点劣势可忽略不计。
(5) any采取了什么样的优化方案?使用场景有哪些?
any采用了一种和sso类似的优化方案,对于比较小的数据类型,存储在对象内,对于较大的数据类型,存储在堆空间,并用void*指针指向堆空间的数据。引入any不是为了实现无类型编程,而是用于替代void*,所以只适用于原来不得不使用void*的场景。
(6) variant可以替代union吗?
可以,variant通过可变参数模板和递归union的方式实现,variant实例化对象内存放有数据类型信息。相比于union,variant优势在于:第一,可以存储复杂类型,而union只能存储trivial类型;第二,variant存储了数据的类型信息,可以进行安全的类型转换。劣势在于,内存更大、访问效率更低。
(7) optional的使用场景有哪些?
optional用于存储单个元素,相比于单个元素多了一个nullopt值,当值为nullopt的时候,表示当前值无效、未初始化、或者缺失。optional常见应用场景是用作函数返回值表示返回值是否有效、用作函数参数表示参数是否缺失,但用作函数参数时需要注意,标准要求optional数据必须存储在对象内,因此在对`optional``执行赋值操作的时候,会发生一次对象拷贝。
(8) any和variant性能差异?
首先,any存储数据的方式有两种,对于比较小的数据类型,会存储在对象上,对于比较大的数据类型,会存在堆上;variant则会把数据存放在对象上;第二,variant内存大小在编译期确定,any内存大小需要在运行时确定;第三,对于variant模板,如果其参数列表上的所有类型是trivial类型,variant就是trivial类型,而any不是trivial类型。因此any效率更低。
(9) sizeof(function)大小是多少?function的实现方式?
std::function的大小等于2个机器字长,这是因为std::function可以封装任何可调用对象,其中普通函数指针长度为1个机器字长,成员函数指针长度为2个机器字长,因此为了能够封装任何可调用对象,其内存大小为2个机器字长。std::function内部封装有一个函数指针指向可调用对象,然后重载实现构造函数、=运算符、()运算符。
(10) 零拷贝原则是什么?
C++ 中的零拷贝(Zero Copy)原则是指在数据传输过程中尽量避免不必要的数据复制,以提高性能和降低内存消耗。C++中的对象拷贝经常发生在赋值、传参、返回值、不同类型引用赋值过程中,解决方案有,第一,传指针、传引用;第二,视图,比如string_view;第三,编译期进行类型转换,比如as_const,reference_wrapper,std::decay;第四,浅拷贝;第五,编译器优化,比如RVO。
(11) forward和move的作用?
move有两个作用,第一,进行赋值或者拷贝的时候调用对象的移动赋值函数或者移动构造函数,把原对象在堆上的资源移为己有;第二,告诉编译器调用参数为右值引用类型的函数。forward作用是告诉编译器,是调用参数类型为左值引用类型的函数,还是调用参数类型为右值类型的函数。如果没有move和forward,任何实参传递给函数后,都会变成左值类型,这就会造成参数类型为右值引用的函数永远无法被调用。
(12) 有了move,为什么还要引入forward?
在非模板编程中,通过使用move保证右值永远以右值的方式传递,不使用move保证左值永远以左值的方式传递,完全可以用move替代forward;但是在模板编程中,我们是无法知道T&&是左值还是右值的,从而无法得知是否该使用move,有了forward后,我们无需知道具体类型,就可以保证右值永远以右值的方式传递下去,左值永远以左值的方式传递下去。简言之,forward配合引用折叠可以实现完美转发,而move无法实现完美转发。
(13) 引用折叠规则是什么?万能引用是什么?
引用折叠规则是:&&折叠到&&,结果是&&;其它情况下的引用折叠结果都是&。在C++11中,使用&&来声明的引用被称为右值引用,但当这个引用出现在模板参数中时,并且引用前的类型不是具体类型时,就称之为万能引用,比如T&&和auto&&就是万能引用,之所以把它称为万能引用,是因为T&&既可以表示左值引用又可以表示右值引用。
(14) constexpr if和SFINEA比较?
constexpr if和 SFINAE(Substitution Failure Is Not An Error)是两种用于条件编译的技术,都可以在编译时根据条件选择不同的代码路径。constexpr if通过语句来实现条件编译;而SFINAE利用模板特化、模板匹配规则来实现条件编译。
(15) type traits作用和实现方式?
type traits可以帮助我们在编译期间获取类型信息,以便执行类型相关的操作,比如进行类型转换,判断两个类型是否是继承关系、是否有公共基类。所有的type traits模板都是借助 SFINAE实现的。
(16) 模板的全特化和偏特化是什么?
首先,模板函数只有全特化,没有偏特化,模板类有全特化和偏特化。模板全特化指的是,在元编程中给定所有模板参数的具体类型;模板偏特化也称为部分特化,指的是在元编程中给定部分模板参数的具体类型。引入特化和偏特化是为了给特定的类型提供单独的实现。
(17) 可变参数模板有哪些应用场景?
可变参数模板一般用于实现可变参数函数、可变参数容器。STL内的function,tuple,variant,emplace_back()都是通过可变参数模板实现的。
(18) 模板的匹配规则是什么?
模板函数的匹配顺序是:首先根据函数名进行匹配;若找到多个函数名匹配的模板,再根据参数列表进行匹配,这种匹配过程被叫做重载决议。模板类的匹配顺序是:首先根据类名进行匹配;若找到多个类名匹配的模板,再按照全特化、偏特化、通用模板的的优先级进行匹配。
(19) CRTP是什么?
CRTP 的核心思想是通过模板继承来实现静态多态性。它的基本原理是基类是模板类,派生类将自身作为模板参数传递给基类,
class Derived : public Base<Derived>{},从而使得基类能够调用派生类的成员函数。
(20) 顺序容器和关联容器有什么区别?
关联容器中的元素是按关键字的大小顺序来保存和访问的,顺序容器中的元素是按它们在容器中的位置来顺序保存和访问的。
(21) 容器的内存释放原则和拷贝策略?线程安全?
首先,标准库容器采用RAII机制,自动释放占用的内存,但如果容器内存放的是指针类型,销毁容器时,只会自动释放指针自身占用的内存,需要用户手动释放指针指向的内存;第二,容器之间赋值,会调用容器的构造函数或者移动构造函数,赋值完成后,每个容器都会有自己独立的副本;第三,通过容器提供的接口访问数据,其返回值一般是迭代器类型或者引用类型,而不是值类型,这样做是为了减少数据的拷贝。另外,标准库的容器不是线程安全的。
(22) 迭代器和指针的区别?
迭代器是对指针的浅层封装,在封装指针的基础上,重载实现了构造函数、重载运算符函数,引入迭代器是为了屏蔽底层的数据结构差异,实现统一的数据访问接口。
(23) 什么是迭代器失效?++iterator和iterator++哪个效率高?
所谓的迭代器失效,是指迭代器内部封装的指针指向的地址失效,当容器发生插入、删除或重排等操作时,已存在的迭代器可能会无效,这是因为这些操作可能导致容器内部的数据结构发生改变,从而使原先有效的指针指向无效的位置。++iterator效率比iterator++高,因为后者会创建一个临时对象并返回,多了一次拷贝操作。
(24) string采用了什么优化手段?
STL标准没有规定
string的实现方式,比较常见的优化方案是小字符串优化sso和写时复制cow,如今主流编译器均采用sso优化,当数据量较小的时候,把数据存放在对象内,数据量较大的时候,把数据存放在堆上。
(25) string::data()和string::c_str()的区别?string可被继承吗?
C++11以前,c_str()返回的是C语言风格的字符串,以\0作为字符串的结尾;data()返回的字符串结尾没有\0。C++11以后,要求string内的字符串必须以C语言风格存储,因此c_str()和data()没什么区别。不建议继承string,虽然编译期间,继承string不会引发编译错误,但运行时容易引发切片(slicing)错误,这是因为string的析构函数不是虚函数。
(26) array和vector的区别?
array的数据存放在对象内,vector的数据存放在堆空间;array的内存大小是固定的,在编译期确定,vector内存大小是动态增减的,在运行期间分配;array可以是trivial类型,vector不是trivial类型,前者初始化或者拷贝效率和裸数组等同,效率更高。
(27) vector内存分配策略?怎么提高效率?capacity()和size()有什么区别?
vector的数据存放在堆空间,采用动态扩容策略,如果内存不够了,会分配新的内存,内存大小为原来的两倍,并把原始数据拷贝到新分配的内存空间;但是vector不会自动缩容,需要手动调用resize()或者shrink_to_fit()缩小内存。为了减少扩容和拷贝,可通过resize()提前分配内存空间,提高效率。capacity()返回的是分配的内存数,size()返回的是实际存放的数据数。
(28) vector::erase()和std::remove()的区别?
erase()函数会实际删除元素和释放内存;remove()不会实际删除元素和释放内存,而是通过移动元素来覆盖需要被删除的元素,并返回一个指向新的逻辑结尾的迭代器。
(29) vector中reserve()和resize()的区别?
reserve()函数用于预分配容器的内存空间,以提前为容器中的元素分配足够的内存;resize()除了预分配内存以外,还会调用容器元素的构造函数,把这些元素构造在分配好的内存上。
(30) vector<bool>是数组吗?和bitset的区别?vector<char>和string的区别?
不是,vector<bool>是vector<T>的特化版本,每个bool值用1位空间存储。vector<bool>和bitset内存占用和读写性能一样,区别是:vector数据存放在堆空间,bitset数据存放在栈内;vector内存大小在运行时确定,bitset大小在编译期确定。vector<char>和string都可以用来存放字符串,区别是,后者针对字符串提供了SSO优化,并且提供了更多的字符串操作方法。
(31) list和vector区别?size(),empty()时间复杂度?
vector的数据存放在连续的内存空间,list的数据存放在非连续内存空间,以链表的形式组织起来;vector访问效率更高,list插入、删除效率更高。empty()的时间复杂度是O(1),size()的时间复杂度,C++11以前是O(N),之后是O(1)。
(32) forward_list和list区别?
forward_list采用单向链表存储数据,list采用双向链表存储数据,前者内存占用更低,但随机访问效率更低。
(33) deque数据结构是什么?
deque采用块状数组结构存储数据,混合了链表和数组两种数据结构,属于vector和list的中间方案,首部尾部增加删除时复杂度都是O(1),因此成为了queue,stack的默认容器。
(34) 适配器是什么?
STL适配器有容器适配器queue,stack,priority_queue,以及迭代器适配器reverse_iterator等等。适配器是对现有组件的的简单封装,采用了一种名为适配器模式的设计模式,使得它们的接口能够适应新的需求。STL容器适配器默认容器都是deque。
(35) 关联容器有哪些?它们的区别是什么?
set、map、unordered_set、unordered_set以及对应的multi容器都是关联容器。set和map底层数据结构都是红黑树,查找复杂度是log(N),set和map内的数据按照键值有序存储,因此若要把自定义类型作为键值,需要重载比较运算符;unordered_set、unordered_set底层数据结构是哈希表,不考虑哈希冲突的情况下,查找复杂度是O(1)。
(36) vector中at()和operator[]有什么区别?map中两者有什么区别?
vector中,operator[]运算符没有提供越界检查,at()函数提供了越界检查功能,因此前者效率更高,后者安全性更高。map中,使用m[key]的方式访问map时,它会根据给定的键查找对应的值,如果指定的键在map中,则返回对应的值,如果键不存在,则会自动插入一个默认构造的值,并返回这个新插入的值的引用;使用map.at(key)的方式访问map,如果键不存在,则会返回越界错误。
(37) map::operator[] 和 map::insert的区别?
两者都可以执行插入操作,区别是,insert执行插入操作;而operator[]在插入操作前,会执行一次查找,如果指定的键在map中不存在,再执行插入。因此insert的插入效率更高。
(38)进行赋值, vector::swap()和operator=的区别?
两者都可以用于容器的初始化。区别是,operator=执行过程,会先分配内存,然后依次调用元素的拷贝构造函数,把元素拷贝到新的容器内;swap()则不会进行内存分配和元素拷贝操作,只进行了指针交换的操作。因此,如果原来的容器不再使用了,使用swap()初始化新的容器,效率比operator=更高。
(39) push_back()和emplace_back()区别?
假设容器内存放的元素类型是T,其定义了T(int)构造函数,T(const T&)拷贝构造,T(T&&)移动构造函数。首先,push_back()是单参数函数,参数类型是const T&或者T&&,emplace_back()是不定参数函数,其参数列表必须和T的普通构造函数、拷贝构造函数或者移动构造函数的参数相同;第二,emplace_back()通过可变参数成员函数模板实现,其编译时长比push_back()更长;第三,push_back()和emplace_back()的执行效率完全相同,只有一种例外情况:push_back(2)比emplace_back(2)多执行一次普通构造函数,因为前者需要通过构造函数把2转化为T类型,然后调用push_back()函数。
(40) clear()会释放内存吗?
对于vector、string,调用clear()后,容器不会释放内存;对于list、forward_list、map、set,调用clear()后会释放内存。这里的内存释放指的是从容器数据结构内释放,并把内存归还给内存分配器的内存池,而不是释放系统内存。
(41) 互斥锁和自旋锁区别?
两者都用于实现临界资源互斥访问,都通过原子指令实现。区别是,第一,互斥锁加锁失败后,线程会阻塞并释放CPU;自旋锁加锁失败后,线程会处于无限循环,直到拿到锁;第二,互斥锁加锁失败后,会切换到内核态并阻塞当前线程,解锁唤醒线程也需要进入内核态,而自旋锁始终处于用户态,因此后者加锁解锁效率更高,但是自旋锁长时间空等会更加占用CPU资源,因此当临界区时长比较短的时候可以用自旋锁,比较长的时候则用互斥锁。
(42) C++标准库提供了哪些解决死锁的方案?
死锁是指在并发程序中,多个进程或线程因为竞争资源而无限期地等待对方所持有的资源的情况。为降低死锁出现的几率,标准库提供了一些方案,第一,使用超时锁timed_mutex,当线程等待时间超时,自动释放锁;第二,使用递归锁recursive_mutex,解决嵌套调用引起的死锁;第三,利用RAII机制,比如unique_lock,lock_guard,scope_lock,解决由于用户忘记释放锁而引起的死锁;第四,采用非阻塞方案try_lock,当获取锁失败,不等待而是继续执行。
(43) 线程和协程的区别?为什么引入协程?
两者区别是,第一,线程的调度需要切换到内核态,由操作系统完成,协程的调度在用户态完成,由用户程序程序进行调度;第二,抢占式协程和抢占式线程的实现原理不同,抢占式协程由编译器插入时间片或者由操作系统信号实现,线程的抢占通过时间片中断实现。引入协程是为了实现异步非阻塞编程,传统的线程在异步资源返回前,往往会阻塞当前线程,但是引入协程后,在异步资源返回前,当前线程不必阻塞,而可以执行其它协程中的任务。
(44) 什么是有栈协程和无栈协程?哪种更好?
有栈和无栈是实现协程的两种方案,有栈协程有两种常见实现方案,第一,每个协程都有一个固定大小的栈帧,用于保存协程现场,进行协程切换的时候,保存现场只需要拷贝寄存器数据到栈帧并切换栈帧即可;第二,所有协程共用一个栈帧,进行协程切换的时候,保存现场需要拷贝寄存器数据、栈帧数据到堆空间。无栈协程不是说协程执行不使用栈空间,而是说,协程内的局部变量会被分配在堆空间,而不是栈空间,无栈协程是一种特殊语法形式,由编译器实现。两者互有优劣,无栈协程效率更高,第一,有栈协程的实现需要运行时开销,而无栈协程由编译器实现,编译器可对其进行针对性优化;第二,有栈协程共用一个栈帧,相比于有栈协程的第一种方案,内存占用更小,相比于有栈协程的第二种方案,切换开销更小,因为它的切换是通过函数返回的形式实现的,只需要保存寄存器数据。但是,无栈协程的兼容性更好。
(45) 条件变量的实现原理?进行条件判断的时候能用if替代while吗?
互斥锁只能实现对资源的互斥访问,而不能实现线程同步,引入条件变量就是为了实现线程同步。条件变量是基于互斥锁和等待队列实现的,wait()调用后,若发现条件变量没有被占用,则继续执行,若发现条件变量被占用,则释放锁并阻塞当前线程,把线程放到等待队列上;notify()调用后,会唤醒阻塞的线程。条件判断不能用if替代while进行条件判断,假如生产者线程调用notify_all(),采用if语句,所有消费者线程被唤醒后都会进入临界区,可能会引发data race,采用while语句则不会出现这个问题,因为线程被唤醒后,会再次执行条件判断,竞争获取锁,最终只有一个线程会进入临界区。
(46) 条件变量和信号量区别?
首先,信号量是通过互斥锁、等待队列、计数器实现的,条件变量是通过互斥锁和等待队列实现,没有计数功能;第二,信号量既可以充当互斥锁,也可以充当条件变量;第三,如果等待队列上没有任务,信号量调用notify后,信号会被保存,条件变量调用notify后,信号会丢失;通过信号量可以实现线程同步,条件变量需要和互斥锁配合使用才能实现线程同步,前者使用起来更简单,后者内存占用和性能更好。
(47) 无锁编程的memory order有哪些?
C++提供了6种可以应用于原子变量的内存次序,可以归为5类:relaxed,release,acquire,consume,sequential consistency。relaxed作用和volatile类似,保证内存一致性,不影响指令重排;release作用是防止当前指令前的指令被重排到当前指令后;acquire作用是保证当前指令后的指令被重排到当前指令前;consume将被废弃,没有多作了解;sequential consistency的作用等于release+acquire。
(48) 为什么要在父线程执行detach()或者join()?
调用detach()或者join()后,子线程的状态会从unjoinable变为joinable,父线程执行结束的时候,若发现子线程状态是joinable则会调用terminate()终止子线程,反之则什么也不做。在父进程内不调用detach()或者join(),如果父线程执行完子线程还没执行完,会导致子线程异常终止。
(49) async、promise、packaged_task可互相替代吗?
不能,三者都用于简化线程间同步编程,返回值都存放在future对象,区别是async、packaged_task、promise使用方便程度依次递减,编程灵活度依次递增。async像异步函数调用,使用起来最简单;packaged_task则只需要把task传递给子线程即可;promise则需要把promise对象和future对象分别传递给生产者线程和消费者线程,使用起来最难,但最灵活。
(50) 什么是无锁编程?
无锁编程指的是不使用互斥锁、自旋锁编程,而是利用原子指令实现临界资源的互斥访问和线程之间的同步。其实互斥锁、自旋锁都是通过原子指令实现的,我们在利用这些锁编写程序的时候,本质上还是在使用原子指令进行编程,无锁编程的效率之所以更高,是因为它对临界区的划分粒度更细,节省了大量由于竞争资源而导致的忙等和阻塞时间。
转载至:https://zhuanlan.zhihu.com/p/642495198
觉得有用就自己搬过来记录,不用两边跑来跑去,无商用意义,侵权必删。
本文接续上篇C++面试题总结,选题聚焦C++17及以前版本,补充STL、模板、并发与同步相关内容。目录和题目部分列出了众多问题,如可变参数函数原理、容器区别、线程与协程差异等,为C++学习和面试提供参考。

1251

被折叠的 条评论
为什么被折叠?



