C++ 基础知识汇总
Cpp对象
- struct 和 class 区别?
- struct的成员默认是公有的,而类的成员默认是私有的;
- C中的struct不能包含成员函数,C++中的class可以包含成员函数。
- 构造函数和析构函数可不可以为虚函数,为什么?
- 构造函数不可以是虚函数,构造函数的调用顺序是从基类到派生类,因为基类部分先于派生类部分构造。如果基类构造函数是虚函数,那么它将无法正常地被调用,因为在调用虚函数之前必须先构造对象。
- 析构函数可以为虚函数,因为当基类的指针指向派生类对象的时候,发生多态,如果不将基类的析构函数定义为虚函数的话,那么派生类的析构函数就无法执行。
- 拷贝构造函数如果用值传递会有什么影响?
- 拷贝构造函数用值传递时,参数传递本身就会调用拷贝构造函数,这样就无限循环调用了。
- 如何限制一个类对象只能在堆(栈)上分配空间?
- 限制对象只能在堆上构造,可以将将类的构造函数和析构函数设为私有的(保护的),这样编译器无法自动调用其构造函数或析构函数,就只能在堆上分配了。由于构造函数不可在外部调用,此时需要提供静态函数用于在堆上构造对象。
1 | class A |
- 限制对象只能在栈上构造,只有使用 new 运算符才会在堆上构造,而 new 运算符会调用
operator new
,因此只需要将operator new
定义为私有即可(通常也将operator delete
一并设置成私有)。
1 | class A |
- 拷贝构造函数参数中为什么要加const?
- 加了const之后就是一个常量左值引用(const-reference),可以引用右值,常量左值引用(const-reference)可以接受左值,右值,常量左值、常量右值。
- 谈一谈关键词 public protected private?
在类成员中限定访问权限:
- private: 只能由该类中的函数、其友元函数访问。
- protected: 可以被该类中的函数、子类的函数、以及其友元函数访问。
- public: 可以被该类中的函数、子类的函数、其友元函数访问,也可以由该类的对象访问。
类继承后方法属性变化:
- 使用private继承,父类的所有方法在子类中变为private。
- 使用protected继承,父类的protected和public方法在子类中变为protected,private方法不变。
- 使用public继承,父类中的方法属性不发生改变。
- 什么是多态?
- 通过基类指针或引用绑定到派生类对象上,并通过它调用基类和派生类中都有的同名、同参数表的虚函数,编译时并不确定要执行的是基类还是派生类的虚函数;而当程序运行到该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。多态可以重用接口或者说规范接口。
- 多态是如何实现的?
- 存在虚函数的类都会有一个虚函数表 vtbl,用于存储虚函数对应的函数地址,对于基类和派生类,虚表中同样偏移量的表项对应的函数地址是不同的。同时类中还含有一个虚表指针 vptr,在通过基类指针或引用调用虚函数时,通过这个虚表指针去调用虚表对应表项的函数,从而实现多态。
- 虚函数可以内联吗?
- 虚函数可以定义为内联函数,但是当虚函数表现为多态的时候不能内联。因为内联是在编译期间编译器认为能够内联就将函数内容展开;而多态发生在运行期间,在编译期间并不知道要调用的具体函数内容,故无法内联。
1 | // 此处的虚函数 who(),是通过类(Base)的具体对象(b)来调用的, |
- 说一说纯虚函数?
- 纯虚函数和虚函数一样,使用
virtual
关键字修饰,不同的是需要在末尾加上=0
修饰,含有纯虚函数的类成为抽象类,抽象类不能实例化。纯虚函数用来为其派生类规定接口,在派生类中实现这些纯虚函数。
- 如何避免类拷贝?
- 可以将拷贝构造函数和拷贝赋值函数声明为
private
,更好的方法是在public
中将其声明为delete
。在 C++ 中,通常会定义一个简单的Noncopyable
类,这个类禁用了拷贝构造和拷贝赋值,其它类只需要继承这个类就行。
- 多继承会出现什么问题?如何解决?
- 多继承是 C++ 特有的继承模式,其它更为现代的面向对象语言像 Java、Python 等只支持单继承。多继承会出现菱形继承问题,从而可能造成命名冲突和数据冗余等问题,可以通过虚继承来解决,使得相同的只被保留一份。实际上我认为好的设计不应该使用多继承,继承应该严格遵守 is-a 的关系,也就是里氏替换原则。在这方面,Java 的设计就要好很多,Java 只支持单继承,而对于接口复用则通过 Interface 来实现,不会显得和 C++ 这么乱。
- C++ 类对象初始化顺序?
初始化顺序如下:
- 首先执行基类的构造函数,构造基类部分。
- 调用成员变量的构造函数,初始化成员变量。
- 调用自身构造函数。
销毁时顺序恰好相反:
- 调用自身的析构函数。
- 调用成员变量的析构函数,销毁成员变量。
- 调用基类的析构函数,销毁基类部分。
- 如何禁止一个类被实例化?
- 将构造函数声明成
private
或者delete
,这种手法在单例模式中经常使用,通常提供一个静态成员函数来返回这个单例。
- 为什么使用成员初始化列表会更快一些?
- 因为在调用构造函数之前成员会调用默认构造函数执行默认初始化,而使用列表初始化则省去了这一过程,直接调用相应的构造函数进行构造。
- 深拷贝和浅拷贝的区别?
- 如果类中有指针指向了其它资源,浅拷贝只是单纯的对指针的值进行拷贝,这导致两个对象同时指向同一片资源,其中一者修改或销毁资源会使另一者可见。深拷贝则是对这片资源也进行拷贝。实际上在 Java 和 Python 中默认都是浅拷贝的,他们变量赋值本身具备浅拷贝的语义,这样大大节省了资源复制的成本,要实现深拷贝需要专门的方法,如 Java 中重写 clone() 方法。
- 实现一个类成员函数,要求不允许修改类的成员变量?
- 在函数末尾使用
const
关键字修饰,这本质上是将传入函数的this
指针参数声明为const
(底层 const),这样在函数内修改成员变量会遭到编译器拒绝。
- 如何让类不能被继承?
- 使用
final
关键字。
- 能不能在构造函数或析构函数中调用虚函数?
- 可以,但是这是危险的行为。在基类的构造函数执行时,其派生类部分还没有构造,这样虚函数无法发生多态,而是会调用基类的函数。同样的,在基类析构函数执行时,其派生类部分已经被销毁了,虚函数同样无法发生多态,而是会调用基类的函数。
内存管理
- new 和 malloc 的区别?
- 都可用来申请动态内存和释放内存,都是在堆(heap)上进行动态的内存操作。
- malloc和free是c语言的标准库函数,new/delete是C++的运算符。
- new会自动调用对象的构造函数,delete 会调用对象的析构函数, 而malloc返回的都是void指针。
- new 和 malloc 如何判断是否申请到内存?
- malloc :成功申请到内存,返回指向该内存的指针;分配失败,返回 NULL 指针。
- new :内存分配成功,返回该对象类型的指针;分配失败,抛出 bad_alloc 异常。
- new 和 delete 原理?
- 对于内置类型,new 调用 operator new 函数,进而调用 malloc 函数进行内存分配;delete 调用 operator delete 函数,进而调用 free 函数回收内存。new[] 和 delete[] 分别调用 operator new[] 和 operator delete[],实际上就是计算好总的内存大小进而调用 malloc 和 free。
- 对于自定义类型(自定义的类),new 调用 operator new 函数,进而调用 malloc 分配内存,之后在该内存调用构造函数;delete 首先调用析构函数,然后调用 operator delete 函数,进而调用 free 回收内存。new[] 和 delete[] 类似,只是调用 N 次构造函数和析构函数。
- malloc 的底层实现原理?
- malloc 会调用 brk 和 mmap 系统调用来向操作系统索要内存。其中 brk 系统调用就是移动堆分区的指针,在访问这段新内存时触发操作系统的缺页处理,从而分配内存。mmap 可用来将磁盘文件映射到内存中,也可以匿名映射,通过匿名映射的方式可以获取内存,而不映射到具体的文件上。
- 内存分配的区域?
- 堆区:一般由程序员自己手动分配和回收,分配时向高地址增长。
- 栈区:由编译器自动分配和释放,存放运行函数期间的局部变量、函数参数、返回数据等,分配时向低地址增长。
- 全局区(静态存储区):存放全局变量、静态变量。(对应于目标文件中的.data和.bss段)
- 常量区:存放常量和字符串常量。(对应于目标文件中的.rodata段)
- 代码区:存放二进制代码。(对应于目标文件中的.text段)
关键字
- extern 关键字
- extern 关键字用于声明一个变量,
extern int a
告诉编译器,有个int
类型的变量a
定义在其它地方,请(在链接阶段)去别的地方查找这个变量。
- static 关键字
- static 关键字修饰变量时,说明这个变量的生存周期是永久的,变量存放在全局区,即使函数调用结束之后变量也不会被销毁。如果 static 变量定义在头文件并被多个源文件包含,会导致每个源文件有一个变量的副本,他们之间的值互不影响(因为C++是分离编译的)。
- explicit 关键字
- explicit 关键字用于修饰单个参数的构造函数,防止编译器执行隐式类型转换(隐式的将这个参数构造成对象进行使用)。
- volatile 关键字
- volatile 关键字修饰某个变量告诉编译器该变量随时可能发生改变,每次存储或读取该变量时,应该直接从内存中读取,而不是某个寄存器中的(编译器认为没有改变的)值,编译器有时会有这样的优化,这个关键字会禁止编译器对这个变量进行此优化。这个关键字在嵌入式硬件编程里用的多,有些寄存器的值会自己变化。
通常情况下,编译器做一些这种优化(使用 volatile 就不会优化了):
1 | x = 10; |
会被优化成:
1 | x = 20; |
- extern C 的作用?
C++ 程序需要调用 C 语言编写的函数时,需要通过这个包裹:
1 | extern "C" { |
因为 C++ 和 C 语言函数的签名不同,C++ 为了支持重载等复杂机制,签名更加复杂,为了能够在链接时找到对应的 C 语言编写的函数,需要通过 extern C 来指示编译器。
- 声明和定义的区别?
- 声明用于告诉编译器存在这个名字符号,并不实际分配内存,使用
extern
关键字来声明一个变量或函数。 - 定义则是实际分配内存构造出相应的变量、类或函数。
- 引用是否会占用内存空间?
- 实际上会占用内存空间(一般为一个指针的大小),在 C++ 中,引用是变量的一个别名,和指针效果类似,不同的是指针可以为
nullptr
且可以改变指向的变量。引用经常被编译器实现为指针,且是一个指针常量,不能为空或改变指向的变量。
- strcpy和memcpy的区别?
- strcpy 只能用来复制字符串(以
null
结尾),memcpy可以用来复制任何长度的连续内存块,此外,memcpy 由于不用查找字符串的结尾符,通常要比 strcpy 快。
- memcpy原理?
- memcpy 实现固定长度连续内存的拷贝,当内存有重叠时,拷贝结果为未定义(这种情况可以使用memmove)。为了提高效率,memcpy 实现一般使用汇编实现,且考虑拷贝长度大小、内存对齐等诸多因素,绝不是单纯的逐字节拷贝。
- strcpy 有什么缺陷?
不检查目的区域边界,可能导致缓冲区溢出。
编译内存相关
- C++ 程序编译过程?
分为以下四步:预处理、编译、汇编、链接。
- 预处理:将
#include
、条件编译、注释等进行展开和删除。 - 编译:将源代码(.cpp)翻译成汇编代码(.s)。
- 汇编:通过机器码查表将汇编代码翻译为机器指令,也就是目标文件(.o)。
- 链接:解决上述目标文件中的外部符号引用问题,即将多个目标文件链接至可执行程序。
链接分为静态链接和动态链接。
- 静态链接:从静态库中拷贝代码至最终的可执行程序中,完成符号的重定位,执行时不依靠其它库文件。
- 动态链接:将符号重定位延迟到程序执行(动态链接器在加载共享模块时),程序在执行时调用动态链接库中的符号和函数。多个程序可以共享动态链接库,因此这种方法更节省空间,且动态库升级时不需要重新编译整个程序,但需要保证二进制兼容。
- 什么是内存对齐?内存对齐的原则?为什么要进行内存对齐,有什么优点?
-
访问的变量的地址需要是变量本身大小的整数倍,这种访问被称为是内存对齐的访问。
-
对齐规则如下:所有基础类型的对齐字节大小为本身的大小,也就是说 char 类型变量需要按照 1 字节对齐,int 类型变量需要按照 4 字节对齐,也就是说其地址需要被 4 整除。对于 struct, union, class 类型的变量,其对齐字节大小为内部非静态成员的最大对齐值。
-
某些硬件只支持访问对齐的数据,对于非对齐数据访问引发异常。即使是支持对于非对齐数据的访问,其性能要低于访问对齐数据(访问非对齐数据可能会触发多次内存访问)。
- 如何计算一个类占用的字节大小?
遵循以下原则:
- 和结构体遵循相同的字节对齐规则。
- 类大小只和普通成员变量(包括常量)有关,和成员函数无关,和静态成员变量无关。
- 若存在虚函数,则会增加一个虚函数表指针的大小。
- 对于空的类,其大小为 1 个字节,为了保证不同类地址不同。
- 什么是内存泄漏?
- 内存泄漏指由于错误或者疏忽导致某些不再使用的内存没有被释放,主要为程序员对堆上内存使用不当造成。例如 new 了对象,不使用的时候忘记 delete。
- 事前使用智能指针等管理内存,防止内存泄漏,事后通过内存泄漏检测工具进行检测。
- 说说C++中的智能指针?
- 共享指针(shared_ptr):资源可以被多个指针共享,使用计数机制表明资源被几个指针共享。通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptr、weak_ptr 来构造,调用 release() 释放资源的所有权,计数减一,当计数减为 0 时,会自动释放内存空间,从而避免了内存泄漏。
- 独占指针(unique_ptr):独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造和移动赋值构造(调用 move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过该方法进行赋值。
- 弱指针(weak_ptr):指向 share_ptr 指向的对象,能够解决由shared_ptr带来的循环引用问题。
C++ 11 新特性
本人常用有以下几个:
- auto 和 decltype 关键字,可用于简化写法。
- lambda 表达式,非常常用。
- 右值引用,移动语义,用于进一步提高效率,减少不必要的复制。
- 智能指针,作为很好的内存管理工具,尽量代替裸指针分配内存。
- nullptr。
- 范围 for 循环。
- enum class。
- 多线程相关,包括 std::thread, std::mutex, std::lock_guard, std::unique_lock, std::condition_variable等。
语言特性
- 左值和右值的区别,左值引用和右值引用的区别,如何将左值转换为右值?
- 左值和右值定义有些模糊,但左值表示一个在内存中占据可识别位置的对象,而右值则是表示一个对象的值(一般是字面常量或者表达式求值的临时对象),左值持久,右值短暂。
- 左值引用就是别名,不能绑定到右值上(除了 const reference);相反,右值引用只能绑定到右值上,用于接管将要销毁的对象。右值提出其实就是为了更进一步提升效率。
- 将左值转换为右值可以通过
std::move
强制完成。std::move
本质上就是一个强制类型转换,无条件的将实参强制转换为右值。
- 什么是指针?指针的大小?
- 指针用来保存一个地址值,从而能够通过地址访问到相应的对象(包括变量和函数)。由于保存的是地址,它的大小和操作系统的位数有关系,64位操作系统通过64位地址总线寻址,因此指针大小为8个字节;类似的32位操作系统为4个字节。指针本质上是一个整型值,不同类型的指针只是告诉编译器如何去解释相应内存地址处的对象。
- 什么是野指针和悬空指针?
- 悬空指针就是指针指向的内存被释放后,当前指针就为悬空指针,此时访问该对象是未定义行为(Undefined Behavior)。
- 野指针就是指针指向未意料的位置,如未初始化的指针,此时访问该对象也是未定义行为。
- nullptr 相比于 NULL 的优势?
- NULL 是一个宏,被定义成 0,实际上是一个整形的字面量,并不具备指针的类型。对于整形参数和指针类型参数重载的函数,NULL 会优先匹配整形那个,这是不被预料的。
1 | void func(int* tmp); |
- nullptr 是在 C++ 11后用来弥补上述缺点的,nullptr 本身具备类型
nullptr_t
,且可被隐式转换成任意指针类型。
- 指针和引用的区别?
- 指针保存对象的地址,引用是一个别名;因此指针可以改变指向,而引用一旦绑定对象就不能改变。
- 指针可以为空,即不指向任何对象;引用必须绑定变量。
- 指针占用内存,引用在内存不占内存空间。关于这一点是指使用
sizeof
取不到引用的大小(取到的是对象本身大小),但通常编译器将引用实现成指针,因此也会占用空间。
- 常量指针和指针常量区别?
- 指针常量:也即顶层
const
,指针变量本身是常量,不能修改指向,也就是不能修改其保存的地址值。 - 常量指针:也即底层
const
,指针指向的对象是一个常量,不能通过这个指针来修改指向的变量。
1 | const int* p; // const 在 * 前,常量指针;底层 const |
- 迭代器作用?
- 迭代器是一种抽象的设计概念,可以通过迭代器访问容器的元素而不需要暴露容器的具体实现。C++ 中迭代器结合泛型算法可以方便的实现如排序、求和等功能,只需要传入头迭代器、尾迭代器就可以让算法访问到容器元素。
- C++ 中的强制转换?
C++ 中也支持 C 风格的强制类型转换,为了避免带来隐患,C++ 提供了几种不同的类型转换函数,让程序员指明转换的语义。
- static_cast:主要是用于基本数据类型之间的转换,static_cast 没有运行时的类型检查,需要程序员自己判断转换是否安全。此外,还可用于基类和派生类指针或引用之间的转换,其中派生类向基类转换是安全的(只是无法访问派生类多出来的成员了),向下转换则是危险的。
1 | int x = 1; |
-
dynamic_cast:主要用于类层级之间的向上或向下转换,与 static_cast 不同的是,dynamic_cast 有运行时类型检查,从派生类到基类转换时和 static_cast 效果一致,反过来则会检查对象的实际类型,转换失败则变成空指针。
-
reinterpret_cast:简单的重新解释内存中的位,常用于指针类型之间的转换如将
int*
转换为char*
等,是最危险的转换,程序员使用时需要清楚的知道自己在干什么。 -
const_cast:用来移除变量的 const 属性。
x=x+1
,x+=1
,x++
,++x
哪个效率高?
- 大多数情况下,效率相同,编译器会优化生成相同的机器码。某些情况下,
x++
由于要临时保存x
的旧值会慢一些,可忽略不计。
STL 和算法相关
- 重写 sort 函数比较器时,能否使用比较
return l >= r
?
- 不能!sort 排序算法要求比较器函数是严格弱序(strict weak order),严格弱序有以下几条性质:
(1)非自反性:
(2)非对称性:如果,那么
(3)传递性:如果,且,那么
(4)不可比性的传递性:如果和不具可比性,与不具可比性,那么和也不具可比性
显然return l >= r
不是严格弱序的关系(违背了第(1)和(2)条)。如果这样定义可能会导致 sort 算法指针越界。
- array, vector, list, deque 哪些容器可以使用 sort 进行排序?
- std::sort 只支持随机型迭代器(RandomAccessIterator),故可以对 array, vector 以及 deque 进行排序,list 提供的是双向迭代器(bidirectionIterator),要对list进行排序,可以调用自己的成员函数 list.sort()。
- map的key使用一个自定义类,需要注意什么?
- map是有序的,使用自定义类作为key需要明确大小关系。有几种方法,第一种是重载类的
<
运算符,第二种方法是在模板参数中传入比较器:
1 | auto myComp = [](const Person& a, const Person& b){ |
- 使用 unordered_map 则不需要上述操作,因为其内部采用哈希表实现,不需要对元素排序。
- 有哪些排序算法,其中稳定性如何?