自学内容网 自学内容网

【C++】零碎知识点(易忘 / 易错)总结回顾

一、C++ 历史版本


二、命名空间

使用命名空间的目的:对标识符的名称进行本地化,以避免命名冲突或名字污染

命名空间可以嵌套

同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中

使用 using 可以将命名空间中某个成员引入:using xyl::a;

使用 using namespace 将命名空间名称引入:using namespace xyl;

std 是 C++ 标准库的命名空间名,C++ 将标准库的定义实现都放到这个命名空间中


三、输入输出

1、cin 和 cout

  • 使用 cout 标准输出对象(控制台)和 cin 标准输入对象(键盘)时,必须包含 <iostream> 头文件以及按命名空间使用方法使用 std
  • cout 和 cin 是全局的流对象,endl 是特殊的 C++ 符号

C++ 将所有功能实现在 std 命名空间下,为了和 C 头文件区分和正确使用命名空间,规定 C++ 头文件不带 .h

using namespace std 展开,标准库就全部暴露了,如果定义跟库重名的类型 / 对象 / 函数,就会存在冲突问题

建议在项目开发中使用像 std::cout 这样使用时指定命名空间 + using std::cout 展开常用的库对象 / 类型等方式


四、缺省参数

缺省参数是声明或定义函数时为函数的参数指定一个缺省值

半缺省参数必须从右往左依次来给出,不能间隔着给

缺省参数不能在函数声明和定义中同时出现

缺省值必须是常量或者全局变量

C 语言不支持(编译器不支持)


五、函数重载

C++ 允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 / 类型 / 类型顺序)不同,常用来处理实现功能类似而数据类型不同的问题

C++ 支持函数重载的原理:名字修饰

为什么 C++ 支持函数重载,而 C 语言不支持函数重载呢?

gcc 的函数修饰后名字不变

  • 因为同名函数没法区分,所以 C 语言没办法支持重载

g++ 的函数修饰后变成:_Z+函数长度+函数名+类型首字母

  • C++ 是通过函数修饰规则来区分的,只要参数不同,修饰出来的名字就不一样,所以 C++ 支持重载
  • 如果两个函数的函数名和参数一样,返回值不同是不构成重载的,因为调用时编译器没办法区分

六、引用

1、概念

引用是给已存在的变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间

但引用在底层实现上实际是有空间的,因为引用是按照指针方式来实现的(引用定义和引用访问与指针定义和指针解引用访问在汇编层完全类似)

引用类型必须和引用实体是同种类型的


2、特性

  • 引用在定义时必须初始化
  • 一个变量可以有多个引用
  • 引用一旦引用一个实体,就不能引用其他实体

3、常引用

const 引用的好处:保护实参,避免被误改,而且它可以传普通对象,也可以传 const 对象


4、引用做返回值

如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),就可以使用引用返回;而如果已经还给系统了,则必须使用传值返回


5、引用和指针的不同点

  1. 概念上,引用定义一个变量的别名,指针存储一个变量地址
  2. 引用在定义时必须初始化,指针没有要求
  3. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
  4. 没有 NULL 引用,但有 NULL 指针
  5. 在 sizeof 中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数
  6. 引用自加即引用的实体增加 1,指针自加即指针向后偏移一个类型的大小
  7. 有多级指针,但是没有多级引用
  8. 访问实体方式不同,指针需要显式解引用,引用是编译器自己处理
  9. 引用比指针使用起来相对更安全,指针容易出现野指针、空指针等非法访问等问题

七、内联函数

1、概念

编译时 C++ 编译器会在调用内联函数的地方展开其函数体,没有函数调用建立栈帧的开销,内联函数可以提升程序运行的效率


2、特性

inline 是一种以空间换时间的做法

  • 优势:少了调用开销,提高程序运行效率
  • 缺陷:可能会使目标文件变大

inline 对于编译器而言只是一个建议,不同编译器关于 inline 实现机制可能不同,一般建议:将函数规模较小、不是递归且频繁调用的函数采用 inline 修饰,否则编译器会忽略 inline 特性

inline 不建议声明和定义分离,否则会导致链接错误,因为 inline 被展开后就没有函数地址了


八、C++11 新特性

1、auto

auto 作为一个新的类型指示符来指示编译器,auto 声明的变量必须由编译器在编译时期推导而得(为了避免与 C++98 中的 auto 发生混淆,C++11 只保留了 auto 作为类型指示符的用法)

使用 auto 定义变量时必须对其进行初始化

auto 并非是一种 “类型” 的声明,而是一个类型声明时的 “占位符”

用 auto 声明指针类型时,用 auto 和 auto* 没有任何区别 ,但用 auto 声明引用类型时则必须加 &

当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错

auto 不能推导的场景:

  • 不能作为函数的参数
  • 不能直接用来声明数组

2、基于范围的 for 循环

其底层和普通遍历等是一样的

使用条件:

  • for 循环迭代的范围必须是确定的
  • 迭代的对象要实现 ++ 和 == 的操作

3、指针空值 nullptr

空指针并不是不存在,它是内存的第一个字节的编号,一般不使用这个字节存有效数据

用空指针一般是用来初始化,表示指针指向一块没有存放有效数据的空间

NULL 实际是一个宏,它可能被定义为字面常量 0,或者被定义为无类型指针 (void*) 的常量。出于清晰和安全的角度考虑,C++11 中新增了 nullptr 用于表示空指针

不需要包含头文件,因为 nullptr 是关键字

sizeof(nullptr) 与 sizeof((void*)0) 所占的字节数相同


九、类和对象

1、类

C 语言在结构体中只能定义变量,C++ 在结构体内不仅可以定义变量,还可以定义函数

类定义结束时后面的分号不能省略


(1)两种定义方式

1、声明和定义全部放在类体中

  • 成员函数如果在类中定义,编译器可能(如果符合 inline 条件:编译指令比较少)就会将其当成内联函数处理

2、类声明放在 .h 文件中,成员函数定义放在 .cpp 文件中(推荐)

  • 成员函数名前需要加类名::

(2)类的访问限定符及封装

A. 访问限定符

实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用

访问权限作用域:从该访问限定符出现的位置开始直到下一个访问限定符出现时为止,如果后面没有访问限定符,作用域就到 } 即类结束

class 的默认访问权限为 private,struct 的默认权限为 public(因为 struct 要兼容 C)

访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别


B. 封装

面向对象的三大特性之一

封装:将数据和操作数据的方法进行有机结合,隐藏(private / protected)对象的属性和实现细节,仅对外公开(public)接口来和对象进行交互


(3)类的作用域

作用域主要是影响编译器的搜索规则


(4)类的实例化

用类类型创建对象的过程,称为类的实例化

类是对对象进行描述的,定义出一个类并没有分配实际的内存空间来存储它

一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,用来存储类成员变量


(5)类对象模型

一个类的大小,实际就是该类中 “成员变量” 之和,要注意内存对齐和空类的大小

编译器给了空类一个字节来唯一标识这个类的对象,起到占位但不存储实际数据,标识其存在的作用


(6)this 指针

C++ 编译器给每个 “非静态的成员函数” 增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有 “成员变量” 的操作都是通过该指针去访问,只不过所有的操作对用户是透明的(用户不需要来传递,编译器自动完成)

实参和形参位置不能显示传递和接收 this 指针,但可以在成员函数内部使用 this 指针


A. 特性
  • this 指针的类型:类类型* const,在成员函数中不能给 this 指针赋值
  • 只能在 “成员函数” 的内部使用
  • 当对象调用成员函数时,将对象地址作为实参传递给 this 形参,所以在对象中不存储 this 指针,this 指针是存放在栈区的,因为它是一个形参
  • this 指针是 “成员函数” 第一个隐含的指针形参,在 VS2019 下面传递 this 指针,一般情况是由编译器通过 ecx 寄存器自动传递,这样 this 访问变量可以提高效率,且不需要用户传递

B. 用途
  • 解决名称冲突:当形参和成员变量同名时,可用 this 指针来区分
  • 在类的非静态成员函数中返回对象本身,可使用 return *this

(7)类的 6 个默认成员函数(主要是 4 个)

A. 构造函数(初始化)
a. 特征
  • 函数名与类名相同
  • 无返回值,也不用写 void
  • 构造函数可以重载
  • 在对象的整个生命周期内只调用一次(初始化只能初始化一次,而构造函数体内可以多次赋值)

构造函数调用后,对象中有了初始值,但不能将其称为对对象中成员变量的初始化。构造函数体中的语句只能将其称为赋初值,而不能称作初始化

构造函数的主要任务并不是开空间创建对象,而是初始化对象

如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明

默认生成的构造函数对内置类型成员不作处理,而对自定义类型成员会去调用它的默认构造函数

  • 对于类中的内置类型成员 —> 不做处理(为随机值,除非声明时给了缺省值,注意这里不是初始化)
  • 对于类中的自定义类型成员 —> 自动调用它的默认构造函数

无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个,否则会出现二义性

  • 无参构造函数
  • 全缺省构造函数
  • 没写,编译器默认生成的构造函数

类中包含这些成员时,必须放在初始化列表位置进行初始化

  • 引用成员变量
  • const 成员变量
  • 自定义类型成员(且该类没有默认构造函数时)

成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关


b. explicit 关键字

用 explicit 修饰构造函数,将会禁止构造函数的隐式转换


B. 析构函数(清理)
  • 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时编译系统会自动调用析构函数,完成对象中资源的清理工作
  • 特征
    • 无参数,无返回值类型
    • 一个类只能有一个析构函数
    • 析构函数不能重载
    • 后定义的先析构
  • 编译器生成的默认析构函数,对自定义类型成员调用它的析构函数
    • 对于类中的内置类型成员 —> 不处理(销毁时不需要资源清理,最后系统直接将其内存回收即可)
    • 对于类中的自定义类型成员 —> 调用它的析构函数完成清理工作

C. 拷贝构造函数

只有单个形参,该形参是对本类类型对象的引用(一般常用 const 修饰,防止其值误被修改),在用已存在的类类型对象创建新对象时由编译器自动调用


a. 特征

拷贝构造函数是构造函数的一个重载形式

使用传值方式编译器会直接报错(传值引发对象的拷贝,继续传值引发对象的拷贝,层层传值引发对象的拷贝调用),而引用在初始化时不会调用拷贝构造函数,而是直接将引用绑定到已经存在的对象上

若未显示定义,编译器会生成默认的拷贝构造函数

浅拷贝:默认的拷贝构造函数对象按内存存储,按字节序完成拷贝,这种拷贝叫做浅拷贝(值拷贝)

一个对象修改会影响另一个对象

默认拷贝构造会导致析构两次,程序崩溃

对于类中的内置类型成员 —> 值拷贝

对于类中的自定义类型成员 —> 自动调用它的拷贝构造函数来完成拷贝初始化

类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝


D. 赋值运算符重载
a. 运算符重载

函数原型:返回值类型 operator 操作符(参数列表)

内置类型可以直接使用运算符运算,自定义类型无法直接使用运算符,因为编译器不知道要如何运算

两种方式

  1. 重载成类的成员函数(形参数目看起来比该运算符需要的参数少一个,因为成员函数有隐含的 this 指针,且为函数的第一个形参)
  2. 重载成类的友元函数(必须有一个参数是类的对象)

不能通过连接其他符号来创建新的操作符,比如 operator@

重载操作符必须有一个类类型参数

.* :: sizeof ?: . 这 5 个运算符不能重载


b. 赋值运算符重载

格式:

参数类型:const T&

返回值类型:T&

有返回值是为了支持连续赋值

需要检测是否自己给自己赋值

返回 *this:支持连续赋值


赋值运算符只能重载成类的成员函数,不能重载成全局函数

重载成全局函数时就没有 this 指针了,此时就需要给两个参数

赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,所以只能重载成类的成员函数

用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝

如果类中未涉及到资源管理,赋值运算符是否实现都可以,一旦涉及到资源管理则必须要实现赋值运算符重载


c. 前置++和后置++重载

前置++和后置++都是一元运算符

为了让前置++与后置++能正确重载,规定后置++重载时多增加一个 int 类型的参数,但调用函数时该参数不用传递,编译器自动传递

前置返回的是引用,后置返回的是值


(8)const 成员

  • const 修饰类成员函数,实际修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对类的任何成员进行修改
  • const 修饰成员函数的好处:const 对象和非 const 对象都可以调用
  • const 成员(只读)函数内不可以调用其它的非 const 成员(可读可写)函数(权限放大);非 const 成员(可读可写)函数内可以调用其它的 const 成员(只读)函数(权限缩小)。

(9)static 成员

静态成员变量一定要在类外进行初始化

A. 特性
  • 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
  • 静态成员变量在编译阶段分配内存
  • 静态成员变量必须在类外初始化(定义),定义时不添加 static 关键字,类内只是声明
  • 类静态成员可以用 类名::静态成员 或者 对象.静态成员来访问
  • 静态成员函数没有隐藏的 this 指针,不能访问任何非静态成员,只能访问静态成员变量
  • 类外访问不到私有静态成员函数
  • 静态成员函数不可以调用非静态成员函数,因为静态成员函数没有 this 指针,无法区分到底是哪个对象的成员
  • 非静态成员函数可以调用类的静态成员函数,因为非静态成员函数有 this 指针

(10)友元

友元会增加耦合度,破坏封装

A. 友元函数
  • 友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明 ,声明时需要加 friend 关键字
  • 友元函数可访问类的私有和保护成员,但不是类的成员函数
  • 友元函数不能用 const 修饰
  • 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
  • 一个函数可以是多个类的友元函数
  • 友元函数的调用与普通函数的调用原理相同

B. 友元类
  • 友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员
  • 友元关系是单向的,不具有交换性
  • 友元关系不能传递
    如果 C 是 B 的友元, B 是 A 的友元,则不能说明 C 是 A 的友元
  • 友元关系不能继承

(11)内部类

如果一个类定义在另一个类的内部,这个内部的类就叫做内部类

内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员

外部类对内部类没有任何优越的访问权限,但内部类会受到外部类的类域的限制和访问限定符的限制

内部类就是外部类的友元类,内部类可以通过外部类的对象参数来访问外部类中的所有成员,但外部类不是内部类的友元


A. 特性
  • 内部类可以定义在外部类的 public、protected、private 都是可以的
  • 内部类可以直接访问外部类中的 static 成员,不需要外部类的对象 / 类名
  • sizeof(外部类)=外部类,和内部类没有任何关系

2、对象

(1)匿名对象

匿名对象没有名字,它的生命周期只在这一行


(2)拷贝对象时的一些编译器优化

隐式类型,连续构造+拷贝构造 --> 优化为直接构造

连续构造+拷贝构造 --> 优化为一个构造

连续拷贝构造+拷贝构造 --> 优化一个拷贝构造

连续拷贝构造+赋值重载 --> 无法优化

如果编译器没有优化就是 9 次,优化后为 7 次:

Widget u、Widget y 都是首次创建对象

在第一次 return w 时是作为 Widget u 的拷贝对象,符合优化条件

在第二次 return w 时是作为 Widget y 的拷贝对象,符合优化条件


十、C++ 内存管理方式

在申请自定义类型的空间时,new / delete 和 malloc / free 最大区别:new / delete 还会调用构造函数和析构函数

注意匹配问题:new / delete 和 new[] / delete[]

  • 申请和释放单个元素的空间,使用 new 和 delete 操作符
  • 申请和释放连续的空间,使用 new[] 和 delete[]

十一、operator new 与 operator delete 函数

new 和 delete 是用户进行动态内存申请和释放的操作符,operator new 和 operator delete 是系统提供的全局函数

new 在底层调用 operator new 全局函数来申请空间,delete 在底层通过 operator delete 全局函数来释放空间

operator new 实际也是通过 malloc 来申请空间,operator delete 最终是通过 free 来释放空间的


十二、new 和 delete 的实现原理

1、内置类型

new 在申请空间失败时会抛异常,malloc 会返回 NULL


2、自定义类型

(1)new 的原理

调用 operator new 函数申请空间,在申请的空间上执行构造函数,完成对象的构造


(2)delete 的原理

在空间上执行析构函数,完成对象中资源的清理工作,调用 operator delete 函数释放对象的空间


(3)new T[N] 的原理

调用 operator new[] 函数,在 operator new[] 中实际调用 operator new 函数完成 N 个对象空间的申请,在申请的空间上执行 N 次构造函数


(4)delete[] 的原理

在释放的对象空间上执行 N 次析构函数,完成 N 个对象中资源的清理。调用 operator delete[] 释放空间,实际在 operator delete[] 中调用 operator delete 来释放空间


十三、定位 new 表达式(placement-new)

定位 new 表达式:是在已分配的原始内存空间中调用构造函数初始化一个对象

需要手动调用析构函数,因为定位 new 不会自动释放内存。

new (place_address) type 或者 new (place_address) type(initializer-list)

  • place_address 必须是一个指针
  • initializer-list 是类型的初始化列表

一般是配合内存池使用的。因为内存池分配出的内存没有初始化,所以如果是自定义类型的对象,需要使用 new 的定义表达式进行显示调用构造函数进行初始化


十四、内存泄漏

1、分类

  • 堆内存泄漏(Heap leak)
  • 系统资源泄漏

如何检测内存泄漏?

在 VS 下,可以使用 Windows 操作系统提供的 _CrtDumpMemoryLeaks() 函数进行简单检测,该函数只报出了大概泄漏了多少个字节,没有其他更准确的位置信息


避免内存泄漏:

  • 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放
  • 采用 RAII 思想或者智能指针来管理资源

十五、泛型编程

泛型编程 :编写与类型无关的通用代码,是代码复用的一种手段

C 语言不支持泛型编程,C++ 支持泛型编程


1、模板是泛型编程的基础


(1)函数模板(Function Template)

函数模板不是一个实在的函数,编译器不能为其生成可执行代码

typename 是用来定义模板参数关键字,也可以使用 class 关键字代替(在这里 typename 和 class 没有区别,但不能使用 struct 代替 class)


A. 模板参数实例化
  • 隐式实例化:让编译器根据实参推演模板参数的实际类型
  • 显式实例化:在函数名后的 <> 中指定模板参数的实际类型

B. 模板参数的匹配规则
  • 对于非模板函数和同名函数模板,如果其他条件都相同,在调动时会优先调用非模板函数而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配的函数,那么将选择模板
  • 模板函数不允许自动类型转换,但普通函数可以进行自动类型转换

(2)类模板(Class template)

  • 类模板是对一批仅成员数据类型不同的类的抽象
  • 类模板的成员函数是按需实例化,只有当程序用到它时才进行实例化
  • 类模板中函数放在类外进行定义时,需要加模板参数列表
  • 类模板成员函数的模板形参由调用该函数的对象的类型来确定,对象的模板实参能够确定成员函数的模板形参
  • 类模板的使用都是显式实例化
  • 类模板中的成员函数都是函数模板
  • 类模板实例化与函数模板实例化不同, 类模板实例化需要在类模板名字后跟 <> ,然后将实例化的类型放在 <> 中即可
  • 类模板名字不是真正的类,而实例化的结果才是真正的类
  • 模板如果没有实例化,编译器是不会去检查它内部的语法的

2、模板参数

(1)类类型形参

类型形参:出现在模板参数列表中,跟在 class / typename 之类的参数类型名称


(2)非类型形参

非类型形参:用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用

  • 非类型的模板参数在编译期就能确认结果
  • 浮点数、类对象以及字符串是不允许作为非类型模板参数的

不管哪种模板参数,都可以给缺省值


3、模板的特化

(1)函数模板特化

  • 必须要先有一个基础的函数模板
  • 关键字 template 后面接一对空的尖括号 <>
  • 函数名后跟一对尖括号,尖括号中指定需要特化的类型
  • 函数形参表:必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报错
  • unordered_map 默认支持 string 做 key 就是特化一个场景

(2)类模板特化

必须要先有一个基础的类模板

关键字 template 后面接一对空的尖括号 <>

类名后跟一对尖括号 <>,尖括号中指定需要特化的类型

A. 分类
a. 全特化

将模板参数列表中所有的参数都确定化


b. 偏特化

任何针对模版参数进一步进行条件限制设计的特化版本

  • 部分特化,将模板参数类表中的一部分参数特化
  • 参数进一步的限制

3、模板分离编译

一个程序由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式

函数 / 类模板不支持分离编译

  • 可以将声明和定义放到一个文件 "xxx.hpp" 里面或者 "xxx.h" 里
  • 模板定义的位置显式实例化(不推荐)


4、模板的优缺点

(1)优点

  • 模板复用了代码,节省资源,更快的迭代开发,C++ 的 STL 因此而产生
  • 增强了代码的灵活性

(2)缺点

  • 模板会导致代码膨胀问题,也会导致编译时间变长
  • 出现模板编译错误时,错误信息非常凌乱,不易定位错误

十六、STL

Standard Template Libaray,标准模板库:是 C++ 标准库的重要组成部分


1、容器

(1)string

string 是对字符串进行管理的类,实际上就是一个管理字符数组的顺序表

在使用 string 类时,必须包含 #include 头文件以及 using namespace std;

空串并不是什么都没有,第一个字符是 '\0'

size() 与 length() 方法底层实现原理完全相同,引入 size() 的原因是为了与其他容器的接口保持一致

clear() 只是将 string 中有效字符清空,不改变底层空间大小

resize 在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变

  • 既要开好空间,还要对这些空间初始化,就可以选择使用 resize

reserve 为 string 预留空间,不改变有效元素个数,当 reserve 的参数小于 string 的底层空间总大小时,reserver 不会改变容量大小

如果 n 大于当前字符串容量,则该函数使容器将其容量增加到 n 个字符 / 更大

在所有其他情况下,缩小字符串容量被视为非绑定请求。容器可以自由实现优化,但要保留容量大于 n 的字符串

此函数对字符串长度没有影响,并且不能更改其内容

支持迭代器的容器就支持范围 for

operator[] 函数会检查越界(pos 必须 < size)

尽量不要用 insert() 和 erase(),因为要挪动字符,时间效率低

npos 在 string 里面是一个静态成员变量:static const size_t npos = -1;


A. VS 和 g++ 下 string 结构的说明

VS 下 string 的结构:string 总共占 28 个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义 string 中字符串的存储空间

  • 当字符串长度 < 16 时,使用内部固定的字符数组来存放
  • 当字符串长度 >= 16 时,从堆上开辟空间

B. g++ 下 string 的结构

g++下,string 是通过写时拷贝实现的,string 对象总共占 4 个字节,内部只包含了一个指针(用来存储字符串),该指针将来指向一块堆空间,内部包含字段有

  • 空间总大小
  • 字符串有效长度
  • 引用计数

C. 写时拷贝
  • 是在浅拷贝的基础之上增加了引用计数的方式来实现的
  • 引用计数:用来记录资源使用者的个数

(2)vector

vector 是表示可变大小数组的序列容器

A. vector 和数组的区别
  • vector 采用的连续存储空间来存储元素,也就意味着可以采用下标对 vector 的元素进行访问,和数组一样高效
  • 但它又不像数组,vector 的大小是可以动态改变的,而且它的大小会被容器自动处理
  • vector 使用动态分配数组来存储它的元素,但每当一个新的元素加入到容器时,vector 并不会每次都重新分配大小

B. 与其它动态序列容器相比(deque、list 和 forward_list)

vector 在访问元素的时候更高效,在末尾添加和删除元素相对高效

对于其它不在末尾的删除和插入操作,效率更低

比起 list 和 forward_list 统一的迭代器和引用更好

resize 在开空间的同时还会进行初始化,会影响 size


C. 迭代器的主要作用
  • 让算法能够不用关心底层数据结构,其底层实际就是一个指针,或者是对指针进行了封装,比如 vector 的迭代器就是原生态指针 T*
  • 迭代器失效实际就是迭代器底层对应指针所指向的空间被销毁

D. 指定位置之前插入元素操作导致迭代器失效的 2 种场景

insert 插入元素时增容,会挪动数据,而 pos 还指向已被释放的空间(非法空间),所以 pos 位置迭代器失效

insert 插入元素时没有增容,但 pos 位置意义变了,不再指向原来的值,所以 pos 位置迭代器失效


E. 删除指定位置元素的操作导致迭代器失效的 2 种场景
a. erase 可以缩容

比如有 200 个容量的空间、200 个有效数据,删除后只剩下 70 个有效数据,此时想把容量缩容至一半 —— 开 100 个容量的空间,把旧空间内容拷贝后释放,那么 pos 就是野指针了

erase 删除 pos 位置元素后,之后的元素会往前移。虽然没有导致底层空间发生改变,但 pos 位置的意义发生了改变

erase 返回的是被删除数据的下一个数据的位置

  • 如果 pos 刚好是最后一个元素,那么删了之后 pos 刚好是 end 的位置,而 end 位置是没有元素的,那么 pos 也是失效了

b. vector 插入或删除元素会导致当前迭代器和后面所有元素的迭代器失效

迭代器失效解决办法:在使用前,对迭代器重新赋值即可

如果想要继续通过迭代器操作 vector 中的元素,需要给 pos 重新赋值


F. operator[] 和 at 的区别:对边界检查的处理方式不同

operator[] 使用下标访问运算符 [] 可以直接通过索引来获取元素,不进行边界检查

  • 这种方式比较高效,适用于已经进行了边界检查或者明确知道索引是有效的情况

at() 函数也用于通过索引来获取元素,但它会进行边界检查(如果传入的索引超过了有效的范围,at() 函数会抛出 std::out_of_range 异常)

  • 这种方式更安全,建议在需要进行边界检查或者对索引的有效性不确定时使用

operator[] 执行更快但不进行边界检查,而 at() 相对安全但速度稍慢,因为它会进行边界检查并抛出异常

如果对象中涉及到资源管理时,不能使用 memcpy 进行对象之间的拷贝,因为 memcpy 是浅拷贝,否则可能会引起内存泄漏甚至程序崩溃


G. 优点
  • 支持随机访问
  • 空间利用率高,底层是连续空间,不容易造成内存碎片
  • CPU 高速缓存命中率很高

H. 缺点
  • 空间不够时需要增容,增容代价很大(需要经过重新配置空间、元素搬移、释放原空间等),同时还存在一定的空间浪费
  • 头部和中间插入删除,效率很低 O(n)

(3)list

list 是可以在常数时间 O(1) 内在任意位置进行插入和删除的序列式容器,并且该容器可以前后双向迭代

list 不支持 [] 运算符

list 的底层是带头双向循环链表结构

在 list 中进行插入是不会导致 list 的迭代器失效的,只有在删除时迭代器才会失效,并且失效的只是指向被删除节点的迭代器 ,其他迭代器不会受到影响


A. list 与 forward_list 最主要的不同
  • forward_list 是单链表,只能朝前迭代,不支持尾插、尾删,对比双向链表的唯一优势就是每个节点少存一个指针
  • forward_list 在实际中用的很少,单链表尾插和尾删效率太低

B. 与其他的序列式容器相比(array,vector,deque)
  • list 在容器内任意位置进行插入、删除、移动元素方面的执行效率通常表现更好
  • list 和 forward_list 最大的缺陷:不支持随机访问
  • list 还需要一些额外的空间来保存每个节点的相关联信息

C. 优点
  • 按需申请释放空间,不会浪费空间
  • 任意位置插入和删除数据都是 O(1)

D. 缺点
  • 不支持随机访问
  • 空间利用率低,底层不是连续的空间,小节点容易造成内存碎片
  • CPU 高速缓存命中率很低

(4)list 与 vector 的对比


(5)deque

可以在头尾两端进行插入和删除操作,时间复杂度为 O(1)


A. 对比(优点)
  • 与 vector 比较,头插和头删效率高,不需要搬移元素,扩容时也不需要搬移大量的元素
  • 与 list 比较,底层是连续空间,空间利用率比较高,不需要存储额外字段

B. 缺点
  • 不适合遍历,因为在遍历时,deque 的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低
  • deque 在中间插入删除数据效率很低

deque 并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际 deque 类似于一个动态的二维数组(deque 的迭代器设计就比较复杂,包含 4 个指针)

deque 作为 stack 和 queue 底层默认容器的原因:

stack 是一种后进先出的特殊线性数据结构,因此只要具有 push_back() 和 pop_back() 操作的线性结构,都可以作为 stack 的底层容器(比如 vector 和 list 都可以)

queue 是先进先出的特殊线性数据结构,只要具有 push_back() 和 pop_front() 操作的线性结构,都可以作为 queue 的底层容器,比如 list

STL 中对 stack 和 queue 默认选择 deque 作为其底层容器

  • stack 和 queue 不需要遍历(所以 stack 和 queue 没有迭代器),只需要在固定的一端或者两端进行操作
  • stack 中元素增长时,deque 比 vector 的效率高(扩容时不需要搬移大量数据)
  • queue 中元素增长时,deque 不仅效率高,而且内存使用率高

2、算法

为什么 C++98 建议使用各自容器里的 swap,而不建议使用算法里的 swap?

因为算法里 swap 的 C++98 的实现,无论是 string、vector、list 使用它都会涉及深拷贝的问题,而且这里的深拷贝代价很大,需要深拷贝 3 次 —— 当 l1 和 l2 交换,这里会把 l1 拷贝构造一份 c,然后把 l2 赋值于 l1,c 赋值于 l2,才完成交换

而如果是容器里的 swap,只需要交换头指针即可(假设是 vector,只要把 l1 和 l2 对应的 _start、_finish、_endofstorage 交换)


3、迭代器

行为像指针一样的类型

单链表 forward_list、哈希表 unordered_set/map 迭代器就是单向迭代器

  • 特征:能 ++,不能 --

双向链表 list、红黑树 set/map 迭代器就是双向迭代器

  • 特征:能 ++ / --

string、vector、deque 迭代器就是随机迭代器

  • 特征:可以 ++ / -- / + / -,一般随机迭代器底层都是一个连续的空间

4、配接器(容器适配器)

虽然 stack 和 queue 中也可以存放元素,但在 STL 中并没有将其划分在容器的行列,而是将其称为容器适配器,是因为 stack 和 queue 只是对其他容器的接口进行了包装

(1)stack & queue

A. stack

stack 是作为容器适配器被实现的

标准容器 vector、deque、list 均可以作为 stack 的容器适配器。如果没有为 stack 指定特定的底层容器,默认情况下使用 deque

stack 是没有迭代器的(如果有了迭代器就可以随意访问元素,也就无法保证后进先出的性质)


B. queue

queue 是作为容器适配器被实现

标准容器类 deque 和 list 可以作为 queue 的容器适配器。 如果没有为 queue 实例化指定容器类,默认使用标准容器 deque

queue 是没有迭代器的


(2)priority_queue

优先队列是一种容器适配器,默认为大堆

标准容器类 vector 和 deque 可以作为 priority_queue 的容器适配器。如果没有为 priority_queue 实例化指定容器类,默认使用 vector

在 vector 上又使用了堆算法将 vector 中元素构造成堆的结构,因此 priority_queue 就是堆

需要支持随机访问迭代器,以便始终在内部保持堆结构

需要包含头文件 #include <priority_queue>

适配器是一种设计模式


5、仿函数(函数对象)

调用仿函数实际上就是通过仿函数类对象调用重载后的 operator() 运算符

类模板一般是显式实例化的,在 <> 中指定模板参数的实际类型,所以类模板是传类型

函数模板一般是隐式实例化,让编译器根据实参推演模板参数的实际类型,所以函数模板是传对象


6、空间配置器

空间配置器就是为各个容器高效的管理空间(空间的申请与回收)的

接口包括 allocate 和 deallocate,用于分配和释放内存


十七、继承

继承是类设计层次的复用

基类的 private 成员在派生类中无论以什么方式继承都是不可见的(这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是在语法上限制派生类对象不管是在类里面还是在类外面都不能去访问它)

派生类对象可以赋值(前提是公有)给 ① 基类的对象 / ② 基类的指针 / ③ 基类的引用

基类对象不能赋值给派生类对象

  • 但基类的指针 / 引用可以通过强制类型转换赋值给派生类的指针 / 引用

重载是对同一作用域中才存在的概念,而隐藏只需要和基类成员函数同名就能构造

  • 当派生类定义一个同名函数时,它会隐藏基类中所有同名的函数,包括参数不同的重载版本。

1、派生类的默认成员函数

派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员

如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用

派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化

派生类的 operator= 必须要调用基类的 operator= 完成基类的复制,设计子类析构时只要保证自己的资源正确释放即可(和拷贝构造基本一致)

派生类的析构函数会在被调用完成后,会自动调用基类的析构函数清理基类成员(这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序 —— 要符合先定义的先析构)

  • 对于继承的基类成员 ——> 把它作为一个整体,派生类的析构函数调用完成后,会自动调用基类的析构函数完成清理工作
  • 对于类中的内置类型成员 ——> 不处理
  • 对于类中的自定义类型成员 ——> 调用它的析构函数完成清理工作

派生类对象析构清理要先调用派生类析构,再调基类的析构

在一些场景下,析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成 destrutor(),所以父类析构函数在不加 virtual 的情况下,子类析构函数和父类析构函数会构成隐藏关系


2、友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员


3、静态成员

基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员


4、菱形继承

(1)单继承:一个子类只有一个直接父类时,称这个继承关系为单继承


(2)多继承:一个子类有两个或两个以上的直接父类时,称这个继承关系为多继承


(3)菱形继承:菱形继承是多继承的一种特殊情况

菱形继承存在数据冗余和二义性的问题,虚拟继承可以解决该问题:将 virtual 加在菱形中间腰部的位置

菱形中间腰部的两个类的对象的地址(这两个指针叫虚基表指针,所指向的两个内容叫虚基表)

虚基表中存的偏移量,通过偏移量可以找到基类对象

为什么需要偏移量?
  • 指针是无法识别自己指向的是哪个类的对象(可能指向自己,也可能指向子类),不同对象中的虚基类成员的偏移量是不一样的,所以也只能通过偏移量来计算出基类对象的位置
  • 菱形中间腰部的对象、对象指针、对象引用访问继承的虚基类的对象中的成员,都要取偏移量来计算虚基类的对象的成员的位置
为什么不直接存偏移量,而是用虚基表来存?

也是可以的,但选择使用虚基表来存储的原因是:引入多态时,此处还需要再存储一个值

virtual 已经能解决菱形继承所带来的问题了,为什么不建议使用?

因为效率降低了,它的这个对象模型变的更加复杂。原先编译器编译完就可以直接找到,因为它们是紧挨着的,而现在却必须得通过指针来找到偏移量,再与现在的地址进行相加才可以找到


5、优先使用对象组合,而不是类继承


十八、多态

当不同的对象去完成某个相同的行为时会产生出不同的状态


1、在继承中要构成多态还有 2 个条件

1)必须通过基类的指针或者引用调用虚函数

原理:不管基类指针或者引用指向的是基类还是派生类,都是先找到虚表指针(对象中的前 4 个字节),再通过虚表指针找到虚表,取出对应虚函数的地址并调用该虚函数

为什么不用基类对象调用:派生类对象赋值给基类对象不会拷贝派生类的虚表指针,只会拷贝对象中的数据成员过去

  • 那么既然不会把派生类的虚表指针拷贝过去,那基类对象就不能调用派生类的虚函数了

2)被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(覆盖)


2、虚函数

被 virtual 修饰的类成员函数称为虚函数

虚函数的重写 (覆盖):派生类中有一个跟基类完全相同的虚函数(返回值类型、函数名字、参数列表完全相同)

两个例外

1、协变(基类与派生类虚函数返回值类型不同)

  • 即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用

2、析构函数的重写(基类与派生类析构函数的名字不同)

  • 如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写(这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor)

3、override 和 final(C++11)

override 和 final 两个关键字可以帮助用户检测是否重写

  • override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
  • final:修饰虚函数,表示该虚函数不能再被重写


4、抽象类

在虚函数的后面写上 =0,则这个函数为纯虚函数

  • 纯虚函数规范了派生类必须重写

包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象

  • 派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象

派生类中重写基类的虚函数可以不加 virtual 关键字,因为基类的虚函数的接口(声明)被继承下来了,在派生类依旧保持虚函数属性


5、原理

一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表(虚表)中

虚函数表本质是一个存放虚函数指针的指针数组(这个数组最后面一般放了一个 nullptr)

虚函数表创建的时机是在编译期间

虚函数表指针 _vfptr 创建的时机:在构造函数中的初始化列表位置(这个时候才会生成虚表指针,并把虚函数表的首地址赋给虚表指针)

  • 对象什么时候创建出来,_vfptr 就什么时候创建出来(运行时)
  • 程序在编译期间,编译器会为构造函数中增加为 _vfptr 赋值的代码

派生类对象由两部分构成:

  1. 基类继承下来的成员,虚表指针也就是存在这部分的
  2. 自己的成员

基类对象和派生类对象的虚表是不一样的

基类虚函数在派生类中完成了重写,所以派生类对象的虚表中存的是重写的函数

虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖(重写是语法的叫法,覆盖是原理层的叫法)

派生类的虚表生成

  • 1、先将基类中的虚表内容拷贝一份到派生类虚表中
  • 2、如果派生类重写了基类中某个虚函数,就用派生类自己重写的虚函数区覆盖虚表中基类的虚函数
  • 3、派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后

基类和派生类无论是否完成了虚函数的重写,都有各自独立的虚表

一个类的所有对象共享同一张虚表

虚函数存在哪里?虚表存在哪里?
  • 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样,都是存在代码段的
  • 对象中存的不是虚表,存的是虚函数表指针
  • VS 下虚表是存在常量区(代码段)的

6、静态绑定与动态绑定

静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为编译时多态性和静态多态

比如:函数重载、内联函数、函数模板

动态绑定又称后期绑定(晚绑定),在程序运行期间根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为运行时多态性和动态多态,比如:虚函数


7、虚函数表

(1)单继承


(2)多继承

这里 Derive 对象的两张虚表中的重写的 Derive::func1 函数的函数地址虽然不一样,但是当 Base1 或 Base2 指针指向 Derive对象时,调的都是 Derive 中的 func1,是同一个函数


十九、set 和 map、multiset 和 multimap(树形结构的关联式容器)

1、关联式容器

序列式容器的底层为线性序列的数据结构,里面存储的是元素本身

关联式容器存储的是 <key, value> 结构的键值对,在数据检索时比序列式容器效率更高


利用 make_pair 函数模板构造一个 pair 对象(键值对)

二叉平衡搜索树(红黑树 )作为其底层结构,容器中的元素是一个有序的序列


2、set(集合)

set - C++ Reference (cplusplus.com)

按照升序来进行排序

  • < 升序,less(默认)
  • > 降序,定义 set 时模板参数中要写上 greater

实际在底层存储 <value, value> 的键值对且每个 value 必须是唯一的

  • 插入元素时只需要插入 value 即可,不需要构造键值对

可以去重

set 中的元素不能在容器中修改(元素总是 const),但是可以从容器中插入或删除 O(logN)

  • 不能修改是因为 set 内部实现是基于哈希表的,哈希表中的元素是根据元素的哈希值来进行存储和查找的。如果一个元素被修改了,那么它的哈希值也会发生变化,这样就会导致原来存储该元素的位置无法再次找到该元素,从而破坏了 set 的内部结构

set 容器通过 key 访问单个元素的速度通常比 unordered_set 容器慢,但它允许根据顺序对子集进行直接迭代

find 查找元素

  • 1、algorithm 文件中的 find 函数的底层是暴力查找,全部节点遍历一遍 O(N)
  • 2、set 的成员函数 find,O(logN)

需要包含头文件 #include <set>


3、map(映射)

map - C++ Reference (cplusplus.com)

元素总是按照键值 key 进行比较排序的,默认升序

  • < 升序,less(默认)
  • > 降序,定义 map 时模板参数中要写上 greater

map 中通过键值访问单个元素的速度通常比 unordered_map 容器慢,但 map 允许根据顺序对元素进行直接迭代

map 中的 key 是唯一的,不能修改,但 key 对应的映射值 value 可以修改

支持下标访问符,即在 [] 中放入 key,就可以找到对应的 value

与 operator[] 类似的操作 at() 函数,是通过 key 找到与 key 对应的 value 然后返回其引用

  • 当 key 不存在时,operator[] 用默认 value 与 key 构造键值对然后插入,返回该默认 value,而 at() 函数是直接抛异常

map 中的 operator[] 底层实际上调用的 insert() 函数,为插入查找

  • 如果在 map 中,说明 insert 插入失败,insert 函数返回的 pair 对象会带出指向该元素的迭代器,通过这个迭代器,可以拿到该元素 key 对应的映射值 value,然后函数返回其对应映射值 value 的引用
  • 如果不在 map 中,说明 insert 插入成功,插入这个新元素 <key, value()>,函数返回其对应映射值 value 的引用
    • 当元素不存在时,map::at 也会抛出异常
  • 拿到函数返回的 value,可以对其进行修改

向 map 中插入(insert)元素

  • 存在就返回一个 pair 对象:<指向该元素的迭代器, false>
  • 不存在就插入该元素 <key, value>,返回一个 pair 对象:<指向该元素的迭代器, true>

需要包含头文件 #include <map>


4、multiset

multiset - C++ Reference (cplusplus.com)

按照升序来进行排序

与 set 的区别:multiset 中的元素可以重复,而 set 中的 value 是唯一的

multiset 元素的值不能在容器中进行修改(因为元素总是 const 的),但可以从容器中插入或删除

  • 不能修改是因为 multiset 内部实现是基于哈希表的,哈希表中的元素是根据元素的哈希值来进行存储和查找的。如果一个元素被修改了,那么它的哈希值也会发生变化,这样就会导致原来存储该元素的位置无法再次找到该元素,从而破坏了 multiset 的内部结构

multiset 容器通过 key 访问单个元素的速度通常比 unordered_multiset 容器慢

需要包含头文件 #include <set>


5、multimap

multimap - C++ Reference (cplusplus.com)

multimap 和 map 的唯一不同就是:map 中的 key 是唯一的,而 multimap 中的 key 是可以重复的

multimap 通过 key 访问单个元素的速度通常比 unordered_multimap 容器慢

没有重载 operator[] 操作

  • 因为 multimap 中的元素是按照键值有序存储的,而 operator[] 操作需要通过键值来访问元素,这样会破坏 multimap 中元素的有序性。因此,multimap 只提供了通过迭代器来访问元素的方式,如 find()、lower_bound()、upper_bound() 等函数
  • 因为 multimap 允许多个相同的键,所以无法确定 operator[] 应该返回哪个值

需要包含头文件 #include <map>


红黑树如何实现对传进来的不同类型的数据都能进行比较呢?

通过传给模板参数 KeyOfValue 的是 set 的仿函数,还是 map 的仿函数来应对不同类型数据的比较


二十、哈希

1、unordered 系列关联式容器

遍历出来不是有序的,迭代器是单向迭代器

底层使用了哈希结构

unordered_map 和 unordered_set 不允许数据冗余,支持 [] 操作符


(1)unordered_map

unordered_map - C++ Reference (cplusplus.com)

没有对 <key, value> 按照任何特定的顺序排序,为了能在 O(1) 内找到 key 所对应的 value,unordered_map 将相同哈希值的键值对放在相同的桶中

通过 key 访问单个元素要比 map 快,但它通常在遍历元素子集的范围迭代方面效率较低

它的迭代器至少是前向(单向)迭代器

前向迭代器指的是 “向前移动”,是从当前元素到下一个元素的过程

operator[] 实际调用的是哈希桶的插入操作,用参数 key 与 V() 构造一个默认值往底层哈希桶中插入,如果 key 不在哈希桶中,则插入成功,返回 V();如果 key 在哈希桶中,那么插入失败,返回 key 对应的 value

unordered_map 中 key 是不能重复的,因此 count 函数的返回值最大为 1


(2)unordered_set

cplusplus.com/reference/unordered_set/unordered_set/unordered_set/

unordered_set 中 key 是不能重复的,因此 count 函数的返回值最大为 1

unordered_multimap 和 unordered_multiset 允许数据冗余,不支持 [] 操作符


哈希(散列)函数构造出来的结构称为哈希表(Hash Table / 散列表)


2、底层结构

(1)哈希冲突

对于两个数据元素的关键字 ki 和 kj(i != j),有 ki != kj,但是 Hash(ki) == Hash(kj)

不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞

A. 解决哈希冲突的两种常见的方法
a. 闭散列(开放定址法)

通过哈希函数计算出这个数据对应的哈希位置,如果该位置出现了哈希冲突,就重新探测一个空闲位置,将其插入

如果整个数组都没有空位置,此时就需要对数组进行扩容操作,降低哈希冲突的概率,提高性能

要严格控制闭散列中的元素数量:

  • 1、如果元素数量太多,插入元素时很容易发生哈希冲突,会导致插入和查找的效率大幅度降低
  • 2、查找元素最坏要查找到空位置时才能停止,所以闭散列是不能存满的,如果存满就没有空位置了,当查找的元素又不在闭散列中时,就会陷入死循环

负载因子


b. 开散列(哈希桶 / 链地址法)

先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶(开散列中每个桶中放的都是发生哈希冲突的元素),各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中

Hash 冲突并不是每次都会发生,而且会不断的进行动态扩容,所以碰撞的几率会减少,所以冲突的链表并不会像开放寻址法的数组那样长

搜索效率由桶的长度决定,桶的长度短,效率就高

负载因子

哈希桶一般是把负载因子控制在 1 以内(平均每个位置下面挂了一个节点),等于 1 就要扩容了

表为空或者负载因子为 1 就说明需要扩容

开散列每次扩容 2 倍 / 一个素数的大小,都是为了减少 Hash 冲突,而减少 Hash 冲突的就是为了让时间复杂度降低到 O(1),因为一旦 Hash 冲突,时间复杂度可能就不再是 O(1) 了

最好的情况:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,此时可以给哈希表增容

如果出现极端情况,所有节点都挂在了哈希表中的一个桶下面,导致链表越来越长,这将会极大影响查找节点和删除节点的效率,但插入节点的效率不受影响(因为是头插)

  • 当链表长度达到一定长度的时候,就会把链表转化为红黑树
    • C++ 库里面暂时没有这样做,但 Java 的 HashMap 使用的是这种方案,原因就是因为红黑树查询的时间复杂度是比链表要快的(JDK 中每个桶默认超过 8 个就会转成红黑树)

检查该哈希桶中是否存在该节点:如果不存在,就进行头插

使用链地址法比开地址法节省存储空间

因为开放地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a<=0.7,而表项所占空间又比指针大的多


(2)哈希函数

引起哈希冲突的一个原因可能是:哈希函数设计不够合理

A. 哈希函数设计原则

哈希函数的定义域必须包括需要存储的全部关键码(如果散列表允许有 n 个地址时,其值域必须在 0 到 n-1 之间)

哈希函数计算出来的地址能均匀分布在整个空间中

哈希函数应该比较简单

B. 直接定址法(常用)

取关键字的某个线性函数为散列地址:Hash(Key) = A*Key + B


a. 优点
  • 简单
  • 均匀

b. 缺点
  • 需要事先知道关键字的分布情况

c. 使用场景
  • 适合查找比较小且连续的情况

d. 举例

计数排序


C. 除留余数法(常用)

开一段固定大小的空间,比如哈希表中允许的地址数为 n,按照哈希函数:Hash(key) = key%n,得到的余数就是该关键码的哈希地址,存放到哈希表对应位置中

a. 缺陷
  • 适用于整数的存储(字符串、浮点数不能直接存储,因为不能直接取模)
  • 余数相同时会出现哈希冲突
  • 哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

3、位图(bitset)

位图是用数组实现的,就是用每一位来存放某种状态,适用于海量数据,数据无重复的场景,通常是用来判断某个数据存不存在的

面对判断一个数在不在海量数据中的问题,红黑树和哈希表的查找效率都挺高的,但是数据过多难以存起来,同时红黑树和哈希表附带的内存消耗所需的空间更大


(1)特点

  • 节省空间(因为不需要存储数据集,只是标记某个数在不在这个数据集中)

(2)应用

快速查找某个数据是否在一个集合中

排序 + 去重

求两个集合的交集、并集等

操作系统中磁盘块标记


4、布隆过滤器(bloomfilter)

将哈希与位图结合


(1)核心思想

一个值映射多个位。用多个哈希函数,将一个数据映射到位图结构中


(2)优点

  • 增加和查询元素的时间复杂度为:O(k),k 为哈希函数的个数(与数据量大小无关)
  • 哈希函数相互之间没有关系,方便硬件并行运算
  • 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势
  • 在能够承受一定的误判时,布隆过滤器比其他数据结构有很大的空间优势
  • 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
  • 使用同一组散列函数的布隆过滤器可以进行交、并、差运算

(3)缺点

A. 删除困难

一般情况下不能从布隆过滤器中删除元素(因为在删除一个元素时,可能会影响其他元素)

一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,记录有多少个值映射到这个位了(比如使用两个比特位来记录,最多可以记录 3 个值),插入元素时给 k 个计数器(k 个哈希函数计算出的哈希地址)+1,删除元素时,给 k 个计数器 -1,通过多占用几倍存储空间的代价来增加删除操作,但可能会存在计数回绕的问题

B. 有误判率,不能准确判断元素是否在集合中
  • 补救方法:再建立一个白名单,存储可能会误判的数据
C. 不能获取元素本身
D. 哈希函数的个数和插入元素个数在确定的情况下,布隆过滤器的长度越长,误判率越低

(4)应用

在一些允许误判的地方


二十一、C++11

C++11 - cppreference.com

1、统一的列表初始化

使用初始化列表时,可添加等号 =,也可不添加

创建对象时可以使用列表初始化方式调用构造函数初始化


2、声明

(1)auto

(2)decltype

将变量的类型声明为表达式指定的类型

(3)nullptr


3、范围 for 循环


4、STL

(1)新容器


(2)新方法

  • 提供了 cbegin 和 cend 方法返回 const 迭代器等(实际意义不大,因为 begin 和 end 也可以返回 const 迭代器)
  • C++11 更新后,容器中增加的新方法最后用的都是插入接口函数的右值引用版本

5、智能指针

(1)作用

自动释放 new 出来的内存,避免内存泄漏。

(2)在 C++ 中,动态内存管理是通过一对运算符来完成的

  • new:在动态内存中为对象分配空间并返回一个指向该对象的指针,可以选择对对象进行初始化
  • delete:接受一个动态对象的指针,销毁该对象并释放与之关联的内存

(3)RAII(Resource Acquisition Is Initialization)

一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等)的简单技术

在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源

  • 不需要显式地释放资源
  • 采用这种方式,对象所需的资源在其生命期内始终保持有效

本质就是一个类模板,它可以创建任意类型的指针对象,当智能指针对象使用完之后,对象会自动调用析构函数去释放该指针所指向的空间

所有的智能指针类模板中都需要包含一个指针对象、构造函数和析构函数


(4)原理

  • RAII 特性
  • 重载 operator* 和 opertaor->,具有像指针一样的行为

(5)std::auto_ptr

cplusplus.com/reference/memory/auto_ptr/

C++98 就提供了 std::auto_ptr,该指针采取的措施是管理权转移的思想,也就是原对象拷贝给新对象时, 原对象就会被设置为 nullptr,此时就只有新对象指向一块资源空间

复制或者赋值都会改变资源的所有权

  • 在 STL 容器中使用 auto_ptr 存在着重大风险,因为容器内的元素必须支持可复制和可赋值

不支持对象数组的内存管理


(6)std::unique_ptr

cplusplus.com/reference/memory/unique_ptr/

C++11 选择使用更严谨的 unique_ptr 取代了 auto_ptr

直接将拷贝构造函数和赋值重载函数给禁用掉,因此不让其进行拷贝和赋值,是简单粗暴的防拷贝


(7)std::shared_ptr

cplusplus.com/reference/memory/shared_ptr/

通过引用计数的方式来实现多个 shared_ptr 对象之间共享资源

shared_ptr 在其内部给每个资源都维护着一份计数,用来记录该份资源被几个对象共享

在对象被销毁时,说明自己不使用该资源了,对象的引用计数 -1

如果引用计数是 0,就说明自己是最后一个使用该资源的对象,必须释放该资源;如果不是 0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其它对象就成野指针了

std::shared_ptr::get 用来将指针的访问权限传递给代码,只有在确定代码不会 delete 指针的情况下,才能使用 get

不要用 get 初始化另一个智能指针或者为另一个智能指针赋值,否则会造成二次释放

shared_ptr 是线程安全的,引用计数的加减是加锁保护的,但是指向资源不是线程安全的

  • 智能指针管理的对象存放在堆上,两个线程中同时去访问,会导致线程安全问题

std::shared_ptr 的循环引用:

假设要使用定义一个双向链表,如果想要让创建出来的链表的节点都定义成 shared_ptr 智能指针,那么就需要将节点内的 _pre 和 _next 都定义成 shared_ptr 的智能指针。当其中两个节点互相引用的时候,就会出现循环引用的现象

  • 解决方案:在引用计数的场景下,把节点中的 _pre 和 _next 改成 weak_ptr

shared_ptr 类中提供了一个构造函数,可以自定义一个删除器去指定析构函数的删除方式,这个自定义删除器可以是函数指针、仿函数、lamber、包装器

头文件为 #include <memory>


(8)C++11 和 boost 中智能指针的关系

C++98 中产生了第一个智能指针 auto_ptr

C++boost 给出了更实用的 scoped_ptr 和 shared_ptr 和 weak_ptr

C++TR1,引入了 shared_ptr 等(但 TR1 并不是标准版)

C++11 引入了 unique_ptr 和 shared_ptr 和 weak_ptr

  • unique_ptr 对应 boost 的 scoped_ptr,并且这些智能指针的实现原理是参考 boost 中的实现的

6、右值引用和移动语义

(1)左值引用和右值引用

C++11 中新增了右值引用的语法特性,无论左值引用还是右值引用,都是给对象取别名

A. 左值和左值引用

左值是一个表示数据的表达式(如变量名或解引用的指针),可以获取它的地址 + 可以对它赋值,左值可以出现赋值符号的左边,右值不能出现在赋值符号左边

定义时 const 修饰符后的左值,不能给它赋值,但可以取它的地址

左值引用只能引用左值,不能引用右值,但是 const 左值引用既可引用左值,也可引用右值

a. 短板

当函数返回对象是一个局部变量,出了函数作用域就不存在了,此时就不能使用左值引用返回,只能传值返回。右值引用和移动语义可以解决该问题


B. 右值和右值引用

右值是一个表示数据的表达式(如字面常量、表达式返回值,函数返回值(这个不能是左值引用返回))

右值不能取地址

  • 给右值取别名后,会导致右值被存储到特定位置,且可以取到该位置的地址
    • 不能取字面值常量 10 的地址,但是 rr1 引用后,可以对 rr1 取地址,也可以修改 rr1。如果不想 rr1 被修改,可以用 const int&& rr1 去引用

右值引用只能引用右值,不能引用左值,但是右值引用可以引用 move 以后的左值(将左值转化为右值)

移动构造本质是将参数右值的资源窃取过来占为已有,就不用做深拷贝了。移动构造中没有新开空间拷贝数据,所以效率提高了


(2)完美转发

模板中的 && 不代表右值引用,而是万能引用。它只是提供了能够同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值

std::forward 完美转发在传参的过程中保留对象原生类型属性。


7、新的类功能

(1)移动构造函数和移动赋值运算符重载

如果没有自己实现移动构造函数且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动构造。对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造(默认移动赋值跟移动构造完全类似)。

如果自己提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值

如果自己提供了拷贝构造,就不会生成移动构造了,可以使用 default 关键字显示指定移动构造生成

在函数声明加上 =delete 指示编译器不生成对应函数的默认版本,称 =delete 修饰的函数为删除函数

继承和多态中的 final 与 override 关键字


8、可变参数模板

把带省略号的参数称为 “参数包”,它里面包含了 0~N(N>=0)个模版参数。

是无法直接获取参数包 args 中的每个参数的, 只能通过展开参数包的方式来获取参数包中的每个参数

使用可变模版参数的一个主要特点:如何展开可变模版参数

  1. 递归函数方式展开参数包
  2. 逗号表达式展开参数包

逗号表达式会按顺序执行逗号前面的表达式

通过初始化列表来初始化一个变长数组

例如 {(printarg(args), 0)...} 将会展开成((printarg(arg1),0), (printarg(arg2),0), (printarg(arg3),0), etc...),最终会创建一个元素值都为 0 的数组 int arr[sizeof... (Args)]

逗号表达式在创建数组的过程中会先执行逗号表达式前面的部分 printarg(args) 打印出参数,也就是说在构造 int 数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包

empalce:emplace_back 支持可变参数。在数组原有空间的基础上,构造元素并插入元素到数组末尾,不需要经历拷贝构造的过程


9、lambda 表达式

书写格式:[捕捉列表] (参数列表) mutable -> 返回值类型 { 函数体 }

捕捉列表能够捕捉上下文中的变量供 lambda 函数使用

  • [var]:表示值传递方式捕捉变量 var
  • [=]:表示值传递方式捕获所有父作用域中的变量(包括 this)
  • [&var]:表示引用传递捕捉变量 var
  • [&]:表示引用传递捕捉所有父作用域中的变量(包括 this)
  • [this]:表示值传递方式捕捉当前的 this 指针
  • lambda 函数总是一个 const 函数,mutable 可以取消其常量性。使用该修饰符时,参数列表不可省略(即使参数为空),因为 mutable 与参数列表的语法结构是相关的
  • 最简单的 lambda 函数为: []{};

lambda 表达式实际是一个匿名函数,该函数无法直接调用,如果想要直接调用,可借助 auto 将其赋值给一个变量

父作用域指包含 lambda 函数的语句块


语法上捕捉列表可由多个捕捉项组成:

[=, &a, &b]:以引用传递的方式捕捉变量 a 和 b,值传递方式捕捉其他所有变量

[&,a, this]:值传递方式捕捉变量 a 和 this,引用方式捕捉其他变量


捕捉列表不允许变量重复传递,否则就会导致编译错误

  • 比如 [=, a]:= 已经以值传递方式捕捉了所有变量,捕捉 a 重复

在块作用域以外的 lambda 函数捕捉列表必须为空

在块作用域中的 lambda 函数仅能捕捉父作用域中的局部变量,捕捉任何非此作用域或者非局部变量都会导致编译报错

lambda 表达式之间不能相互赋值,即使看起来类型相同

允许使用一个 lambda 表达式拷贝构造一个新的副本

可以将 lambda 表达式赋值给相同类型的函数指针


(1)函数对象(仿函数)

从使用方式上来看,函数对象与 lambda 表达式完全一样

在底层编译器对于 lambda 表达式的处理方式,完全就是按照函数对象的方式处理的。如果定义了一个 lambda 表达式,编译器会自动生成一个类,在该类中重载了 operator()


10、包装器

(1)function 包装器(适配器)

本质是一个类模板

需要包含头文件 #include <functional>


(2)bind(适配器)

std::bind 函数定义在头文件中,是一个函数模板,它接受一个可调用对象来生成一个新的可调用对象,以此来 “适应” 原对象的参数列表

一般用它可以把一个原本接收 N 个参数的函数 func,通过绑定一些参数返回一个接收 M 个(M 可以大于 N)参数的新函数

使用 std::bind 函数还可以实现参数顺序调整等操作

调用 bind 的一般形式:auto newCallable = bind(callable, arg_list);

  • newCallable 本身是一个可调用对象,arg_list 是一个逗号分隔的参数列表,对应给定的 callable 的参数
  • 当调用 newCallable 时,newCallable 会调用 callable,并传给它 arg_list 中 的参数
  • arg_list 中的参数可能包含形如 _n 的名字,其中 n 是一个整数,这些参数是 “占位符”,表示 newCallable 的参数,它们占据了传递给 newCallable 的参数的 “位置”。数值 n 表示生成的可调用对象中参数的位置:_1 为 newCallable 的第一个参数,_2 为第二个参数,以此类推

11、线程库

(1)thread 类

cplusplus.com/reference/thread/thread/?kw=thread

C++11 中最重要的特性就是支持线程,使得 C++ 在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念

线程是操作系统中的一个概念,线程对象可以关联一个线程,用来控制线程以及获取线程的状态

当创建一个线程对象后,没有提供线程函数,那么该对象实际没有对应任何线程

当创建一个线程对象后,并且给线程关联线程函数,该线程就被启动,与主线程一起运行。线程函数一般情况下可按照三种方式提供

  1. 函数指针
  2. lambda 表达式
  3. 函数对象

thread 类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不意向线程的执行

可以通过 jionable() 函数判断线程是否是有效的,如果是这几种任意情况,则线程无效

  1. 采用无参构造函数构造的线程对象
  2. 线程对象的状态已经转移给其他线程对象
  3. 线程已经调用 jion 或者 detach 结束

要使用标准库中的线程,必须包含 <thread> 头文件


(2)线程函数参数

线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参

  • 如果想要通过形参改变外部实参时,必须借助 std::ref() 函数

如果是类成员函数作为线程参数时,必须将 this 作为线程函数参数


(3)原子性操作库(atomic)

多线程最主要的问题是共享数据带来的问题(线程安全)

如果共享数据都是只读的就没问题,因为只读操作不会影响到数据

但当一个 / 多个线程要修改共享数据时,虽然加锁可以解决,但是加锁有一个缺陷:其他线程会被阻塞,影响程序运行的效率,而且锁如果控制不好还容易造成死锁

原子操作:不可被中断的一个或一系列操作,使得线程间数据的同步变得非常高效

在 C++11 中,不需要对原子类型变量进行加锁解锁操作,线程能够对原子类型变量互斥的访问

可以使用 atomic 类模板:atmoic<T> t; 来定义出需要的任意原子类型

原子类型通常属于 “资源型” 数据,多个线程只能访问单个原子类型的拷贝

因此在 C++11 中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及 operator= 等(为了防止意外,标准库已经将 atmoic 模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了)


(4)lock_guard 与 unique_lock

锁控制不好时可能会造成死锁,最常见的有

  • 在锁中间代码返回
  • 在锁的范围内抛异常

C++11 采用 RAII 的方式对锁进行了封装,即 lock_guard 和 unique_lock


A. lock_guard
  • std::lock_gurad 是 C++11 中定义的模板类
  • 缺陷:单一,用户没有办法对该锁进行控制

B. unique_lock

unique_lock 是以独占所有权的方式管理 mutex 对象的上锁和解锁操作,即其对象之间不能发生拷贝

与 lock_guard 不同的是,unique_lock 更加的灵活,提供了更多的成员函数

  • 上锁 / 解锁操作:lock、try_lock、try_lock_for、try_lock_until 和 unlock
  • 修改操作:移动赋值、交换 swap(与另一个 unique_lock 对象互换所管理的互斥量所有权)、释放 release(返回它所管理的互斥量对象的指针,并释放所有权)
  • 获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与 owns_lock() 的功能相同)、mutex(返回当前 unique_lock 所管理的互斥量的指针)

C. mutex 的种类
a. std::mutex

该类的对象之间不能拷贝,也不能进行移动

mutex 最常用的函数

lock():上锁,锁住互斥量

  • 如果该互斥量当前没有被锁住,则调用线程将该互斥量锁住,直到调用 unlock 之前,该线程一直拥有该锁
  • 如果当前互斥量被其他线程锁住,则当前的调用线程被阻塞住
  • 如果当前互斥量被当前调用线程锁住,则会产生死锁(deadlock)

unlock():解锁,释放对互斥量的所有权

try_lock():尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞

  • 如果当前互斥量没有被其他线程占有,则该线程锁住互斥量,直到该线程调用 unlock 释放互斥量
  • 如果当前互斥量被其他线程锁住,则当前调用线程返回 false,而并不会被阻塞掉
  • 如果当前互斥量被当前调用线程锁住,则会产生死锁

b. std::recursive_mutex

允许同一个线程对互斥量多次上锁(递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock()

std::recursive_mutex 的特性和 std::mutex 大致相同


c. std::timed_mutex

比 std::mutex 多了两个成员函数:

1)try_lock_for()

接受一个时间范围,表示在这一段时间范围之内,线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时,则返回 false

2)try_lock_until()

接受一个时间点作为参数,在指定时间点未到来之前,线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时,则返回 false

d. std::recursive_timed_mutex

D. condition_variable

cplusplus.com/reference/condition_variable/

用来进行线程之间的互相通知


二十二、异常

1、C 语言传统的处理错误的方式

(1)终止程序

如 assert

缺陷:用户难以接受


(2)返回错误码

缺陷:需要自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到 errno 中表示错误


2、C++ 异常

throw:当问题出现时,程序会抛出一个异常,这是通过使用 throw 关键字来完成的

catch:在想要处理问题的地方通过异常处理程序捕获异常,catch 关键字用于捕获异常,可以有多个 catch 进行捕获

try:try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块

  • try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码。

(1)异常的使用

A. 异常的抛出和捕获
a. 异常的抛出和匹配原则
  • 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个 catch 的处理代码
  • 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个
  • 抛出异常对象后会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被 catch 以后销毁
  • catch(...) 可以捕获任意类型的异常,但不知道异常错误是什么
  • 实际中抛出和捕获的匹配原则有例外(并不都是类型完全匹配):可以抛出的派生类对象,使用基类来捕获

b. 在函数调用链中异常栈展开匹配原则
  • 首先检查 throw 本身是否在 try 块内部,是的话再查找匹配的 catch 语句。如果有匹配的,则调到 catch 的地方进行处理。没有匹配的 catch 就退出当前函数栈,继续在调用函数的栈中进行查找匹配的 catch
  • 如果到达 main 函数的栈,依旧没有匹配的,则终止程序
  • 沿着调用链查找匹配的 catch 子句的过程称为栈展开,所以最后都要加一个 catch(...) 捕获任意类型的异常,否则当有异常没捕获时,程序就会直接终止
  • 找到匹配的 catch 子句并处理后,会继续沿着 catch 子句后面继续执行

B. 异常的重新抛出

可能单个的 catch 不能完全处理一个异常,那么在进行一些校正处理后,再交给更外层的调用链函数来处理,catch 就可以通过重新抛出将异常传递给更上层的函数进行处理


C. 异常安全

最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化

最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)

C++ 中异常经常会导致资源泄漏的问题

  • 在 new 和 delete 中抛出了异常,导致内存泄漏
  • 在 lock 和 unlock 之间抛出了异常导致死锁
  • C++ 经常使用 RAII 来解决以上问题

D. 异常规范

exception - C++ Reference (cplusplus.com)

目的:为了让函数使用者知道该函数可能抛出的异常有哪些

  • 可以在函数的后面接 throw(类型),列出这个函数可能抛掷的所有异常类型

若无异常接口声明,则此函数可以抛掷任何类型的异常

声明可以不给,但是加上会让人更容易理解

C++98 中,函数的后面接 throw(),表示函数不抛异常

在 C++11 中,若一个函数明确不抛异常的话,就加 noexcept。可能会抛异常就什么都不加


E. 优点

异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的 bug

异常体系通过抛出异常可以直接跳到 main 函数中 catch 捕获的地方,main 函数直接处理错误,而不需要每一层都显式处理

很多的第三方库都包含异常,比如 boost、gtest、gmock 等常用的库,使用它们也需要使用异常

部分函数使用异常更好处理

  • 构造函数没有返回值,不方便使用错误码方式处理
  • T& operator 这样的函数,如果 pos 越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误

F. 缺点
  • 异常会导致程序的执行流乱跳,并且非常的混乱,如果是运行时出错抛异常就会乱跳,会导致跟踪调试时以及分析程序时比较困难
  • 异常会有一些性能的开销(现代硬件速度很快,可以忽略不计)
  • C++ 没有垃圾回收机制,资源需要自己管理。有了异常就非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用 RAII 来处理资源的管理问题
  • C++ 标准库的异常体系定义的不好,导致各自定义各自的异常体系非常混乱

G. 异常规范

抛出异常类型都继承自一个基类

函数是否抛异常、抛什么异常,都使用 func() throw(); 的方式规范化


二十三、类型转换

1、强制类型转换操作符

(1)static_cast

用于非多态类型的转换(静态转换),编译器隐式执行的任何类型转换都可以用 static_cast,但它不能用于两个不相关的类型进行转换


(2)reinterpret_cast

通常为操作数的位模式提供较低层次的重新解释,用于将一种类型转换为另一种不同的类型


(3)const_cast

常用来删除变量的 const 属性,方便赋值


(4)dynamic_cast

用于将一个父类对象的指针 / 引用转换为子类对象的指针或引用(动态转换)

  • 向上转型:子类对象指针 / 引用 --> 父类指针 / 引用(不需要转换,赋值兼容规则)
  • 向下转型:父类对象指针 / 引用 --> 子类指针 / 引用(用 dynamic_cast 转型是安全的)

只能用于父类含有虚函数的类

会先检查是否能转换成功,如果转换成功则返回转换后的指针或引用,否则指针类型返回 nullptr,引用类型则抛出 std::bad_cast 异常

性能开销相对较高


2、RTTI(Run-time Type identification,运行时类型识别)

C++ 通过几种方式来支持 RTTI:

  • typeid 运算符
  • dynamic_cast 运算符

二十四、IO 流

1、标准 IO 流

C++ 系统实现了一个庞大的类库,其中 ios 为基类,其他类都是直接或间接派生自 ios 类

C++ 标准库提供了 4 个全局流对象 cin、cout、cerr、clog

  • 使用 cout 进行标准输出,即数据从内 存流向控制台(显示器)
  • 使用 cin(缓冲流)进行标准输入即数据通过键盘输入到程序中
    • 键盘输入的数据保存在缓冲区中,要提取时是从缓冲区中拿
  • cerr 用来进行标准错误的输出
  • clog 进行日志的输出

在使用时候必须要包含相应的头文件并引入 std 标准命名空间

cin 和 cout 可以直接输入和输出内置类型数据(因为标准库已经将所有内置类型的输入和输出全部重载了)

自定义类型如果要支持 cin 和 cout 的标准输入输出, 需要对 << 和 >> 进行重载


2、文件 IO 流

采用文件流对象操作文件的一般步骤

  • 1)定义一个文件流对象
    • ifstream ifile(只输入用)
    • ofstream ofile(只输出用)
    • fstream iofile(既输入又输出用)
  • 2)使用文件流对象的成员函数打开一个磁盘文件,使得文件流对象和磁盘文件之间建立联系
  • 3)使用提取和插入运算符对文件进行读写操作,或使用成员函数进行读写
  • 4)关闭文件

3、stringstream

多次转换时,必须将上次转换状态清空掉

  • stringstreams 在转换结尾时(最后一个转换后),会将其内部状态设置为 badbit,因此下一次转换是必须调用 clear() 将状态重置为 goodbit 才可以转换,但是 clear() 不会将 stringstreams 底层字符串清空掉
  • 或者采用 s.str("") 将 stringstream 底层 string 对象设置成 "" 空字符串

stringstream 实际是在其底层维护了一个 string 类型的对象用来保存结果

s.str() 可以让 stringstream 返回其底层的 string 对象

stringstream 使用 string 类对象代替字符数组,可以避免缓冲区溢出的危险,而且其会对参数类型进行推演,不需要格式化控制,也不会出现格式化失败的风险

包含头文件 <sstream>


原文地址:https://blog.csdn.net/weixin_74531333/article/details/142499043

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!