该文档内容来源于 https://www.bilibili.com/video/BV1Ea411u7qH 教程
如果你是该视频 或文字内容作者 可以联系我,我这边可以下架此文章。

现代 C++

Part 1:课程介绍

1. 课程的总体概述

为什么要写这门课?

市面上不少 C++ 课程仍以 C++98 的语法和基础概念为主,容易让初学者在掌握基本语法后误以为已经具备了完整的 C++ 开发能力。实际上,C++ 同时包含面向过程、面向对象、模板与泛型编程等多个部分。只用 C++ 的语法编写 C 风格代码,会错过面向对象、RAII、模板和标准库等核心能力。

这门课希望在基础语法之上,系统介绍工程中常用的现代 C++ 知识。内容会有一定难度,但目标是帮助读者建立较完整的知识框架,而不是只记住零散语法。

学习时可以从以下三个方面逐步深入:

  1. 面向过程:理解基本语句、函数、指针,以及代码执行时的内存变化。
  2. 面向对象:在理解对象模型的基础上,掌握封装、继承、多态和资源管理。
  3. 模板与泛型编程:理解类型抽象和编译期机制,这是 C++ 标准库及许多框架的重要基础。

课程内容

本文包含 10 个部分和 3 个附录。标有*的章节为重点内容。C++11 是现代 C++ 的基础,后续标准中的常用特性也建议按实际项目需要继续学习。

  1. Part 1:课程介绍:课程定位与学习路线。
  2. Part 2:C++ 的基本特性:难以归入单一模块的语言基础。
  3. Part 3:类:类的常用特性与对象模型。
  4. Part 4:智能指针:基本用法、所有权语义与使用场景。
  5. Part 5:模板与泛型编程:模板是泛型编程的基础,也是现代 C++ 的核心能力之一。
  6. Part 6:STL 标准库:容器、迭代器、算法、仿函数、适配器和分配器。
  7. Part 7:I/O 库:控制台、文件和字符串流。
  8. Part 8:多线程:线程创建、数据共享、同步与死锁。
  9. Part 9:异常处理:异常的传播、捕获与工程实践。
  10. Part 10:进阶知识:万能引用、引用折叠与完美转发。
  11. 附录 1:STL 算法分类。
  12. 附录 2:STL 标准库提供的函数对象。
  13. 附录 3:STL 常用容器操作。

推荐的学习路线

  1. 掌握基础语法后,尽早结合实际方向继续学习,例如 Linux 开发或网络开发。异常处理等知识脱离项目容易显得抽象,放入真实场景后会更容易理解。
  2. 实践与理论应交替推进。可以先通过项目建立直观认识,再系统补充操作系统、计算机网络、数据结构与算法等基础知识。
  3. C++ 的应用领域较广,没有单一、固定的技术生态,因此具有以下特点:
  • C++ 与底层系统关系紧密,常用于对性能、资源控制或硬件交互要求较高的项目。
  • 学习 C++ 时,应重视操作系统、计算机网络和计算机体系结构等基础知识。
  • 语言特性繁多,需要兼容各种使用场景,有很大的历史包袱。

Part 2:C++ 的基本特性

1. (★)程序的执行过程

程序被执行后就被称为一个进程,一个进程可以被划分为很多区域,这门课我们只需要理解以下的四个区就可以了。

  1. 代码区与只读数据区:机器指令通常存放在代码区,字符串字面量等只读数据通常位于只读数据区。具体布局由编译器、链接器和操作系统共同决定。
  2. 栈区:函数的执行所需的空间,注意,当函数执行完毕,函数对应的栈内存全部销毁。
  3. 堆区:进程用来灵活分配内存的地方,只有手动释放时才会销毁内存。
  4. 静态变量区:用来存储静态变量与全局变量的区域

静态变量:我们常常需要一些局部作用范围,生命周期却很长的变量

全局变量:重要性就不必说了,在c语言程序中经常用到,但在C++中不推荐使用,因为会破坏封装性。

具体的存储方式如图所示:

image

接下来用代码演示一下这几个区域。

堆区和栈区,是程序运行的主要地方。我们用一个最简单的程序来显示栈的用途。

代码演示:

#include<iostream>
#include<vector>

std::vector<int> iVec;

//栈
void test(){
    int i = rand();
    iVec.push_back(i);
}
//栈

//函数执行的区域 可以看成 栈区

//栈
int main(){
    test();
    return 0;
}
//栈

至于堆区,主要意义在于灵活的生命周期,同样是刚才那个例子。

大型对象不一定适合放在栈上,因为线程栈的容量通常有限,具体上限取决于平台和链接选项。不过,改用动态存储并不必然更快:分配和释放堆内存本身也有成本,而且现代容器通常可以利用移动语义避免昂贵复制。

当对象的生命周期需要超出当前作用域,或大小必须在运行时确定时,可以考虑动态分配。实际代码应优先让容器直接保存对象,或使用智能指针表达所有权,避免手工管理裸指针。

代码演示:

#include<iostream>
#include<vector>

std::vector<int*> iVec;

//栈
void test(){
    int* pi = new int(rand());//堆区

    iVec.push_back(pi);
}
//栈

//函数执行的区域 可以看成 栈区

//栈
int main(){
    test();
    for (int* p : iVec) {
        delete p;
    }
    return 0;
}
//栈

而静态变量区:

有很多情况下,我们需要作用范围局限在函数之内,但生命周期却很长的变量,比如统计一个函数被调用的次数。

#include<iostream>
#include<vector>

std::vector<int*> iVec;

//栈
unsigned test(){
    //统计函数被调用的次数的静态变量;
    static unsigned funcCallCount = 0;
    ++funcCallCount;
    return funcCallCount;
}
//栈

//函数执行的区域 可以看成 栈区

//栈
int main(){
    test();
    test();
    test();
    unsigned testFuncCallCount = test();
    std::cout << testFuncCallCount << std::endl;
    return 0;
}
//栈

总结:自动存储期适合生命周期与作用域一致的局部对象;动态存储期适合生命周期或大小需要在运行时控制的对象。两者用途不同,应根据所有权和生命周期选择。

通常每个线程的栈空间都有固定上限,堆则可按需动态分配,但二者的具体大小取决于平台、链接选项和运行环境,不能简单理解为“其余虚拟内存都是堆”。

注意:这节课对内存的划分比较粗糙,但新手理解到这里完全没有问题,这门课也够用了。

程序执行的细节,还有剩下的区域是干什么的,我打算出一门叫做C++内存详解的课程,里面会详细讲解。包括pe文件头,内核区等。

2. (★)new关键字及内存泄露

  1. new关键字是C++用来动态分配内存的主要方式。

代码演示:

#include<iostream>

int main(){
    int* pi = new int{};
    std::cout  << *pi << std::endl;
    delete pi;
    return 0;
}

new可以直接分配单个变量的内存,也可以分配数组。

在分配单个对象的内存时。

当对象是普通变量时,可以分配对应的内存

当对象是类对象时,会调用构造函数,如果没有对应的构造函数,就会报错。

在分配数组对象内存时:

对于普通变量:可以使用“()”将所有对象全部初始化为0。

对于类对象,有没有“()”都一样,均使用默认构造函数,如果没有默认构造函数就会报错。

  1. 内存泄露介绍:内存泄露是经常出现的常见bug。

代码演示:

void leak() {
    int* value = new int(42);
    // 忘记执行 delete value; 这块内存将无法被释放。
}

内存泄露会导致堆内存的逐渐被占用,最终内存用完程序崩溃。常见的情况就是项目测试没问题,上线几天就炸了。然后就会非常麻烦,排查困难,损失很大。

内存泄露是最严重的错误之一,程序不怕报错,就怕一开始运行的好好的,突然就出现了莫名其妙的错误。

这句话也引出了后面的两个部分。

Part4的智能指针可以非常好的避免内存泄露的问题。

Part9的异常处理部分可以恰当的处理程序出现的异常,让程序有错误就立马处理,或直接终止进程,或忽略,不要让异常莫名其妙。这是程序设计的重要理念。

3. 命名空间

C++经常需要多个团队合作来完成大型项目。多个团队就常常出现起名重复的问题,C++就提供了命名空间来解决这个问题。

比如团队A和团队B都需要定义一个叫做Test的类。

这里用代码简单演示:

//Main.cpp

#include <iostream>
#include "ATest.h"
#include "BTest.h"

int main()
{
    A::test();
    B::test();
}
//BTest.h
#pragma once
#include<iostream>
namespace B {
    void test();
}
//BTest.cpp
#include "BTest.h"

namespace B {
    void test() {
        std::cout << "B类" << std::endl;
    }
}
//ATest.h
#pragma once
#include<iostream>
namespace A {
    void test();
}
//ATest.cpp
#include "ATest.h"

namespace A {
    void test() {
        std::cout << "A类" << std::endl;
    }
}

顺便提两点:

  • 命名空间会参与名称限定。例如,在 namespace A 中定义的 Test,其限定名是 A::Test
  • C++ 标准库中的大多数名称都位于 std 命名空间,例如 std::coutstd::cin

using关键字设计的目的之一就是为了简化命名空间的。using关键字在命名空间方面主要有两种用法。

  1. using 命名空间::名称;:将指定名称引入当前作用域。
  2. using namespace 命名空间;:将该命名空间中的名称引入当前作用域。

(★)通常不要在头文件的全局作用域中使用 using namespace,否则会污染所有包含该头文件的翻译单元。引入单个名称的 using 声明则应结合具体作用域判断。

4. (★)C++的标准输入输出简介

输入输出简单来说就是数据在输入设备,内存,硬盘,输出设备之间移动的过程。

C 语言提供了多组函数来实现这些过程。

比如printf就是让数据从内存到显示屏(显示屏就是输出设备)。scanf就是让数据从键盘(键盘是输入设备)到内存。此外还有从内存到磁盘的文件操作函数。

C 语言的输入输出函数简单直接;C++ 则通过统一的流接口和类型系统组织输入输出功能,使不同数据源和目标可以采用相似的操作方式。

C++ I/O 库引入了“流”的抽象,用一组统一接口描述数据的输入与输出。这里的“流”表示数据按顺序传输,并不意味着源数据一定会消失。

io库是一个很大的部分,但现阶段我们只要会使用输入输出流,cout和cin就可以了。

cout可以让数据从内存流到输出设备,cin可以让数据从输入设备流到内存。

4.5 const 关键字介绍

这一节补充 const 的基本语义,帮助区分只读访问、常量表达式和对象存储位置。

const 表示不能通过当前表达式或接口修改对象。它是类型系统的一部分,但并不直接决定对象存储在栈、堆还是静态存储区。

代码演示:

  1. 字符串字面量通常位于只读存储区,修改它会产生未定义行为;整数常量是否占用独立存储空间则由具体实现决定。
  2. const 对象可以位于不同存储区域。mutable 只允许在常成员函数中修改被明确声明为 mutable 的非静态数据成员,并不能用来修改任意 const 对象。通过强制转换修改一个原本就是 const 的对象会产生未定义行为。

5. (★★)auto 关键字的使用

auto 会根据初始化表达式推导变量类型,可用于减少冗长的类型书写。

代码演示:

使用auto推断类型确实简单方便,但有个基本要求,就是在使用auto时清楚的知道编译器会给auto推断出什么类型。

为了学习auto的类型推断,我使用一个boost库来确定变量的具体类型。至于boost是什么,这里就不介绍了,大家可以去百度一下。

boost库的类型推断更加灵活方便,直观。而且Part10讲万能引用,完美转发时boost也是必须使用的,所以这里就提前使用boost了。其实这里采用vs默认提供的类型提示功能也可以。

首先下载,安装boost库,就直接视频演示了,不在文档中描述了。

这两个命令执行完毕,boost库也就编译好了。boost库很大,可以选择编译自己想要的模块,我就直接全部编译了。boost是很复杂的,不是几句话能说清楚,要深入理解可以去官网学习。

两个库,每一个编译都需要十几分钟,所以视频就暂停了。

这两个命令我放在一个boostCommand.txt的文件中,这个文件已经放在最终的文件包中。

auto有几个点需要注意:

有些不好理解,可以多看几遍,或者带着问题学习下面的课程,Part2的所有知识都是反复用到的。

#include<iostream>
#include<boost/type_index.hpp>

using boost::typeindex::type_id_with_cvr;

int main() {

    int i = 100;
    auto i2 = i;
    const int* const pi = &i;
    auto pi2 = pi;
    const int i3 = 100;
    int& i4 = i;
    auto pi3 = i4;
    pi2 = &i3;

    std::cout << type_id_with_cvr<decltype(i)>().pretty_name() << std::endl;
    std::cout << type_id_with_cvr<decltype(i2)>().pretty_name() << std::endl;
    std::cout << type_id_with_cvr<decltype(pi)>().pretty_name() << std::endl;
    std::cout << type_id_with_cvr<decltype(pi2)>().pretty_name() << std::endl;
    std::cout << "pi2:"<< pi2 << std::endl;
    std::cout << type_id_with_cvr<decltype(pi3)>().pretty_name() << std::endl;
    std::cout << "pi3:" << pi3 << std::endl;
    return 0;
}
  1. 单独使用 auto 时,推导结果不会保留顶层引用;如果变量需要是引用,必须显式写成 auto&auto&&
  2. 当初始化表达式是引用时,auto 按被引用对象的类型进行推导。引用本身是类型的一部分,但引用变量不是独立对象。
  3. 按值推导时,auto 会丢弃顶层 const,但保留底层 const。例如,从 const int* 推导仍会得到指向常量的指针。
  4. 使用 auto& 推导引用时,会保留被引用对象的 const 限定。

代码演示:

其实3,4为什么会出现这种情况,因为在传递值时,修改这个值并不会对原有的值造成影响。而传递引用时,修改这个值会直接对原有的值造成影响。

确实不太好理解,尤其是基础不扎实的人。不懂了可以多问问我。

  1. 可以显式写成 const autoconst auto&,为推导结果添加顶层 const

代码演示:

  1. auto 主要影响源代码的可读性和可维护性。它通常不会对程序运行性能造成影响,也不应仅为了追求编译速度而使用。
  2. (★)最重要的一点,就是auto不要滥用,对于一些自己不明确的地方不要乱用auto,否则很可能出现事与愿违的结果,使用类型应该安全为先。
  3. (★)auto主要用在与模板相关的代码中,一些简单的变量使用模板常常导致可读性下降,经验不足还会导致安全性问题。

注意:auto的用法这里大致了解就可以了,Part10会有一节详细的讲auto的类型推断的,这节课是没有涉及右值引用的,那里会将这一部分。而且现在没有讲模板,难以讲出auto关键字的主要用法。

6. (★)静态变量、指针和引用

对象可能具有静态、线程、自动或动态存储期。为便于入门,本节主要讨论静态存储区、栈和堆这三种常见情况。

静态变量区在编译时就已经确定地址,存储全局变量与静态变量。

代码演示:

指针对象本身也有地址,它保存的是另一个对象或函数的地址。指针大小由平台和数据模型决定,不能假定它始终与 int 一样大。

表达式 &a 取得变量 a 的地址;a 表示变量保存的值;若指针 p 指向 a,则 *p 访问 p 所指向的对象。

int a = 42;
int* p = &a;
int& ref = a;

*p = 10;   // 通过指针修改 a
ref = 20;  // 通过引用修改 a

引用可以理解为对象的别名:声明时通常必须绑定到对象,之后不能改为绑定其他对象。引用与指针不是同一种类型,标准也没有规定引用必须由指针实现。与裸指针相比,引用的操作更受限制,但使用悬空引用仍会产生未定义行为。

7. (★★)左值,右值,左值引用,右值引用

首先说一点:在学这节课时不要去想左值,右值,左值引用,右值引用有什么意义。以后会反复使用的,这些概念都很重要。

1. 左值和右值

左值右值从C++11开始就是一个很重要的概念了,但想要真正理解左值,右值不是一件容易的事。

尤其新人要彻底理解左值右值就更加困难了,所以我推荐新手将这些概念死死记住,带着疑惑学习下面的课程,积累的多了,自然就明白了。后面左值,右值的概念会被反复提及。

严格来说,值类别描述的是表达式,而不是对象。C++11 之后,表达式分为 lvaluexvalueprvalue;其中 lvaluexvalue 合称 glvaluexvalueprvalue 合称 rvalue

例如在 int i = 10; 中,表达式 i 是左值,字面量表达式 10 是纯右值。左值通常表示具有身份、可被持续定位的对象;右值通常表示临时值或其资源可以被复用的对象。

“能否放在赋值号左边”只是入门口诀,并不准确:const 左值不能被赋值,而某些右值上的成员调用仍然合法。

接下来就是大量举例了,说明那些是左值,哪些是右值。

代码演示。

2. 引用的分类

(1) 普通左值引用:就是一个对象的别名,只能绑定左值,无法绑定常量对象。

(2) const左值引用:可以对常量起别名,可以绑定左值和右值。

(3) 右值引用(暂时不要去管右值引用有什么用,只要记住语法就可以了,实际用途下一课就会讲到):只能绑定右值的引用。

(4) 万能引用:这节课不讲,等到part10涉及模板时再讲,这是一个很重要,但需要模板等基础的概念。

代码演示:

8. (★★)move函数,临时对象

首先说一点:这节课是第7课的继续,是对右值基础的补充,右值的具体应用要等到Part3的移动语义那里才能完全体现。

1. std::move 函数

(1) std::move 不会移动对象,也不会改变对象的存储位置。它本质上执行类型转换,把表达式转换为将亡值,使移动构造函数或移动赋值运算符有机会被选中。

(2) 对象只有在后续移动操作实际发生时,才可能进入“有效但状态未指定”的状态。除非具体类型另有保证,移动后可以销毁对象或重新赋值,但不应依赖它原来的值。

代码演示:

move函数的具体意义现阶段无需在意,Part3讲移动语义时会体现move函数意义的。

2. 临时对象

临时对象常通过右值表达式访问,因此右值引用可以用于接管其资源。右值引用也能绑定到 std::move 产生的将亡值;它处理的是表达式的值类别,而不只是“有没有地址”。

代码演示:

3. 总结

右值和右值引用这里只介绍语法,等到Part3的第11课,会学习移动构造,右值引用会真正体现出提高程序性效率的功能。

9. (★★)可调用对象

如果一个对象可以使用调用运算符“()”,()里面可以放参数,这个对象就是可调用对象。

(★)注意:可调用对象的概念新手只要记住就可以了,后面会反复用到,这个概念很重要。

可调用对象的分类:

1. 函数

函数自然可以调用()运算符,是最典型的可调用对象。

2. 仿函数

具有operator()函数的类对象(知道有这么个东西就可以了,具体实现过程Part3会讲),此时类对象可以当做函数使用,因此称为仿函数。

3. lambda 表达式

就是匿名函数,普通的函数在使用前需要找个地方将这个函数定义,于是C++提供了lambda表达式,需要函数时直接在需要的地方写一个lambda表达式,省去了定义函数的过程,增加开发效率。

注意:lambda表达式很重要,现代C++程序中,lambda表达式是大量使用的。

lambda 表达式的基本格式:最简形式是 [] {},常见完整形式是 [捕获列表](参数列表) -> 返回类型 { 函数体 }

代码演示:

lambda 各组件介绍:

  1. [] 是捕获列表,表示 lambda 可以访问外层作用域中的哪些局部变量。

(1) []:不捕获任何变量。

(2) [=]:按值隐式捕获使用到的变量。

(3) [&]:按引用隐式捕获使用到的变量。

默认捕获与显式捕获也可以混合使用,例如:

(4) [=, &i]i 按引用捕获,其他使用到的变量按值捕获。

(5) [&, i]i 按值捕获,其他使用到的变量按引用捕获。

当然,也可以捕获单独的变量

(6) [i]:按值捕获 i

(7) [&i]:按引用捕获 i

  1. () 是参数列表;无参数时可以省略。
  2. -> 返回类型 显式指定返回类型;省略时由编译器推导。
  3. {} 中是函数体,与普通函数的函数体类似。

lambda 后面会广泛使用,现在先掌握基本语法即可。

#include<iostream>
#include<functional>
//常用

using ptr_func2 = void(*)(int);
//函数指针,void 表示 函数类型,int表示函数参数

using func_type = std::function<void(int)>;
//C++11标准库的函数包装器,可以存储、复制和调用可调用对象,比如函数、函数指针或lambda表达式,该对象接受一个 int 参数并返回 void

void my_Func2(func_type pt, int t) {
    //lambda中无法使用函数指针,必须使用函数包装器
    pt(t);
}

void func_lambda() {
    int i = 10;
    auto ref = [i](int elem)->int {
        //->int 是返回类型的意思。auto ref 接收返回结果。
        std::cout << "func_lambda" << std::endl;
        std::cout << "func_lambda-> i: " << i << std::endl;
        std::cout << "func_lambda-> elem: " << elem << std::endl;
        return 514;
    }(114);

    int i1 = 1314;
    my_Func2([i1](int i) {
        //
        std::cout << "myFunc" << std::endl;
        std::cout << "myFunc-> i : " << i << std::endl;
        std::cout << "myFunc-> i1 : " << i1 << std::endl;
    }, 520);
}

int main() {
    func_lambda();
}

C++的可调用对象主要就这三个,当然,这三个也可以衍生出很多写法。

最常见的就是函数指针,函数指针的本质就是利用指针调用函数,本质还是函数。

函数指针要细分也可以分为指向类成员函数的指针,指向普通函数的指针。

这些在这里就不演示了。

Part 3:类

首先提一下:类的权限修饰就不讲了,相信大家对于这个已经很了解了,如果不了解,就百度一下吧,非常简单,易懂,直接问我也可以。

1. (★)类介绍,构造函数,析构函数

  1. 类介绍:

(1) 面试的时候经常会听到一个问题,谈一下对面向对象和面向过程的理解。我来说一下我对这两个概念的理解

① 面向对象和面向过程是一个相对的概念。

② 面向过程是按照计算机的工作逻辑来编码的方式,最典型的面向过程的语言就是c语言了,c语言直接对应汇编,汇编又对应电路。

③ 面向对象则是按照人类的思维来编码的一种方式,C++就完全支持面向对象功能,可以按照人类的思维来处理问题。

④ 举个例子,要把大象装冰箱,按照人类的思路自然是分三步,打开冰箱,将大象装进去,关上冰箱。

要实现这三步,我们就要首先有人,冰箱这两个对象。人有给冰箱发指令的能力,冰箱有能够接受指令并打开或关闭门的能力。

但是从计算机的角度讲,计算机只能定义一个叫做人和冰箱的结构体。人有手这个部位,冰箱有门这个部位。然后从天而降一个函数,是这个函数让手打开了冰箱,又是另一个函数让大象进去,再是另一个函数让冰箱门关上。

从开发者的角度看,面向对象适合对具有状态和行为的实体建模。使用面向过程的方式开发大型程序时,也可以通过模块化和抽象保持结构清晰;两种范式各有适用场景。

那么,如何用面向过程的c语言模拟出面向对象的能力呢?类就诞生了,在类中可以定义专属于类的函数,让类有了自己的动作。回到那个例子,人的类有了让冰箱开门的能力,冰箱有了让人打开的能力,不再需要天降神秘力量了。

总结:到现在,大家应该可以理解类的重要性了吧,这是面向对象的基石,也可以说是所有现代程序的基石。

  1. 构造函数:
    类再怎么吹,它也是通过面向过程的机器实现的,类相当于定义了一个新类型,该类型生成在堆或栈上的对象时内存排布和c语言相同。但是c++规定,C++有在类对象创建时就在对应内存将数据初始化的能力,这就是构造函数。

用excel表格演示一下:

构造函数有以下类型。

  1. 普通构造函数:写法代码演示
  2. 复制构造函数:用另一个对象来初始化对象对应的内存,代码演示
  3. 移动构造函数:也是用另一个对象来初始化对象,具体内容会在Part3第13节详细讲解。
  4. 默认构造函数:在满足规则时,编译器可以隐式声明默认构造函数。它会按规则初始化基类和成员;基础类型成员在默认初始化时可能保持未确定值,而类类型成员仍会调用相应构造函数。

代码演示。

总结:构造函数就是C++提供的必须有的在对象创建时初始化对象的方法,(默认的什么都不做也是一种初始化的方式)

  1. 析构函数:

析构函数介绍:当类对象被销毁时,就会调用析构函数。栈上对象的销毁时机就是函数栈销毁时,代码演示。堆上的对象销毁时机就是该堆内存被手动释放时,如果用new申请的这块堆内存,那调用delete销毁这块内存时就会调用析构函数。

代码演示:

总结,当类对象销毁时有一些我们必须手动操作的步骤时,析构函数就派上了用场。所以,几乎所有的类我们都要写构造函数,析构函数却未必需要。

2. (★)this,常成员函数与常对象

  1. this关键字:

(1) this是什么:

this 是指向当前对象的指针。非静态成员函数通过它访问当前对象的成员;可以将其理解为编译器隐式提供的上下文参数,但 C++ 并不需要先转换成 C 语言才能执行。

代码演示:

② 当然,这么说并非完全准确,this是一个关键字,只是我们将它当做指针理解罢了。

this有很多功能是单纯的指针无法满足的。比如每个类函数的参数根本没有名叫this的指针。这不过是编译器赋予的功能罢了。

  1. 常成员函数和常对象

首先说一下:常成员函数和常对象很多人并不在意,确实,都写普通变量也可以。但是,我还是要提一点,在大型程序中,尽量加上const关键字可以减少很多不必要的错误。

这一点,开发过大型程序的人应该深有体会,没开发过大型程序的人也不必在意,记住多用const,这是一个很好的习惯。

(1) const关键字含义:普通的const在Part2的第4.5节就已经讲完了。所以这里说一下常成员函数和常对象。

常成员函数就是无法修改成员变量的函数。可以理解为将this指针指向对象用const修饰的函数。

常对象就是用const修饰的对象,定义好之后就再也不需要更改成员变量的值了。常对象在大型程序中还是很有意义的。

代码演示:

(2) 常成员函数注意事项:

因为类的成员函数已经将this指针省略了,只能在函数后面加const关键字来实现无法修改类成员变量的功能了

① 注意:常函数无法调用了普通函数,否则常函数的这个“常”字还有什么意义。

② 成员函数能写作常成员函数就尽量写作常成员函数,可以减少出错几率。

③ 同名的常成员函数和普通成员函数是可以重载的,常量对象会优先调用常成员函数,普通对象会优先调用普通成员函数

代码演示。

(3) 常对象注意事项:

① 常对象不能调用普通函数,这一点之前其实已经讲过了。

② 常函数在大型程序中真的很重要,很多时候我们都需要创建好就不再改变的对象。

(4) 总结:合理使用常成员函数和常对象可以让接口意图更清晰,并让编译器帮助阻止意外修改。

3. inline,mutable,default,delete

这一节是Part3少有的不带*的课程。

inline和mutable只要知道有这么个关键字就可以了。

default和delete关键字是需要掌握的,但是比较简单,也就放在这里了。

  1. inline关键字

(1) inline关键字的有什么作用:

① 在函数声明或定义中函数返回类型前加上关键字inline就可以把函数指定为内联函数。关键字inline必须与函数定义放在一起才能使函数成为内联,仅仅将inline放在函数声明前不起任何作用。

② 内联函数的作用,普通函数在调用时需要给函数分配栈空间以供函数执行,压栈等操作会影响成员运行效率,于是C++提供了内联函数将函数体放到需要调用函数的地方,用空间换效率。

(2) inline 关键字的注意事项:编译器可以自行决定是否展开函数调用。现代 C++ 中,inline 更重要的作用是允许函数或变量在多个翻译单元中具有相同定义,从而满足单一定义规则。

(3) Inline关键字的总结。使用inline关键字就是一种提高效率,但加大编译后文件大小的方式,现在随着硬件性能的提高,inline关键字用的越来越少了

#include <iostream>


class Test {
public:
    void outPut() const;
    mutable unsigned outPutCallCount = 0;
};

inline void Test::outPut() const {
    ++outPutCallCount;
    std::cout << "Hello World" << std::endl;
}

int main()
{
    Test t;
    t.outPut();
    t.outPut();
    t.outPut();
    t.outPut();
    t.outPut();
    std::cout << t.outPutCallCount << std::endl;
}
  1. mutable关键字

(1) mutable关键字的作用:

① Mutable意为可变的,与const相对,被mutable修饰的成员变量,永远处于可变的状态,即便处于一个常函数中,该变量也可以被更改。

代码演示:

这个关键字在现代C++中使用情况并不多,一般来说只有在统计函数调用次数时才会用到。

(2) mutable关键字的注意事项

mutable 适合缓存、延迟计算、同步对象或调用次数统计等不改变对象逻辑状态的成员,但应避免用它绕过合理的常量约束。

② mutable不能修饰静态成员变量和常成员变量。

(3) 总结:mutable 用于区分对象的物理状态与逻辑状态。使用时应确保修改不会改变外部可观察的逻辑语义。

  1. default关键字

(1) default关键字的作用:default关键字的作用很简单。

① 在编译时不会生成默认构造函数时便于书写。

② 也可以对默认复制构造函数,默认的赋值运算符和默认的析构函数使用,表示使用的是系统默认提供的函数,这样可以使代码更加明显。

③ 现代C++中,哪怕没有构造函数,也推荐将构造函数用default关键字标记,可以让代码看起来更加直观,方便。

代码演示:

总结:default关键字还是推荐使用的,在现代C++代码中,如果需要使用一些默认的函数,推荐用default标记出来。

  1. delete关键字

(1) Delete关键字的作用:C++会为程序生成默认构造函数,默认复制构造函数,默认重载赋值运算符(重载部分会详细讲解)。

在很多情况下,我们并不希望这些默认的函数被生成,在C++11以前,只能有将此函数声明为私有函数或是将函数只声明不定义两种方式。

C++11于是提供了delete关键字,只要在函数最后加上“=delete”就可以明确告诉编译期不要默认生成该函数。

代码演示:

总结:delete关键字还是推荐使用的,在现代C++代码中,如果不希望一些函数默认生成,就用delete表示,这个功能还是很有用的,比如在单例模式中,

5. 友元类与友元函数

抱歉:我在录视频时看错课数了,这应该是第4课的,所以这节课就又当第4课,又当第5课了,下一课还是第六课:

  1. 友元的介绍:友元就是可以让另一个类或函数访问私有成员的简单写法。

代码演示:

  1. 注意:

(1) 友元会破坏封装性,一般不推荐使用,所带来的方便写几个接口函数就解决了。

(2) (★)某些运算符的重载必须用到友元的功能,这才是友元的真正用途,具体怎么重载下一课就会讲。

3. 总结:友元会扩大对非公有成员的访问范围,应在确实需要紧密协作的函数或类中谨慎使用。部分运算符适合实现为非成员函数,并可在需要访问私有状态时声明为友元。

6. (★★)重载运算符

重载运算符在整个C++中拥有非常重要的地位,这一节非常重要。

  1. 重载运算符的作用:

(1) 很多时候,我们希望类对象也能像基础类型一样使用 +-*/=()[]<<>> 等运算符。运算符重载可以为用户定义类型规定这些操作的含义。

(2) C++提供了定义这些行为的方式,就是“operator 运算符”来定义运算符的行为,operator是一个关键字,告诉编译器我要重载运算符了。

  1. 注意:

(1) 我们只能重载 C++ 已有的运算符,所以无法把 ** 定义为指数运算,因为 C++ 中不存在该运算符。

(2) C++重载运算符不能改变运算符的元数,“元数”这个概念就是指一个运算符对应的对象数量,比如“+”必须为“a + b”,也就是说“+”必须有两个对象,那么“+”就是二元运算符。比如“++”运算符,必须写为“a++”,也就是一元运算符。

  1. 重载运算符举例

以下全部用代码演示:

(1) 一元运算符示例:++--、一元 +、一元 -

(2) 二元运算符示例:+-*/=><==<<>>

(3) 调用与下标运算符:()[]

条件运算符 ?: 不能重载。

(4) 类型转换函数:operator 类型()

(5) 内存管理运算符:newdeletenew[]delete[]

注意:“=”类会默认进行重载,如果不需要可以用“delete关键字进行修饰”。

总结:重载运算符非常重要,C++类中几乎都要定义各种各种的重载运算符。

7. (★)普通继承及其实现原理

C++ 面向对象通常概括为三项特性:封装、继承和多态。封装主要通过类的接口与访问控制实现;本节重点介绍继承。

  1. C++继承介绍:C++非继承的类相互是没有关联性的,假设现在需要设计医生,教师,公务员三个类,需要定义很多重复的内容而且相互没有关联,调用也没有规律。如果这还算好,那一个游戏有几千件物品,调用时也要写几千个函数。这太要命了。于是继承能力就应运而生了。

代码演示:

  1. C++继承原理:C++的继承可以理解为在创建子类成员变量之前先创建父类的成员变量,实际上,C语言就是这么模仿出继承功能的。

用excel表格演示一下:

  1. C++继承的注意事项。

(1) C++子类对象的构造过程。先调用父类的构造函数,再调用子类的构造函数,也就是说先初始化父类的成员,再初始化子类的成员。

(2) 若父类没有默认的构造函数,子类的构造函数又未调用父类的构造函数,则无法编译。

(3) C++子类对象的析构过程。先调用父类的析构函数,再调用子类的析构函数。

演示一下:

总结:面向对象三大特性的继承就这么简单,很多人觉得类继承很复杂,其实完全不是这样的,只要明白子类在内存上其实就相当于把父类的成员变量放在子类的成员变量前面罢了。构造和析构过程也是为了这个机制而设计的。

8. (★★)虚函数及其实现原理,override关键字

#include<iostream>
#include<vld.h>
class Spear {
public:
    Spear(const std::string& name_, const std::string& icon_) :name(name_), icon(icon_){
        std::cout << "Spear()" << std::endl;
    }
    virtual ~Spear() {
        std::cout << "Spear()析构" << std::endl;
    }
    virtual void openFire() const {
        std::cout << "Spear::openFire" << std::endl;
    }
protected:
    std::string name;
    std::string icon;
};

class FireSpear :public Spear {
public:
    FireSpear(const std::string& name_, const std::string& icon_, int i_) :Spear(name_, icon_), i(i_) {
        std::cout << "FireSpear()" << std::endl;
    }
    ~FireSpear() {
        std::cout << "FireSpear()析构" << std::endl;
    }
    virtual void openFire() const override {
        std::cout << "FireSpear::openFire" << std::endl;
    }
private:
    int i;
};

class IceSpear :public Spear {
public:
    void openFire() const override{
        std::cout << "IceSpear::openFire" << std::endl;
    }
};

void openFire(const Spear* pSpear) {
    pSpear->openFire();
    delete pSpear;
}

int main() {
    openFire(new FireSpear("asd", "zxc", 10));

    Spear* pSpear = new FireSpear("asd", "zxc", 10);
    pSpear->openFire();
    delete pSpear;
    return 0;
}
  1. 虚函数介绍:

(1) 虚函数就是面向对象的第三大特点:多态。多态非常的重要,它完美解决了上一课设计游戏装备类的问题,我们可以只设计一个函数,函数参数是基类指针,就可以调用子类的功能。比如射击游戏,所有的枪都继承自一个枪的基类,人类只要有一个开枪的函数就可以实现所有枪打出不同的子弹。

(2) 父类指针可以指向子类对象,这个是自然而然的,因为子类对象的内存前面就是父类成员,类型完全匹配。(不要死记硬背,尽量理解原理

(3) 当父类指针指向子类对象,且子类重写父类某一函数时。父类指针调用该函数,就会产生以下的可能

① 该函数为虚函数:父类指针调用的是子类的成员函数。

② 该函数不是虚函数:父类指针调用的是父类的成员函数。

代码演示

  1. 虚函数的注意事项:

(1) 子父类的虚函数必须完全相同,为了防止开发人员一不小心将函数写错,于是C++11添加了override关键字。

代码演示:

(2) (★) 如果要通过基类指针删除派生类对象,基类析构函数必须是虚函数,否则行为未定义。对于明确不支持多态删除的基类,则不必一概要求虚析构函数。

代码演示

  1. override 关键字的作用:让编译器检查该成员函数是否确实重写了基类虚函数,从而及时发现函数名、参数或限定符不匹配等问题。

4. 虚函数实现多态的原理介绍

(1) 动态绑定和静态绑定:

① 静态绑定:程序在编译时就已经确定了函数的地址,比如非虚函数就是静态绑定。

② 动态绑定:程序在编译时确定的是程序寻找函数地址的方法,只有在程序运行时才可以真正确定程序的地址,比如虚函数就是动态绑定。

(2) 虚函数是如何实现动态绑定的呢?

① 每个有虚函数的类都会有一个虚函数表,对象其实就是指向虚函数表的指针,编译时编译器只告诉了程序会在运行时查找虚函数表的对应函数。每个类都会有自己的虚函数表,所以当父类指针引用的是子类虚函数表时,自然调用的就是子类的函数。

代码演示:

  1. 总结:虚函数是C++类的重要特性之一,很简单,但使用频率非常高,至于如何实现的也要掌握。

9. 静态成员变量与静态函数

#include<iostream>
class Test {
public:
    static unsigned getI() {
        return i;
    }
private:
    static unsigned i;
};

unsigned Test::i = 114;

int main() {
    Test t;
    std::cout << t.getI() << std::endl;
}
  1. 静态成员变量:

(1) Part2的第六节课就讲过C语言的静态成员变量,在编译期就已经在静态变量区明确了地址,所以生命周期为程序从开始运行到结束,作用范围为与普通的成员变量相同。这些对于类的静态成员变量同样适用。

代码演示:

(2) 类的静态成员变量因为创建在静态变量区,所以直接属于类,也就是我们可以直接通过类名来调用,当然通过对象调用也可以。

代码演示:

  1. 静态成员变量的注意项:

(1) 静态成员变量必须在类外进行初始化,否则会报未定义的错误,不能用构造函数进行初始化。因为静态成员变量在静态变量区,只有一份,而且静态成员变量在编译期就要被创建,成员函数那都是运行期的事情了

  1. 静态成员函数的特点:静态成员函数就是为静态成员变量设计的,就是为了维持封装性。

代码演示:

10. (★)纯虚函数

  1. 纯虚函数介绍:

(1) 还是那个枪械射击的例子,基础的枪类有对应的对象吗?没有。它唯一的作用就是被子类继承。

(2) 基类的openfire函数实现过程有意义吗?没有。它就是用来被重写的。

(3) 所以纯虚函数的语法诞生了,只要将一个虚函数写为纯虚函数,那么该类将被认为无实际意义的类,无法产生对象。纯虚函数也不用去写实际部分。写了编译期也会自动忽略。

代码演示:

  1. 纯虚函数的注意事项:

(1) 没什么注意事项,这个语法非常简单。

  1. 总结:纯虚函数的特点就是语法简单,却经常使用,必会。

11. RTTI

RTTI使用频率不是很高,但仍然有一定的意义,应当掌握。

  1. RTTI介绍:

(1) RTTI(Run Time Type Identification)即通过运行时类型识别,程序能够通过基类的指针或引用来检查这些指针或引用所指向的对象的实际派生类。

(2) 在多态层次中,指针或引用的静态类型可能与对象的动态类型不同。RTTI 提供 dynamic_casttypeid 进行运行时类型查询;实际设计中也可以通过虚函数分派行为,通常不必主动判断具体类型。

  1. RTTI的使用场景:可能有很多人会疑惑RTTI的作用,所以单独拿出来说一下。

(1) 异常处理:这是RTTI最主要的使用场景,具体作用在异常处理章节会详细讲解。

(2) IO操作:具体作用等到IO章节会详细讲解。

  1. RTTI的使用方式:RTTI的使用过程就两个函数

(1) typeid函数:typeid函数返回的一个叫做type_info的结构体,该结构体包括了所指向对象的实际信息,其中name()函数就可以返回函数的真实名称。type_info结构体其他函数没什么用.

代码演示:

(2) dynamic_cast函数:C++提供的将父类指针转化为子类指针的函数。

代码演示:

  1. RTTI的注意事项:

(1) 当使用typeid函数时,父类和子类必须有虚函数(父类有了虚函数,子类自然会有虚函数),否则类型判断会出错。

#include<iostream>
/// <summary>
/// 主要看main函数里面的代码
/// </summary>

class Spear {
public:
    Spear(const std::string& name_, const std::string& icon_) :name(name_), icon(icon_) {
        std::cout << "Spear()" << std::endl;
    }
    virtual ~Spear() {
        std::cout << "Spear()析构" << std::endl;
    }
    virtual void openFire() const {
        std::cout << "Spear::openFire" << std::endl;
    }
protected:
    std::string name;
    std::string icon;
};

class FireSpear :public Spear {
public:
    FireSpear(const std::string& name_, const std::string& icon_, int i_) :Spear(name_, icon_), i(i_) {
        std::cout << "FireSpear()" << std::endl;
    }
    ~FireSpear() {
        std::cout << "FireSpear()析构" << std::endl;
    }
    virtual void openFire() const override {
        std::cout << "FireSpear::openFire" << std::endl;
    }
private:
    int i;
};

class IceSpear :public Spear {
public:
    void openFire() const override {
        std::cout << "IceSpear::openFire" << std::endl;
    }
};

void openFire(const Spear* pSpear) {
    pSpear->openFire();
    delete pSpear;
}

int main() {

    Spear* pSpear = new FireSpear("asd", "zxc", 10);

    std::cout << "RTTI->typeid:" << typeid(*pSpear).name() << std::endl;


    if (std::string(typeid(*pSpear).name()) == "class FireSpear") {
        std::cout << "std::string(typeid(*pSpear).name())::"<<std::string(typeid(*pSpear).name()) << std::endl;
        FireSpear* pFireSpear = dynamic_cast<FireSpear*>(pSpear);
        //父类强转子类
        if (pFireSpear) {
            std::cout << "cast FireSpear success :" << pFireSpear << std::endl;
        }
    }

    delete pSpear;
    return 0;
}
  1. RTTI总结:就是C++在运行阶段判断对象实际类型的唯一方式。

12. 多继承

首先提一下:多继承了解一下就可以了。

  1. 多继承的概念

(1) 就是一个类同时继承多个类,在内存上,该类对象前面依次为第一个继承的类,第二个继承的类,依次类推。

代码演示:

  1. 多继承的注意点:

(1) 多继承最需要注意的点就是重复继承的问题,这个问题下一个将会详细讲解。

(2) 多继承会使整个程序的设计更加复杂,平常不推荐使用。C++语言中用到多继承的地方主要就是借口模式。相较于C++,java直接取消了多继承的功能,添加了借口。

3. 多继承的总结:多继承这个语法虽然在某些情况下使代码写起来更加简洁,但会使程序更加复杂难懂,一般来说除了借口模式不推荐使用。

13. 虚继承及其实现原理

  1. 虚继承的概念:虚继承就是为了避免多重继承时产生的二义性问题。虚继承的问题用语言不好描述,但用代码非常简单,所以直接写代码了。

代码演示:

  1. 虚继承的实现原理介绍:

(1) 使用了虚继承的类会有一个虚继承表,表中存放了父类所有成员变量相对于类的偏移地址。

(2) 按照刚才的代码,B1,B2类同时有一个虚继承表,当C类同时继承B1和B2类时,每继承一个就会用虚继承表进行比对,发现该变量在虚继承表中偏移地址相同,就只会继承一份。

  1. 虚继承的注意点:没什么需要注意的,语法简单。
  2. 虚继承的总结:这个语法就是典型的语法简单,但在游戏开发领域经常使用的语法,其它领域使用频率会低很多。

14. (★★)移动构造函数与移动赋值运算符

  1. 对象移动的概念:

(1) 对一个体积比较大的类进行大量的拷贝操作是非常消耗性能的,因此C++11中加入了“对象移动”的操作

(2) 所谓的对象移动,其实就是把该对象占据的内存空间的访问权限转移给另一个对象。比如一块内存原本属于A,在进行“移动语义”后,这块内存就属于B了。

  1. 移动语义为什么可以提高程序运行效率。因为我们的各种操作经常会进行大量的“复制构造”,“赋值运算”操作。这两个操作非常耗费时间。移动构造是直接转移权限,这是不是就快多了。

注意:在进行转移操作后,被转移的对象就不能继续使用了,所以对象移动一般都是对临时对象进行操作(因为临时对象很快就要销毁了)。

代码演示:

注意这里的右值引用不能是const的,因为你用右值引用函数参数就算为了让其绑定到一个右值上去的!就是说这个右值引用是一定要变的,但是你一旦加了const就没法改变该右值引用了。

3. 默认移动构造函数和默认移动赋值运算符

会默认生成移动构造函数和移动赋值运算符的条件:

只有一个类没有定义任何自己版本的拷贝操作(拷贝构造,拷贝赋值运算符),且类的每个非静态成员都可以移动,系统才能为我们合成。

可以移动的意思就是可以就行移动构造,移动赋值。所有的基础类型都是可以移动的,有移动语义的类也是可以移动的。


#include <iostream>

class Test {
public:
    Test() = default;

    Test(const Test& test) {
        //普通的赋值构造函数
        if (test.str) {
            //判断str是否空指针
            str == new char[strlen(test.str) + 1]();

            strcpy_s(str, strlen(test.str) + 1, test.str);
        }
        else {
            str = nullptr;
        }
    }

    Test(Test&& test){
        //移动构造函数
        if (test.str) {
            str = test.str;
            test.str = nullptr;
        }
        else {
            str = nullptr;
        }
    }

    Test& operator=(const Test& test) {
        //普通的赋值运算符
        if (this == &test) {
            //判断有没有自赋值的情况
            return *this;
        }
        if (str) {
            //判断是否为空字符串
            delete[] str;
            str = nullptr;
        }
        if (test.str) {
            //如果不是空字符串
            str = new char[strlen(test.str) + 1]();
            strcpy_s(str, strlen(test.str) + 1, test.str);
        }
        else {
            str = nullptr;
        }
        return *this;
    }

    Test& operator=(Test&& test) {
        //移动赋值运算符
        if (this == &test) {
            //判断有没有自赋值的情况
            return *this;
        }
        if (str) {
            //判断是否为空字符串
            delete[] str;
            str = nullptr;
        }
        if (test.str) {
            //如果不是空字符串
            str = test.str;
            test.str;
        }
        else {
            str = nullptr;
        }
        return *this;
    }
private:
    char* str = nullptr;
};


Test makeTest() {
    Test  t;
    return t;
}

int main()
{
    Test t = makeTest();
    std::cout << "Hello World!\n";
}

Part 4:智能指针

1. (★)智能指针概述

  1. 为什么要有智能指针:在Part2的第二节课已经讲过,直接使用new和delete运算符极其容易导致内存泄露,而且非常难以避免。于是人们发明了智能指针这种可以自动回收内存的工具。
  2. 智能指针一共就三种:普通的指针可以单独一个指针占用一块内存,也可以多个指针共享一块内存。

(1) 共享型智能指针:shared_ptr,同一块堆内存可以被多个shared_ptr共享。

(2) 独享型智能指针:unique_ptr,同一块堆内存只能被一个unique_ptr拥有。

(3) 弱引用智能指针:weak_ptr,也是一种共享型智能指针,可以视为对共享型智能指针的一种补充

3. (★)智能指针注意事项:

智能指针和裸指针不要混用,接下来的几节课会反复强调这一点。(这一点太重要了,所以上来就提了)

2. (★)shared_ptr

1. shared_ptr的工作原理

(1) 我们在动态分配内存时,堆上的内存必须通过栈上的内存来寻址。也就是说栈上的指针(堆上的指针也可以指向堆内存,但终究是要通过栈来寻址的)是寻找堆内存的唯一方式。

(2) 所以我们可以给堆内存添加一个引用计数,有几个指针指向它,它的引用计数就是几,当引用计数为0是,操作系统会自动释放这块堆内存。

2. Shared_ptr的常用操作

(1) shared_ptr的初始化

① 使用new运算符初始化

代码演示:

一般来说不推荐使用new进行初始化,因为C++标准提供了专门创建shared_ptr的函数“make_shared”,该函数是经过优化的,效率更高。

② 使用make_shared函数进行初始化:

代码演示:

注意:优先使用 std::make_shared。不要让多个独立的 shared_ptr 分别接管同一个裸指针,否则会形成多个控制块并导致重复释放。

③ 当然使用复制构造函数初始化也是没有问题的。

代码演示:

(2) shared_ptr的引用计数:

智能指针就是通过引用计数来判断释放堆内存时机的。

use_count()函数可以得到shared_ptr对象的引用计数。

代码演示:

3. 智能指针可以像普通指针那样使用,”share_ptr”早已对各种操作进行了重载,就当它是普通指针就可以了。

代码演示:

4. Shared_ptr的常用函数

(3) unique函数:判断该shared_ptr对象是否独占若独占,返回true。否则返回false。

代码演示:

(4) reset函数:

① 当reset函数有参数时,改变此shared_ptr对象指向的内存。

② 当reset函数无参数时,将此shared_ptr对象置空,也就是将对象内存的指针设置为nullptr。

代码演示:

(5) get函数,强烈不推荐使用:

代码演示:

如果一定要使用,那么一定不能delete返回的指针。

(6) swap函数:交换两个智能指针所指向的内存

① std命名空间中全局的swap函数

② shared_ptr类提供的swap函数

  1. 关于智能指针创建数组的问题。

代码演示:

  1. shared_ptr 的传参方式应表达所有权语义。函数需要共享所有权时按值传递;只需观察对象且不延长生命周期时,通常传 T&const T&T*;需要修改调用方持有的智能指针时再传 shared_ptr<T>&。复制 shared_ptr 会更新引用计数,不能仅凭对象大小判断传参方式。

代码演示:

#include<iostream>
#include<vld.h>

void myFunc(std::shared_ptr<int> sharedI) {
    std::cout << "myFunc->sharedI4[10]:" << sharedI.get()[10] << std::endl;
}

int main() {
    std::shared_ptr<int> sharedI(new int(100));
    //不推荐使用,CPP有个make_shared来初始化
    std::cout << sharedI.use_count() << std::endl;
    std::shared_ptr<int> sharedI2 = std::make_shared<int>(100);
    std::cout << sharedI2.use_count() << std::endl;
    //推荐用法。

    std::cout << "是否独占sharedI2:"<< sharedI2.unique() << std::endl;
    //unique函数判断shared_ptr对象是否独占,若独占,返回true,否则返回flase;

    std::shared_ptr<int> sharedI3(sharedI2);
    //复制构造
    std::cout << "sharedI2的使用次数:" << sharedI2.use_count() << std::endl;
    std::cout << "是否独占sharedI2:" << sharedI2.unique() << std::endl;
    //unique函数判断shared_ptr对象是否独占,若独占,返回true,否则返回flase;


    sharedI2.reset();
    //reset 在没有形参的时候,将shared_ptr对象置空,也就是将对象内存设置为nullptr;
    //rest 有参参数时候,改变此shared_ptr对象指针的内存。
    std::cout << "sharedI2的使用次数:" << sharedI2.use_count() << std::endl;
    //use_count()可以得到shared_prt对象的引用计数

    sharedI2.reset(new int(1000));
    //有参的形式好像只能这么写
    std::cout << "sharedI2的使用次数:" << sharedI2.use_count() << std::endl;

    //sharedI.reset(sharedI2); 这种是不行的必须是下面那种写法。
    sharedI = sharedI2;
    //智能指针


    swap(sharedI, sharedI2);
    //交换指针,方法一
    sharedI.swap(sharedI2);
    //交换指针,方法二

    std::cout << "*sharedI :" << *sharedI << std::endl;
    std::cout << "*sharedI2:" << * sharedI2 << std::endl;

    std::shared_ptr<int> sharedI4(new int[100]());
    //指针指针创建数组方式。
    sharedI4.get()[10] = 114;
    std::cout << "sharedI4[10]:" << sharedI4.get()[10] << std::endl;

    myFunc(sharedI4);
    //用智能指针作为参数传递时直接传递就可以了。
    std::cout << "sizeof(sharedI4):" << sizeof(sharedI4) << std::endl;

    return 0;
}
  1. shared_ptr总结:在现代程序中,当想要共享一块堆内存时,优先使用shared_ptr,可以极大的减少内存泄露的问题。

3. (★)weak_ptr

1. weak_ptr介绍:

(1) 这个智能指针是在C++11的时候引入的标准库,它的出现完全是为了弥补shared_ptr天生有缺陷的问题,其实shared_ptr可以说近乎完美。

(2) 只是通过引用计数实现的方式也引来了引用成环的问题,这种问题靠它自己是没办法解决的,所以在C++11的时候将shared_ptr和weak_ptr一起引入了标准库,用来解决循环引用的问题。

2. shared_ptr 的循环引用问题:如果两个由 shared_ptr 管理的对象彼此持有对方,双方的引用计数都无法降为 0,因而不能自动释放。通常应把其中一个非拥有关系改为 weak_ptr

  1. weak_ptr的作用原理:weak_ptr的对象需要绑定到shared_ptr对象上,作用原理是weak_ptr不会改变shared_ptr对象的引用计数。只要shared_ptr对象的引用计数为0,就会释放内存,weak_ptr的对象不会影响释放内存的过程。

重新回到刚才的代码:

4. weak_ptr 总结:weak_ptr 表达不拥有对象的观察关系,常用于打破 shared_ptr 循环引用,也可用于缓存等需要检查对象是否仍存活的场景。

#include <iostream>
#include<vld.h>

void 示例() {
    std::shared_ptr<int> sharedI = std::make_shared<int>(100);
    std::cout << "示例->sharedI.use_count():" << sharedI.use_count() << std::endl;

    std::weak_ptr<int> weakI(sharedI);
    //配合shared_ptr使用
    std::cout << "示例->sharedI.use_count():" << sharedI.use_count() << std::endl;
    //他不会增加shared_ptr 引用计数

    std::cout << "\n\n";
}

class B;

class A {
public:
    std::weak_ptr<B> weakB;

};

class B {
public:
    std::shared_ptr<A> sharedA;
};

void 示例2(){
    std::shared_ptr<A> sharedA = std::make_shared<A>();
    std::shared_ptr<B> sharedB = std::make_shared<B>();

    sharedA->weakB = sharedB;
    sharedB->sharedA = sharedA;
}


int main()
{
    示例();
    示例2();
    return 0;
}

4. (★)unique_ptr

  1. uniqe_ptr介绍:独占式智能指针,在使用智能指针时,我们一般优先考虑独占式智能指针,因为消耗更小。如果发现内存需要共享,那么再去使用“shared_ptr”。
  2. unique_ptr的初始化:和shared_ptr完全类似

(1) 使用new运算符进行初始化

代码演示:

(2) 使用make_unique函数进行初始化

代码演示:

3. unique_ptr的常用操作

(1) unque_ptr禁止复制构造函数,也禁止赋值运算符的重载。否则独占便毫无意义。、

代码演示:

(2) unqiue_ptr允许移动构造,移动赋值。移动语义代表之前的对象已经失去了意义,移动操作自然不影响独占的特性。

代码演示:

(3) reset函数:

① 不带参数的情况下:释放智能指针的对象,并将智能指针置空。

② 带参数的情况下:释放智能指针的对象,并将智能指针指向新的对象。

代码演示:

  1. 将unque_ptr的对象转化为shared_ptr对象,当unique_ptr的对象为一个右值时,就可以将该对象转化为shared_ptr的对象。

这个使用的并不多,需要将独占式指针转化为共享式指针常常是因为先前设计失误。

注意:shared_ptr对象无法转化为unique_ptr对象。

代码演示:

#include <iostream>
#include <vld.h>

void 示例1() {
    std::unique_ptr<int> uniqueI = std::make_unique<int>(100);
    std::unique_ptr<int> uniqueI2 = std::make_unique<int>(200);
    std::unique_ptr<int> uniqueI3(std::move(uniqueI));
    //允许移动构造,使用后uniqueI 释放。

    uniqueI2 = std::move(uniqueI);
    //方法二
}


void myfunc(std::unique_ptr<int> unique) {
    std::shared_ptr<int> sharedI(std::move(unique));
    //将unique_ptr 转换为 shared_ptr;且保证 unique_ptr 的之后不再使用,std::move 右值的代价。

}

int main()
{
    示例1();

    std::cout << "Hello World!\n";
}

5. (★★)智能指针的使用范围

这节课一共就几句话,但仍然是两个(★),足以说明如何使用智能指针的重要性。

1. 能使用智能指针就尽量使用智能指针,那么哪些情况属于不能使用智能指针的情况呢?

有些函数必须使用C语言的指针,这些函数又没有替代,这种情况下,才使用普通的指针,其它情况一律使用智能指针。

必须使用C语言指针的情况包括:

(1) 网络传输函数,比如windows下的send,recv函数,只能使用c语言指针,无法替代.

(2) c语言的文件操作部分。这方面C++已经有了替代品,C++的文件操作完全支持智能指针,所以在做大型项目时,推荐使用C++的文件操作功能(Part7会详细讲解)。

除了以上两种情况,剩下的均推荐使用智能指针。

2. 我们应该使用哪个智能指针呢?

(1) 优先使用unique_ptr,内存需要共享时再使用shared_ptr。

(2) 当使用shared_ptr时,如果出现了循环引用的情况,再去考虑使用weak_ptr。

3. 总结:智能指针部分就这样了,东西真的不多,但都非常重要,很常用的。

Part 5:模板与泛型编程

1. (★)模板介绍,类模板与模板实现原理

1. 模板的重要性模板是C++最重要的模块之一,很多人对模板的重视不够,这一章一定要好好学,所有课时都是重点。

C++的三大模块,面向过程,面向对象,模板与泛型。面向过程就是C语言,面向对象就是类,现在轮到模板与泛型了。

2. 模板的介绍:

(1) 模板能够实现一些其他语法难以实现的功能,但是理解起来会更加困难,容易导致新手摸不着头脑。

(2) 模板分为类模板和函数模板,函数模板又分为普通函数模板和成员函数模板。

3. 类模板基础:

这节课讲一下类模板,函数模板下一课再讲

(1) 类模板的写法与使用十分固定

代码演示:注意,这段代码非常有代表性,在下一课补完后,一定要掌握,多看几遍。

4. 模板的实现原理:

模板需要编译两次,在第一次编译时仅仅检查最基本的语法,比如括号是否匹配。等函数真正被调用时,才会真正生成需要的类或函数。

所以这直接导致了一个结果,就是不论是模板类还是模板函数,声明与实现都必须放在同一个文件中。因为在程序在编译期就必须知道函数的具体实现过程。如果实现和声明分文件编写,需要在链接时才可以看到函数的具体实现过程,这当然会报错。

于是人们发明了.hpp文件来存放模板这种声明与实现在同一文件的情况。

#include <iostream>
#include <vld.h>
#include <memory>
#include <vector>
#include "MyArray.hpp"

int main()
{
    MyArray<int> arrayI(100);
    std::cout << *arrayI.begin() << std::endl;
}
//MyArray.hpp
template<typename T>
class MyArray {
    using iterator = T*;
    using const_iterator = const T*;
public:
    MyArray(size_t count);
    ~MyArray();
    iterator begin()const;
    const_iterator cbegin()const;
private:
    T* data;
};

//类外实现
template<typename T>
MyArray<T>::MyArray(size_t count) {

    if (count) {
        data = new T[count]();
    }
    else {
        data = nullptr;
    }
}
//类外实现
template<typename T>
MyArray<T>::~MyArray() {
    {
        if (data) {
            delete[] data;
        }
    }

}

template<typename T>
//注意这个 typename,和返回iterator类型
typename MyArray<T>::iterator MyArray<T>::begin() const{
    return data;
}

template<typename T>
typename MyArray<T>::const_iterator MyArray<T>::cbegin() const{
    return data;
}

2. (★)initializer_list与typename

  1. initializer_list的用法

(1) initializer_list介绍:initializer_list其实就是初始化列表,我们可以用初始化列表初始化各种容器,比如“vector”,“数组”。

代码演示:

(2) 这节课的主要任务是在上一课的代码中加入initializer_list。

代码演示:

  1. typename的用法

(1) 在定义模板时表示这个一个待定的类型

代码演示:

(2) 在类外表明自定义类型时使用

代码演示:

在C++的早期版本,为了减少关键字数量,用class来表示模板的参数,但是后来因为第二个原因,不得不引入typename关键字。

//MyArray.hpp
#include<type_traits>
#include <memory>
//模板特化,必会,必备,因为原本视频使用的是普通指针,这里已经使用智能指针,这部分已经用不上了
template<typename T>
struct get_type {
    using type = T;
};

template<typename T>
struct get_type<T*>
//通过这个类获得T的类型,
{
    using type = T;
};
//模板特化,回忆的时候可以不用参考这部分内容

template<typename T>
class MyArray {
    using iterator = T*;
    using const_iterator = const T*;
public:
    MyArray(size_t count);
    ~MyArray();
    iterator begin()const;
    const_iterator cbegin()const;
    MyArray(const std::initializer_list<T>& list);
    MyArray(std::initializer_list<T>&& list);
    T& operator[](unsigned count)const;
private:
    std::unique_ptr<T[]> data;
};

//类外实现
template<typename T>
MyArray<T>::MyArray(size_t count) {

    if (count) {
        data = std::make_unique<T[]>(count);
    }
    else {
        data = nullptr;
    }
}
//类外实现
template<typename T>
MyArray<T>::~MyArray() = default;


template<typename T>
//注意这个 typename,和返回iterator类型
typename MyArray<T>::iterator MyArray<T>::begin() const {
    return data;
}

template<typename T>
typename MyArray<T>::const_iterator MyArray<T>::cbegin() const {
    return data;
}

template<typename T>
MyArray<T>::MyArray(const std::initializer_list<T>& list) {
    if(list.size()){
        data = std::make_unique<T[]>(list.size());
        unsigned count = 0;
        for (const auto& elem : list) {
            data[count++] = elem;
        }
    }
    else {
        data = nullptr;
    }
}

template<typename T>
MyArray<T>::MyArray(std::initializer_list<T>&& list) {//右值

    if (list.size()) {
        unsigned count = 0;
        data = std::make_unique<T[]>(list.size());
        for (const auto& elem : list) {
            data[count++] = elem;
        }
    }
    else {
        data = nullptr;
    }
}
template<typename T>
T& MyArray<T>::operator[](unsigned count)const {
    return data[count];
}
#include <iostream>
#include <vector>
#include <vld.h>
#include "MyArray.hpp"

int main()
{
    int i1 = 10;
    int i2 = 20;
    int i3 = 30;
    int i4 = 40;
    int i5 = 50;

    std::initializer_list<int*> iList{ &i1,&i2,&i3,&i4 };
    MyArray<int*> arrayPi(iList);
    for(unsigned i = 0; i < 4; ++i) {
        std::cout << *arrayPi[i] << std::endl;
    }

    return 0;
}

3. (★)函数模板,成员函数模板

  1. 普通函数模板的写法与类模板类似

代码演示:

在现代C++中,函数模板一直普遍使用,一定要掌握。

  1. 成员函数模板

代码演示:

成员函数模板使用情况也不少,需要掌握的

#include<iostream>
#include<vector>

namespace mystd {
    //1.
    template<typename iter_type,typename func_type>
    void for_each(iter_type first, iter_type last, func_type func) {
        for (auto iter = first; iter != last; ++iter) {
            func(*iter);
        }
    }


    //2.
    template<typename T>
    class MyVector {
    public:
        template<typename T2>
        void outPut(const T2 elem);

    };

    //类外定义
    template<typename T>
    //先写类外模板头,再写自己模板头
    template<typename T2>
    void MyVector<T>::outPut(const T2 elem) {
        std::cout << elem << std::endl;
    }
}

void test1() {
    std::vector<int> iver{ 1,2,3,4,5 };
    mystd::for_each(iver.begin(), iver.end(), [](int& elem) {
        ++elem;
        }
    );
    for (int elem : iver) {
        std::cout << elem << std::endl;
    }
}
void test2() {
    mystd::MyVector<int> MyVec;
    MyVec.outPut<int>(20);
}


int main() {
    test1();
    test2();
    return 0;
}

4. (★)默认模板参数

默认模板参数:

(1) 默认模板参数是一个经常使用的特性,比如在定义vector对象时,我们就可以使用默认分配器。

(2) 模板参数就和普通函数的默认参数一样,一旦一个参数有了默认参数,它之后的参数都必须有默认参数

(3) 函数模板使用默认模板参数

代码演示:

(4) 类模板使用模板参数

代码演示:

类模板使用模板参数的注意点:

#include<iostream>
#include<vector>
#include<functional>

namespace mystd {
    //1.
    using void_int_func_type = std::function<void(int&)>;
    template<typename iter_type, typename func_type = void_int_func_type>
    void for_each(iter_type first, iter_type last, func_type func = [](int& elem) {
        ++elem;
        })
    {
        //模板有默认值,形参也必须要有参数
        for (auto iter = first; iter != last; ++iter) {
            func(*iter);
        }
    }


    //2.
    template<typename T,typename allocator = std::allocator<T>>
    class MyVector {
    public:
        template<typename T2>
        void outPut(const T2 elem);

    };

    //类外定义
    template<typename T,typename allocator>
    //先写类外模板头,再写自己模板头
    template<typename T2>
    void MyVector<T, allocator>::outPut(const T2 elem) {
        std::cout << elem << std::endl;
    }
}

void test1() {
    std::vector<int> iver{ 1,2,3,4,5 };
    mystd::for_each(iver.begin(), iver.end());
    for (int elem : iver) {
        std::cout << elem << std::endl;
    }
}
void test2() {
    mystd::MyVector<int> MyVec;
    MyVec.outPut<int>(20);
}


int main() {
    test1();
    test2();
    return 0;
}

5. (★)模板的重载,全特化和偏特化

  1. 模板的重载

(1) 函数模板可以重载(类模板不能重载)。例如,char*std::string 都可以表达字符串,但接口语义和内存管理方式不同,因此可以根据类型提供不同实现。只有在性能分析证明有必要时,才应为了效率增加专用重载。

代码演示:

其实函数模板的重载和普通函数的重载没有什么区别。

在讲完类模板的特化后就能知道重载和特化的区别了,这一点暂时不用在意。

  1. 模板的特化

(1) 模板特化的意义:函数模板可以重载以应对更加精细的情况。类模板不能重载,但可以特化来实现类似的功能。

(2) 模板特化分为全特化和偏特化。全特化为模板参数提供一组完整的具体实参;偏特化只针对一类参数模式,且仅适用于类模板和变量模板,函数模板不能偏特化。

这么说可能有些繁琐,直接看代码其实并不复杂,

代码演示:

(3) 模板的偏特化:偏特化就是介于普通模板和全特化之间,只存在部分类型明确化,而非将模板唯一化。

代码演示:

(4) 其实对于函数模板来说,特化与重载可以理解为一个东西。

总结:函数模板的重载,类模板的特化。还是比较重要的知识点,应当掌握,在一些比较复杂的程序中,模板重载与特化是经常使用的。

#include <iostream>

//重载
template<typename T>
void test(const T& parm) {
    std::cout << "void test(const T& parm)" << std::endl;
}

template<typename T>
void test(T* parm) {
    std::cout << "void test(T* parm)" << std::endl;
}


void test(double parm) {
    std::cout << "void test(double parm)" << std::endl;
}
//重载

//特化
template<typename T1, typename T2>
class Test {
public:
    Test() {
        std::cout << "common template" << std::endl;
    }
};
//指针版特化
template<typename T1, typename T2>
class Test < T1*, T2*> {
    //偏特化
public:
    Test() {
        std::cout << "point(指针)  semi-special" << std::endl;
    }
};
//特化
template<typename T2>
class Test <int,T2>{
    //偏特化
public:
    Test() {
        std::cout << "int semi-special" << std::endl;
    }
};
//特化
template<>
class Test<int, int> {
    //全特化
public:
    Test() { std::cout << "int,int complete special" << std::endl; }
};



int main()
{
    //重载
    test(199);
    int i = 100;
    test(&i);
    test(2.2);
    //重载


    Test<int*, int*>  test;
    Test<int, double> test2;
    Test<int, int>    test3;
    Test<int*, int>   test4;
}

Part 6:STL 标准库

1. (★)stl介绍与6大模块介绍

  1. stl的介绍:

(1) stl就是(standard template library)的简称,定义在std命名空间中,定义了C++常用的容器与算法等。

可以说stl极大的提高了我们的程序开发效率。

在C++开发中,可以说:不会用stl的人,会用stl但不懂stl实现原理的人,既会使用stl,又懂得stl实现原理的人是完完全全的三个档次。

(2) 泛型编程的概念:用模板进行编程,可以实现一些其它方式难以实现的功能,但对于新手来说,泛型编程可能会难以理解,摸不着头脑。

也就是说,模板是学习泛型编程的基础。

注意:泛型编程不属于面向对象编程的范畴,泛型编程和面向对象编程是并列的。

(3) stl作为泛型编程的最典型代表,它实现了其它编程方式难以实现的效果,比如将整个模板库分为六个部分,每个部分可以单独设计。举个最简单的例子,vector和map在数据结构方面完全不一样,但stl可以设计出“迭代器”这个模块,让该模块可以在不同的数据结构中按照同样的方式运行。这种技术没有泛型编程是难以实现的。

  1. 学习stl的注意事项

(1) 学习stl一定要有全局观念,不要局限于单个容器,重点在于明白六大组件之间的联系。

(2) 当然,如果只是单纯为了应付当前的业务,单独学一下某个容器的用法也没有问题。

  1. SLT的六大容器介绍:

(1) 容器(container):是一种数据结构,也就是真正用来存储数据的地方。分为三类

① 顺序式容器:

② 关联式容器:

③ 无序式容器:其实无序式容器也是一种关联式容器,但是既然C++标准委员会将无序容器与关联式容器平行的列了出来,那么我们这里也就让无序式容器和关联式容器平行吧。

(2) 迭代器(iterator):提供了可以访问任何容器的方法。

(3) 算法(alogorithm):用来操作容器中的数据的模板函数。

(4) 仿函数(functor)

(5) 适配器(adaptor)

(6) 分配器(allocator)

这一课只要知道有这六大模块就可以了。至于这六大模块是干什么的,后面慢慢介绍。

2. (★)容器

容器的各项操作我已经单独列出来的了,就在附页3。这里只介绍最核心的操作。

这门课就不讲基础的数据结构了,这些东西建议熟练之后用来提升自己。数组,链表,树,哈希表如果不明白,可以去百度一下,新手了解概念就可以了。

1. 顺序容器(sequence container):元素按明确的线性顺序排列,位置由插入顺序和插入位置决定,与元素的值无关。

(1) vector:将元素置于一个动态数组中,可以随机存储元素(也就是用索引直接存取)。

数组尾部添加或删除元素非常迅速。但在中部或头部就比较费时。

代码演示:

(2)deque:“double end queue”的缩写,也就是双端队列。deque的实现相比于vector有些复杂,但本质仍然是优化过的动态数组,只不过相比于单纯的动态数组,在前面添加或删除元素非常快了。

可以随机存储元素。头部和尾部添加或删除元素都非常快(略慢与vector)。但在中间插入元素比较费时(和vector差不多)。

代码演示:

(3) list:本质就是链表,所以自然具有了链表的属性。

不能随机存取元素(也就是list无法用索引存取元素)。在任何位置插入和删除元素都比较迅速。(在任何位置插入删除元素的时间相同,在元素头部操作慢于deque,在元素尾部操作慢于deque和vector)

代码演示:

(4) string:没什么好说的,就是把普通字符串封装了一下

代码演示:

(5) forward_list:单项链表,简单来说就是受限的list,凡是list不支持的功能,它都不支持。做各种支持的操作效率都会高于list,最典型的就排序算法了,forword_list要优于list。

① ForwordList 只提供前向迭代器,而不是双向迭代器。因此它也不支持反向迭代器。

② ForwordList不提供成员函数 size()。

③ ForwordList 没有指向最末元素的锚点。基于这个原因,不提供用以处理最末元素的成员 back(),push_back(),pop_back()。

2. 关联容器(associative container):元素按键组织,与插入顺序无关。

(1) set/multiset:使用“红黑树”实现,是一种高度平衡的二叉树,如果大家不了解红黑树,可以去百度一下。了解个大概就可以了。二叉树的本质决定了set/multiset的元素存取值取决于元素本身的值,和插入顺序无关。

内部元素的值依据元素的值自动排列,与插入顺序无关。set内部相同数值的元素只能出现一次,multiset内部相同数值的元素可出现多次。容器用二叉树实现,便于查找。

代码演示:

(2) map/multimap:使用“红黑树”实现,是一种高度平衡的二叉树。

内部元素是成对的“key/value”,也就是“键值/实值”,内部元素依据其键值自动排序,map内部相同的键值只能出现一次,multimap则可以出现多次。

代码演示:

  1. 无序式容器(unordered container):

(1) unordered_map/unordered_multimap:使用“哈希表”实现的,由于哈希表的特性,实现了真正的无序。如果不理解为什么使用“哈希表”就是真正无序的,可以去百度一下“哈希表”,或者干脆直接记住就可以了。

使用方法也是“key/value”,和map/multimap类似。

(2) unordered_set/unorder_multiset:同样使用“哈希表”实现的。自然具有了哈希表实现的容器的特点。

使用方法和setl/multiset类似。

  1. 关联式容器和无序式容器的对比:

(1) 关联式容器都是有序的,对于那些对顺序有要求的操作,关联式容器效率会高很多。(比如增加元素,删除元素)

(2) 无序容器都是真正的无序,在查找数据方面有着优势。(比如修改特定元素,查找元素)

(3) 从内存消耗的角度讲,无序容器要高于关联容器不过这并不重要。

一句话来说,如果从这两类容器中选一个使用的话。如果是增加,删除元素比较频繁,就使用关联式容器。如果修改元素,查找元素比较平凡,就使用无序容器。

5. 我们在处理数据时应该选择什么容器呢?

(1) 在我们需要使用存储“key/value”的容器时,只能使用map/multimap/unoredered_map/unordered_multimap。如果增加删除频繁,就使用map/multimap,修改,查找频繁,就使用unordered_map/unoredered_multimap。

在真正的大型项目中,常常会对这两种容器进行测试,普通练习靠感觉就可以了

(2) 在处理普通元素:

① 当元素需要频繁插入删除时,选择顺序容器。

1) 如果在尾部插入删除,选择vector

2) 在头部,尾部插入删除,选择deque

3) 在中间插入,删除,选择list

② 当元素需要频繁查找时,选择.set/multiset/unorder_set/unorder_multiset。

1) 频繁增加,删除时,选set,

2) 频繁查找,修改时,选ordered_set

我们发现,对于普通元素,容器的选择不怎么容易判断。

其实在真正的大型项目中,要对各种容器进行测试的,普通练习一般选择vector或set就可以了。这两个使用是比较频繁的,

// Part6:2.容器.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//

#include <iostream>
#include <vector>
#include <set>
#include <deque>
#include <algorithm>

int main()
{
    std::set<int> seti{ 1,2,3,4 };
    std::vector<int> veci(seti.begin(),seti.end());
    for (int elem : veci) {
        std::cout << elem << std::endl;
    }
    std::cout << "-------------" << std::endl;
    std::deque<int> dequei(veci.begin(), veci.end());
    for (int elem : dequei) {
        std::cout << elem << std::endl;
    }

    std::vector<int> ivec(10, 5);//初始10个5的数组。
    auto iter = ivec.insert(++ivec.cbegin(), 100);
    //返回的是插入数组的下表;
    std::cout << "std::distance(ivec.begin(), iter):" << std::distance(ivec.begin(), iter) << std::endl;

    std::cout << "-----------" << std::endl;

    for (auto iter = dequei.crbegin(); iter != dequei.crend(); ++iter)
    {
        //逆序遍历
        std::cout << *iter << std::endl;
    }
    std::cout << "-----------" << std::endl;
    std::vector<int> ivec1{ 1,2,3,4,5,6,7,8 };
    std::vector<int> ivec2{ 1,2,3,4 };

    ivec.assign({ 7,9,2,7,8 });
    //容器被重新赋值

    ivec1.assign(ivec1.begin(), ivec1.begin() + 3);
    //截取区间3个

    ivec1.assign(10, 9);//十个元素每个初始值为9;
    for (int elem : ivec1) {
        std::cout << elem << std::endl;
    }

}

3. (★)迭代器

1. 迭代器介绍:迭代器提供统一的元素访问接口,使算法可以适配多种底层存储结构。不同迭代器支持的操作能力并不完全相同。

  1. 迭代器的作用:能够让迭代器与容器,算法在设计,使用时互不干扰,又能无缝耦合起来。使用迭代器可以灵活操作各种容器算法,而不需要考虑不同容器间的差异。

4. (★)算法

  1. stl的算法可以分为九个种类,具体有什么已经在“附录一”中完全列举了。

(1) 查找算法:

(2) 排序算法:

(3) 删除和替换算法:

(4) 排列组合算法:

(5) 算数算法:

(6) 生成和异变算法:

(7) 关系算法:

(8) 集合算法:

(9) 堆算法:

在这里只列举一些比较常用的,剩下的那些大家如果使用可以在“附录一”中查找。

代码演示:

#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;

void test01()
{
    vector<int> ivec{ 1,2,3,3,4,5 };
    auto iter = adjacent_find(ivec.cbegin(), ivec.cend());
    if (iter == ivec.cend()) {
        cout << "container dont't have adjacent element" << endl;
    }
    else {
        cout << distance(ivec.cbegin(), iter) << endl;
    }
}

void test02()
{
    // 用一些整数值初始化一个向量
    vector<int> ivec{ 1, 2, 3, 3, 4, 5 };

    // 使用adjacent_find找到第一对满足条件的相邻元素
    auto iter = adjacent_find(ivec.cbegin(), ivec.cend(), [](int elem1, int elem2) {
        return elem1 >= 4 && elem2 >= 4;
        });

    // 检查是否找到满足条件的相邻元素
    if (iter == ivec.cend()) {
        cout << "容器中没有满足条件的相邻元素" << endl;
    }
    else {
        // 如果找到,输出从容器开头到找到的元素对的距离
        cout << "找到位置:" << distance(ivec.cbegin(), iter) << endl;
    }
    //找到4,5相邻且大于等于4.
}

void test03() {
    vector<int> ivec{ 1, 2, 3, 3, 4, 5 };
    sort(ivec.begin(), ivec.end(), [](int elem1, int elem2) {
        return elem1 > elem2;
        });
    cout << binary_search(ivec.cbegin(), ivec.cend(), 4, [](int elem1, int elem2) {
        return elem1 > elem2;
        }) << endl;
}

void test04() {
    vector<int> ivec{ 1, 2, 3, 3, 4, 5 };
    cout << count_if(ivec.cbegin(), ivec.cend(), [](int elem) { return elem > 3; }) << endl;
    //输出的是大于3的个数有多少个。
}

void test05() {
    vector<int> ivec{ 1, 2, 3, 3, 4, 5 };
    cout << count(ivec.cbegin(), ivec.cend(), 3) << endl;
    // 使用count算法计算在向量中出现的特定值的个数(这里是数字3)
}


void test06() {
    std::vector<int> ivec1{ 3,4 };
    std::vector<int> ivec2{ 1,2,3,4,1,2,3,4,1,2,3,4 };
    auto iter = find_end(ivec2.cbegin(), ivec2.cend(), ivec1.cbegin(), ivec1.cend());
    cout << distance(ivec2.cbegin(), iter) << endl;
}

void test07() {
    std::vector<int> ivec1{ 3,4 };
    std::vector<int> ivec2{ 1,2,3,4,1,2,3,4,1,2,3,4 };

    auto iter = std::find_first_of(ivec2.cbegin(), ivec2.cend(), ivec1.cbegin(), ivec1.cend());
    //查找第一次出现这个的元素
    cout << distance(ivec2.cbegin(), iter) << endl;
}

void test08() {
    std::vector<int> ivec1{ 3,4 };
    std::vector<int> ivec2{ 1,2,3,4,1,2,3,4,1,2,3,4 };
    auto iter = std::find(ivec2.cbegin(), ivec2.cend(), 3);
    //查找第一次出现这个的元素
    cout << distance(ivec2.cbegin(), iter) << endl;
}

void test09() {
    std::vector<int> ivec1{ 3,4 };
    std::vector<int> ivec2{ 1,2,3,4,5,6,7,8 };
    auto iter = std::find_if(ivec2.cbegin(), ivec2.cend(), [](int elem) {
        return elem > 3 && elem % 2;
        //大于3的奇数。
        });
    cout << distance(ivec2.cbegin(), iter) << endl;
}


void test10() {
    //合并
    vector<int> ivec1{ 1,3,5,7 };
    vector<int> ivec2{ 2,4,6,8 };
    vector<int> ivec3(8);
    sort(ivec1.begin(), ivec1.end());
    sort(ivec2.begin(), ivec2.end());
    merge(ivec1.begin(), ivec1.end(), ivec2.begin(), ivec2.end(), ivec3.begin());
    for (auto elem : ivec3) {
        cout << elem << endl;
    }
}

void test11() {
    //随机洗数据打乱顺序。
    vector<int> ivec{ 1, 2, 3, 4, 5, 6 ,7 };
    random_shuffle(ivec.begin(), ivec.end());
    for (auto elem : ivec) {
        cout << elem << endl;
    }
}

void test12() {
    // 创建一个整数向量
    vector<int> ivec{ 1, 2, 3, 4, 5, 6, 7 };

    // 使用 remove 将值为 3 的元素移到容器末尾
    auto iter = remove(ivec.begin(), ivec.end(), 3);

    // 打印容器中的元素
    for (auto it = ivec.cbegin(); it != iter; ++it) {
        cout << *it << endl;
    }
}

void test13() {
    vector<int> ivec{ 1, 2, 3, 3, 4, 5, 6, 7 };
    auto iter = unique(ivec.begin(), ivec.end());
    for (auto it = ivec.cbegin(); it != iter; ++it) {
        cout << *it << endl;
    }
}


int main()
{
    test13();
}

5. 仿函数

  1. 仿函数定义:就是一个可以调用“()”运算符的类对象,在Part2的第10节,Part3的第五节就已经详细介绍过仿函数了。将operator()重载的类的对象就是仿函数。

简单来说,就是我们在用算法时最后一个参数需要一个可调用对象,stl本身已经帮我们定义了很多可调用对象,不用我们自己再去定义了。

#include <iostream>
#include <vector>
#include <algorithm>
//https://learn.microsoft.com/zh-cn/cpp/dotnet/functional-stl-clr?view=msvc-170#unary_negate
//仿函数文档,建议看。
using namespace std;

void test() {
    plus<int>iPlus;
    cout << iPlus(1, 2);
}


void test02() {
    vector<int>ivec{ 5,2,1,6,8,2,9 };
    sort(ivec.begin(), ivec.end(), greater<int>());
    for (auto i : ivec) {
        cout << i << endl;
    }
}

int main()
{
    test02();
}

6. 适配器与分配器

  1. 容器适配器包装底层容器,并提供受限的操作接口。例如,std::stack<int> 提供栈操作,默认使用 std::deque<int> 存储数据。
  2. STL的适配器有哪些:标准库提供了三种顺序容器适配器,没有关联型容器的适配器。分别是queue(队列),priority_queue(优先级队列),stack(栈)。
  3. 适配器的使用:

(1) 要使用适配器,首先需要引入对应的头文件

① 使用 std::stack 需要包含 <stack>

② 使用 std::queuestd::priority_queue 需要包含 <queue>

代码演示:

(2) 容器适配器必须有匹配的容器:如图所示

种类默认顺序容器可用顺序容器说明
stackdequevectordequelist底层容器需支持 back()push_back()pop_back()
queuedequedequelist底层容器需支持 front()back()push_back()pop_front()
priority_queuevectorvectordeque底层容器需支持随机访问迭代器

代码演示:

(3) 适配器的初始化:

① 使用默认底层容器:std::stack<int> stk;

② 指定底层容器:std::stack<int, std::vector<int>> stk;

  1. 分配器提一下就可以了。在分配动态内存时,直接使用new,delete容易产生内存碎片化的问题,不同的分配器有不同的分配内存的方法,可以大幅提高程序对堆内存的使用效率,我们直接使用默认的分配器就可以了

Part 7:I/O 库

1. io库介绍

  1. io就是input,output的简写,也就是输入输出功能。在Part2的第4节课,就已经介绍过io功能的本质,数据在内存,磁盘,输入输出设备之间移动就是io功能。

常用流类的关系可以简化为:ios_base 提供格式与状态的基础能力,basic_ios 管理流缓冲区和状态;istreamostream 分别提供输入、输出接口,iostream 同时支持两者。文件流和字符串流在这些接口之上绑定不同的缓冲区。

  1. io库组成部分:

(1) C++定义了ios这个基类来定义输入输出的最基本操作,这个类的具体功能我们无需了解,只需了解C++io库所有的类都继承自这个类即可。

(2) istream,ostream这两个类直接继承自ios类。

① ostream类定义了从内存到输出设备(比如显示器)的功能,我们最常使用的cout就是ostream类的对象。

② istream类定义了从输入设备比如键盘)到内存的功能,我们最常用的cin就是istream类的对象。

③ iostream文件定义了ostream和istream类的对象,就是cout和cin。所以我们只要简单的引入iostream这个头文件,就可以方便的使用这两个对象

注意:这个输入,输入时相对于内存来说的,输入到内存,是istream。

(3) ifstream,ofstream类分别继承自istream类和ostream类。

① ifstream定义了从磁盘到内存的功能。因为istream重载了“<<”运算符,所以ifstream对象也可以用“<<”运算符来将文件数据写入内存。除了“=”的所有重载运算符都是可以被继承的。

② ofstream定义了从内存到磁盘的功能。与ifstream同理,也可以用“>>”操作数据流。

③ fstream文件引入了ifstream和ofstream,所以我们只要引入ftream这个头文件,就可以使用文件流功能了。

注意:这个输入输出同样是相对内存来说的。

内存与输入输出设备的数据流动,磁盘与内存的数据流动已经介绍完了。磁盘和输入输出设备直接无法直接交互,必须通过内存。

io库还为我们额外定义了字符串的输入输出类,因为对字符串的操作极为频繁,所以这个库还是很有意义的。

(4) istringstream,ostringstream分别继承自istream类和ostream类

① istringstream定义了从指定字符串到特定内存的功能。与ifstream同理,也可以用“<<”运算符操作数据。

② ostringstream定义了从特定内存到指定字符串的功能。可以用“>>”操作数据。

③ sstream头文件就引入了istringstream和ostringstream,所以我们只要引入sstream这个头文集,就可以使用字符串与内存直接交互数据的功能。

所以我们使用io库主要就三个头文件,iostream,fstream,sstream。接下来三节课会对这三个文件的使用依次讲解。

2. (★)io库的注意事项

提示:这节课的介绍,这节课都是一些理论性的东西,有疑惑很正常,可以带着疑惑去学下一节课,有了代码就好理解了。

1. io对象无法使用拷贝构造函数和赋值运算符

代码演示:

所以我们使用流对象无法使用值传递,一般使用引用进行传递。

2. Io对象的状态

(1) io操作是非常容易出现错误的操作,一些错误是可以修复的,另一部分则发生在系统更深处,已经超出了应用程序可以修正的范围。

比如我们使用cin向一个int类型的数中输入一个字符串,会使cin这个对象出现错误。

代码演示:

所以我们在使用io对象时都应该判断io对象的状态。

比如:while(cin >> val) 或if(cin >> val)(不要只用这两个进行控制,最好搭配iostate来使用)

代码演示:

(2) 我们需要知道流对象错误的原因,因为不同的错误需要不同的处理方法。

io库定义了iostate类型,可以完整的表示io对象当前的状态。在不同的平台中, iostate实现方法略有区别,在vs中直接用int来代表iostate类型,将不同的位置1 以表示不同的状态。可以与位操作符一起使用来一次检测或设置多个标志位。

可以用rdstat函数来获得io对象当前用iostat类型来表示的状态:

代码演示:

(3) iostata类型有以下状态

① badbit状态,系统级错误,一旦表示badbit的位被置为1,流对象就再也无法使用了。

② failbit状态,代表可恢复错误,比如想读取一个数字却读取了一个字符,这种错误就是可以恢复的。当badbit位被置1时,failbit位也会被置1。

③ eofbit状态,当到达文件结束位置时,eofbit和failbit位都会被置1。

④ goodbit状态,表示流对象没有任何错误。

只要badbit,failbit,eofbit有一位被置为1,则检测流状态的条件就会失败。

(4) 标准库还定义了一组成员函数来查询这些标志位的状态

① good()函数在所有错误位均未置1的情况下返回true。

② bad(),fail(),eof()函数在对应位被置1的情况下返回true。因为badbit位被置1或eofbit位被置1时,failbit位也会被置为1。所以用fail()函数可以准确判断出流对象是否出现错误。

③ 实际上,我们将流对象当做条件使用的代码就等价于“!fail()”

(5) 流对象的管理

① rdstate函数,返回一个iostate值,对应当前流状态

② setstate(flag) 函数,将流对象设置为想要的状态

③ clear函数:是一个重载的函数。

  1. clear():把流状态重置为 goodbit
  2. clear(flag):把流状态设置为 flag;它并不是只清除某一个标志位。

④ ignore函数:

作用:提取输入字符并丢弃他们。

函数原型:istream& ignore (streamsize n = 1, int delim = EOF)

读取到前n个字符或在读这n个字符进程中遇到delim字符就停止,把读取的这些东西丢掉

代码演示:

#include<iostream>
#include<sstream>
#include<fstream>

//必背
void test() {
    int i = 10;

    // 使用循环读取输入,直到遇到文件结束符(EOF)
    while (std::cin >> i, !std::cin.eof()) {

        // 检查输入流是否处于错误状态
        if (std::cin.bad()) {
           /*
           good() : 流处于正常状态,没有发生错误。
           eof()  : 流处于文件结束状态。已经读取到文件末尾。
           fail() : 流发生了可恢复的错误,例如期望读取一个整数,但实际输入的是字母。
           bad()  : 流发生了不可恢复的错误,表示流已经失效,不能再进行有效的输入或输出操作。
            */
            throw std::runtime_error("cin错误");
            //std::runtime_error 是用于表示运行时错误的一种异常类型
        }

        // 检查输入是否匹配预期的类型
        if (std::cin.fail()) {

            // 清除错误状态标志
            std::cin.clear();

            // 忽略输入缓冲区中的无效字符,直到遇到换行符
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');

            // 提示用户数据格式错误,要求重试
            std::cout << "数据格式错误,请重试" << std::endl;

            continue; // 跳过当前循环迭代,重新尝试输入
        }

        // 打印有效输入值
        std::cout << i << std::endl;
    }
}

void test02() {

}


void main() {
    test();
}

3. 内存与输入输出设备的交互(iostream)

1. getline:

其实iostream已经没什么好讲的了,比较常用的就是这个getline了,getline其实并不复杂,不过是按行接收数据罢了,因为存储在string对象中,所以不容易出现格式错误,但仍然可能出现系统错误,所以在企业级程序中,还是应当对bad的情况进行处理。

代码演示:

2. get:

还有个不怎么常用的get函数。get函数的用法和getline类似,只不过get是以字符的格式进行接收。在企业级代码中仍然需要对bad的情况进行处理。

剩下的也没什么了,iostream常见的用法在上一课已经讲过了,iostream就这些了。

#include<iostream>
#include<string>
using namespace std;

void test() {
    string str;
    while (getline(cin, str), !std::cin.eof()) {
        if (cin.bad()) {
            throw runtime_error("cin 错误");
        }
        cout << str << endl;
    }
}


int main() {
    test();
}

4. (★)内存与磁盘的交互(fstream)

  1. fstream相对于iostream。多了很多自己独有的操作

(1) I/O 库提供 ifstreamofstream 类型,使用文件流时需要自行创建相应对象。

(2) fstream对象创建方式有三种

① 可以使用默认构造函数进行定义。例如: ifstream fstrm,

代码演示:

② 也可以在创建流对象时打开想要打开的文件。例如ifstream fstrm(s)。s可以是字符串,也可以是c风格的字符串指针。文件的mode依赖于流对象的类型。

代码演示:

③ 也可以在打开文件时就指定文件的mode。例如ifstream fstrm(s, mode)

代码演示:

(3) fstrm.open(s)函数,打开名为s的文件,并将文件与fsrm绑定,s可以是一个string,也可以是一个c风格的字符串指针。

代码演示:

(4) fstrm.close() 用于显式关闭文件。流对象析构时也会自动关闭文件,因此通常依靠 RAII 即可;需要提前关闭或检查关闭结果时再显式调用。

代码演示:

(5) fstrm.is_open()函数,返回一个bool值,指出与fstrm关联的文件是否成功打开且尚未关闭。

代码演示:

如果新手只看这些描述,可能会很迷糊,所以我接下来要写一段代码,大家只要把这段代码记住,文件部分就没有问题了。

这段代码的目的是:让客户输入文件名称,如果文件不存在,就让客户重新输入文件名称,如果文件存在,就将文件全部内容输出。

  1. 文件模式:

(1) in以读的方式打开

(2) out以写的方式打开

(3) app在进行写操作时定位到文件末尾

(4) ate打开文件后立即定位到文件末尾

(5) trunc截断文件(也就是文件已有的全部删除,重新开始写)

(6) binary以二进制方式打开文件

  1. 文件模式需要强调以下几点

(1) 与ifstream关联的文件默认in模式打开。

(2) 与ofstream关联的文件默认out模式打开

(3) 与fstream关联的文件默认in和out模式打开

(4) 默认情况下,即使我们没有指定trunc,以out模式打开的文件也会被截断。为了保持以out模式打开的文件的内容,我们必须同时指定app模式或in模式。

(5) 只可以对ifstream或fstream的对象设定in的模式

(6) 只可以对ofstream或fstream的对象设定out的模式

(7) 只有当out模式被设置时才可以设置trunc模式

(8) ate和binary模式可以应用与任何类型的文件流对象,且可以与任何其它文件模式组合使用。

代码演示:

4. 总结:文件流这部分还是有一些东西的,新手理解起来可能有些困难,没办法,用的多了就好了。其实常用的就那么几点。

#include <iostream>
#include <fstream>
#include <string>
using namespace std;

void test() {
    string fileName;
    string fileContent;

    while (getline(cin,fileName), !cin.eof())
    {
        if (cin.bad()) {
            throw runtime_error("cin 出错");
        }
        ifstream ifs(fileName);
        if (ifs.is_open()) {
            while (getline(ifs, fileContent)) {

                cout << fileContent << endl;
            }
            if (ifs.bad()) {
                throw runtime_error("ifs 出错");
            }

            ifs.close();
        }
        else {
            ifs.clear();
            ifs.ignore(numeric_limits<streamsize>::max(), '\n');
            cout << "文件不存在,请在尝试一遍。" << endl;
        }
        cout << "进程结束" << endl;
    }
}

int main()
{
    test();
}

5. 内存之中对于字符串的操作(sstream)

  1. string流介绍:string流可以向string对象写入数据,也可以从string对象读取数据。与文件操作类似,只不过数据交互变成了从内存到内存。

代码演示:string流默认包含一个string对象,当然,我们也可以指定。

  1. string流有哪些

(1) istringstream从string对象读取数据

(2) ostringstream向string对象写数据

(3) stringstream既可以从string对象读取数据,也可以向string对象写数据

  1. string流对象继承自iostream对象,除了继承得来的操作,string流对象还有自己的成员来管理流相关的string。

(1) 对于string流,io库是没有像cout,cin这样的自定流对象的。流对象需要我们自己去定义

① sstream strm:sstream代表一个string流对象的类型,以下同理。strm是一个未绑定的stringstream对象。

② Sstream strm(s):strm是一个绑定了s的拷贝的string流对象。s是一个string对象

(2) strm.str():返回strm所保存的string的拷贝。

(3) strm.str(s):将s拷贝到strm中,返回void

  1. string流对象的作用

(1) 对数据类型进行转化,也就是string和其它类型的转化,这是string流对象最重要的功能。

① string转化为int等类型。

代码演示:

② int等类型转化为string。

代码演示:

(2) 用于对空格分隔的字符串的切分,

代码演示:

#include <iostream>
#include <sstream>

void 字符串转Int() {
    std::string str("12");
    std::stringstream strStream(str);

    int i = 0;
    strStream >> i;
    if (strStream.bad()) {
        throw std::runtime_error("strStream 出错");
    }
    if (strStream.fail()) {
        strStream.clear();
        strStream.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
        std::cout << "string 格式错误" << std::endl;
    }
    else {
        std::cout << i+1 << std::endl;
    }
}

void int转换String() {
    int srcI = 100;
    std::stringstream strStream;
    strStream << srcI << std::endl;
    // << std::endl   如果在多线程中,不刷新可能会有出错

    if (strStream.bad()) {
        throw std::runtime_error("strStream 出错");
    }
    else {
        std::cout << strStream.str() << std::endl;
    }
}

void 分割空格() {
    std::string srcString("hello world hello world");
    std::string destString;
    std::stringstream strStream(srcString);
    while (strStream >> destString) {
        std::cout << destString << std::endl;
    }
    if (strStream.bad()) {
        throw std::runtime_error("strStream 出错");
    }
}

int main()
{
    分割空格();
}

Part 8:多线程

注意:多线程的东西其实有很多,这门课只讲常用的部分,把这些学会基本就够用了。

1. 多线程基本概念介绍

1. 多线程的重要性:

(1) 对于一个专业的C++开发来说,多线程是必须掌握的模块。

(2) 现代程序都是多线程程序了。因为单核处理器的性能早已经达到了瓶颈,只能往多核方向发展。现代的个人计算机都是4核起步,工作站,服务器就更不用说了。

(3) 工作站可以理解为处理能力更强的大型个人计算机,常见的12核,16核。服务器有48核的,甚至更多。

(4) 对于一个计算机来说,是不是说核越多好呢?不是,多核会导致单核的工作性能下降。当核数多到一定程度后,反而总体运行效率下降了。不过,这并不影响现代计算机核数越来越多的趋势。

(5) 传统的单线程程序同时只能在一个核上运行,这是不是太浪费资源了。计算机有8个核,你就用了一个,暴殄天物啊。多线程程序可以使用多个核,极大提高程序运行效率。现在网络通信,音频,视频,游戏服务都是多线程程序。

2. 并发与并行:

并发表示多个任务在重叠的时间段内推进,不要求同一时刻真正同时执行;并行表示多个任务在同一时刻由不同执行资源运行。多核系统既可以并发,也可以并行;当可运行线程多于硬件线程时,操作系统会进行调度和时间片切换。

3. 进程:进程是正在运行的程序实例,拥有独立的虚拟地址空间及文件、句柄等资源。线程在进程内执行,并共享进程的大部分资源。不同操作系统的实现细节有差异,但这一抽象在主流平台上都适用。

4. 线程:线程是进程内可被调度的执行单元,具有自己的调用栈和寄存器状态。Linux 与 Windows 的底层实现不同,但 std::thread 提供了可移植的 C++ 接口。

  1. 总结:
    本节只介绍进程与线程的基本概念。C++11 标准线程库在主流平台上提供一致接口,但调度策略和性能特征仍取决于操作系统实现。

2. (★)线程的创建

  1. 主线程介绍:一个程序执行起来就是一个进程。而main函数就是主线程,一旦主线程执行完毕,主线程结束,整个进程就会结束。
  2. 子线程介绍:一个线程可以创建其他线程,它们会并发执行。线程之间共享进程资源,因此访问共享数据时必须正确同步。若 main 返回或调用 std::exit,进程终止,仍在运行的线程也会随进程结束;这不是主线程逐一发送终止信号。
  3. 子线程的创建方式:很简单,直接使用thread类就可以了。

代码演示:

括号中只要是一个可调用对象就没有问题了。

  1. 子线程创建后如果就不管了,那么会出现非常严重的问题。

(1) 有些子线程负责对部分数据的处理,主线程必须要等到子线程处理完毕才能继续执行,所以join函数就诞生了。

代码演示

使用了join函数后,主线程就会处于挂起状态,直到子线程执行完毕才可以继续执行。

(2) 如果不需要等待线程,可以调用 detach() 将其与 std::thread 对象分离。分离线程仍属于当前进程,进程结束时它也会终止,因此不能依赖它在 main 返回后继续完成任务。

代码演示:

detach() 之后无法再通过原 std::thread 对象执行 join()。分离线程使用的共享对象必须比线程活得更久,否则容易发生悬空访问;工程代码通常更容易安全管理可汇合线程,C++20 也可使用 std::jthread 自动请求停止并汇合。


#include <iostream>
#include <thread>
//线程 库

void test() {
    //() 括号中只要一个可调用对象就没问题了。
    std::thread MyThread([]() {
        std::cout << "hello world" << std::endl;
        /*
        ......假设要执行很多功能
        */
        });
    MyThread.join();//如果子线程没有结束,那么主线程会在这里等待子线程结束。
}
void test2() {
    std::thread MyThread([]() {
        for (int i = 0; i < 100000000; i++) {

        }

        });
    MyThread.detach();
}

int main()
{
    test2();

}

3. (★)传递线程参数

(1) 创建 std::thread 时,参数默认经过衰变后存储在线程对象内部,并在新线程调用目标函数时传入。需要传递引用时,应显式使用 std::refstd::cref

代码演示:

(2) 传递参数注意事项:

① 指针和引用并非绝对不能传给分离线程,关键是它们所指对象的生命周期必须覆盖线程的全部访问过程。由于这一点很难可靠保证,分离线程通常应按值持有所需数据,或使用具有明确共享生命周期的对象。

代码演示:

② 如果实参需要隐式转换,最好在创建线程之前显式构造目标类型,尤其不要让转换依赖即将离开作用域的字符数组或其他临时资源。

(3) 总结:

① 小型且可复制的值通常直接按值传递。

② 类类型按值传递最容易管理生命周期,编译器也可以使用移动构造减少开销。只有确实需要访问原对象,并能保证同步和生命周期时,才使用 std::refstd::cref 传递引用。

(4) std::ref的用法:

因为 std::thread 默认保存参数副本,即使目标函数的形参是引用,也不会自动引用原实参。std::ref(obj) 会创建 reference_wrapper,使目标函数接收对 obj 的引用;调用方必须保证 obj 在线程使用期间始终有效,并正确同步并发访问。

代码演示:

#include <iostream>
#include <thread>
#include <string>
#include <windows.h>

class Test {
public:
    Test(int i) {
        std::cout <<  "Test->线程ID:" << std::this_thread::get_id() << std::endl;
        //输出线程ID;
    }
    Test(const Test& test) {
        std::cout << "Test(const Test& test):" << std::endl;
    }
};

void test(int i, const int& refI, const int* pi) {

    //int i                值传递        接收地址与传递过来的地址不一样。
    //const int& refI      引用传递      接收地址与传递过来的地址不一样。
    //const int* pi        指针传递      传递地址一样。
    for (unsigned i = 0; i < 1000000; ++i) {
        int i2 = *pi;
    }
}

void test02(Test& str) {
    std::cout <<"子线程ID:" << std::this_thread::get_id() << std::endl;

    //隐式转换很有可能,在主线程执行完成后,这边都还没执行。
    //避免隐式转换。
}

int main()
{
    int i = 100;
    //test()
    /*std::thread MyThread(test, i, i, &i);*/
    //test()

    std::cout << "主线程ID:" << std::this_thread::get_id() << std::endl;
    Test t(10);
    std::thread MyThread(test02,std::ref(t));
    //MyThread.detach();
    MyThread.join();
    Sleep(1);
    //睡一秒
}

4. (★)线程 ID 的概念

  1. 线程id定义:每个线程都有自己的id,不管是主线程还是子线程都有自己的id。直接使用std::this_thread::get_id()就可以获得当前线程的id。

代码演示:

  1. 注意:线程是依附于进程存在的,所以不同的进程可以有相同的线程id。

这一课很简单,但这个知识点不知道往哪里放,就单独拿出来了。

5. (★)数据共享与数据保护

  1. 多个线程的执行顺序是乱的,具体执行方法和处理器的调度机制有关系。从开发者的角度讲,就是没有规律的。

代码演示:

  1. 在讲数据保护问题之前,为了帮助大家理解数据保护问题,这里额外扩展一些关于汇编的知识。科班的同学应该很熟悉,给非科班的人介绍一下。

一个进程运行时,数据存储在内存中。如果一个数据要进行运算,必须先将数据拷贝到寄存器中。比如要对栈上的一个int i进行“++”操作,需要将i的值拷贝到寄存器中,将该值自加后再拷贝到原来的内存。

如果此时有两个线程均进行的是这样的操作,可能出现两个进程都拷贝了i原来的值到寄存器,然后各种加一,再拷贝到i对应内存的情况,最终导致i这个变量只自加了一次。

这是同时写数据的情况,那么一读一写呢?这也是有问题的,谁知道读数据时写数据步骤已经到了哪里,谁知道读出来的是个什么东西。

  1. 数据保护问题:

(1) 数据保护问题总共有三种情况:

① 至少两个线程对共享数据均进行读操作,完全不会出现数据安全问题。

② 至少两个线程对共享数据均进行写操作,会出现数据安全问题,需要数据保护。

③ 至少两个线程对共享数据有的进行读,有的进行写,也会出现数据安全问题,需要进行数据保护。

代码演示:

数据保护的方法一共就两种:互斥锁,原子操作。

4. 互斥锁:

(1) 互斥锁的作用原理很简单,对共享数据加锁,当一个线程对这块数据进行操作时,别的线程就无法对该区域数据进行操作。

代码演示:

(2) 这种方式的互斥锁有个弊端,就是lock()之后容易忘记unlock(),就和指针类似。于是和智能指针类似,也有了lock_guard,用来防止开发人员忘了解锁。

代码演示:

  1. 原子操作:(使用频率远远不及互斥锁)

(3) 原子操作保证对该原子对象的指定操作不可被其他线程观察到“执行了一半”的状态,并按所选内存序提供相应的同步语义。它并不意味着其他处理器会停止工作,也不能自动保护多个对象之间的复合不变量。

代码演示:

总结:在编写多线程代码时,数据保护是一个必须考虑,非常常用的功能。互斥锁的使用频率是远远高于原子操作,原子操作看似简单,但当需要保护的数据很多时,就会极其复杂。

一般来说,独立计数或简单状态可考虑原子类型;涉及多个变量或复合不变量时,互斥锁通常更清晰。

#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>

unsigned g_num = 0;
std::atomic<unsigned> g_num2{0};
std::mutex myMutex;

void incrementAtomic() {
    for (unsigned i = 0; i < 1'000'000; ++i) {
        ++g_num2;
    }
}

void incrementWithMutex() {
    std::lock_guard<std::mutex> lock(myMutex);
    for (unsigned i = 0; i < 1'000'000; ++i) {
        ++g_num;
    }
}

int main() {
    std::thread thread1(incrementWithMutex);
    std::thread thread2(incrementWithMutex);
    thread1.join();
    thread2.join();

    std::thread thread3(incrementAtomic);
    std::thread thread4(incrementAtomic);
    thread3.join();
    thread4.join();

    std::cout << g_num << '\n';
    std::cout << g_num2 << '\n';
}

6. (★)死锁

死锁就像两个人在互相等对方。A说,等B来了就去B现在所在的地方;B说,等A来了我就去A所在的地方,结果就是A和B都在等对面过来才能去对面。这就导致了一个死循环,放在多线程中,就是死锁。

举个例子:

解决方法也很简单。

  1. 只要让两个锁顺序一致就可以了。

代码演示:

  1. 但是让两个锁顺序一致常常是说起来容易,做起来难。于是C++11提供了std::lock。这个模板可以保证多个互斥锁绝对不会出现死锁的问题。同时提供了std::adopt_lock的功能来避免忘记释放锁的问题。

总结:死锁是一个比较常见的bug,面试时也经常询问死锁相关的知识。

这一节课上完,多线程的主体部分就讲完了,后面都是使用频率较低的东西,也就是个补充。所以前六节课必须学会,每一课都是重点。后面的我就不讲了。

#include <iostream>
#include <thread>   //互斥锁头文件
#include <mutex>    //原子锁

unsigned g_num = 0;
std::mutex myMutex1;
std::mutex myMutex2;

void test() {
    for (unsigned i = 0; i < 10000000; ++i) {

        std::lock(myMutex1, myMutex2);   //注意这里
        //锁的范围尽量缩小。范围大影响性能。
        std::lock_guard<std::mutex> lgl(myMutex1,std::adopt_lock);
        //std::adopt_lock   放弃加锁
        /*
        假设这里行了一大堆代码。
        */
        std::lock_guard<std::mutex> lg2(myMutex2,std::adopt_lock);
        ++g_num;
        //以后完美解决死锁的办法。
    }
}

//解决死锁的问题很简单,保证2个的顺序一致就行了
int main()
{
    std::thread myThread(test);
    for (unsigned i = 0; i < 10000000; ++i) {
        std::lock(myMutex1, myMutex2);  //注意这里
        //锁的范围尽量缩小。范围大影响性能。
        std::lock_guard<std::mutex> lg2(myMutex1, std::adopt_lock);
        /*
        假设这里行了一大堆代码。
        */
        std::lock_guard<std::mutex> lg1(myMutex2, std::adopt_lock);
        ++g_num;
    }
    myThread.join();
    std::cout << g_num << std::endl;
}

Part 9:异常处理

异常处理的前情提要:很多人不喜欢使用异常处理,认为它麻烦,应对可能出现的错误要写那么多代码,会非常麻烦。

但实际上不是这样的,我们只需要在一些开发人员难以控制,比较容易出错的地方对异常进行处理就可以了,需要进行异常处理的地方并不多。

举几个例子。

  1. 接收传递过来的被除数,我们难以判断被除数是否为0,此时异常处理就很有意义了。
#include <iostream>

int 除法(int 除数, int 被除数) {
    if (!被除数) {
        throw std::string("被除数为0(出错)");
    }
    return 除数 / 被除数;
}

void clientIntputNum(const std::string&& str, int& num) {
    std::cout << str << std::endl;
    while (std::cin >> num, !std::cin.eof()) {
        if (std::cin.bad()) {
            throw std::runtime_error("cin 出错");
        }
        if (std::cin.fail()) {
            std::cin.clear();
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
            std::cout << "数值出错,请再次尝试。" << std::endl;
            continue;
        }
        break;
    }
}

void test() {
    //多线程不要用隐式转换,前辈被坑惨了。
    int 除数 = 0;
    int 被除数 = 0;
    clientIntputNum("请输入 除数:", 除数);
    clientIntputNum("请输入 被除数:", 被除数);

    try {
        std::cout << 除法(除数, 被除数) << std::endl;
    }
    catch (const std::string& str) {
        std::cout << "异常出错,异常行为是:" << str << std::endl;
    }
    catch (...) {
        //接收各种异常的情况
        std::cout << "例外情况,例外是:未知例外" << std::endl;
    }
}

//int main()
//{
//    test();
//}
  1. 接收文件名,如果文件不存在,我们可以按照之前的写法要求重发一遍,也可以直接报异常,异常就是文件不存在。
#include<iostream>
#include<fstream>

void outPutFileContent(const std::string& fileName) {
    std::ifstream ifs(fileName);
    std::string fileLineContent;
    if (ifs.is_open()) {
        while (ifs >> fileLineContent) {
            std::cout << fileLineContent << std::endl;
        }
        if (ifs.bad()) {
            throw std::runtime_error("ifs 运行时错误");
        }
        ifs.close();
    }else{
        if (ifs.bad()) {
            throw std::runtime_error("ifs 运行时错误");
        }
        if (ifs.fail()) {
            throw std::string("ifs 不存在");
        }
    }
}
void test02() {
    std::string str;
    std::cin >> str;
    try {
        outPutFileContent(str);
    }
    catch (const std::string& except) {
        std::cout << "except occers , exception is:" << except << std::endl;
    }
    catch (...) {
        std::cout << "except occers , exception is: unknown exception" << std::endl;

    }
}


//int main() {
//    test();
//}
  1. 我们在动态分配内存时,经常出现内存不足的情况(在大型程序中,这是非常常见)。比如我们需要动态分配一个未知大小的数组,数组大小等待传入。使用new操作符会直接抛出bad_alloc的异常。
#include <iostream>
#include <memory>
#include <vector>
void test03() {
    std::vector<std::shared_ptr<int>> unique_int_vec;
    try {
        while (1) {
            //int* pi = new int[100000000]();
            std::unique_ptr<int> uniqueI(new int[1000000]());
            unique_int_vec.push_back(std::move(uniqueI));
        }
    }
    catch (const std::bad_alloc& except) {
        std::cout << except.what() << std::endl;
        //what() 发生什么异常
    }
    catch (...) {
        std::cout << "未知出错。" << std::endl;
    }
}

//int main() {
//    test03();
//}

对new的处理非常重要,大家如果做专业的C++开发,会经常用到。

此外使用智能指针时如果内存分配不够也会抛出bad_alloc的异常

  1. 有个vector,我们需要接受一个参数,然后取出参数对应的数组元素。此时就经常出现数组的越界问题。
#include <iostream>
#include <memory>
#include <vector>
void test04() {
    std::vector<int> ivec{ 1,2,3,4,5 };
    try {
        ivec.at(19);
    }
    catch (const std::out_of_range& except) {
        std::cout << except.what() << std::endl;
    }
    catch (...) {
        std::cout << "未知出错。" << std::endl;
    }
}

//int main()
//{
//    test04();
//}

最常用的基本就这些例子了,剩下的也都和这些类似。

异常处理这一章东西不多,一会儿把这些例子演示一下就可以了。

#include <iostream>
#include <memory>
#include <vector>

template<typename T>
class Test {
public:
    T& operator[](unsigned count) const
    {
        if (count >= data.size()) {
            throw std::out_of_range("Test->data is out of range");
        }
    }
private:
    std::vector<T> data;
};

void test5() {
    try {
        Test<int> t;
        t[1];
    }
    catch (const std::out_of_range& except) {
        std::cout << "自定义类Test出错:" << except.what() << std::endl;
    }
    catch (...) {
        std::cout << "未知错误" << std::endl;
    }
}

int main() {
    test5();
}

1. 异常处理介绍

  1. 异常是程序在执行期间产生的问题(编译期出现的错误在写代码时开发环境就有提示)。C++的异常是指程序运行时发生的特殊情况。
  2. 异常提供了一种转移程序控制权的方式。C++的异常处理涉及到三个关键字:try,catch,throw。

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

(2) catch:在你想要处理问题的地方,通过异常处理程序捕获异常。catch关键字用于捕获异常。

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

可能抛出异常的代码放在 try 块中,后面跟一个或多个 catch 处理器:

try {
    // 可能抛出异常的操作
} catch (const std::exception& ex) {
    std::cerr << ex.what() << '\n';
} catch (...) {
    // 处理其他异常;通常应记录或继续抛出
}
  1. 抛出异常:throw语句可以在代码块的任何地方抛出异常,throw抛出的表达式的结果决定了抛出的异常的类型。

代码演示:

2. C++的标准异常

  1. C++ 标准库提供了一系列异常类型。基类 std::exception 定义在头文件 <exception> 中,具体异常还分布在 <stdexcept><new> 等头文件中。

常见层次可简化为:std::exception 是标准异常基类;std::logic_error 表示通常可由程序逻辑避免的问题,std::runtime_error 表示往往只能在运行时发现的问题;std::bad_alloc 等异常则直接派生自 std::exception。不需要死记整个继承树,使用时查阅对应头文件即可。

  1. 别看图很复杂,异常种类有很多,但经常使用的其实就几个。

(1) bad_alloc错误,使用new分配内存失败就会抛出bad_alloc错误。

(2) std::out_of_range:使用容器的 at() 进行越界访问时会抛出该异常;operator[] 通常不做边界检查。二者适用于不同场景,不能简单认为其中一个总是更好。

(3) runtime_error错误,运行时错误,只有在程序运行时才能检测到的错误。这是一个相对的概念,和logic_error形成对比。logic_error可以读代码读出来,runtime_error就不行。

我们也经常将一些读代码无法判断的异常标识为runtime_error。

(4) ... 错误,可以接受任何错误,我们一般都会在catch最后加上“...”,这样就可以接受所有类型的异常了。

代码演示:

剩下的异常,我也用代码演示一下,这些异常使用频率比较低,但在某些情况下也是需要使用的。

  1. 自定义异常类型,其实需要自定义异常类型的情况真的非常少,这里就不介绍了,其实和标准异常也是一样的。

Part 10:其他重要知识点

1. 万能引用与引用折叠

  1. 万能引用的概念:

(1) C++11除了带来了右值引用,还带来了万能引用,也就是既能当做左值,又能当做右值的引用。

注意:万能引用是既可以被编译期处理为左值引用,又可以被编译期处理为右值引用。不是既是左值引用又是右值引用,不违背C++一个引用不是左值引用,就是右值引用的基本说法,万能引用会在编译期被当做左值引用或右值引用处理。

  1. 万能引用的格式:万能引用的格式有两种

(1) 模板形式:

template<typename T>
void func(T&& parm);

代码演示:

这里的 T&& 是转发引用(也常被称为万能引用)。

注意:只有当 T 需要由函数调用进行类型推导,并且形参恰好写成 T&& 时,它才是转发引用。

以下的写法均不是万能引用:

const T&& 是普通右值引用,std::vector<T>&& 也是右值引用。下面类中的 T 在实例化类时已经确定,因此成员函数的 T&& 不是转发引用:

template<typename T>
class MyVector {
public:
    void push_back(T&& elem);
};

注意:此时T&& 不是万能引用,因为T影响的是MyVector的类型。

只有这样写,才是万能引用。

template<typename T>
class MyVector {
public:
    template<typename T2>
    void push_back(T2&& elem);
};

此时T2&& 的类型完全独立于MyVector类了,每调用一次push_back函数,都要推断T2的类型。

(2) auto型:

auto&& var = var2;

代码演示:

这里的 auto&& 是转发引用。

const auto&& var 不是转发引用。

  1. 当实参是左值时,转发引用会根据引用折叠规则推导为左值引用;实参是右值时,它会推导为右值引用。

代码演示:

  1. 引用折叠:引用折叠其实概念很简单

一个引用不是左值引用就是右值引用,当一个万能引用被认为左值引用时,类型应该是T& &&,此时类型就会折叠为T&。

简单来说,就是引用符号太多了,折叠为“&”或“&&”

看代码:


#include <iostream>
#include <boost/type_index.hpp>
using boost::typeindex::type_id_with_cvr;

template<typename T>
void test(T&& elem) {//万能引用。
    std::cout << "elem type is:" << type_id_with_cvr<decltype(elem)>().pretty_name() << std::endl;
    std::cout << "T type is:" << type_id_with_cvr<T>().pretty_name() << std::endl;
}

template<typename T>
class MyVector
{
public:
    template<typename T2>
    static void push_back(T2&& elem) {
        //万能引用

    }
};


int test1()
{
    int i = 100;
    test(std::move(i));//万能引用

    //分割,第二种万能引用
    auto&& elem = i;
    //const auto&& elem = i;不能算是万能引用。
    std::cout << type_id_with_cvr<decltype(elem)>().pretty_name() << std::endl;

    return 0;
}


//int main() {
//    test1();
//}

2. 完美转发

  1. C++完美转发的定义:完美转发是什么呢?说到底,它描述的其实就是一个参数传递的过程,能够将一个传递到一个函数的参数,再通过该函数原封不动的传递给另一个函数(这里的原封不动不单是指参数的值,更包括参数的类型,参数的限定符

光用语言描述确实描述不清楚,所以。

代码演示:

  1. 我们发现以前的传递参数的方法都无法在万能引用中解决完美转发的问题。

代码演示:

  1. 于是C++提供了forward模板来解决完美转发的问题,forward模板可以使参数推断出它原来的类型,实现了完美转发。

4. 总结:完美转发通常与万能引用配合使用,使参数在继续传递时保留原有的值类别和限定符。

#include <boost/type_index.hpp>
#include <iostream>
#include <utility>

using boost::typeindex::type_id_with_cvr;

template<typename T>
void func2(T&& parm) {
    std::cout << type_id_with_cvr<decltype(parm)>().pretty_name() << '\n';
}

template<typename T>
void func1(T&& parm) {
    func2(std::forward<T>(parm));
}

int main() {
    int value = 10;
    func1(value);
    func1(10);
}

最后再说一点,其实Part10原本还是打算讲一些东西的,后来想了想,这些都是比较复杂的东西了,新手根本用不到,用的到的人也都是一些老家伙了,完全有了自己查找资料的能力,所以像萃取这种知识就没有讲。

这些东西,绝对够新手看了,非常的全,也非常实用。

附录 1:STL 算法

注意:我这个文档的主要功能还是给STL的算法分个类,要查看算法严格的描述,还是去微软官网查看吧,官方文档又准又全。

一、查找算法(13 个):查找或统计序列中的元素

adjacent_find:

在iterator对标识元素范围内,查找一对相邻重复元素,找到则返回指向这对元素的第一个元素forwardIterator。否则返回最后一个元素的forwardIterator。还有个重载版本实现按需查找

binary_search:

在有序序列中查找 value,找到则返回 true。重载版本使用指定的比较函数对象判断元素的顺序关系。

count:

利用等于操作符,把标志范围内的元素与输入值比较,返回相等元素个数。

count_if:

利用输入的操作符,对标志范围内的元素进行操作,返回结果为true的个数。

equal_range:

注意:使用 equal_rangelower_boundupper_bound 前,序列必须按照相同的比较规则有序。

功能类似equal,返回一对iterator,第一个表示lower_bound,第二个表示upper_bound。

find:

find 使用相等比较在指定范围中搜索目标值。匹配时返回指向该元素的迭代器,否则返回范围末尾迭代器。

find_end:

在指定范围内查找"由输入的另外一对iterator标志的第二个序列"的最后一次出现。找到则返回最后一对的第一个迭代器,否则返回输入的"另外一对"的第一个迭代器。重载版本使用用户输入的操作符代替等于操作。

find_first_of:

在指定范围内查找"由输入的另外一对iterator标志的第二个序列"中任意一个元素的第一次出现。重载版本中使用了用户自定义操作符。

find_if:

使用输入的函数代替等于操作符执行find。

lower_bound:

返回一个iterator,指向在有序序列范围内的可以插入指定值而不破坏容器顺序的第一个位置。重载函数使用自定义比较操作。

upper_bound:

返回一个iterator,指向在有序序列范围内插入value而不破坏容器顺序的最后一个位置,该位置标志一个大于value的值。重载函数使用自定义比较操作。

search:

这两个是真的不好描述,去微软官网查看一下吧,简单,比我在这里总结的强多了。

search_n:

二、排序和通用算法(14 个):提供元素排序策略

inplace_merge:

merge:

nth_element:

partial_sort:

partial_sort_copy:

partition:

random_shuffle:

reverse:

reverse_copy:

rotate:

rotate_copy:

sort:

stable_sort:

stable_partition:

三、复制、替换和移除算法(15 个)

copy:

copy_backward:

iter_swap:

remove:

remove_copy:

remove_if:

remove_copy_if:

replace:

replace_copy:

replace_if:

replace_copy_if:

swap:

swap_range:

unique:

unique_copy:

四、排列组合算法(2 个):计算给定集合的相邻排列

next_permutation:

prev_permutation:

五、算术算法(4 个)

accumulate:

partial_sum:

inner_product:

adjacent_difference:

六、生成和变换算法(6 个)

fill:

fill_n:

for_each:

generate:

generate_n:

transform:

七、关系算法(8 个)

equal:

includes:

lexicographical_compare:

max:

max_element:

min:

min_element:

mismatch:

八、集合算法(4 个)

set_union:

set_intersection:

set_difference:

set_symmetric_difference:

九、堆算法(4 个)

make_heap:

pop_heap:

push_heap:

sort_heap:

附录 2:STL 标准库提供的函数对象

1. 算术类仿函数

std::plus<T>

std::minus<T>

std::multiplies<T>

std::divides<T>

取模

std::modulus<T>

取负

std::negate<T>

2. 关系运算类仿函数

等于

std::equal_to<T>

不等于

std::not_equal_to<T>

大于

std::greater<T>

大于等于

std::greater_equal<T>

小于

std::less<T>

小于等于

std::less_equal<T>

3. 逻辑运算仿函数

逻辑与

std::logical_and<T>

逻辑或

std::logical_or<T>

逻辑非

std::logical_not<T>

附录 3:STL 常用容器操作

注意:

1. 不需要死记硬背,用的多了自然就会了。

2. 容器的各种函数最好的方式就是打开vs,函数所有的参数都有显示,看不懂就去微软官网查一查。

1. vector的各种函数

1. 构造函数

  1. vector():创建一个空的 vector
  2. vector(const std::allocator<T>& al):使用指定的分配器创建一个空的 vector。通常使用默认分配器即可。
  3. vector(std::vector<T>&& right, const std::allocator<T>& al):移动构造函数,并允许指定分配器。
  4. vector(const std::vector<T>& vec, const std::allocator<T>& al):复制构造函数,并允许指定分配器。
  5. vector(std::initializer_list<T> initList, const std::allocator<T>& al):使用初始化列表构造容器,并允许指定分配器。
  6. vector(iter first, iter last, const std::allocator<T>& al):用迭代器区间 [first, last) 构造容器,并允许指定分配器。只要迭代器和值类型满足要求,也可以使用 dequelist 等容器的迭代器。
  7. vector(size_t count, const std::allocator<T>& al):创建包含 count 个值初始化元素的 vector,并允许指定分配器。
  8. vector(size_t count, const T& value):创建包含 count 个元素的 vector,每个元素的值均为 value

2. 增加函数

  1. void push_back(const T& value):向容器末尾复制一个元素。
  2. void push_back(T&& value):向容器末尾移动一个元素。
  3. iterator insert(const_iterator where, std::initializer_list<T> initList):在 where 前插入初始化列表中的元素,返回指向第一个新元素的迭代器;若列表为空则返回 where
  4. iterator insert(const_iterator where, iter first, iter last):在 where 前插入区间 [first, last) 中的元素。
  5. iterator insert(const_iterator where, size_type count, const T& value):在 where 前插入 countvalue
  6. iterator insert(const_iterator where, const T& value):在 where 前复制插入 value
  7. iterator insert(const_iterator where, T&& value):在 where 前移动插入 value

3. 删除函数

  1. iterator erase(const_iterator where):删除 where 指向的元素,返回指向其后一个元素的迭代器。
  2. iterator erase(const_iterator first, const_iterator last):删除区间 [first, last),返回指向最后一个被删除元素之后位置的迭代器。
  3. void pop_back():删除容器中的最后一个元素。
  4. void clear():删除容器中的所有元素。

4. 遍历函数

  1. T& at(size_type pos):返回 pos 位置元素的引用;越界时抛出 std::out_of_range
  2. const T& at(size_type pos) constat() 的常量版本。
  3. T& front():返回首元素的引用。
  4. const T& front() constfront() 的常量版本。
  5. T& back():返回尾元素的引用。
  6. const T& back() constback() 的常量版本。
  7. iterator begin():返回指向容器第一个元素的迭代器。
  8. const_iterator begin() constbegin() 的常量版本。
  9. const_iterator cbegin() const:显式取得常量迭代器。
  10. std::vector<T>::iterator end():返回指向容器末尾位置的迭代器,即最后一个元素的下一个位置。

end()函数也有两个常量版本,和begin类似,就不写了。

  1. reverse_iterator rbegin():返回指向最后一个元素的反向迭代器。

同样有两个常量版本。

  1. reverse_iterator rend():返回指向第一个元素之前位置的反向迭代器。

同样有两个常量版本。

5. 判断函数

  1. bool empty() const:判断容器是否为空;为空返回 true,否则返回 false

6. 大小函数

  1. size_type size() const:返回当前容器中的元素个数。
  2. size_type capacity() const:返回无需重新分配内存即可容纳的元素数量。
  3. size_type max_size() const:返回实现和分配器所允许的理论最大元素数量。

7. 其它函数

  1. void swap(std::vector<T>& vec):交换两个同类型容器的数据。
  2. void assign(size_type count, const T& value):将容器内容替换为 countvalue
  3. void assign(iter first, iter last):将容器内容替换为区间 [first, last) 中的元素。

first,last都是迭代器,可以不是vector类型的迭代器,deque,list类型也可以。

  1. void assign(std::initializer_list<T> initList):将容器内容替换为初始化列表中的元素。
  2. void resize(size_t newSize):将容器的元素数量调整为 newSize。如需预留容量,应使用 reserve()

2. deque 的常用函数

deque的各种函数与vector类似,我就不再重复一遍了。

这里只介绍vector不同的地方:

(1) deque支持在容器前面插入删除,操作。也就是支持以下的三个函数

void push_front(const T& value);

void push_front(T&& value);

void pop_front();

3. list的各种函数

listdeque 类似,均支持在首尾增加、删除元素。

list 不支持随机访问,因此没有 operator[]at();此外,它还提供适合链表结构的 splicemerge 等操作。

4. string的各种函数

string虽然也是顺序容器,但因为本质是对字符串的封装,所以和其它容器在用法上有较大区别。

  1. 获取封装字符串的函数。

(1) const char* c_str() const:返回指向空字符结尾字符串的指针。 修改 string 的操作可能使此前取得的指针失效,因此不要长期保存该指针。

代码演示一下:

(2) data():返回指向连续字符存储区的指针。自 C++11 起,返回范围末尾保证有空字符;自 C++17 起,非 const string 还提供可写的 char* data() 重载。

(3) size_type copy(char* ptr, size_type count, size_type pos = 0) const

string 对象的一部分复制到 ptr 指向的字符数组中。copy() 不会自动在末尾添加 \0

ptr表示复制到哪个数组。

count表示复制string对象的几个字符

off表示从string的哪个字符开始复制。

  1. 字符串比较函数。

compare函数:这个函数重载比较多,用的时候在vs中查看一下就可以了。可以用string对象的任意部分与另一个字符串进行比较,

其它函数就和vector类似了,同样支持随机选取,支持容器末尾插入。

5. forward_list

和list差不多,只不过是没有size()函数,没有push_back和pop_back函数。

6. 关联容器的函数和顺序容器有差别的也就那么几个

在讲map,set时基本已经讲过了,这里就不单独讲了。