C++ 基础知识汇总

Cpp对象

  1. struct 和 class 区别?
  • struct的成员默认是公有的,而类的成员默认是私有的;
  • C中的struct不能包含成员函数,C++中的class可以包含成员函数。
  1. 构造函数和析构函数可不可以为虚函数,为什么?
  • 构造函数不可以是虚函数,构造函数的调用顺序是从基类到派生类,因为基类部分先于派生类部分构造。如果基类构造函数是虚函数,那么它将无法正常地被调用,因为在调用虚函数之前必须先构造对象。
  • 析构函数可以为虚函数,因为当基类的指针指向派生类对象的时候,发生多态,如果不将基类的析构函数定义为虚函数的话,那么派生类的析构函数就无法执行。
  1. 拷贝构造函数如果用值传递会有什么影响?
  • 拷贝构造函数用值传递时,参数传递本身就会调用拷贝构造函数,这样就无限循环调用了。
  1. 如何限制一个类对象只能在堆(栈)上分配空间?
  • 限制对象只能在堆上构造,可以将将类的构造函数和析构函数设为私有的(保护的),这样编译器无法自动调用其构造函数或析构函数,就只能在堆上分配了。由于构造函数不可在外部调用,此时需要提供静态函数用于在堆上构造对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A
{
protected:
A(){}
~A(){}
public:
static A* create()
{
return new A();
}
void destory()
{
delete this;
}
};
  • 限制对象只能在栈上构造,只有使用 new 运算符才会在堆上构造,而 new 运算符会调用 operator new,因此只需要将operator new定义为私有即可(通常也将operator delete一并设置成私有)。
1
2
3
4
5
6
7
8
9
class A
{
private:
void* operator new(size_t t){} // 注意函数的第一个参数和返回值都是固定的
void operator delete(void* ptr){} // 重载了new就需要重载delete
public:
A(){}
~A(){}
};
  1. 拷贝构造函数参数中为什么要加const?
  • 加了const之后就是一个常量左值引用(const-reference),可以引用右值,常量左值引用(const-reference)可以接受左值,右值,常量左值、常量右值。
  1. 谈一谈关键词 public protected private?

在类成员中限定访问权限:

  • private: 只能由该类中的函数、其友元函数访问。
  • protected: 可以被该类中的函数、子类的函数、以及其友元函数访问。
  • public: 可以被该类中的函数、子类的函数、其友元函数访问,也可以由该类的对象访问

类继承后方法属性变化:

  • 使用private继承,父类的所有方法在子类中变为private。
  • 使用protected继承,父类的protected和public方法在子类中变为protected,private方法不变。
  • 使用public继承,父类中的方法属性不发生改变。
  1. 什么是多态?
  • 通过基类指针或引用绑定到派生类对象上,并通过它调用基类和派生类中都有的同名、同参数表的虚函数,编译时并不确定要执行的是基类还是派生类的虚函数;而当程序运行到该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。多态可以重用接口或者说规范接口。
  1. 多态是如何实现的?
  • 存在虚函数的类都会有一个虚函数表 vtbl,用于存储虚函数对应的函数地址,对于基类和派生类,虚表中同样偏移量的表项对应的函数地址是不同的。同时类中还含有一个虚表指针 vptr,在通过基类指针或引用调用虚函数时,通过这个虚表指针去调用虚表对应表项的函数,从而实现多态。
  1. 虚函数可以内联吗?
  • 虚函数可以定义为内联函数,但是当虚函数表现为多态的时候不能内联。因为内联是在编译期间编译器认为能够内联就将函数内容展开;而多态发生在运行期间,在编译期间并不知道要调用的具体函数内容,故无法内联。
1
2
3
4
5
6
7
8
9
 // 此处的虚函数 who(),是通过类(Base)的具体对象(b)来调用的,
// 编译期间就能确定了,所以它可以是内联的,但最终是否内联取决于编译器。
Base b;
b.who();

// 此处的虚函数是通过指针调用的,呈现多态性,需要在运行时期间才能确定,
// 所以不能为内联。
Base *ptr = new Derived();
ptr->who();
  1. 说一说纯虚函数?
  • 纯虚函数和虚函数一样,使用 virtual 关键字修饰,不同的是需要在末尾加上 =0修饰,含有纯虚函数的类成为抽象类,抽象类不能实例化。纯虚函数用来为其派生类规定接口,在派生类中实现这些纯虚函数。
  1. 如何避免类拷贝?
  • 可以将拷贝构造函数和拷贝赋值函数声明为 private,更好的方法是在 public 中将其声明为 delete。在 C++ 中,通常会定义一个简单的 Noncopyable 类,这个类禁用了拷贝构造和拷贝赋值,其它类只需要继承这个类就行。
  1. 多继承会出现什么问题?如何解决?
  • 多继承是 C++ 特有的继承模式,其它更为现代的面向对象语言像 Java、Python 等只支持单继承。多继承会出现菱形继承问题,从而可能造成命名冲突和数据冗余等问题,可以通过虚继承来解决,使得相同的只被保留一份。实际上我认为好的设计不应该使用多继承,继承应该严格遵守 is-a 的关系,也就是里氏替换原则。在这方面,Java 的设计就要好很多,Java 只支持单继承,而对于接口复用则通过 Interface 来实现,不会显得和 C++ 这么乱。
  1. C++ 类对象初始化顺序?

初始化顺序如下:

  • 首先执行基类的构造函数,构造基类部分。
  • 调用成员变量的构造函数,初始化成员变量。
  • 调用自身构造函数。

销毁时顺序恰好相反:

  • 调用自身的析构函数。
  • 调用成员变量的析构函数,销毁成员变量。
  • 调用基类的析构函数,销毁基类部分。
  1. 如何禁止一个类被实例化?
  • 将构造函数声明成 private 或者 delete,这种手法在单例模式中经常使用,通常提供一个静态成员函数来返回这个单例。
  1. 为什么使用成员初始化列表会更快一些?
  • 因为在调用构造函数之前成员会调用默认构造函数执行默认初始化,而使用列表初始化则省去了这一过程,直接调用相应的构造函数进行构造。
  1. 深拷贝和浅拷贝的区别?
  • 如果类中有指针指向了其它资源,浅拷贝只是单纯的对指针的值进行拷贝,这导致两个对象同时指向同一片资源,其中一者修改或销毁资源会使另一者可见。深拷贝则是对这片资源也进行拷贝。实际上在 Java 和 Python 中默认都是浅拷贝的,他们变量赋值本身具备浅拷贝的语义,这样大大节省了资源复制的成本,要实现深拷贝需要专门的方法,如 Java 中重写 clone() 方法。
  1. 实现一个类成员函数,要求不允许修改类的成员变量?
  • 在函数末尾使用 const 关键字修饰,这本质上是将传入函数的 this 指针参数声明为 const(底层 const),这样在函数内修改成员变量会遭到编译器拒绝。
  1. 如何让类不能被继承?
  • 使用 final 关键字。
  1. 能不能在构造函数或析构函数中调用虚函数?
  • 可以,但是这是危险的行为。在基类的构造函数执行时,其派生类部分还没有构造,这样虚函数无法发生多态,而是会调用基类的函数。同样的,在基类析构函数执行时,其派生类部分已经被销毁了,虚函数同样无法发生多态,而是会调用基类的函数。

内存管理

  1. new 和 malloc 的区别?
  • 都可用来申请动态内存和释放内存,都是在堆(heap)上进行动态的内存操作。
  • malloc和free是c语言的标准库函数,new/delete是C++的运算符。
  • new会自动调用对象的构造函数,delete 会调用对象的析构函数, 而malloc返回的都是void指针。
  1. new 和 malloc 如何判断是否申请到内存?
  • malloc :成功申请到内存,返回指向该内存的指针;分配失败,返回 NULL 指针。
  • new :内存分配成功,返回该对象类型的指针;分配失败,抛出 bad_alloc 异常。
  1. 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 次构造函数和析构函数。
  1. malloc 的底层实现原理?
  • malloc 会调用 brk 和 mmap 系统调用来向操作系统索要内存。其中 brk 系统调用就是移动堆分区的指针,在访问这段新内存时触发操作系统的缺页处理,从而分配内存。mmap 可用来将磁盘文件映射到内存中,也可以匿名映射,通过匿名映射的方式可以获取内存,而不映射到具体的文件上。
  1. 内存分配的区域?
  • 堆区:一般由程序员自己手动分配和回收,分配时向高地址增长
  • 栈区:由编译器自动分配和释放,存放运行函数期间的局部变量、函数参数、返回数据等,分配时向低地址增长
  • 全局区(静态存储区):存放全局变量、静态变量。(对应于目标文件中的.data和.bss段)
  • 常量区:存放常量和字符串常量。(对应于目标文件中的.rodata段)
  • 代码区:存放二进制代码。(对应于目标文件中的.text段)

关键字

  1. extern 关键字
  • extern 关键字用于声明一个变量,extern int a 告诉编译器,有个int类型的变量a定义在其它地方,请(在链接阶段)去别的地方查找这个变量。
  1. static 关键字
  • static 关键字修饰变量时,说明这个变量的生存周期是永久的,变量存放在全局区,即使函数调用结束之后变量也不会被销毁。如果 static 变量定义在头文件并被多个源文件包含,会导致每个源文件有一个变量的副本,他们之间的值互不影响(因为C++是分离编译的)。
  1. explicit 关键字
  • explicit 关键字用于修饰单个参数的构造函数,防止编译器执行隐式类型转换(隐式的将这个参数构造成对象进行使用)。
  1. volatile 关键字
  • volatile 关键字修饰某个变量告诉编译器该变量随时可能发生改变,每次存储或读取该变量时,应该直接从内存中读取,而不是某个寄存器中的(编译器认为没有改变的)值,编译器有时会有这样的优化,这个关键字会禁止编译器对这个变量进行此优化。这个关键字在嵌入式硬件编程里用的多,有些寄存器的值会自己变化。

通常情况下,编译器做一些这种优化(使用 volatile 就不会优化了):

1
2
x = 10;
x = 20;

会被优化成:

1
x = 20;
  1. extern C 的作用?

C++ 程序需要调用 C 语言编写的函数时,需要通过这个包裹:

1
2
3
extern "C" {
int strlen(const char*); // 告诉 C++ 编译器按照 C 语言函数签名形式去链接该函数
}

因为 C++ 和 C 语言函数的签名不同,C++ 为了支持重载等复杂机制,签名更加复杂,为了能够在链接时找到对应的 C 语言编写的函数,需要通过 extern C 来指示编译器。

  1. 声明和定义的区别?
  • 声明用于告诉编译器存在这个名字符号,并不实际分配内存,使用extern关键字来声明一个变量或函数。
  • 定义则是实际分配内存构造出相应的变量、类或函数。
  1. 引用是否会占用内存空间?
  • 实际上会占用内存空间(一般为一个指针的大小),在 C++ 中,引用是变量的一个别名,和指针效果类似,不同的是指针可以为nullptr且可以改变指向的变量。引用经常被编译器实现为指针,且是一个指针常量,不能为空或改变指向的变量。
  1. strcpy和memcpy的区别?
  • strcpy 只能用来复制字符串(以null结尾),memcpy可以用来复制任何长度的连续内存块,此外,memcpy 由于不用查找字符串的结尾符,通常要比 strcpy 快。
  1. memcpy原理?
  • memcpy 实现固定长度连续内存的拷贝,当内存有重叠时,拷贝结果为未定义(这种情况可以使用memmove)。为了提高效率,memcpy 实现一般使用汇编实现,且考虑拷贝长度大小、内存对齐等诸多因素,绝不是单纯的逐字节拷贝。
  1. strcpy 有什么缺陷?

不检查目的区域边界,可能导致缓冲区溢出。

编译内存相关

  1. C++ 程序编译过程?

分为以下四步:预处理、编译、汇编、链接。

  • 预处理:将#include、条件编译、注释等进行展开和删除。
  • 编译:将源代码(.cpp)翻译成汇编代码(.s)。
  • 汇编:通过机器码查表将汇编代码翻译为机器指令,也就是目标文件(.o)。
  • 链接:解决上述目标文件中的外部符号引用问题,即将多个目标文件链接至可执行程序。

链接分为静态链接和动态链接。

  • 静态链接:从静态库中拷贝代码至最终的可执行程序中,完成符号的重定位,执行时不依靠其它库文件。
  • 动态链接:将符号重定位延迟到程序执行(动态链接器在加载共享模块时),程序在执行时调用动态链接库中的符号和函数。多个程序可以共享动态链接库,因此这种方法更节省空间,且动态库升级时不需要重新编译整个程序,但需要保证二进制兼容。
  1. 什么是内存对齐?内存对齐的原则?为什么要进行内存对齐,有什么优点?
  • 访问的变量的地址需要是变量本身大小的整数倍,这种访问被称为是内存对齐的访问。

  • 对齐规则如下:所有基础类型的对齐字节大小为本身的大小,也就是说 char 类型变量需要按照 1 字节对齐,int 类型变量需要按照 4 字节对齐,也就是说其地址需要被 4 整除。对于 struct, union, class 类型的变量,其对齐字节大小为内部非静态成员的最大对齐值。

  • 某些硬件只支持访问对齐的数据,对于非对齐数据访问引发异常。即使是支持对于非对齐数据的访问,其性能要低于访问对齐数据(访问非对齐数据可能会触发多次内存访问)。

  1. 如何计算一个类占用的字节大小?

遵循以下原则:

  • 和结构体遵循相同的字节对齐规则。
  • 类大小只和普通成员变量(包括常量)有关,和成员函数无关,和静态成员变量无关。
  • 若存在虚函数,则会增加一个虚函数表指针的大小。
  • 对于空的类,其大小为 1 个字节,为了保证不同类地址不同。
  1. 什么是内存泄漏?
  • 内存泄漏指由于错误或者疏忽导致某些不再使用的内存没有被释放,主要为程序员对堆上内存使用不当造成。例如 new 了对象,不使用的时候忘记 delete。
  • 事前使用智能指针等管理内存,防止内存泄漏,事后通过内存泄漏检测工具进行检测。
  1. 说说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 新特性

本人常用有以下几个:

  1. auto 和 decltype 关键字,可用于简化写法。
  2. lambda 表达式,非常常用。
  3. 右值引用,移动语义,用于进一步提高效率,减少不必要的复制。
  4. 智能指针,作为很好的内存管理工具,尽量代替裸指针分配内存。
  5. nullptr。
  6. 范围 for 循环。
  7. enum class。
  8. 多线程相关,包括 std::thread, std::mutex, std::lock_guard, std::unique_lock, std::condition_variable等。

语言特性

  1. 左值和右值的区别,左值引用和右值引用的区别,如何将左值转换为右值?
  • 左值和右值定义有些模糊,但左值表示一个在内存中占据可识别位置的对象,而右值则是表示一个对象的值(一般是字面常量或者表达式求值的临时对象),左值持久,右值短暂。
  • 左值引用就是别名,不能绑定到右值上(除了 const reference);相反,右值引用只能绑定到右值上,用于接管将要销毁的对象。右值提出其实就是为了更进一步提升效率。
  • 将左值转换为右值可以通过std::move强制完成。std::move本质上就是一个强制类型转换,无条件的将实参强制转换为右值。
  1. 什么是指针?指针的大小?
  • 指针用来保存一个地址值,从而能够通过地址访问到相应的对象(包括变量和函数)。由于保存的是地址,它的大小和操作系统的位数有关系,64位操作系统通过64位地址总线寻址,因此指针大小为8个字节;类似的32位操作系统为4个字节。指针本质上是一个整型值,不同类型的指针只是告诉编译器如何去解释相应内存地址处的对象。
  1. 什么是野指针和悬空指针?
  • 悬空指针就是指针指向的内存被释放后,当前指针就为悬空指针,此时访问该对象是未定义行为(Undefined Behavior)。
  • 野指针就是指针指向未意料的位置,如未初始化的指针,此时访问该对象也是未定义行为。
  1. nullptr 相比于 NULL 的优势?
  • NULL 是一个宏,被定义成 0,实际上是一个整形的字面量,并不具备指针的类型。对于整形参数和指针类型参数重载的函数,NULL 会优先匹配整形那个,这是不被预料的。
1
2
3
4
5
6
7
void func(int* tmp);
void func(int tmp);

int main() {
func(NULL); // 匹配 func(int tmp);
func(nullptr); // 匹配 func(int* tmp);
}
  • nullptr 是在 C++ 11后用来弥补上述缺点的,nullptr 本身具备类型nullptr_t,且可被隐式转换成任意指针类型。
  1. 指针和引用的区别?
  • 指针保存对象的地址,引用是一个别名;因此指针可以改变指向,而引用一旦绑定对象就不能改变。
  • 指针可以为空,即不指向任何对象;引用必须绑定变量。
  • 指针占用内存,引用在内存不占内存空间。关于这一点是指使用 sizeof 取不到引用的大小(取到的是对象本身大小),但通常编译器将引用实现成指针,因此也会占用空间。
  1. 常量指针和指针常量区别?
  • 指针常量:也即顶层 const,指针变量本身是常量,不能修改指向,也就是不能修改其保存的地址值。
  • 常量指针:也即底层 const,指针指向的对象是一个常量,不能通过这个指针来修改指向的变量。
1
2
const int* p;  // const 在 * 前,常量指针;底层 const
int* const p; // const 在 * 后,指针常量;顶层 const
  1. 迭代器作用?
  • 迭代器是一种抽象的设计概念,可以通过迭代器访问容器的元素而不需要暴露容器的具体实现。C++ 中迭代器结合泛型算法可以方便的实现如排序、求和等功能,只需要传入头迭代器、尾迭代器就可以让算法访问到容器元素。
  1. C++ 中的强制转换?

C++ 中也支持 C 风格的强制类型转换,为了避免带来隐患,C++ 提供了几种不同的类型转换函数,让程序员指明转换的语义。

  • static_cast:主要是用于基本数据类型之间的转换,static_cast 没有运行时的类型检查,需要程序员自己判断转换是否安全。此外,还可用于基类和派生类指针或引用之间的转换,其中派生类向基类转换是安全的(只是无法访问派生类多出来的成员了),向下转换则是危险的。
1
2
3
4
5
int x = 1;
double d = static_cast<double>(x);

double y = 1.222;
int i = static_cast<int>(y); // 转换精度丢失
  • dynamic_cast:主要用于类层级之间的向上或向下转换,与 static_cast 不同的是,dynamic_cast 有运行时类型检查,从派生类到基类转换时和 static_cast 效果一致,反过来则会检查对象的实际类型,转换失败则变成空指针。

  • reinterpret_cast:简单的重新解释内存中的位,常用于指针类型之间的转换如将int*转换为char*等,是最危险的转换,程序员使用时需要清楚的知道自己在干什么。

  • const_cast:用来移除变量的 const 属性。

  1. x=x+1x+=1x++++x哪个效率高?
  • 大多数情况下,效率相同,编译器会优化生成相同的机器码。某些情况下,x++由于要临时保存x的旧值会慢一些,可忽略不计。

STL 和算法相关

  1. 重写 sort 函数比较器时,能否使用比较return l >= r
  • 不能!sort 排序算法要求比较器函数是严格弱序(strict weak order),严格弱序有以下几条性质:

(1)非自反性:x!<xx !< x
(2)非对称性:如果x<yx < y,那么y!<xy !< x
(3)传递性:如果x<yx < y,且y<zy < z,那么x<zx < z
(4)不可比性的传递性:如果xxyy不具可比性,yyzz不具可比性,那么xxzz也不具可比性

显然return l >= r不是严格弱序的关系(违背了第(1)和(2)条)。如果这样定义可能会导致 sort 算法指针越界。

  1. array, vector, list, deque 哪些容器可以使用 sort 进行排序?
  • std::sort 只支持随机型迭代器(RandomAccessIterator),故可以对 array, vector 以及 deque 进行排序,list 提供的是双向迭代器(bidirectionIterator),要对list进行排序,可以调用自己的成员函数 list.sort()。
  1. map的key使用一个自定义类,需要注意什么?
  • map是有序的,使用自定义类作为key需要明确大小关系。有几种方法,第一种是重载类的 < 运算符,第二种方法是在模板参数中传入比较器:
1
2
3
4
5
auto myComp = [](const Person& a, const Person& b){
...
}

map<Person, int, decltype(&myComp)> mp;
  • 使用 unordered_map 则不需要上述操作,因为其内部采用哈希表实现,不需要对元素排序。
  1. 有哪些排序算法,其中稳定性如何?

参考文献

  1. https://blog.csdn.net/Awesomewan/article/details/123948929
  2. https://zhuanlan.zhihu.com/p/114311142