C++

C++

内存机制

基本数据类型

char(1字节),bool(1字节),short(2字节),int(4字节),long(8字节),float(4字节),double(8字节)

怎么判断float或者double为0

float32位中,有1位符号位,8位指数位,23位尾数为 double64位中,1位符号位,11位指数位,52位尾数位;

float的精度误差在1e-6;double精度误差在1e-15

要判断一个单精度浮点数:则是if( abs(f) <= 1e-6); 要判断一个双精度浮点数:则是if( abs(f) <= 1e-15 );

sizeof是编译时执行还是运行时执行

编译时,即sizeof返回大小为声明类型大小

内存泄露的定义,如何检测与避免?

动态分配内存所开辟的空间,在使用完毕后未手动释放,导致一直占据该内存,即为内存泄漏。

造成内存泄漏的几种原因:

1)类的构造函数和析构函数中new和delete没有配套

2)在释放对象数组时没有使用delete[],使用了delete

3)没有将基类的析构函数定义为虚函数,当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确释放,因此造成内存泄露

检测:

主要思路就是使每个new和delete匹配上

  1. 在new和delete外面多包一层,并替换原有的new和delete运算符,将每次new和delete的信息输入到log中或者print出来,程序运行结束后检查是否每个new的对象都被delete
  2. 重载new和delete,将用new手动分配的内存地址用一个链表连起来,每次delete的时候删除对应地址的节点,最后遍历一遍这个链表,剩下的节点就是泄露的内存地址
  3. Valgrind(没用过。。。)
  4. 智能指针

C++内存管理

BSS段(未初始化数据区):通常用来存放程序中未初始化的全局变量和静态变量的一块内存区域。BSS段属于静态分配,程序结束后静态变量资源由系统自动释放。

image-20201108193834991

DATA段:存放程序中已初始化的全局变量的一块内存区域。数据段也属于静态内存分配。数据段包含经过初始化的全局变量以及它们的值。DATA段又可分为读写(RW)区域和只读(RO)区域。RO段保存常量;RW段则是普通非常量全局变量,静态变量就在其中

TEXT段:存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读。在代码段中,也有可能包含一些只读的常数变量

可执行程序在运行时又多出两个区域:栈区和堆区。

栈区:由编译器自动释放,存放函数的参数值、局部变量等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存放到栈中。然后这个被调用的函数再为他的自动变量和临时变量在栈上分配空间。每调用一个函数一个新的栈就会被使用。栈区是从高地址位向低地址位增长的,是一块连续的内存区域,最大容量是由系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。

堆区:用于动态分配内存,位于BSS和栈中间的地址区域。由程序员申请分配和释放。堆是从低地址位向高地址位增长,采用链式存储结构。频繁的 malloc/free造成内存空间的不连续,产生碎片。当申请堆空间时库函数是按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈要低的多。

给函数传入一个指针参数,可以用这个指针申请内存吗

不行,传入的指针实际上是一个副本,虽然函数中这个指针和函数外的指针指向的地址相同,但它们是两个指针;当用malloc分配内存时函数内指针指向的地址发生改变,但是却不会影响函数外原来指针指向的地址,当函数结束时函数内的临时参数指针副本会被销毁,导致这块内存无法被找到

正确的做法是传入指向这个指针的指针来改变这个指针的指向地址,或者让函数直接返回指向新地址的指针

image-20201108182314860

指针 / 数组区别

(除了字符串情况之外)如果要在声明的同时进行初始化,指针需要用new的方式初始化,而数组要用{}的方式初始化

int main() {
    int *n1 = new int[5]; // 内存分配在堆上
//    int *n2 = {1,2,3,4,5}; // 指针声明初始化只能用new
    int n3[] = {1,2,3,4,5}; // 内存分配在栈上
//    int n4[] = new int[5]; // 数组声明初始化只能用显式数组列表
    int *n5 = n3; // 指针可以赋值初始化,让它指向一个数组
//    int n6[] = n1; // 数组却只能显式声明数组列表是什么
}

字符串能不能修改

char str[] = "hello, world";
str[1] = 'a';

这个程序可以正常运行,因为这里的hello world是放在栈里面的而不是只读数据区,所以可以进行修改

char *str = "hello, world";
str[1] = 'a';

这个程序会出现错误,因为这里的hello world是一个字符串常量,放在DATA段并且是只读的,对只读内容进行修改的话会报错

并且C++11会直接给警告说禁止string literal到char *的转换,而是应该写成

const char *str = "hello, world";
str[1] = 'a';

才可以通过,而现在str是一个const,修改其内容自然是被禁止的了

字符串能不能重新赋值

char str[] = "hello, world";
str = "Shit";

这个程序是错误的,因为数组是不能重新赋值的

const char *str = "hello, world";
str = "Shit";

这个程序可以正常运行,因为虽然*str是const,但是str本身不是const,让str指向另一个string literal是合法的操作

堆和栈在内存中的区别

堆:一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收

栈:由编译器(Compiler)自动分配释放

堆:操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序;由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中

栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。

堆:不连续的内存区域

栈:连续的内存区域

堆:是由new分配的内存,一般速度比较慢,而且容易产生内存碎片

栈:由系统自动分配,速度较快,程序员是无法控制的

没有构造对象时能访问成员函数吗

原来编译器找成员函数位置是根据声明类型找的么。。。声明时就已经知道了这个对象的类型,所以即使这个对象实际上只是一个空指针,编译器也能找到成员函数的位置并执行;但虚函数的话是运行期才知道函数指针是哪个(虚函数表)不能仅凭声明类型知道成员函数是哪个,所以会异常

image-20201030220828266

引用和指针的区别

指针指向对象地址,引用可以看作对象的一个别名

int n = 5; 指针:int *ptr = &n; 引用:int &ref = n;

指针可以指向NULL,引用不行

指针可以重新赋值,引用不行

指针可以在C里使用,引用不行

指针大小取决于系统是32位还是64位,引用大小为对象大小

什么时候使用指针,什么时候使用引用

需要返回函数内局部变量的内存的时候用指针。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的

对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小

malloc和new区别

new是C++关键字,malloc是C的库函数

使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸

new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型

new不仅申请内存还调用类的构造函数初始化成员变量,malloc只申请内存

new失败报异常,malloc失败返回空指针

delete和delete[]区别

delete只会调用一次析构函数,而delete[]会调用每个成员的析构函数

用new分配的内存用delete释放,用new[]分配的内存用delete[]释放

大端序小端序

数字11 22 33 44 (4字节),强制转换为字符指针(1字节)

大端序:低位 -> 高位;小端序:高位 -> 低位

#include <stdio.h>

int main(){

  int a = 0x11223344;

  int* pi = &a;

  char* pc = (char *) pi;

  printf("%x/n", *pc);

}

内存对齐

如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那 么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。

内存对齐主要遵循下面三个原则:

  1. 结构体变量的起始地址能够被其最宽的成员大小整除
  2. 结构体每个成员相对于起始地址的偏移能够被其自身大小整除,如果不能则在前一个成员后面补充字节
  3. 结构体总体大小能够被最宽的成员的大小整除,如不能则在后面补充字节

类的大小

class base {
public:
		base()=default;
		~base()=default;
private:
		static int a;
		int b;
		char c;
};

计算结果:8,静态变量a不计算在对象的大小内;类的大小与构造函数,析构函数,普通成员函数无关;结果为4+4=8(字节对齐)

注意:类的数据成员按其声明顺序加入内存

class A {};
int main(){
  cout<<sizeof(A)<<endl;// 输出 1;
  A a; 
  cout<<sizeof(a)<<endl;// 输出 1;
  return 0;
}

空类的大小是1, 在C++中空类会占一个字节,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。当该空白类作为基类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。

class Empty {};

class HoldsAnInt {
  int x;
  Empty e;
};

在这种情况下,空类的1字节是会被计算进去的。而又由于字节对齐的原则,所以结果为4+4=8。

继承空类的派生类,如果派生类也为空类,大小也都为1。

class A { virtual Fun(){} };
int main(){
  cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
  A a; 
  cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
  return 0;
}

因为有虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是8字节(64位系统)

class A
{
public:
    char b;
    short c;
    virtual void fun() {}
};
class B
{
public:
    char a;
    virtual void fun() {}
    short b;
};

编译器(gcc 和 微软)一般会把虚指针放在类的内存空间的最前面的位置,不管虚函数声明的位置。考虑对齐,大小为8+8=16

class A { static int a; };
int main(){
  cout<<sizeof(A)<<endl;// 输出 1;
  A a; 
  cout<<sizeof(a)<<endl;// 输出 1;
  return 0;
}

静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小

class A { static int a; int b; };;
int main(){
  cout<<sizeof(A)<<endl;// 输出 4;
  A a; 
  cout<<sizeof(a)<<endl;// 输出 4;
  return 0;
}

静态成员a不占用类的大小,所以类的大小就是b变量的大小 即4个字节

class A
{
    int a;
    char b;
};
class C : public A
{
public:
    char c;
};

上面这段代码,不同的编译器结果不同,VS的结果是 8 和 12, GCC是8 和 8。VS中 相当于

class C
{
    A a;
    char c;
};

A的大小为8,对齐值为4, 则考虑总体对齐 8 + 1 + 3(padding) = 12。 GCC 则是

class C
{
    int a;
    char b;
    char c;
};

结果为 4 + 1 + 1 + 2 = 8。

class A
{
    virtual void fun() {}
};

class B
{
    virtual void fun2() {}
};
class C : public  A, public B
{
public:
    virtual void fun3() {}
};

结果为 8 8 16。分析:类A一个虚函数表,类B一个虚函数表,类C继承了两个虚函数表,并把自己的虚函数写在了继承顺序中第一个虚函数表中。

class A{
public:
    int a = 1;
};

class B: virtual public A{
public:
    int b = 1;
};

class C: virtual public A{
public:
    int c = 1;
};

class D: public B, C{
public:
    int d = 1;
};

结构为4,16,16,40,虚继承的情况下即使没有虚函数也会有一个指向虚基类的指针(但没有指向虚表的指针了,指向虚表的指针是与基类共享的),内存对齐后B和C都是16,D为B+C+int并要跟B,C中的虚指针(8)对齐,结果为40

调用的压栈过程

函数的调用过程:

1)从栈空间分配存储空间

2)从实参的存储空间复制值到形参栈空间

3)进行运算

形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。

数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。

当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。

可执行文件的生成过程

  • 预编译(预编译器处理如 #include#define 等预编译指令,生成 .i.ii 文件)
  • 编译(编译器进行词法分析、语法分析、语义分析、中间代码生成、目标代码生成、优化,生成 .s 文件)
  • 汇编(汇编器把汇编码翻译成机器码,生成 .o 文件)
  • 链接(连接器进行地址和空间分配、符号解析、重定位,生成 .out 文件)

符号解析:目标文件定义和引用符号,符号解析的目的是将每个符号引用和一个符号定义联系起来;

重定位:把每个符号定义与一个存储器位置联系起来,然后修改对这些符号的引用,是的他们指向这个存储器位置,从而实现重定位。

静态链接和动态链接

如果函数库的一份拷贝是可执行文件的物理组成部分,称之为静态链接。静态链接当链接程序时,需要使用的每个库函数的一份拷贝被加入到可执行文件中。静态链接使用静态库进行链接,生成的程序包含程序运行所需要的全部库,可以直接运行,不过静态链接生成的程序体积较大(即使是在静态链接中,整个库文件也并没有全部装入到可执行文件中,所装入的只是需要的函数)。

如果可执行文件只是包含了文件名,让载入器在运行时能够寻找程序所需要的函数库,称之为动态链接。动态链接允许系统提供一个庞大的函数库集合,可以提供许多有用的服务,程序在运行时寻找它们。动态链接使用动态链接库进行链接,外部函数被真正调用之前,运行时载入器并不解析它们。所以动态链接即使链接了函数库,如果没有实际调用,也不会带来额外开销。动态链接生成的程序体积较小,但是必须依赖所需的动态库,否则无法执行。

怎么在main函数之前和之后执行代码

image-20201031133827730

全局变量会在main函数执行之前进行初始化,可以利用这一点在main之前执行代码 atexit函数可以注册一个在main函数结束后要调用的函数,atexit注册的函数结束后程序才会真正exit;atexit可以在程序的任意位置调用

或者通过在全局变量对象的析构函数也能实现在main之后运行函数(main结束后会释放全局变量的内存)

image-20201109141055379

被free回收的内存是立即返还给操作系统吗?为什么

不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。

malloc原理

1、空闲存储空间以空闲链表的方式组织(地址递增),每个块包含一个长度、一个指向下一块的指针以及一个指向自身存储空间的指针。( 因为程序中的某些地方可能不通过malloc调用申请,因此malloc管理的空间不一定连续。) 2、当有申请请求时,malloc会扫描空闲链表,直到找到一个足够大的块为止(首次适应)(因此每次调用malloc时并不是花费了完全相同的时间)。 3、如果该块恰好与请求的大小相符,则将其从链表中移走并返回给用户。如果该块太大,则将其分为两部分,尾部的部分分给用户,剩下的部分留在空闲链表中(更改头部信息)。因此malloc分配的是一块连续的内存。 4、释放时,首先搜索空闲链表,找到可以插入被释放块的合适位置。如果与被释放块相邻的任一边是一个空闲块,则将这两个块合为一个更大的块,以减少内存碎片

如何定义一个只能在堆上(栈上)生成对象的类?

  • 只能在堆上

方法:将析构函数设置为私有

原因:C++ 是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。

  • 只能在栈上

方法:将 new 和 delete 重载为私有

原因:在堆上生成对象,使用 new 关键词操作,其过程分为两阶段:第一阶段,使用 new 在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将 new 操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象。

面向对象

OOP三大特性

封装 :就是将一个类的使用和实现分开,只保留部分接口和方法与外部联系;private仅本类可见;protected子类可见;public都可见

继承:子类自动继承其父级类中的属性和方法,并可以添加新的属性和方法或者对部分属性和方法进行重写。继承增加了代码的可重用性

多态:多个子类中虽然都具有同一个方法,但是这些子类实例化的对象调用这些相同的方法后却可以获得完全不同的结果

重载和重写

重载overload:在同一个类中,函数名称相同参数不同,未体现多态

重写override:也叫覆盖,子类重新定义父类中有相同名称相同参数的虚函数,主要是在继承关系中出现的,被重写的函数必须是virtual的,重写函数的访问修饰符可以不同,尽管virtual是private的,子类中重写函数改为public,protected也可以,体现了多态。

c重载时返回值是否可以相同

可以相同可以不同,但是如果参数的个数、类型、次序都相同,方法名也相同,仅返回值不同,则无法构成重载

友元函数 & 友元类

友元函数是可以直接访问类的私有成员的非成员函数,是定义在类外的普通函数,它不属于任何类,但需要在类的定义中加以声明,声明时只需在友元的名称前加上关键字friend

class Box
{
   double width;
public:
   friend void printWidth( Box box );
   void setWidth( double wid );
};

void printWidth( Box box )
{
   /* 因为 printWidth() 是 Box 的友元,它可以直接访问该类的任何成员 */
   cout << "Width of box : " << box.width <<endl;
}

友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)

class A
{
public:
    friend class C;                         //这是友元类的声明
private:
    int data;
};

class C             //友元类定义,为了访问类A中的成员
{
public:
    void set_show(int x, A &a) { a.data = x; cout<<a.data<<endl;}
}

(1) 友元关系不能被继承。

(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明

(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明

struct和class区别

使用struct时,它的成员的访问权限默认是public的,而class的成员默认是private的

struct的继承默认是public继承,而class的继承默认是private继承

class可以用作模板,而struct不能

构造函数使用初始化列表和函数中赋值的区别

注意:Test2构造函数中的参数t1为引用,这样能少一次拷贝行为,如果不是引用而是普通变量的话会把实参拷贝一份再传入构造函数

image-20201017211432878

如果赋值的话,Test2会先新建一个Test1对象test1,然后通过赋值运算符把参数t1的值复制过去

image-20201017211523694

如果直接初始化列表的话,Test2会直接拷贝一个Test1并设置为自己的成员变量

继承

公有成员在程序中类的外部是可访问的。您可以不使用任何成员函数来设置和获取公有变量的值

私有成员变量或函数在类的外部是不可访问的,只有类和友元函数可以访问私有成员

保护成员变量或函数与私有成员十分相似,但有一点不同,保护成员在派生类(即子类)中是可访问的

image-20200903202336092

虚函数的目的,实现

虚函数是一种实现多态的机制:父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数

在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率

纯虚函数 & 抽象类

纯虚函数:没有函数体的虚函数,必须在非抽象类中实现

virtual int Fun1() = 0;

抽象类:包含纯虚函数的类;只能作为基类来派生新类使用,不能创建抽象类的对象,抽象类的指针和引用 -> 由抽象类派生出来的类的对象。如果一个类从抽象类派生而来 它必须实现了基类中的所有纯虚函数,才能成为非抽象类。抽象类中在成员函数内可以调用纯虚函数,在构造函数/析构函数内部不能使用纯虚函数

为什么只能用指针和引用实现多态,而对象不可以?

Base *base =  new Derive();

此时会把derive指针bitwise拷贝到base指针里面,所以base指向的是Derive对象,其虚表指针指向的也是Derive类的虚函数表

Base base = Derive();

此时会把Derive对象bitwise拷贝到base对象里面,但是C++在拷贝到时候并不会拷贝虚表指针,所以以对象形式赋值之后base的虚表指针指向的仍然是Base类的虚表,所以实现不了多态

引用某种意义上跟指针也类似,所以也可以实现多态

虚继承

虚继承用于解决多继承条件下的菱形继承问题(浪费存储空间、存在二义性)。

//间接基类A
class A{
protected:
    int m_a;
};

//直接基类B
class B: public A{
protected:
    int m_b;
};

//直接基类C
class C: public A{
protected:
    int m_c;
};

//派生类D
class D: public B, public C{
public:
    void seta(int a){ m_a = a; }  //命名冲突
    void setb(int b){ m_b = b; }  //正确
    void setc(int c){ m_c = c; }  //正确
    void setd(int d){ m_d = d; }  //正确
private:
    int m_d;
};

int main(){
    D d;
    return 0;
}

这段代码实现了上图所示的菱形继承,第 25 行代码试图直接访问成员变量 m_a,结果发生了错误,因为类 B 和类 C 中都有成员变量 m_a(从 A 类继承而来),编译器不知道选用哪一个,所以产生了歧义。

为了消除歧义,我们可以在 m_a 的前面指明它具体来自哪个类

void seta(int a){ B::m_a = a; }

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的 A 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。

//间接基类A
class A{
protected:
    int m_a;
};

//直接基类B
class B: virtual public A{  //虚继承
protected:
    int m_b;
};

//直接基类C
class C: virtual public A{  //虚继承
protected:
    int m_c;
};

//派生类D
class D: public B, public C{
public:
    void seta(int a){ m_a = a; }  //正确
    void setb(int b){ m_b = b; }  //正确
    void setc(int c){ m_c = c; }  //正确
    void setd(int d){ m_d = d; }  //正确
private:
    int m_d;
};

int main(){
    D d;
    return 0;
}

子类重写了父类虚方法之后,可以调用父类的这个虚方法吗

还真他妈可以。。。

image-20201031183154055

虚函数(virtual)可以是内联函数(inline)吗?

内联是在编译器建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。

为什么对于存在虚函数的类中析构函数要定义成虚函数

image-20200903205957863

(声明为基类指针的子类对象,delete时只调用基类的析构函数,不调用子类析构函数)

为了实现多态进行动态绑定,将派生类对象指针绑定到基类指针上,对象销毁时,如果析构函数没有定义为虚函数,则会调用基类的析构函数,显然只能销毁部分数据。如果要调用对象的析构函数,就需要将该对象的析构函数定义为虚函数,销毁时通过虚函数表找到对应的析构函数。

static函数为什么不能是虚函数

static成员没有this指针,static function都是静态决议的(编译的时候就绑定了) 而virtual function 是动态决议的(运行时候才绑定)

构造函数为什么不能是虚函数

在调用构造函数时,虚表指针并没有在对象的内存空间中,必须要构造函数调用完成后才会形成虚表指针

那构造函数可以调用虚函数吗

可以,但这样虚不虚都没区别了

父类的构造函数中调用的是父类的虚函数,在子类中调用的是子类的虚函数。因为调用父类的构造函数时还没有子类,所以此时会调用父类的虚函数;调用子类的构造函数时有子类了,所以调用的是子类的虚函数

构造函数

C++中的构造函数主要有四种类型:默认构造函数、重载构造函数,拷贝构造函数,移动构造函数

  • 默认构造函数是当类没有实现自己的构造函数时,编译器默认提供的一个构造函数。
  • 重载构造函数也称为一般构造函数,一个类可以有多个重载构造函数,但是需要参数类型或个数不相同。可以在重载构造函数中自定义类的初始化方式。
  • 拷贝构造函数是在发生对象复制的时候调用的,例如以调用函数时以对象形式传入参数,或者调用=赋值运算符时
  • 移动构造函数见右值引用部分

image-20200903235617154

为什么拷贝构造函数参数需要是引用

不是引用的话调用拷贝构造函数本身就需要复制参数,要复制参数就需要调拷贝构造函数,尼玛没完了

拷贝构造函数参数必须是const吗

不是也行,编译器不会拦着你,但是为了安全(拷贝构造函数不应该修改原对象的值)还是加上比较好

什么时候调用拷贝构造函数

在C++中,下面三种对象需要调用拷贝构造函数(有时也称“复制构造函数”):

  1) 一个对象作为函数参数,以值传递的方式传入函数体;

  2) 一个对象作为函数返回值,以值传递的方式从函数返回;

  3) 一个对象用于给另外一个对象进行初始化(常称为复制初始化),即用=运算符赋值时

什么时候需要自定义拷贝构造函数

默认的拷贝构造函数是把原对象的二进制内容以bitwise的形式拷贝到新的对象里,有些时候光是拷贝二进制无法满足用户对拷贝构造函数的要求,此时就需要自定义拷贝构造函数

例如初始化时给了对象Anew了一个指向B对象指针的情况,此时如果把二进制内容直接复制过去会导致原对象和拷贝的对象的指针都指向同样的B对象,而用户可能想两个对象指向不同的B对象,此时就得自定义拷贝构造函数

image-20201030220244448

image-20201030220253390

析构函数能抛出异常吗

通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

模版

image-20200904153017442

编译器进行编译的时候,编译器会产生类的模板函数的声明,当时实际确认类型后调用的时候,会根据调用的类型进行再次帮我们生成对应类型的函数声明和定义。我们称之为二次编译

为什么模板成员函数不能是虚函数

当前的编译器都期望在处理类的定义的时候就能确定这个类的虚函数表的大小,如果允许有类的虚成员模板函数,那么就必须要求编译器提前知道程序中所有对该类的该虚成员模板函数的调用,而这是不可行的。

为什么模版实现要放在头文件里

C++标准明确表示,当一个模板不被用到的时侯它就不该被实例化出来

编译器只有同时看到对模版类的定义和调用时才会生成对应包含实际类型的类的定义,但是很遗憾,编译器每次只编译一个cpp文件,如果定义和调用放在两个cpp里面的话,编译器就啥都生成不了。。。

如果跟普通类一样在头文件中声明,cpp中实现的话,编译器不会生成任何有意义的代码,因为模板类的cpp文件中并没有调用这个模板类,所以编译器生成的.o文件里面不会有这个模版类的可执行代码,实际结果就会像没有模板类的这个cpp文件一样。编译器在main中看到对模版类的调用时,不知道这个模版类的定义是什么,所以会认为链接的时候能够找到一个对应的定义。此时链接编译好的模版类.o文件会发现里面啥都没有,所以会报错

如果放在头文件里,main函数中用到模板类时由于一开始有include模板类的头文件,所以此时编译器能够找到模板类的实现,并根据实际调用的情况生成正确的的.o文件

唯一能绕过去的办法就是头文件里面声明,cpp中实现,并且main中要调用哪一种模版就在cpp最后加一行对应的调用,这样编译器发现有要调用这个种类的模板,就会在.o文件中生成对应的可执行代码,链接的时候就可以链接上了

不过正常人都不会选择这种做法。。。

模板的本质和宏差不多,就是一系统预定义,不能把它和普通的源文件混淆,所以理所应当的模板实现应该以宏定义一样对待写在头文件里

强制类型转换

四种强制类型转换操作符分别为:static_cast、dynamic_cast、const_cast、reinterpret_cast

  • 1)static_cast : 用于各种隐式转换。具体的说,就是用户各种基本数据类型之间的转换,比如把int换成char,float换成int等。以及派生类(子类)的指针转换成基类(父类)指针的转换。

    特性与要点:

    1. 它没有运行时类型检查,所以是有安全隐患的。
    2. 在派生类指针转换到基类指针时,是没有任何问题的,在基类指针转换到派生类指针的时候,会有安全问题
    3. static_cast不能转换const,volatile等属性
  • 2)dynamic_cast: 用于动态类型转换。具体的说,就是在基类指针到派生类指针,或者派生类到基类指针的转换。 dynamic_cast能够提供运行时类型检查,只用于含有虚函数的类。 dynamic_cast如果不能转换返回NULL。

  • 3)const_cast: 用于去除const常量属性,使其可以修改 ,也就是说,原本定义为const的变量在定义后就不能进行修改的,但是使用const_cast操作之后,可以通过这个指针或变量进行修改; 另外还有volatile属性的转换。

  • 4)reinterpret_cast 几乎什么都可以转,用在任意的指针之间的转换,引用之间的转换,指针和足够大的int型之间的转换,整数到指针的转换等。但是不够安全。

static_cast 和 reinterpret_cast 操作符修改了操作数类型。它们不是互逆的; static_cast 在编译时使用类型信息执行转换,在转换执行必要的检测(诸如指针越界计算, 类型检查). 其操作数相对是安全的。另一方面;reinterpret_cast 仅仅是重新解释了给出的对象的比特模型而没有进行二进制转换, 例子如下:

int n=9; 
double d=static_cast < double > (n); 

上面的例子中, 我们将一个变量从 int 转换到 double。 这些类型的二进制表达式是不同的。 要将整数 9 转换到 双精度整数 9,static_cast 需要正确地为双精度整数 d 补足比特位。其结果为 9.0。而reinterpret_cast 的行为却不同:

int n=9; 
double d=reinterpret_cast<double & > (n);

在进行计算以后, d 包含无用值. 这是因为 reinterpret_cast 仅仅是复制 n 的比特位到 d, 没有进行必要的分析

image-20200904232256093

STL

容器 底层数据结构 时间复杂度 有无序 可不可重复 其他
array 数组 随机读改 O(1) 无序 可重复 支持随机访问
vector 数组 随机读改、尾部插入、尾部删除 O(1) 头部插入、头部删除 O(n) 无序 可重复 支持随机访问
deque 双端队列 头尾插入、头尾删除 O(1) 无序 可重复 一个中央控制器 + 多个缓冲区,支持首尾快速增删,支持随机访问
forward_list 单向链表 插入、删除 O(1) 无序 可重复 不支持随机访问
list 双向链表 插入、删除 O(1) 无序 可重复 不支持随机访问
stack deque / list 顶部插入、顶部删除 O(1) 无序 可重复 deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时
queue deque / list 尾部插入、头部删除 O(1) 无序 可重复 deque 或 list 封闭头端开口,不用 vector 的原因应该是容量大小有限制,扩容耗时
priority_queue vector + max-heap 插入、删除 O(log2n) 有序 可重复 vector容器+heap处理规则
set 红黑树 插入、删除、查找 O(log2n) 有序 不可重复
multiset 红黑树 插入、删除、查找 O(log2n) 有序 可重复
map 红黑树 插入、删除、查找 O(log2n) 有序 不可重复
multimap 红黑树 插入、删除、查找 O(log2n) 有序 可重复
unordered_set 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 不可重复
unordered_multiset 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 可重复
unordered_map 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 不可重复
unordered_multimap 哈希表 插入、删除、查找 O(1) 最差 O(n) 无序 可重复

array底层原理

array 与内置数组类似,大小是固定的,因此不支持增加元素、删除元素以及改变容器大小的功能。 在使用 array 时,必须同时指定元素类型和大小array<int,20>

此容器是一个聚合类型,其语义等同于保有一个 C 风格数组 T[N] 作为其唯一非静态数据成员的结构体。该结构体结合了 C 风格数组的性能、可访问性与容器的优点,比如可获取大小、支持赋值、随机访问迭代器等。

vector底层原理

vector底层是一个动态数组,储存空间连续;包含三个迭代器,start和finish之间是已经被使用的空间范围,end_of_storage是整块连续空间包括备用空间的尾部。

vector的size属性等于finish-start,表示当前vector中有多少元素;而capacity属性等于end_of_storage-start,表示当前vector分配的内存可以容纳多少元素

当空间不够装下数据(vec.push_back(val))时,会自动申请另一片更大的空间,然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间;对vector的任何操作一旦引起了空间的重新配置**,指向原vector的所有**迭代器会都失效了

当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。

vector动态扩容的机制

push_back(只从屁股插入一个数据 )一般按两倍来扩容

insert(从迭代器位置插入数据,可插入多个)触发扩容时,如果要插入的数据量比旧容量小,则按两倍扩容;如果要插入的数据量比原来的旧容量还要大,即表示即使按两倍扩容了,依然存不下要插入的数据,此时将会按照旧容量加要插入的数据量来扩容,保证一次扩容就能容下要插入的数据

记住扩容后vector还得把原来的元素一个一个复制到新地址

image-20201101154950990

vector中reserve和resize的区别

reserve会对vector的capacity进行调整,但是size不会变,扩充的内存并没有初始化,直接用[]访问可能会出错

resize即调整capacity也调整size,并且会往里填充对应类型的对象(resize传入第二个参数val时会把多出来的空间都用val填充,没传入的话就填入默认初始化的对象)

image-20201101142954449

vector的元素类型可以是引用吗?

vector的底层实现要求连续的对象排列,引用并非对象,没有实际地址,因此vector的元素类型不能是引用

vector迭代器失效的情况

当插入一个元素到vector中,由于引起了内存重新分配,所以指向原内存的迭代器全部失效。

当删除容器中一个元素后,该迭代器所指向的元素已经被删除,那么也造成迭代器失效。erase方法会返回下一个有效的迭代器,所以当我们要删除某个元素时,需要用erase方法

vector删除元素

erase方法会删除迭代器指向的元素,并且返回指向下一个元素的迭代器

for(auto iter=veci.begin(); iter!=veci.end(); iter++){
      if( *iter == 3) veci.erase(iter);
}

这样使用是错误的,因为earase结束后,iter变成了野指针,iter++就产生了错误

for(auto iter=veci.begin(); iter!=veci.end(); iter++){
      if( *iter == 3) iter = veci.erase(iter);
}

这样也是错误的,因为当vector有两个连续的3时,遇到第一个3会用erase删除第一个3,并返回指向第二个3的迭代器,继续下一个循环时这个迭代器++,会指向下一个元素,这样就删不掉第二个3了

for(auto iter=veci.begin(); iter!=veci.end()){
      if( *iter == 3) iter = veci.erase(iter);
  		else iter++;
}

这样进入下一个循环时,如果当前元素是3则迭代器不会++,下一个循环的迭代器指向第二个3并进入if判断,第二个3被正常删除;当前元素不是3则正常++

vector释放内存

vec.clear():清空内容,但是不释放内存(capacity不变)

vec.shrink_to_fit():请求容器降低其capacity使其和size相同

vec.clear(); vec.shrink_to_fit();:清空内容,且释放内存。

骚操作:vector().swap(vec)也可以清空内容,且释放内存

swap方法的原理是交换两个vector的内部指针以达到“交换整个容器”的效果,所以在和默认的临时变量swap后,成员变量_managedObjectArray确实是个空的容器(包括内存),

而获得原来vector指针的临时变量会在函数结束时析构,而vector正是在其析构函数中释放内存的,所以在函数结束时,原来指针指向的vector中多余的内存都被释放

list的底层原理

list的底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间。插入删除效率都很高(不管是头尾还是中间)因为只需要插入一个节点,不像vector一样要考虑扩容和移动元素

list不支持随机访问某个位置的元素,只能从头尾一个一个找过去,即不支持[]操作符,也没有vector.at()这样的方法

forward_list的底层原理

forward_list 底层实现上是单链表,且实质上无任何多余开销,与 list 相比,此容器在不需要双向迭代时提供更有效地利用空间的存储。forward_list 的迭代器不支持iter–操作(即不支持反向迭代),同时 forward_list 也不支持size()操作。

deque的底层原理

deque是一个双端队列,存储空间连续;在头尾两端进行元素的插入跟删除操作都有理想的时间复杂度,并且支持快速的随机访问;但在中间添加删除元素代价很大,而且占用内存也多

deque可以直接用push_front, push_back, pop_front, pop_back从头尾添加删除元素

deque还可以直接用at随机访问某个位置的元素(list就做不到)

  • deque的实现方式:

和 vector 容器采用连续的线性空间不同,deque 容器存储数据的空间是由一段一段等长的连续空间构成,各段空间之间并不一定是连续的,可以位于在内存的不同区域。

为了管理这些连续空间,deque 容器用数组(数组名假设为 map)存储着各个连续空间的首地址。也就是说,map 数组中存储的都是指针,指向那些真正用来存储数据的各个连续空间

image-20201106153453485

通过建立 map 数组,deque 容器申请的这些分段的连续空间就能实现“整体连续”的效果。换句话说,当 deque 容器需要在头部或尾部增加存储空间时,它会申请一段新的连续空间,同时在 map 数组的开头或结尾添加指向该空间的指针,由此该空间就串接到了 deque 容器的头部或尾部。

stack / queue底层原理

STL的stack和queue就是deque/list封装了一下

priority_queue的底层原理

priority_queue<Type, Container, Functional>

Type为数据类型, Container为保存数据的容器,Functional为元素比较方式。

如果不写后两个参数,那么容器默认用的是std::vector<>,比较方式默认用std::less,也就是优先队列是大顶堆,队头元素最大。如果要小顶堆则第三个模版参数填std::greater

(greater和less需要先#include

如果要自定义比较方式就得自己写一个重载了bool operator()的comparator结构体传进去

vector,deque,list

vector:连续存储结构,每个元素在内存上是连续的;支持高效的随机访问和在尾端插入/删除操作,但其他位置的插入/删除操作效率低下;相当于一个数组,但是与数组的区别为:内存空间的扩展

deque:双端队列,连续存储结构,即其每个元素在内存上也是连续的,类似于vector,不同之处在于deque除了具有vector尾端插入/删除操作外,还支持高效的首端插入/删除操作。

list:非连续存储结构,具有双链表结构,每个元素维护一对前向和后向指针,因此支持前向/后向遍历。支持高效的随机插入/删除操作,但随机访问效率低下

map,set,multiset,multimap的底层原理

都是红黑树,内部元素有序

multiset和multimap允许相同元素/相同key,map和set不允许

set不能直接改变元素的值,只能先删除旧元素再添加新元素

map可以通过key改变value

为什么map和set的插入删除效率比用其他序列容器高?

对于关联容器来说,不需要做内存拷贝和内存移动。map和set容器内所有元素都是以节点的方式来存储,其节点结构和链表差不多,指向父节点和子节点

为什么map / set每次insert之后,以前保存的iterator不会失效?

内存位置没变。。。只是红黑树中各节点互相指向的关系变了,但指针指向的地址仍然是有效的

unordered_map,unordered_set,unordered_multiset,unordered_multimap的底层原理

哈希表版本的map,set,multiset,multimap,内部元素无序,内存消耗较大

STL中的sort()算法是用什么实现的,stable_sort()呢

STL中的sort()在数据量大时,采用快排quicksort,分段递归;一旦分段后的数量小于某个门限值,改用插入排序Insertion sort,避免quicksort深度递归带来的过大的额外负担,如果递归层次过深,还会改用heapsort(堆排序),stable_sort()是归并排序。

push和emplace区别

image-20201101170718643

image-20201101170829017

emplace会把一个右值直接move过去,而不是像push一样复制过去 Note:如果是emplace({})的话效果一样,也是先调用构造函数再move过去

多线程

C++有哪些锁

互斥锁:pthread_mutex_t

条件变量:pthread_cond_t

自旋锁:pthread_spin_lock

读写锁:pthread_rwlock_t

C++关键字

const关键字

  1. 变量声明使用const:

const int *p与int const *p相同:*p表示的值不能更改,但可以更改p指向的地址或者通过const int *p = &a; a = 3; 更改

int *const p:p表示的地址不能更改,不能通过p = &n更改p指向的地址,但可以更改这个地址存放的数据

  1. 函数前后使用const

前面使用const表示返回值为const,后面加 const表示函数不可以修改class的成员

  1. 参数使用const:

不能改变这个作为参数的指针指向的对象

const成员变量必须在构造函数中通过 :member() 的方式初始化

image-20201017213447679

注意用const修饰成员变量时必须要在构造函数中用初始化列表的方式初始化(声明时初始化也行,但这样会导致所以这个类的实例中这个成员变量的值都一样并且无法改变)

原因很简单,因为这个成员变量是const,所以不能用赋值的方式(=)给常量赋值,而初始化列表的方式不会涉及赋值只会将传入参数拷贝一份给const成员变量(例子见下)

define和const的联系与区别

define定义的常量没有类型,只是进行了简单的替换,可能会有多个拷贝,占用的内存空间大,const定义的常量是有类型的,存放在静态存储区,只有一个拷贝,占用的内存空间小。

define定义的常量是在预处理阶段进行替换,而const在编译阶段确定它的值。

define不会进行类型安全检查,而const会进行类型安全检查,安全性更高。

const可以定义函数而define不可以。

extern关键字

image-20200919135311891

由于没有#include “func.h”,必须用extern表示变量/函数在main.c之外的文件中声明过

extern表示变量/函数在其它文件中声明过了

static关键字

  1. 静态成员变量

静态成员变量是该类的所有对象所共有的。对于普通成员变量,每个类对象都有自己的一份拷贝。而静态成员变量一共就一份,无论这个类的对象被定义了多少个,静态成员变量只分配一次内存,由该类的所有对象共享访问。

因为静态数据成员在全局数据区分配内存,由本类的所有对象共享,所以,它不属于特定的类对象,不占用对象的内存,而是在所有对象之外开辟内存,在没有产生类对象时其作用域就可见。因此,在没有类的实例存在时,静态成员变量就已经存在,我们就可以操作它;

静态成员变量存储在全局数据区。static 成员变量的内存空间既不是在声明类时分配,也不是在创建对象时分配,而是在初始化时分配。静态成员变量必须初始化,而且只能在类体外进行(并且声明时要加static成员变量类型)

image-20201017210647896

但static const或const static需要在类体内声明时初始化,在构造函数中或类外初始化都会报错

image-20201017205019584

类的静态成员变量访问形式1:<类对象名>.<静态数据成员名> ;类的静态成员变量访问形式2:<类类型名>::<静态数据成员名>,也即,静态成员不需要通过对象就能访问。

静态数据成员和普通数据成员一样遵从public,protected,private访问规则;

sizeof 运算符不会计算静态成员变量

  1. 静态成员函数

静态成员之间可以相互访问,即静态成员函数仅可以访问静态成员变量、静态成员函数,不能访问非静态成员函数和非静态成员变量;

非静态成员函数可以任意地访问静态成员函数和静态数据成员;

由于没有this指针的额外开销,静态成员函数与类的全局函数相比速度上会稍快;

调用静态成员函数,两种方式:通过成员访问操作符(.)和(->),也即通过类对象或指向类对象的指针调用静态成员函数;直接通过类来调用静态成员函数。<类名>::<静态成员函数名>(<参数表>)

  1. 静态全局变量

该变量在全局数据区分配内存;

未经初始化的静态全局变量会被程序自动初始化为0(自动变量的自动初始化值是随机的);

静态全局变量在声明它的整个文件都是可见的,而在文件之外是不可见的(其它文件中可以定义相同名字的变量,不会发生冲突)

  1. 静态局部变量

通常,在函数体内定义了一个变量,每当程序运行到该语句时都会给该局部变量分配栈内存。但随着程序退出函数体,系统就会收回栈内存,局部变量也相应失效。

但有时候我们需要在两次调用之间对变量的值进行保存。通常的想法是定义一个全局变量来实现。但这样一来,变量已经不再属于函数本身了,不再仅受函数的控制,这给程序的维护带来不便。

静态局部变量正好可以解决这个问题。静态局部变量保存在全局数据区,而不是保存在栈中,每次的值保持到下一次调用,直到下次赋新值。

静态局部变量在全局数据区分配内存;

静态局部变量在程序执行到该对象的声明处时被首次初始化,即以后的函数调用不再进行初始化;

静态局部变量一般在声明处初始化,如果没有显式初始化,会被程序自动初始化为0;

静态局部变量始终驻留在全局数据区,直到程序运行结束。但其作用域为局部作用域,当定义它的函数或语句块结束时,其作用域随之结束;

  1. 静态函数

在函数的返回类型前加上static关键字,函数即被定义为静态函数。静态函数与普通函数不同,它只能在声明它的文件当中可见,不能被其它文件使用。

image-20201112143939468

静态全局/成员变量在main开始前初始化,main结束后销毁

静态局部变量在运行到定义的那一行时初始化,main结束后销毁

为什么静态方法不能调用非静态变量

静态方法被调用时如果要调用非静态变量就需要对象先被生成,但静态方法却可以在没生成对象时被调用,此时访问成员变量肯定会出错,所以不可以

volatile关键字

image-20200904225321320

例如多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible。一般说来,volatile用在如下的几个地方:

explict关键字

explicit的作用是用来声明类构造函数是显示调用的,而非隐式调用,所以只用于修饰单参构造函数。因为无参构造函数和多参构造函数本身就是显示调用的。再加上explicit关键字也没有什么意义。

explict只能用来修饰类构造函数

#include<cstring>
#include<string>
#include<iostream>

class Explicit{
public:
    Explicit(int size){
      	std::cout << " the size is " << size << std::endl;
    }
    Explicit(const char* str){
      	std::string _str = str;
      	std::cout << " the str is " << _str << std::endl;
    }
    Explicit(const Explicit& ins){
     	 std::cout << " The Explicit is ins" << std::endl;
    }
    Explicit(int a,int b){
      	std::cout << " the a is " << a  << " the b is " << b << std::endl;
    }
};

int main(){
    Explicit test0(15);
    Explicit test1 = 10;// 隐式调用Explicit(int size)

    Explicit test2("RIGHTRIGHT");
    Explicit test3 = "BUGBUGBUG";// 隐式调用Explicit(const char* str)

    Explicit test4(1, 10);
    Explicit test5 = test1;
}

上面的程序虽然没有错误,但是对于Explicit test1 = 10;Explicit test2 = "BUGBUGBUG";这样的句子,把一个int类型或者const char*类型的变量赋值给Explicit类型的变量看起来总归不是很好,并且当程序很大的时候出错之后也不容易排查。所以为了禁止上面那种隐式转换可能带来的风险,一般都把类的单参构造函数声明的显示调用的,就是在构造函数加关键字``explicit`。如下:

#include<cstring>
#include<string>
#include<iostream>

class Explicit{
public:
    explicit Explicit(int size){
      	std::cout << " the size is " << size << std::endl;
    }
    explicit Explicit(const char* str){
      	std::string _str = str;
      	std::cout << " the str is " << _str << std::endl;
    }
    Explicit(const Explicit& ins){
     	 std::cout << " The Explicit is ins" << std::endl;
    }
    Explicit(int a,int b){
      	std::cout << " the a is " << a  << " the b is " << b << std::endl;
    }
};

int main(){
    Explicit test0(15);
    Explicit test1 = 10;// 无法调用

    Explicit test2("RIGHTRIGHT");
    Explicit test3 = "BUGBUGBUG"; // 无法调用

    Explicit test4(1, 10);
    Explicit test5 = test

inline关键字

inline是内联的意思,可以定义比较小的函数。因为函数频繁调用会占用很多的栈空间,进行入栈出栈操作也耗费计算资源,所以可以用inline关键字修饰频繁调用的小函数。编译器会在编译阶段将代码体嵌入内联函数的调用语句块中。

inline 必须与函数定义体放在一起才能使函数成为内联,仅将 inline 放在函数声明前面不起任何作用。

在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数;如果在类声明中未给出成员函数定义,而又想内联该函数的话,那在类外要加上 inline,否则就认为不是内联的。

内联是以**代码膨胀(复制)**为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。 如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。

包含了循环,分支的函数不能设为内联吗

inline只是个建议,所以循环,分支,递归的函数可以加inline,但是实际上编译器不会内联(复杂函数内联代价比较大)

inline和define区别

  • 内联函数在编译时展开,而宏在预编译时展开

  • 在编译的时候,内联函数直接被嵌入到目标代码中去,而宏只是一个简单的文本替换。

  • 内联函数可以进行诸如类型安全检查、语句是否正确等编译功能,宏不具有这样的功能。

  • inline可以不展开,宏一定要展开。因为inline指示对编译器来说,只是一个建议,最后能否真正内联,看编译器的意思,它如果认为函数不复杂,能在调用点展开,就会真正内联,并不是说声明了内联就会内联,声明内联只是一个建议而已。

  • 宏定义在形式上类似于一个函数,但在使用它时,仅仅只是做预处理器符号表中的简单替换,因此它不能进行参数有效性的检测,也就不能享受C++编译器严格类型检查的好处,另外它的返回值也不能被强制转换为可转换的合适的类型,这样,它的使用就存在着一系列的隐患和局限性。

设计模式

C++单例模式

#include <iostream>

using namespace std;

class A{
private:
    A(){}
    // C++中如果要求一个类能被复制需要实现赋值运算符或者复制构造函数,设置成私有并删除以防拷贝
	  A(const A&) = delete; // 删除拷贝构造函数
    A& operator=(const A&) = delete; // 删除 = 号赋值运算符(运算符重载)
    ~A(){}
    static A* instance; // static member so only one instance

public:
  // static getInstance method so it can be called withont an actual instance of A
    static A *getInstance(){ 
        if (A::instance == nullptr){
            A::instance = new A();
        }
        return A::instance;
    }
};

// 饿汉版:程序开始的时候就将instance赋值为A类对象指针,线程安全
A* A::instance = A::getInstance();

// 懒汉版:一开始将instance赋值为空指针,要用的时候再赋值为A类对象指针,线程不安全
// A *A::instance = nullptr; 

int main(){
    A *a1 = A::getInstance();
    A *a2 = A::getInstance();
    if (a1 == a2) cout << "Same instance" << endl; // Same instance
}

懒汉版线程安全单例

懒汉单例是非线程安全的:假设两个线程同时首次调用该类的静态方法instance(),即它们会同时判断p指针是否指向NULL,则这两个线程会各自实例p指针,出现错误。

解决方法是初始化单例时同时初始化一个互斥锁,并且getInstance函数中先给互斥锁加锁,然后再判断是否已经有了单例,最后解锁互斥锁

设计一个不能拷贝的类

class NoCopy {
public:
    NoCopy() {}

private :
    NoCopy(const NoCopy &copy) = delete;
    NoCopy &operator=(const NoCopy &copy) = delete;
};

int main() {
    NoCopy a;
    NoCopy b;
    b = a; // error: 'operator=' is a private member of 'NoCopy'
    return 0;
}

C++11

nullptr关键字 (C++11)

nullptr 出现的目的是为了替代 NULL。在某种意义上来说,传统 C++ 会把 NULL0 视为同一种东西,这取决于编译器如何定义 NULL,有些编译器会将 NULL 定义为 ((void*)0),有些则会直接将其定义为 0

为了解决这个问题,C++11 引入了 nullptr 关键字,专门用来区分空指针、0。nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。

constexpr关键字 (C++11)

C++11 提供了 constexpr 让用户显式的声明函数或对象构造函数在编译期会成为常数,这个关键字明确的告诉编译器应该去验证 len_foo 在编译器就应该是一个常数。

C++14中constexptr 函数可以在内部使用局部变量、递归、循环和分支等简单语句

default & delete关键字 (C++11)

class Magic {
public:
    Magic() = default;  // 显式声明使用编译器生成的构造
    Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
    Magic(int magic_number);
}

auto关键字 (C++11)

// 由于 cbegin() 将返回 vector<int>::const_iterator 
// 所以 itr 也应该是 vector<int>::const_iterator 类型
for(auto itr = vec.cbegin(); itr != vec.cend(); ++itr);

// C++14 开始是可以直接让普通函数具备返回值推导
template<typename T, typename U>
auto add(T x, U y) {
    return x+y
}

auto还可以用于简化迭代

/* 没有auto时的迭代方式:使用迭代器
 * std::vector<int> arr(5, 100);
 * for(std::vector<int>::iterator i = arr.begin(); i != arr.end(); ++i) {
 * std::cout << *i << std::endl;
 * }
 */

// & 启用了引用, 如果没有则对 arr 中的元素只能读取不能修改
for(auto &i : arr) {    
    std::cout << i << std::endl;
}

decltype关键字 (C++11)

auto x = 1;
auto y = 2;
decltype(x+y) z;

初始化列表 (C++11)

C++98:普通数组、POD (plain old data,没有构造、析构和虚函数的类或结构体)类型都可以使用 {} 进行初始化,也就是我们所说的初始化列表。而对于类对象的初始化,要么需要通过拷贝构造、要么就需要使用 () 进行

C++11:允许构造函数或其他函数像参数一样使用初始化列表

// C++98

#include <iostream>
#include <vector>

using namespace std;

class T {
public:
    int n, m;
};


int main() {
    T t1 = {12, 123}; // OK for compiling
    T t2{12, 123}; // expected ';' at end of declaration
    return 0;
}

POD类型时可以使用T t1 = {12, 123}的方式进行初始化

C++98不支持T t2{12, 123}这样的语句,故不能这样初始化

// C++98

#include <iostream>
#include <vector>

using namespace std;

class T {
public:
    int n, m;

    T(int n, int m) {
        this->n = n;
        this->m = m;
    }
};


int main() {
    T t1 = {12, 123}; // non-aggregate type 'T' cannot be initialized with an initializer list
    T t2{12, 123}; // no matching constructor for initialization of 'T'
    T t3(12, 123); // OK for compiling
    return 0;
}

有构造函数之后不再是POD类型,两种列表初始化的方式都不行,只能用构造函数的方式初始化

// C++11

#include <iostream>
#include <vector>

using namespace std;

class T {
public:
    int n, m;

    T(int n, int m) {
        this->n = n;
        this->m = m;
    }

//    int foo(){}
};


int main() {
    T t1 = {12, 123}; // OK for compiling
    T t2{12, 123}; // OK for compiling
    T t3(12, 123); // OK for compiling
    return 0;
}

C++11支持即使有构造函数的非POD类型也可以列表初始化,如t1;并且提供了t2所示的语法,提供了统一的语法来初始化任意的对象(把t1和t3的语法融合为t2的方式)

尖括号 (C++11)

在传统 C++ 的编译器中,>>一律被当做右移运算符来进行处理;这在传统C++编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译

#include <iostream>
#include <vector>

using namespace std;


int main() {
    vector<int> v1(10);
    vector<vector <int>> v2; // legal in C++11
    v2.push_back(v1);
    return 0;
}

变长参数模板 (C++11)

image-20200919152853236

委托构造 & 继承构造 (C++11)

image-20200919155139599

image-20200919155147809

override & final关键字 (C++11)

image-20200919155841816

image-20200919155812072

image-20200919155820570

using关键字 (C++11)

image-20200919165948292

强枚举类型 (C++11)

在传统 C++中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至枚举类型的枚举值名字不能相同,这不是我们希望看到的结果。

image-20200919174448465

enum Side{ Right, Left };
enum Thing{ Wrong, Right }; // error: redefinition of enumerator 'Right'

C++11 引入了枚举类(enumaration class),并使用 enum class 的语法进行声明:

enum class new_enum : unsigned int {
    value1,
    value2,
    value3 = 100,
    value4 = 100
};

这样定义的枚举实现了类型安全,首先他不能够被隐式的转换为整数,同时也不能够将其与整数数字进行比较,更不可能对不同的枚举类型的枚举值进行比较。但相同枚举值之间如果指定的值相同,那么可以进行比较:

if (new_enum::value3 == new_enum::value4) {
    // 会输出
    std::cout << "new_enum::value3 == new_enum::value4" << std::endl;
}

image-20200919181720830

lambda (C++11)

#include <iostream>
#include <thread>
#include <vector>

using namespace std;

auto lam = [](int n, int m){
    return n+m;
};

int main() {
    int res = lam(1, 2);
    cout << res << endl;
    return 0;
}

捕获列表,其实可以理解为参数的一种类型,lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的,这时候捕获列表可以起到拿到外部数据值的作用

  • 值捕获:与参数传值类似,值捕获的前期是变量可以拷贝,不同之处则在于,被捕获的变量在 lambda 表达式被创建时拷贝,而非调用时才拷贝;lambda表达式创建后改变变量,lambda中不会发生变化
  • 引用捕获:与引用传参类似,引用捕获保存的是引用,值会发生变化。
  • 隐式捕获:手动书写捕获列表有时候是非常复杂的,这种机械性的工作可以交给编译器来处理,这时候可以在捕获列表中写一个 &= 向编译器声明采用 引用捕获或者值捕获.

image-20200919183447898

  • 泛型 Lambda:auto 关键字不能够用在参数表里,这是因为这样的写法会与模板的功能产生冲突。但是 Lambda 表达式并不是普通函数,所以 Lambda 表达式并不能够模板化。这就为我们造成了一定程度上的麻烦:参数表不能够泛化,必须明确参数表类型;幸运的是,从 C++14 开始,Lambda 函数的形式参数可以使用 auto 关键字来产生意义上的泛型

image-20200919183724921

右值引用 (C++11)

左值(lvalue, left value),顾名思义就是赋值符号左边的值。准确来说,左值是表达式(不一定是赋值表达式)后依然存在的持久对象。

右值(rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。

而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

纯右值(prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true;要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、Lambda 表达式都属于纯右值。

将亡值(xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++中,纯右值和右值是统一个概念),也就是即将被销毁、却能够被移动的值。

将亡值可能稍有些难以理解,我们来看这样的代码:

std::vector<int> foo() {
    std::vector<int> temp = {1, 2, 3, 4};
    return temp;
}


std::vector<int> v = foo();

在这样的代码中,函数 foo 的返回值 temp 在内部创建然后被赋值给 v,然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大,这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v 是左值、foo() 返回的值就是右值(也是纯右值)。

但是,v 可以被别的变量捕获到,而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。

将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

  • 右值引用和左值引用

需要拿到一个将亡值,就需要用到右值引用的申明:T &&,其中 T 是类型。右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。

C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值,有了它我们就能够方便的获得一个右值临时对象,例如:

  • 移动语义

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。

image-20200919223530282

在上面的代码中:

  1. 首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
  2. 函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。

从而避免了无意义的拷贝构造,加强了性能

下面这个例子直观一点:

当有移动构造函数时将亡值会以移动的方式从被调用函数返回结果传回给main;没有移动构造函数时只能复制一遍(消耗较大)

image-20200920094036279

image-20200920094052465

#include <iostream> // std::cout
#include <utility>  // std::move
#include <vector>   // std::vector
#include <string>   // std::string

int main() {
    std::string str = "Hello world.";
    std::vector<std::string> v;
    // 将使用 push_back(const T&), 即产生拷贝行为
    v.push_back(str);
    // 将输出 "str: Hello world."
    std::cout << "str: " << str << std::endl;
    // 将使用 push_back(const T&&), 不会出现拷贝行为
    // 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
    // 这步操作后, str 中的值会变为空
    v.push_back(std::move(str));
    // 将输出 "str: "
    std::cout << "str: " << str << std::endl;
    return 0;
}

要调用参数为右值的移动函数,就必须传右值进去;此时传右值仅仅表示要去找参数为右值的对应函数,具体怎么通过移动减少拷贝开销要看这个函数怎么实现

image-20201108200529546

  • 完美转发

前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

image-20200919211536459

对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。因此 reference(v) 会调用 reference(int&),输出『左值』。而对于pass(v)而言,v是一个左值,为什么会成功传递给 pass(T&&) 呢?

这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用,既能左引用,又能右引用。但是却遵循如下规则

image-20200919211550732

因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。这才使得 v 作为左值的成功传递。

完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候,保持原来的参数类型(左引用保持左引用,右引用保持右引用)。为了解决这个问题,我们应该使用 std::forward 来进行参数的转发(传递):

image-20200919211456940

普通传参一定传左值,move一定转换为右值,forward保持原来性质

unordered_map & unordered_set (C++11)

我们已经熟知了传统 C++ 中的有序容器 std::map/std::set,这些元素内部通过红黑树进行实现,插入和搜索的平均复杂度均为 O(log(size))。在插入元素时候,会根据 < 操作符比较元素大小并判断元素是否相同,并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 < 操作符的顺序来逐个遍历。

而无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant),在不关心容器内部元素顺序时,能够获得显著的性能提升。

noexcept 的修饰和操作 (C++11)

使用 noexcept 修饰过的函数如果抛出异常,编译器会使用 std::terminate() 来立即终止程序运行。

智能指针 (C++11)

  • shared_ptr

利用引用计数->每有一个指针指向相同的一片内存时,引用计数+1,每当一个指针取消指向一片内存时,引用计数-1,减为0时释放内存。

但还不够,因为使用 std::shared_ptr 仍然需要使用 new 来调用,这使得代码出现了某种程度上的不对称。

std::make_shared 就能够用来消除显示的使用 new,所以std::make_shared 会分配创建传入参数中的对象,并返回这个对象类型的std::shared_ptr指针。<>中填入类名,()中填入初始化用的参数,例如:

std::shared_ptr 可以通过 get() 方法来获取原始指针,通过 reset() 来减少一个引用计数,并通过get_count()来查看一个对象的引用计数,unique(): 判断是否是唯一指向当前内存的shared_ptr.。例如:

image-20200920005708179

  • unique_ptr

    “唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(禁止拷贝、赋值),可以释放所有权,转移所有权。
    
std::unique_ptr<int> pointer = std::make_unique<int>(10);   // make_unique 从 C++14 引入
std::unique_ptr<int> pointer2 = pointer;    // 非法

image-20200920004258513

既然是独占,换句话说就是不可复制。但是,我们可以利用 std::move 将其转移给其他的 unique_ptr(或者用unique_ptr的swap方法也可以),例如

image-20200920005310162

  • weak_ptr

如果你仔细思考 std::shared_ptr 就会发现依然存在着资源无法释放的问题。看下面这个例子(循环引用):

image-20201031143530136

image-20200920004850088

image-20200920004905179

image-20201031143503235

std::weak_ptr 没有 * 运算符和 -> 运算符,所以不能够对资源进行操作,它的唯一作用就是用于检查 std::shared_ptr 是否存在,expired() 方法在资源未被释放时,会返回 true,否则返回 falselock()方法用来获取weak_ptr指向对象的shared_ptr