C++学习笔记
C++基础入门
1 C++初识
1.1 第一个C++程序
1 |
|
1.2 注释
1.3 变量
1.4 常量
1 |
|
1.5 关键字
1.6 标识符命名规则
- 不能是关键字;
- 只能由字母、数字、下划线组成;
- 第一个字符必须为字母或下划线;
- 区分大小写。
2 数据类型
2.1 整型
| 数据类型 | 占用空间 |
|---|---|
| short | 2 字节 |
| int | 4 字节 |
| long | Win 4 字节,Linux 4 字节(32 位)或 8 字节(64 位) |
| long long | 8 字节 |
2.2 sizeof()
作用:返回当前数据的类型长度(字节)。
2.3 浮点型
| 数据类型 | 占用空间 |
|---|---|
| float | 4 字节 |
| double | 8 字节 |
小数默认是双精度,可以在数字后面加上 f 转换为单精度。
科学计数法:3e-2 = 3 * 0.1 ^ 2。
注意:小数在输出时默认最多只有 6 位有效数字。
2.4 字符型
单引号括起来,只能有一个字符。
查看字符 ch 对应的 ASCII 编码值:(int) ch。
常用:a - 97、A - 65。
2.5 转义字符
\n:换行;\\:输出\;\t:水平制表符;- ……
2.6 字符串
1 | // 需要包含头文件:#include <string> |
2.7 布尔类型
占用空间:1 字节。
0 代表 false,除 0 以外的都代表 true。
注意:布尔类型本质是整型,只能依靠整数转换,当输入 0.1 时,被编译后会被直接去掉小数点,即 0.1 变 0,因此还是代表 false。
2.8 数据的输入
从键盘获取输入:cin >> 变量。
3 运算符
3.1 算术运算符
3.2 赋值运算符
3.3 比较运算符
3.4 逻辑运算符
4 程序流程结构
4.1 选择结构
4.1.1 if
4.1.2 三目运算符
4.1.3 switch
1 | switch(表达式) |
注意:
- switch 中的表达式只能是整型或字符型;
- 若 case 里没有写 break,则程序会一直向下执行。
4.2 循环结构
4.2.1 while
4.2.2 do while
4.2.3 for
4.3 跳转结构
4.3.1 break
4.3.2 continue
4.3.3 goto
5 数组
5.1 一维数组
5.2 二维数组

二维数组数组名:
- 查看该数组所占内存空间:元素类型大小 * 元素个数;
- 获取该数组首地址。
1 | // 行数 |
6 函数
6.1 定义
6.2 调用
6.3 值传递
6.4 常见样式
- 无参无返:
void function(){...}; - 无参有返;
- 有参无返;
- 有参有返。
6.5 声明
6.6 分文件编写
7 指针
7.1 基本概念
7.2 指针变量的定义和使用
1 | &:取地址 |
7.3 指针所占的内存空间
在 32 位操作系统下,指针变量占 4 个字节(大多数都是 32 位),64 位操作系统的指针变量的大小为 8 字节。
7.4 空指针和野指针
空指针:指向内存中编号为 0 的空间(不可以被访问),用于初始化指针变量。
1 | int * p = NULL; |
注意:0 - 255 之间的内存编号是被系统占用的,因此不可以访问。
野指针:指向非法内存空间的指针。
7.5 const 修饰指针
1 | // 常量指针 |
7.6 指针和数组
1 | int arr[10] = {...}; |
7.7 指针和函数
利用指针作为函数的参数,可以修改实参的值。
7.8 指针、数组、函数
数组在作为函数参数传入时,默认传入的是其首地址,不用再加 & 取地址符。
8 结构体
8.1 基本概念
结构体是用户自定义的数据类型。
8.2 定义和使用
1 | struct Student |
8.3 结构体数组
数组中存放的元素是结构体变量。
8.4 结构体指针
1 | struct student s = {...}; |
8.5 结构体嵌套结构体
8.6 结构体做函数参数
值传递和引用传递:将函数中的形参改为指针,可以减少内存空间(一个结构体变量可能很大,但指针只会占用 4 字节的空间),而且不会复制新的副本出来。
8.7 结构体中const的使用场景
1 | void printStudents(const student *s) |
C++核心编程
1 内存分区模型
- 代码区:存放函数体的二进制代码,由操作系统进行管理;
- 全局区:存放全局变量、静态变量以及常量;
- 栈:由编译器自动分配释放,存放函数的参数值、局部变量等;
- 堆:由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收。
分区的意义:不同区域存放的数据,赋予不同的生命周期,使编程更加灵活。
1.1 程序运行前
程序编译后,生成了 .exe 可执行文件,且还未执行该程序时,内存分为两个区域。
代码区:
- 存放 CPU 执行的机器指令(二进制的);
- 是共享、只读的。
全局区:
- 存放全局变量、静态变量;
- 还包含了常量区(字符串常量、其它常量(被 const 修饰的变量));
- 该区域的数据在程序结束后由操作系统释放。
1.2 程序运行后
栈区:
- 由编译器自动分配和释放,存放函数的参数值、局部变量等;
- 注意:不要返回局部变量的地址,栈开辟的数据由编译器自动释放;
- 局部变量存放在栈上,函数执行完后自动释放。
堆区:
- 由程序员分配和释放,若程序员不释放,程序结束时由操作系统回收;
- 用 new 关键字在堆中开辟内存。
1.3 new操作符
开辟内存:
new;释放内存:
delete。
1 | // 在堆区开辟数据 |
注意:创建的是什么类型的数据,就用什么类型的指针去接收,如:创建 int 类型的数或数组,就用 int 类型的指针去接收。
2 引用
2.1 基本使用
作用:给变量起别名;
语法:
数据类型 &别名 = 原名。
2.2 注意事项
- 引用必须要初始化;
- 引用一旦初始化后,就不可以再更改。
1 | int a = 10; |
2.3 引用做函数参数
函数传参时,可以使用引用让形参修饰实参,以简化使用指针修改实参的方式。
1 | void swap1(int a, int b) |
总结:通过引用参数产生的效果同按地址传递是一样的,但引用的语法更清楚简单。
2.4 引用做函数的返回值
- 不要返回局部变量的引用;
- 函数的调用可以作为左值。
1 | int& test1() |
2.5 引用的本质
引用在 C++ 内部的实现是一个指针常量。
1 | int &ref = a; // 相当于:int * const ref = &a; |
2.6 常量引用
作用:主要用来修饰形参,防止误操作。
在函数参数列表中,可以加 const 修饰形参,防止形参改变实参。
1 | int a = 10; |
3 函数高级
3.1 函数默认参数
函数的形参列表是可以有默认值的。
- 在函数的参数列表中,如果某个位置已经有了默认值,那么从这个位置往后,从左到右都必须有默认值;
- 如果一个函数在声明的时候有默认参数,实现的时候就不能再有默认参数了。声明和实现只能有一个有默认参数。
1 | int func(int a, int b = 20, int c = 30) |
3.2 函数占位参数
函数的形参列表里可以有占位参数,用来做占位,调用函数时必须填补该位置。
1 | void func(int a, int) |
注意:占位参数也可以有默认参数。
3.3 函数重载
满足条件:
- 同一个作用域下;
- 函数名称相同;
- 函数参数类型不同,或个数不同,或顺序不同。
注意事项:
- 引用作为函数重载条件;
- 函数重载碰到函数默认参数。
1 | // 引用作为函数重载条件 |
4 类和对象
4.1 封装
4.1.1 封装的意义
- 将属性和行为作为一个整体,表现事物;
- 把属性和行为放在不同的权限下,加以控制。
访问权限包括:public、protected、default、private。
4.1.2 struct和class的区别
默认的访问权限不同:
- struct:public;
- class:private。
4.1.3 成员属性设置为私有
可以自己控制读写权限。
4.2 对象的初始化和清理
4.2.1 构造函数和析构函数
如果我们自己不提供构造和析构函数,编译器会提供默认的(空实现)。
- 构造函数:在创建对象时为对象的成员属性赋值,由编译器自动调用;
- 析构函数:在对象销毁前系统自动调用,执行一些清理工作。
构造函数:
- 没有返回值,也不写 void;
- 名称与类名相同;
- 可以有参数,可以重载;
- 程序在创建对象时会自动调用构造,无须手动调用,而且只会调用一次。
析构函数:
- 没有返回值,也不写 void;
- 名称为
~类名; - 不可以有参数,不可以重载;
- 程序在对象销毁前会自动调用析构,无须手动调用,而且只会调用一次。
4.2.2 构造函数的分类及调用
- 按参数:有参构造、无参构造;
- 按类型:普通构造、拷贝构造。
1 | class Person |
注意:这里的 p1-p7 都是局部变量(是在栈上创建的),不是在堆上创建的(需要用 new),因此在 main() 函数运行结束后都会被释放,会依次调用它们的析构函数。
4.2.3 拷贝构造函数的调用时机
- 使用一个已经创建完毕的对象来初始化一个新对象;
- 值传递的方式给函数参数传值;
- 以值方式返回局部对象。
1 | class Person |
4.2.4 构造函数调用规则
默认情况下,C++ 的编译器会至少给一个类添加 3 个函数:
- 默认构造函数(无参,函数体为空);
- 默认析构函数(无参,函数体为空);
- 默认拷贝构造函数,对属性进行值拷贝。
构造函数调用规则如下:
- 如果用户定义有参构造函数,C++ 不再提供默认无参构造,但是会提供默认拷贝构造;
- 如果用户定义拷贝构造函数,C++ 不会再提供其它构造函数。
4.2.5 深拷贝与浅拷贝
- 浅拷贝:简单的赋值拷贝操作;
- 深拷贝:在堆区重新申请空间,进行拷贝操作。
1 | class Person |
如果利用编译器提供的拷贝构造函数,会做浅拷贝操作。
注意:对于栈上的对象,先进后出,先构造的会后析构。

解决:深拷贝,被拷贝的指针会指向堆区中新的内存,但内容与原来相同。
1 | class Person |
总结:如果属性有在堆区开辟的,一定要自己提供拷贝构造函数,防止浅拷贝带来的问题。
4.2.6 初始化列表
1 | class Person |
4.2.7 类对象作为类成员
- 当其它类的对象作为本类的成员时,构造的时候会先构造其它类的对象,再构造自身;
- 析构的顺序与构造相反。
1 | class Phone |
4.2.8 静态成员
带有 static 关键字的成员:
- 静态成员变量:
- 所有对象共享同一份数据;
- 在编译阶段分配内存;
- 类内声明,类外初始化(必须有一个初始值)。
- 静态成员函数:
- 所有对象共享同一个函数;
- 静态成员函数只能访问静态成员变量。
1 | class Person |
4.3 C++ 对象模型和 this 指针
4.3.1 成员变量和成员函数分开存储
- 只有非静态成员变量才属于类的对象上;
- 静态成员变量、静态/非静态成员函数都不属于类的对象上。
空对象占用的内存空间为:1 字节(是为了区分空对象占内存的位置,每个空对象应该有一个独一无二的内存地址)。
如果对象包含有非静态的成员变量,则不算占用的这 1 字节。
4.3.2 this 指针
this 指针指向被调用的成员函数所属的对象。
特点:
- this 指针隐含在每一个非静态的成员函数内;
- this 指针不需要定义,直接使用即可。
用途:
- 当形参和成员变量同名时,可以用 this 指针来区分;
- 在类的非静态成员函数中返回对象本身,可以
return *this。
1 | class Person |
4.3.3 空指针访问成员函数
C++ 中是可以使用空指针去调用成员函数的,但是也要注意有没有用到 this 指针(需要加以判断)。
1 | class Person |
错误原因:此时的 this 指针为 NULL,不能用
NULL->age去访问成员变量,因为根本就没有这个对象。
4.3.4 const 修饰成员函数
常函数:
- 成员函数加
const后,这个函数我们称为常函数; - 常函数内不可以修改成员属性;
- 成员属性声明时加关键字
mutable后,在常函数中依然可以修改。
常对象:
- 声明对象前加
const称该对象为常对象; - 常对象不能调用普通的成员方法、成员变量;
- 常对象只能调用常函数;
- 只能修改常对象的被
mutable修饰的成员变量。
4.4 友元
在程序里,类中某些私有的属性,想让类外特殊的一些函数或者类进行访问,就需要用到友元的技术。
友元的目的就是让一个函数或者类访问另一个类中的私有成员。
友元的关键字:friend。
友元的三种实现:
- 全局函数做友元;
- 类做友元;
- 类的成员函数做友元。
4.4.1 全局函数做友元
1 | class Building |
4.4.2 类做友元
1 | // 先声明Building类 |
4.4.3 类的成员函数做友元
1 | // 先声明Building类 |
注意:其实可以不用在友元成员函数所在的类里面创建对象,直接给友元成员函数传参会更好一点。
4.5 运算符重载
4.6 继承
4.6.1 基本语法
语法:class 子类 : 继承方式 父类。
示例:class A : public B。
- 子类也称为:派生类;
- 父类也称为:基类。
4.6.2 继承方式
- public 继承;
- protected 继承;
- private 继承。
4.6.3 继承中的对象模型
从父类继承过来的成员,哪些属于子类对象中?
- 父类中所有非静态的成员属性都会被子类继承下去;
- 父类的私有成员属性也会被子类继承,只不过是被编译器给隐藏起来了,无法被访问。
4.6.4 继承中构造和析构顺序
子类继承父类后,当创建子类对象,也会调用父类的构造函数。
执行顺序:
- 先构造父类,再构造子类;
- 析构的顺序与构造的顺序相反。
4.6.5 继承同名成员处理方式
当子类与父类出现同名的成员,如何通过子类对象,访问到子类或父类中同名的数据呢?
- 子类对象访问子类同名成员:直接访问即可(
子类对象.成员); - 子类对象访问父类同名成员:需要加作用域(
子类对象.父类::成员); - 当子类与父类拥有同名的成员函数,子类会隐藏父类中同名的成员函数,加作用域可以访问到父类中的同名函数。
4.6.6 继承同名静态成员处理方式
静态成员和非静态成员出现同名,处理方式一致。
- 子类对象访问子类同名成员:直接访问即可(
子类对象.成员); - 子类对象访问父类同名成员:需要加作用域(
子类对象.父类::成员)。
也可以直接通过类名来访问:
Son::成员变量:通过子类的类名访问子类的成员;Father::成员变量:通过父类的类名访问父类的成员;Son::Father::成员变量:通过子类的类名访问父类作用域下面的成员。
当子类与父类拥有同名的静态成员函数,子类也会隐藏父类中同名的静态成员函数,加作用域可以访问到父类中的同名静态函数。
4.6.7 多继承语法
语法:class 子类 : 继承方式 父类1,继承方式 父类2 ...。
多继承可能会引发父类中有同名成员出现,需要加作用域区分。
注意:实际开发中不建议使用多继承,容易造成歧义。
4.6.8 菱形继承
菱形继承(钻石继承):
- 两个派生类继承同一个基类;
- 又有某个类同时继承这两个派生类。
当两个父类拥有相同的数据,需要加作用域区分。这份数据其实在子类中只需要一份即可,菱形继承导致数据有两份,资源浪费。
解决方式:虚继承。
虚继承:
- 在继承方式之前,加上关键字
virtual,变为虚继承; - 最顶部的类叫做:虚基类。
1 | // 虚基类 |
4.7 多态
4.7.1 基本概念
多态的分类:
静态多态:
- 函数重载和运算符重载属于静态多态,复用函数名;
- 函数地址早绑定,编译阶段确定函数地址。
动态多态:
- 派生类和虚函数实现运行时多态;
- 函数地址晚绑定,运行阶段确定函数地址。
实现动态多态:
- 子类重写父类方法;
- 父类引用(指针)指向子类对象;
- 对父类中的方法加
virtual关键字(虚函数),实现晚绑定,执行子类的版本;否则为早绑定,只会执行父类的版本。
4.7.2 纯虚函数和抽象类
纯虚函数:virtual 返回值类型 函数名(参数列表) = 0;。
带有纯虚函数的类就是抽象类。
抽象类的特点:
- 无法实例化对象;
- 子类必须重写抽象类中的纯虚函数,否则也属于抽象类。
4.7.3 虚析构和纯虚析构
多态时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码。
1 | Animal *animal = new Cat(); |
原因:父类指针在析构时,不会调用子类中的析构函数,导致子类中如果有堆区属性,会出现内存泄露。
解决:
- 将父类中的析构函数改为虚析构或者纯虚析构;
- 虚析构和纯虚析构只能有一个。
虚析构和纯虚析构:
- 共性:
- 可以使用父类指针释放子类对象;
- 都需要有具体的函数实现。
- 区别:
- 如果是纯虚析构,该类属于抽象类,无法实例化对象。
1 | class Animal |
总结:
- 若既要使用纯虚函数,又要释放父类在堆区中的数据,就需要使用类内声明纯虚函数,类外写实现的写法;
- 如果子类中没有堆区数据,可以不写虚析构或纯虚析构。
5 文件操作
5.1 文本文件
5.1.1 写文件
5.1.2 读文件
5.2 二进制文件
C++提高编程
1 模板
1.1 模板的概念
C++ 的另一种编程思想被称为泛型编程,主要利用的技术就是模板。
C++ 提供两种模板机制:函数模板、类模板。
1.2 函数模板
1.2.1 函数模板语法
函数模板的作用:建立一个通用函数,其函数返回值类型和形参类型可以不具体指定,用一个虚拟的类型来代表。
语法:
1 | template<typename T> |
解释:
template:声明创建模板;typename:表明其后面的符号是一种数据类型,可以用class代替;T:通用的数据类型,名称可以替换,通常为大写字母。
1 | template<typename T> |
1.2.2 函数模板的注意事项
- 自动类型推导:必须推导出一致的数据类型 T,才可以使用;
- 模板必须要确定出 T 的数据类型,才可以使用;
- 创建模板时,
typename可以替换成class。
1.2.3 普通函数与函数模板的区别
- 普通函数调用时可以发生自动类型转换(隐式类型转换);
- 函数模板调用时,如果利用自动类型推导,不会发生隐式类型转换;
- 如果利用显示指定类型的方式,可以发生隐式类型转换。
普通函数:
1 | int myAdd(int a, int b) |
函数模板:
1 |
|
1.2.4 普通函数与函数模板的调用规则
- 如果函数模板和普通函数都可以实现,优先调用普通函数;
- 可以通过空模板参数列表来强制调用模板函数;
- 函数模板也可以发生重载;
- 如果函数模板可以产生更好的匹配,优先调用函数模板。
1 |
|
1.2.5 模板的局限性
模板的通用性并不是万能的,C++ 提供模板的重载,可以为一些特定的类型(如:数组、自定义数据类型等)提供具体化的模板。
学习模板并不是为了写模板,而是在 STL 能够运用系统提供的模板。
1 | // 比较两个Person对象是否相等 |
1.3 类模板
1.3.1 类模板语法
类模板的作用:建立一个通用类,类中的成员、数据类型可以不具体指定,用一个虚拟的类型来代表。
语法:
1 | template<typename T> |
示例:
1 | template<class NameType, class AgeType> |
1.3.2 类模板与函数模板的区别
- 类模板没有自动类型推导的使用方式;
- 类模板在模板参数列表中可以有默认参数。
1 | template<class NameType, class AgeType = int> |
1.3.3 类模板中成员函数的创建时机
与普通类中成员函数的区别:
- 普通类中的成员函数一开始就可以创建;
- 类模板中的成员函数在调用时才创建。
1.3.4 类模板对象做函数参数
三种传入方式:
- 指定传入的类型:直接显示对象的数据类型(最常用);
- 参数模板化:将对象中的参数变为模板进行传递;
- 整个类模板化:将这个对象类型模板化进行传递。
1 | template<class T1, class T2> |
1.3.5 类模板与继承
当类模板遇到继承时需要注意:
- 当子类继承的父类是一个类模板时,子类在声明的时候,要指定出父类中
T的类型; - 如果不指定,编译器无法给子类分配内存;
- 如果想灵活地指定出父类中
T的类型,子类也需要变为类模板。
1 | template<class T> |
1.3.6 类模板成员函数的类外实现
1 | template<class T1, class T2> |
1.3.7 类模板分文件编写
问题:类模板中成员函数的创建时机是在调用阶段,导致分文件编写时链接不到?
解决:
- 将包含
.h改为包含.cpp源文件; - 将声明和实现写到同一个文件中,并改名为
.hpp文件,hpp 是约定的名称,并不是强制。
1.3.8 类模板与友元
- 全局函数类内实现:直接在类内声明友元即可;
- 全局函数类外实现:需要提前让编译器知道全局函数的存在。
1 | // 提前让编译器知道Person类的存在 |
注意:
- 这里的
printPerson()函数不加friend时是一个成员函数(只要不加static就是成员函数),但加了friend之后就变成了一个全局函数;- 类的成员函数默认就可以调用自身的成员属性不需要友元,反之需要友元的都不是成员函数。
2 STL 初识
2.1 STL 的诞生
2.2 基本概念
STL:标准模板库,分为容器、算法、迭代器,容器和算法之间通过迭代器连接。
STL 几乎所有的代码都采用了模板类或者模板函数。
2.3 六大组件
- 容器:各种数据结构,如:vector、list、deque、set、map 等,用来存放数据;
- 算法:各种常用的算法,如:sort、find、copy、for_each 等;
- 迭代器:连接容器和算法;
- 仿函数:行为类似函数,可以作为算法的某种策略;
- 适配器:一种用来修饰容器、仿函数或迭代器接口的东西;
- 空间配置器:负责空间的配置与管理。
2.4 容器、算法、迭代器
算法通过迭代器访问容器中的数据;
可以将迭代器当作指针去使用。
2.5 容器、算法、迭代器初识
2.5.1 vector 存放内置数据类型
- 容器:
vector; - 算法:
for_each; - 迭代器:
vector<int>::iterator。
1 |
|
for_each 底层源码:

2.5.2 vector 存放自定义数据类型
1 | class Person |
总结:若有
vector<A>,则*it就相当于A。
2.5.3 vector 容器嵌套容器
1 | void test01() |
3 STL - 常用容器
3.1 string
3.1.1 基本概念
string 本质上是一个类,类的内部封装了 char*,用于管理这个字符串,是一个 char* 的容器。string 管理 char* 所分配的内存,不用担心复制越界和取值越界等,由类内部进行负责。
string 类成员方法:
- 查找:
find; - 拷贝:
copy; - 删除:
delete; - 替换:
replace; - 插入:
insert。
3.1.2 构造函数
1 | // 创建一个空字符串 |
3.1.3 赋值操作

示例:
1 | void test01() |
3.1.4 拼接操作
作用:在字符串的末尾追加字符串。

示例:
1 | void test01() |
3.1.5 查找和替换

示例:
1 | // 查找 |
3.1.6 比较操作
字符串之间按字符的 ASCII 码进行对比:
=返回 0;>返回 1;<返回 -1。
注意:
compare()主要是用于比较两个字符串是否相等,比较两个字符串大小的意义不大。

示例:
1 | void test01() |
3.1.7 字符存取
string 中单个字符存取方式有两种:

示例:
1 | void test01() |
3.1.8 插入和删除

示例:
1 | void test01() |
3.1.9 子串
从字符串中获取想要的子串。

示例:
1 | void test01() |
3.1.10 分割操作
1 |
|
注意:
string::npos是一个常数,用来表示不存在的位置(表示size_t的最大值)。npos可以表示string的结束位置,当find()函数在找不到指定值的情况下会返回string::npos。
3.2 vector
3.2.1 基本概念
vector:单端数组。
vector 与普通数组的区别:
- 普通数组是静态空间,大小固定;
vector可以动态扩展。
注意:「动态扩展」并不是在原空间之后续接新空间,而是找更大的内存空间,然后将原数组拷贝到新空间,最后释放原空间。
3.2.2 构造函数

示例:
1 | void test01() |
3.2.3 赋值操作

示例:
1 | void test01() |
3.2.4 容量和大小

3.2.5 插入和删除

示例:
1 | void test01() |
3.2.6 数据存取

示例:
1 | void test01() |
3.2.7 互换容器
实现两个容器内元素进行互换。
函数原型:swap(vec)。
示例:
1 | void test01() |
收缩内存空间的原理:拷贝构造 vector<int>(v) 会利用 v 当前的元素个数(即 size 的大小)初始化一个匿名对象(假设为 x)。匿名对象的内存使用完即被释放。
交换前:

交换后:

3.2.8 预留空间
预留空间的目的:减少 vector 在动态扩展容量时的扩展次数。
函数原型:reserve(int len)。
说明:使容器预留 len 个元素长度,预留位置不初始化,元素不可访问。
示例:
1 | void test01() |
3.3 deque
3.3.1 基本概念
deque:双端数组,可以对头部做插入和删除操作。
deque 与 vector 的区别:
vector对于头部的插入和删除效率低,数据量越大,效率越低;deque对于头部的插入和删除速度比vector快;vector访问元素时的速度比deque快。

底层原理:

3.3.2 构造函数

示例:
1 | // 给形参加上const(只读),可以防止修改容器中的内容 |
3.3.3 赋值操作

3.3.4 大小操作
deque 没有 capacity 的概念,它可以无限地存放数据。

3.3.5 插入和删除

示例:
1 | void printDeque(const deque<int>&d) |
3.3.6 数据存取

3.3.7 排序
算法:sort(iterator beg, iterator end);
注意:对于支持随机访问的迭代器的容器,都可以利用
sort算法直接对其进行排序。
示例:
1 |
|
3.4 stack
3.4.1 基本概念
3.4.2 常用接口

示例:
1 | void test01() |
3.5 queue
3.6.1 基本概念
3.6.2 常用接口

示例:
1 | class Person |
3.6 list
3.6.1 基本概念
节点的组成:
- 数据域:存储数据元素;
- 指针域:存储下一个节点的地址。
底层结构:

优点:
- 可以对任意位置进行快速插入或删除元素,只需要修改指针即可,不需要移动大量元素;
- 采用动态存储分配,不会造成内存浪费和溢出。
缺点:
- 遍历速度比数组慢;
- 占用空间比数组大。
3.6.2 构造函数

3.6.3 赋值和交换

3.6.4 大小操作

3.6.5 插入和删除

3.6.6 数据存取
list 不是用连续线性空间存储数据,相邻元素的地址可能不连续,因此它不支持随机访问,不支持用下标直接跳跃式的访问。

示例:
1 | void test01() |
3.6.7 反转和排序
所有不支持随机访问迭代器的容器,都不可以使用标准算法(全局函数)。但是,不支持随机访问迭代器的容器,内部会提供对应的一些算法(成员函数)。

示例:
1 | bool myCompare(int v1, int v2) |
3.6.8 排序案例
3.7 set
3.7.1 基本概念
有序:插入的元素会自动排序。
属于关联式容器,底层用二叉树实现。
set:不允许重复的元素(去重);multiset:允许重复的元素。
3.7.2 构造和赋值

示例:
1 | void test01() |
3.7.3 大小和交换

3.7.4 插入和删除
插入的时候不能指定位置;
可以直接根据元素的值删除数据。

3.7.5 查找和统计

示例:
1 | void test01() |
3.7.6 set 和 multiset 的区别

示例:
1 | void test01() |
3.7.7 pair 对组
对组:成对出现的数据。

示例:
1 | void test01() |
3.7.8 排序
set 默认是从小到大排序,改变排序规则需要用到仿函数(修改算法的内容)。
(1) 存放内置数据类型
1 | class MyCompare() |
注意:仿函数不是数据类型,其本质上是一个类,但是功能上却是方法。
(2) 存放自定义数据类型
对于自定义数据类型,都需要指定排序规则。
1 | class Person |
3.8 map
3.8.1 基本概念
map中所有的元素都是pair;pair中第一个元素为key,起到索引的作用,第二个元素为value;- 所有元素都会根据
key进行排序; map和multimap属于关联式容器,底层结构是二叉树;- 可以根据
key快速地找到value; map中不允许有重复的key,而multimap中可以有重复的key。
3.8.2 构造和赋值

示例:
1 | void printMap(map<int, int>&m) |
3.8.3 大小和交换
3.8.4 插入和删除

示例:
1 | void test01() |
3.8.5 查找和统计

示例:
1 | void test01() |
3.9.6 排序
示例:
1 | class MyCompare |
4 STL - 函数对象
4.1 函数对象
4.1.1 基本概念
- 重载「函数调用操作符」的类,其对象被称为「函数对象」;
- 函数对象使用重载的
()时,行为类似函数调用,也叫「仿函数」; - 函数对象(仿函数)是一个类,不是一个函数。
4.1.2 函数对象使用
- 函数对象在使用时,可以像普通函数那样调用,可以有参数,可以有返回值;
- 函数对象超出普通函数的概念,函数对象可以有自己的状态;
- 函数对象可以作为参数传递。
示例:
(1) 函数对象在使用时,可以像普通函数那样调用,可以有参数,可以有返回值
1 | class MyAdd |
(2) 函数对象超出普通函数的概念,函数对象可以有自己的状态
1 | class MyPrint |
(3) 函数对象可以作为参数传递
1 | class MyPrint |
4.2 谓词
4.2.1 基本概念
- 返回
bool类型的仿函数称为「谓词」; - 如果
operator()接受一个参数,那么叫做一元谓词; - 如果
operator()接受两个参数,那么叫做二元谓词。
4.2.2 一元谓词
1 | class GreaterFive |
4.2.3 二元谓词
1 | class MyCompare // 类似于java中的comparator比较器 |
4.3 内建函数对象
4.3.1 内建函数对象的意义
STL 内建了一些函数对象,可以分为:
- 算术仿函数;
- 关系仿函数;
- 逻辑仿函数。
使用内建函数对象,需要引入头文件:#include<functional>。
4.3.2 算术仿函数
功能描述:实现四则运算,其中 negate 是一元运算,其它都是二元运算。

示例:
1 |
|
4.3.3 关系仿函数
功能描述:实现比较运算。

示例:
1 |
|
4.3.4 逻辑仿函数
功能描述:实现逻辑运算。

示例:
1 | void test01() |
5 STL - 常用算法
主要头文件:
<algorithm>:是所有 STL 头文件中最大的一个,范围涉及到比较、交换、查找、遍历、复制、修改等;<numeric>:体积很小,只包括几个在序列上面进行简单数学运算的模板函数;<functional>:定义了一些模板类,用以声明函数对象(仿函数)。
5.1 常用遍历算法
5.1.1 for_each
功能描述:遍历容器。
函数原型:
for_each(iterator beg, iterator end, _func);- 其中
_func为函数或者函数对象。
示例:
1 | // 1.普通函数 |
5.1.2 transform
功能描述:将容器中的元素搬运到另一个容器中。
函数原型:
transform(iterator beg1, iterator end1, iterator beg2, _func);1为原容器,2为目标容器;_func为函数对象。
示例:
1 | class Transform |
5.2 常用查找算法

5.2.1 find
功能描述:查找指定元素,找到返回指定元素的迭代器,找不到则返回结束迭代器 end()。

示例:
1 |
|
5.2.2 find_if
功能描述:按条件查找元素。

示例:
1 | // 1.查找内置数据类型 |
5.2.3 adjacent_find
功能描述:查找相邻重复元素。

示例:
1 | void test01() |
5.2.4 binary_search
功能描述:查找指定元素是否存在。

注意:数据必须是有序序列,如果是无序的,则结果未知。
示例:
1 | void test01() |
5.2.5 count
功能描述:统计元素个数。

示例:
1 | // 1.统计内置数据类型 |
5.2.6 count_if
功能描述:按条件统计元素个数。

示例:
1 | // 1.统计内置数据类型 |
5.3 常用排序算法

5.3.1 sort
功能描述:排序。

示例:
1 |
|
5.3.2 random_shuffle
功能描述:指定范围内的元素随机调整次序。

示例:
1 |
|
5.3.3 merge
功能描述:将两个容器中的元素合并,并存储到另一个容器中。

示例:
1 | void test01() |
5.3.4 reverse
功能描述:反转元素顺序。

5.4 常用拷贝和替换算法

5.4.1 copy
功能描述:将容器内指定范围的元素拷贝到另一容器中。

示例:
1 | void test01() |
5.4.2 replace
功能描述:将容器内指定范围的旧元素修改为新元素。

示例:
1 | void test01() |
5.3.3 replace_if
功能描述:将区间内满足条件的元素,替换成指定元素。

示例:
1 | class Greater3 |
5.4.4 swap
功能描述:互换两个容器的元素。

示例:
1 | void test01() |
5.5 常用算术生成算法
算术生成算法属于小型算法,使用时包含的头文件为:#include<numeric>。

5.1.1 accumulate
功能描述:计算区间内的容器元素累计总和。

示例:
1 |
|
5.2.2 fill
功能描述:向容器中填充指定的元素。

示例:
1 |
|
5.6 常用集合算法



5.6.1 set_intersection
功能描述:求两个容器的交集。

示例:
1 |
|
5.6.2 set_union
功能描述:求两个集合的并集。

示例:
1 | void test01() |
5.6.3 set_difference
功能描述:求两个集合的差集。

示例:
1 |
|
Linux
1 Linux 基础
1.1 介绍
1.1.1 背景
1.1.2 名词解释
- GNU:Gnu’s Not Unix
- GPL:GNU 通用公共许可证,授予程序权利
- 自由软件:GNU 项目下的所有软件都基于 GPL,都是自由软件
- 开源软件:软件开放源代码
- POSIX:是针对 UNIX 操作系统制定的标准,对操作系统调用的服务接口进行了标准化,保证所编写的应用程序在源代码一级可以在多种操作系统上进行移植
1.1.3 发行版
- Debian(免费) -> Ubuntu(免费):
- 软件包管理器为
apt - 使用
dpkg进行软件的安装 - 不支持 Oracle 数据库的安装
- 软件包管理器为
- Redhat(收费) -> CentOS(免费):
- 软件包管理器为
rpm - 使用
yum进行软件的在线安装 - 使用
rpm进行软件的本地安装
- 软件包管理器为
- Fedora
- SUSE
版本号:主版本号(年份)+ 副版本号(月份)
- 主版本号:
- 偶数年:稳定版(长期支持)
- 奇数年:测试版
- 副版本号:
- 4 月份:稳定版
- 10 月份:测试版
1.1.4 内核
Linux 内核最主要的 5 个子系统:
- 进程调度(SCHED)
SCHED_OTHER:分时调度策略(默认),是针对普通进程的时间片轮转调度策略SCHED_FIFO:实时调度策略,是针对实时性要求比较高、运行时间短的进程调度策略SCHED_RR:实时调度策略,是针对实时性要求比较高、运行时间长的进程调度策略
- 内存管理(MMU)
- 多个进程间的内存共享策略(主要指虚拟内存)
- 虚拟内存可以让进程拥有比实际物理内存更大的内存
- 每个进程的虚拟内存有不同的地址空间,多个进程的虚拟内存不会冲突

- 虚拟文件系统(VFS)
- 目前 Linux 下最常用的文件系统:ext2、ext3、xfs
- 网络接口
- 网络协议:网络传输的通信标准
- 驱动程序:硬件设备的驱动程序
- 进程间通信
- 管道
- 信号量
- 消息队列
- 共享内存
- 套接字(本地、网络)
1.2 目录
1.2.1 目录结构

各目录含义:
/:根目录bin:二进制可执行文件,大部分的 Linux 命令都在该目录中sbin:即 super bin,是供 root 用户使用的命令dev:设备文件home:家目录,存放有不同的用户目录(~)lib/lib64:操作系统运行过程中需要加载的动态库和静态库mnt/media:挂载外设proc/run:操作系统相关tmp:临时文件,操作系统重启后,该目录中的文件就会被清除usr:UNIX 系统资源文件bin:二进制可执行文件games:游戏include:头文件lib/lib64:动态库和静态库文件local:安装第三方软件的目录sbin:供 root 用户使用的二进制可执行文件src:源文件
boot:开机启动项etc:操作系统和我们自己安装的软件的配置文件opt:安装第三方软件的目录var:系统中经常会发生变化的文件,如:日志文件lost+found:操作系统异常关机时,存放无家可归的临时文件,用于系统恢复
1.2.2 相对路径
含义:相对于当前文件的路径
.或./:当前目录..或../:上一级目录
1.2.3 绝对路径
含义:从系统磁盘起始节点(Linux 下为根目录 /)开始描述的路径
- 可以使用
pwd命令查看当前的绝对路径 - 可以使用
cd直接回到当前用户的家目录(~)
1.3 命令解析器
1.3.1 工作原理
命令解析器是 Linux 操作系统中的一个进程,叫做 bash,它是 shell(即:sh)的增强版本。
Linux 操作系统默认使用的命令解析器是 bash,当然也同样支持 sh。
环境变量 PATH 是 Linux 中的一个全局变量,可以使用 echo $PATH 输出环境变量的值,输出的结果是一长串字符串,包含多个不同的路径(如:/usr/bin),不同的路径之间用 : 进行分隔。
命令解析器在工作时会先去读取 PATH 中的路径,依次搜索不同路径中是否包含当前要执行的命令(可执行程序),然后调用(执行)对应的命令(可执行程序)。
1.3.2 命令提示行
格式:用户名@主机名:工作目录$ ...
$ 代表当前用户是普通用户,可以使用 su root 切换为 root 用户,这时 $ 会变为 #。
可以使用 whoami 命令查看当前登录的用户是谁。
1.3.3 命令行快捷键

2 文件管理命令
2.1 cd
2.2 ls
ls -a:显示隐藏文件ls -l:显示文件详细的属性信息,可以简写为llls -F:如果是目录就在末尾加一个/ls -h:以易读的方式(几 K、几 M)显示文件的大小
2.3 创建和删除目录
mkdir dirName:创建单层目录mkdir a b c:同时创建多个单层目录mkdir a/b/c -p:创建多层目录rmdir dirName:删除单目录(不能包含子目录)rm dirName -r:删除目录(递归)-r:递归删除,rm既可以删文件,又可以删目录(需要加-r)-i:删除时需要确认-f:强制删除,无需确认
tree:以树状结构查看目录的情况
2.4 cp
cp 源文件 目标文件:复制或覆盖文件cp a b/ -r:拷贝目录(递归),将 a 目录拷贝到 b 目录下cp a/* b/ -r:拷贝目录(递归),将 a 目录下的所有文件(*)拷贝到 b 目录下
2.5 mv
mv a b:将文件(或目录)a 移动到目录 b 中,其中目录 b 必须是存在的mv a b:将文件(或目录)a 改名为 b,其中 b 原来是不存在的mv a b:将文件 a 的内容覆盖文件 b 的内容,文件 a 被删除,只剩下文件 b,其中 a 和 b 都必须存在
2.6 查看文件内容
cat filaName:只适合用于看一些比较小的文件,如果文件太大就不能完全显示了more fileName:- 回车:显示下一行
- 空格:向下滚动一屏
- b:返回上一屏
- q:退出 more
less fileName:操作与more类似.head fileName:查看文件的前 10 行(默认)head -n fileName:显示文件的前 n 行
tail fileName:查看文件的后 10 行(默认)tail -n fileName:显示文件的后 n 行
2.7 链接的创建
2.7.1 软链接
软链接:相当于 Windows 中的快捷方式
语法:ln -s 源文件路径 软链接文件的名字(可以带路径)
- 源文件路径最好是写绝对路径
- 创建的链接文件后缀是
.lnk - 文件类型是
l
2.7.2 硬链接
硬链接:文件并不会在磁盘上发生拷贝,只是多出一个新的文件名并且硬链接计数会加 1
语法:ln 源文件路径 硬链接文件的名字(可以带路径)
2.8 文件属性
2.8.1 修改文件权限
语法:chmod xxx fileName
2.8.2 修改文件所有者
语法:sudo chown newOwner fileName
2.8.3 修改文件所属组
语法:sudo chgrp newGroup fileName
2.9 其它
2.9.1 tree
作用:以树状结构显示目录
默认是没有的,需要手动安装:
- Ubuntu:
sudo apt install tree- CentOS:
sudo yum install tree
示例:
tree [-L n]:查看当前目录,n 为显示的目录层数tree dirName [-L n]:查看指定目录,n 为显示的目录层数
2.9.2 pwd
作用:显示当前的工作目录
语法:pwd
2.9.3 touch
作用:创建一个新的空文件,如果指定的文件已经存在,则只会更新文件的修改日期
语法:touch fileName
2.9.4 which
作用:查看命令位于哪个文件夹
语法:which 命令
2.9.5 重定向
输出重定向:改变输出的数据的位置
语法:
>:将文件内容写入到指定文件中,如果文件中已有数据,则会使用新数据覆盖原数据>>:将输出的内容追加到指定的文件尾部
示例:
echo ... > tmp
echo ...:类似于printf,在终端输出 …
3 用户管理命令
3.1 切换用户
su usrName:只切换用户,当前的工作目录不变su - usrName:不仅会切换用户,也会切换工作目录为当前用户的家目录exit:切换回上一个用户
3.2 添加删除用户
3.2.1 添加新用户
普通用户没有添加/删除用户的权限,如果想要添加新用户可以先切换到 root 用户,或者给普通用户添加 sudo 权限。
sudo adduser usrName:添加用户sudo useradd usrName:添加用户(CentOS)sudo useradd -m -s /bin/bash usrName:添加用户(Ubuntu)-m:自动为该用户创建家目录-s /bin/bash:为该用户指定命令解析器
在使用 adduser 添加新用户的时候,有的 Linux 版本执行完成后就结束了,有的版本会提示设置密码等用户信息。
如何查看一个用户是否创建成功?
- 方法一:切换用户进行验证(
su - usrName) - 方法二:查看系统配置文件(
vim /etc/passwd),如果能看到对应的用户信息即说明该用户创建成功
/etc/passwd中的用户信息含义如下:用户名:密码:用户 id:用户所属组 id:,,,:用户家目录:用户使用的命令解析器
示例:
aaa:123:1000:1000:,,,:/home/aaa:/bin/bash
3.2.2 删除用户
删除用户并不是将 /home 下的用户家目录删除就完事儿了,我们需要使用 userdle 命令才能删除用户在系统中的用户 id 和所属组 id 等相关信息,但是需要注意的是在某些 Linux 版本中用户虽然被删除了,但是它的家目录却没有被删除,需要我们手动将其删除。
sudo userdel usrName -r:删除用户-r:删除用户的家目录
CentOS 只支持 userdel 命令,而在 Ubuntu 中既支持 userdel 也支持 deluser 命令
使用 deluser 命令不能添加参数 -r,家目录不能被删除,需要使用 rm 命令删除用户的家目录,比如:
sudo rm /home/usrName
3.3 添加删除用户组
默认情况下,只要创建新用户就会得到一个同名的用户组,并且这个用户属于这个组。一般情况下不需要创建新的用户组,如果有需求可以使用 groupadd 添加用户组,使用 groupdel 删除用户组。
由于普通用户没有添加删除用户组的权限,因此需要在管理员(root)用户下操作,或者在普通用户下借助管理员权限完成该操作。
sudo groupadd grpName:创建用户组sudo groupdel grpName:删除用户组
如何验证呢?
- 方法一:将某个文件的所属组切换为新建的组(
sudo chgrp newGroupName fileName) - 方法二:可以查看
/etc/group文件,里边有用户组相关的信息,示例如下:

在 Ubuntu 中添加删除用户组可以使用 addgroup/groupadd 和 delgroup/groupdel
在 CentOS 中添加和删除用户只能使用 groupadd 和 groupdel
可以使用
which 命令名查看当前 Linux 版本是否支持使用该命令
3.4 修改密码
Linux 中设置用户密码和修改用户密码的方式是一样的,修改用户密码又分为几种情况:
- 修改当前用户的密码:默认是有权限操作的
- 普通用户修改其它普通用户的密码:默认没有权限,需要借助管理员权限才能完成操作
- 普通用户修改 root 用户的密码:默认没有权限,需要借助管理员权限才能完成操作
- root 用户修改普通用户的密码:默认有权限,可以直接修改
修改密码需要使用 passwd 命令,当创建了一个普通用户却没有提示指定密码,或者忘记了用户密码都可以通过该命令来实现自己重置密码的需求。
passwd:修改当前用户的密码sudo passwd usrName:修改非当前用户的密码sudo passwd root:修改 root 用户的密码
如果让一个普通用户可以使用管理员权限执行一些指令其实是非常危险的,因此普通用户默认是没有使用 sudo 的权限的,必须授权才能使用,工作场景中授权操作一定要慎重。
4 压缩命令
4.1 tar
Linux 默认自带两个压缩工具 gzip 和 bzip2,但是它们的缺点是不能打包压缩文件,每个文件都会单独生成一个压缩包,并且压缩之后不会保留原文件。
Linux 自带一个打包工具 tar,默认情况下该工具是不能进行压缩操作的。但是,我们在使用 tar 进行压缩和解压的时候,只需要指定相对应的参数,其内部就会调用对应的压缩工具 gzip 或 bzip2 完成指定的操作。
4.1.1 压缩
tar 参数 生成的压缩包的名字 要压缩的文件1(或目录) 要压缩的文件2 ...:
-c:创建压缩文件-z:使用gzip的方式进行文件压缩-j:使用bzip2的方式进行文件压缩(与-z不能同时使用)-v:压缩过程中显示压缩信息,可以省略不写-f:指定压缩包的名字
关于生成的压缩包的名字,建议使用标准后缀,方便识别:
- 压缩使用 gzip 方式,标准后缀格式为:
.tar.gz- 压缩使用 bzip2 方式,标准后缀格式为:
.tar.bz2一般认为
.tgz文件就等同于.tar.gz文件,因为它们的压缩方式是相同的。
常用参数组合:tar -czvf ...
参数的顺序没有要求,可以随意指定顺序
4.1.2 解压缩
tar 参数 压缩包名 -c 解压目录:
-x:释放压缩文件内容-z:压缩包是使用gzip的方式进行文件压缩-j:压缩包是使用bzip2的方式进行文件压缩-v:解压缩过程中显示解压缩信息-f:指定压缩包的名字
以上参数是将压缩包解压到当前目录,如果需要解压到指定目录,需要指定参数
-C。(这里的 C 是大写)
常用参数组合:tar -xzvf ...
4.2 zip
zip 格式的压缩包在 Linux 中也是很常见的,在某些版本中需要安装才能使用。
Ubuntu:
- 压缩:
sudo apt install zip- 解压缩:
sudo apt install unzipCentOS:
因为 CentOS 可以使用 root 用户登录,基于 root 用户安装软件,因此不需要加 sudo
- 压缩:
sudo yum install zip- 解压缩:
sudo yum install unzip
4.2.1 压缩
使用 zip 压缩目录需要注意一点,必须添加参数 -r,这样才能将子目录中的文件一并压缩,如果要压缩的文件中没有目录,该参数就可以不写了。
语法:zip -r 压缩包名 要压缩的文件
使用
zip压缩文件,会自动生成文件后缀.zip,因此就不需要在压缩包名中额外指定.zip后缀了。
4.2.2 解压缩
如果压缩包中的文件要解压到指定目录需要指定参数 -d,默认是解压缩到当前目录中。
语法:unzip 压缩包名 -d 解压目录
4.3 rar
rar 是 Windows 系统常用的压缩格式,在 Linux 系统中并不常用,需要安装额外的工具。
Ubuntu:
sudo apt install rarCentOS:
需要下载安装包,官方地址:https://www.rarlab.com/download.html,从下载页面找到 Linux 版本的下载链接并复制链接地址,使用
wget下载到本地。
wget https://www.rarlab.com/rar/rarlinux-x64-6.0.0.tar.gz将下载得到的压缩包解压缩,得到解压目录 rar
tar zxvf rarlinux-x64-6.0.0.tar.gz将得到的解压目录移动到
/opt目录中(/opt是软件安装目录,移动是为了方便管理),需要使用 root 用户操作。
mv ./rar /opt给
/opt/rar目录中的可执行程序添加软链接,方便命令解析器可以找到该压缩命令。
ln -s /opt/rar/rar /usr/local/bin/rar
ln -s /opt/rar/unrar /usr/local/bin/unrar
4.3.1 压缩
语法:rar a 压缩包名 要压缩的文件 -r:
- 压缩过程中需要使用参数
a,即压缩归档的意思 - 如果压缩的是目录,需要指定参数
-r
rar 工具会自动添加后缀
.rar,因此我们只用指定压缩包的名字即可。
4.3.2 解压缩
语法:rar/unrar x 压缩包名字 解压目录
解压缩既可以使用
rar命令,也可以使用unrar命令。解压缩默认是解压到当前目录中。
4.4 xz
.xz 格式的文件压缩和解压缩相对比较麻烦,通过一个命令是完不成对应的操作的,需要通过两步操作才行。操作过程中需要使用 tar 工具进行打包,压缩使用的则是 xz 工具。
4.4.1 压缩
- 首先将需要压缩的文件打包:
tar cvf xxx.tar 要压缩的文件 - 再对打包文件进行压缩:
xz -z xxx.tar
使用 xz 工具压缩文件的时候需要添加参数
-z。
最后生成的文件格式为:xxx.tar.xz
4.4.2 解压缩
- 压缩包解压缩:
xz -d xxx.tar.xz - 将
xxx.tar中的文件释放到当前目录:tar xvf xxx.tar
使用
xz工具解压需要使用参数-d。如果解压出来的文件在当前目录中已存在,则会发生文件的覆盖。
5 查找命令
5.1 find
find 的主要功能是根据文件的属性,查找对应的磁盘文件,例如:文件名、文件类型、文件大小、文件的目录深度等,只需要指出属性对应的参数即可。
5.1.1 文件名
根据文件名进行搜索可以有两种方式:精确查询、模糊查询。
模糊查询需要使用对应的通配符:
*:用于匹配零个或多个字符?:用于匹配单个字符
进行模糊查询时,建议将带有通配符的文件名写到引号(单引号或双引号)中,可以防止在某些情况下命令会执行失败。
语法:find 搜索的路径 -name 要搜索的文件名
搜索是递归进行的,他还会搜索指定路径对应的子目录。
示例:
find /root -name "*.txt"
5.1.2 文件类型
语法:find 搜索的路径 -type 文件类型

5.1.3 文件大小
关于文件大小的单位有很多,常用的有:k(小写)、M(大写)、G(大写)。
在进行文件大小判断的时候,需要指定相应的范围,涉及的符号有两个分别为:+(加号)、-(减号)。
语法:find 搜索的路径 -size +/- 文件大小
文件大小需要加单位:
k、M、G。
文件大小的区间划分规则:
-size 4k:表示的区间为(3k, 4k]-size -4k:表示的区间为[0k, 3k]-size +4k:表示的区间为(4k, 正无穷)
5.1.4 目录层级
在搜索某些属性的时候可以指定只搜索某几层目录,相关的参数有:-maxdepth、-mindepth。
这两个参数不能单独使用,必须和其它属性一起使用,也就是搜索某几层目录中满足条件的文件。
语法:find / -maxdepth 5 -name "*.txt"
-maxdepth:最多搜索到第多少层目录-mindepth:至少从第多少层开始搜索
5.1.5 同时执行多个操作
5.1.5.1 exec
-exec 是 find 的参数,可以在 exec 参数后添加其它需要被执行的 shell 命令。命令的尾部需要加一个后缀 {} \;(注意 } 和 \ 之间有一个空格)。
在参数 -exec 后添加的 shell 命令处理的是 find 搜索之后的结果,find 的结果会作为新添加的 shell 命令的输入,最后在终端上输出最终的处理结果。
语法:find 路径 参数 参数值 -exec shell命令2 {} \;
示例:如何在当前目录搜索最多两层目录,且文件名后缀为
.txt的文件,搜索到之后再继续查看文件的详细属性信息?
find ./ -maxdepth 2 -name "*.txt" -exec ls -l {} \;
5.1.5.2 ok
-ok 的使用方式与 -exec 类似,但这个参数是交互式的,在处理 find 结果的时候,会向用户发起询问。
比如在删除搜索结果的时候,需要向用户发出询问。
语法:find 路径 参数 参数值 -ok shell命令2 {} \;
示例:如何找到指定文件,并在删除该文件时向用户进行确认?
find ./ -name "*.txt" -ok rm {} \;
5.1.5.3 xargs
使用 xargs 可以在 find 命令中直接使用管道完成前后命令的数据传递,而不用在指定子命令的尾部添加特殊字符 {} \;,并且在处理数据的时候 xargs 更加高效。
-exec:将 find 查询的结果逐条传递给后边的 shell 命令。
xargs:将 find 查询的结果一次性传递给后边的 shell 命令。
语法:find 路径 参数 参数值 | xargs shell命令2
5.2 grep
grep 命令用于查找文件里符合条件的字符串(搜索的是文件的内容)。
语法:grep "搜索的内容" 搜索的路径/文件 参数
-r:如果需要搜索目录中的文件内容,需要进行递归操作,必须指定该参数-i:对应要搜索的关键字,忽略字符大小写的差别-n:在显示符合样式的那一行之前,标示出该行的行号
要搜索的文件内容,建议放到引号中,因为关键字中可能有特殊字符或空格,从而导致解析错误。
引号使用单引号、双引号都可以。
5.3 locate
locate 是一个简化版的 find,使用这个命令我们可以根据文件名搜索本地的磁盘文件,但是 locate 的效率要比 find 高很多,因为它不搜索具体的目录,而是搜索一个本地的数据库文件,这个数据库中含有本地所有的文件信息。
Linux 系统自动创建这个数据库,并且每天自动更新一次,所以使用 locate 命令查不到最新变动过的文件。为了避免这种情况,可以在使用 locate 之前,先使用 update 命令,手动更新数据库。
updatedb:更新本地数据库文件(非 root 用户需要加 sudo)
语法:
locate 关键字:搜索所有目录下以某个关键字开头的文件locate /home/xxx/关键字:搜索指定目录(必须使用绝对路径)下以某个关键字开头的文件locate 关键字 -i:搜索文件的时候,忽略文件名的大小写locate 关键字 -n 5:只列出前 5 个匹配到的文件名称或路径名称- 基于正则表达式进行文件名匹配,使用参数
-r

6 vim
6.1 vim 的安装
Linux 系统里默认是没有 vim 的,只有一个类似的文本编辑器叫做 vi,但是它的功能不是很强,vim 可以看做是 vi 的增强版。
vim 的安装:
- Ubuntu:
sudo apt install vim - CentOS:
sudo yum install vim
vim 安装完成之后,可以查看一下版本:vim --version
vim 还提供了使用文档,可以直接在终端执行:vimtutor
6.2 vim 的模式
在 vim 中一共有三种模式(打开后默认进入的是命令模式):
- **命令模式:**可以查看文件内容、修改文件、搜索关键字
- **编辑模式:**可以对文件内容进行编辑
- **末行模式:**可以执行 Linux 命令、保存文件、行的跳转、窗口分屏
编辑模式和末行模式之间是不能相互直接切换的。
6.3 命令模式下的操作
通过 vim 打开一个文件,如果文件不存在,并且在退出的时候进行了保存,那么该文件就会被创建出来。
1 | 打开一个文件 |
6.3.1 保存退出
需要先按住 shift 键,然后连续按两次 Z(大写)。
可以通过 shift 键输出大写字母。
1 | ZZ |
6.3.2 代码格式化
1 | gg=G |
6.3.3 光标移动
方式一:使用键盘上的方向键来移动光标
方式二:使用键盘上的字母键来移动光标
h:左j:下k:上l:右
方式三:使用快捷键实现光标的快速跳转

6.3.4 删除命令
在 vim 中所谓的删除就是剪切。

6.3.5 撤销和反撤销
u:撤销ctrl+r:反撤销
对应 Windows 系统中的
ctrl+z、ctrl+y。
6.3.6 复制和粘贴

6.3.7 可视模式
在编辑文件的过程中,有时候需要删除或者需要复制的数据不是整行的,而是一行中的某一部分,这时候可以使用可视模式进行文本的选择,之后再通过相关的快捷键对选中的数据块进行复制或粘贴操作。
有三种方式可以切换到可视模式:
v:字符可视化模式,文本选择是以字符为单位的V:行可视化模式,文本选择是以行为单位的ctrl-V:块可视化模式,可以选择一个矩形内的文本
进入可视模式之后,就可以进行文本块的选择、复制和删除了。

6.3.7.1 字符可视模式
6.3.7.2 行可视模式
6.3.7.3 块可视模式
6.3.7.4 代码注释

6.3.8 替换
命令模式下的替换功能并不强,常用于某个单字符的替换,当需要大规模的修改文件内容时,应切换到编辑模式再进行操作。

6.3.9 查找
在 vim 的命令模式下一共有三种查找方式,首先需要在键盘上输入对应的字符,然后按回车键,vim 就会进行关键字匹配,之后就可以通过 n 或者 N 进行关键字之间的切换了。

6.3.10 查看 man 文档
1 | 打开 man 文档首页 |
man 文档各章节内容:

示例:
在第一章中搜索与 cp 相关的内容:man 1 cp
查询的时候章节号是可以省略的,只是查到的结果不精确。如果不写章节号,则会从第一章开始搜索查询的关键字,如果查询到了,直接就结束了。也就是说,如果查询的是函数,但是这个函数和某个命令的名字是相同的,查询到第一章搜索就结束了。
在 vim 的命令模式下,我们可以直接跳转到 man 文档:
- 找到要查看的函数,然后将光标放到该函数上
- 在键盘上依次输入:
章节号(可选) + K(大写),就会自动跳转到 man 文档中了
6.3.11 切换到编辑模式

编辑模式 -> 命令模式:按 Esc 键
6.4 末行模式下的操作
6.4.1 切换模式
命令模式 -> 末行模式:输入一个 :
末行模式 -> 命令模式:
- 方式一:按两次
Esc键 - 方式二:在末行模式下执行一个完整指令,执行完毕后,自动回到命令模式
6.4.2 保存退出

6.4.3 替换
替换操作对应的命令是 s,并且可以为其指定参数,默认情况下只替换相关行的第一个满足条件的关键字,如果需要整行替换需要加参数 /g。

示例:
将当前文件中所有的 Jack 替换为 Tom::%s/Jack/Tom/g
将当前文件中第 6-10 行所有的 Jack 替换为 Tom::6,10s/Jack/Tom/g
6.4.4 分屏

除了在命令模式下分屏,我们也可以在使用 vim 打开文件的时候直接分屏:
-o:水平分屏-O:垂直分屏
6.4.5 行跳转
1 | :行号 |
6.4.6 执行 shell 命令
1 | :!shell命令 |
6.5 vim 配置文件
vim 配置文件的名字叫做 vimrc,可以设置样式、功能、快捷键等属性,可以分为用户级别和系统级别。
- 用户级别的配置文件:
~/.vimrc,只对当前用户有效 - 系统级别的配置文件:
/etc/vim/vimrc,对所有 Linux 用户都有效
如果两个配置文件都设置了,用户级别的配置文件起作用(用户级别优先级更高)
7 gcc
7.1 安装 GCC
GCC 是 Linux 下的编译工具,是 GNU Compiler Collection 的缩写,包含 gcc、g++ 等编译器,这个工具集不仅包含编译器,还包含其它工具类,例如 ar、nm 等。
GCC 工具集不仅能编译 C/C++ 语言,其它如 Java 等语言均能进行编译,GCC 可以根据不同的硬件平台进行编译,即能进行交叉编译(在 A 平台上编译 B 平台的程序),支持 Linux、Windows 等软件平台。
有些纯净版的 Linux 没有 gcc 编译器,需要自己安装。
在线安装步骤如下:
- Ubuntu:
1 | sudo apt update |
- CentOS:
1 | sudo yum update |
sudo:安装软件必须要有管理员权限
update:更新本地的软件下载列表,得到最新的下载地址
gcc 安装完成之后,可以查看版本:
- 方式一:
gcc -v - 方式二:
gcc --version(信息更少)
g++ 同理。
7.2 gcc 工作流程
GCC 编译器对程序的编译可以分为下面 4 个阶段:
- 预处理/预编译:这个阶段需要 GCC 调用预处理器来完成,最终得到的还是源文件(文本格式),在这个阶段主要做了 3 件事:展开头文件、宏替换、去掉注释行
- 编译和优化:这个阶段需要 GCC 调用编译器对文件进行汇编,最终得到一个汇编文件
- 汇编:这个阶段需要 GCC 调用汇编器对文件进行编译,最终得到一个二进制文件
- 链接:这个阶段需要 GCC 调用链接器对程序需要调用的库进行链接,最终得到一个可执行的二进制文件

编译
.c文件使用 gcc 编译器,编译.cpp文件使用 g++ 编译器。
示例:
1 | 方式一:分步操作 |
-o:用于指定要生成的文件名
7.3 gcc 参数
下面的参数在 gcc 命令中没有位置(顺序)的要求,只需要指定出来即可。
-E:预处理-S:编译-c:汇编file1 -o file2或-o file2 file1:file1 -> file2-I:指定 include 包含文件的搜索目录-g:在编译的时候,生成调试信息,该程序可以被调试器调试(gdb)-D:在编译的时候,指定一个宏-w:不生成任何警告信息(不建议使用,有时候警告就是错误)-Wall:生成所有警告信息-On:n 的取值为 0-3,对应编译器优化的 4 个级别-O0:没有优化-O1:缺省值-O3:最高级别
-l:在编译的时候,指定使用的库-L:在编译的时候,指定搜索的库的路径-fPIC/fpic:生成与位置无关的代码-shared:生成共享目标文件(通常用在建立共享库时)-std:指定 C 方言(标准),如-std=c99,gcc 默认的方言是 GNU C
7.3.1 指定生成的文件名
7.3.2 搜索头文件
当我们在程序中包含了一些头文件,但是其中一些头文件在程序预处理的时候因为找不到无法被展开,导致程序编译失败,这时候我们就可以在 gcc 命令中添加 -I 参数重新指定要引用的头文件路径,保证编译顺利完成。
示例:
1 | 展示文件结构 |
7.3.3 指定一个宏
在程序中我们可以使用宏定义一个常量,也可以通过控制某段代码是否能够被执行。
在下面的程序中第 8 行判断是否定义了一个叫做 DEBUG 的宏,如果没有定义,则第 9 行代码就不会被执行了,通过阅读代码能够知道这个宏是没有在程序中定义的。

在编译的时候,指定一个宏 DEBUG,从而执行对应的代码:
1 | gcc test.c -D DEBUG |
应用场景:可以用于控制日志是否输出,而不用删除对应的日志代码。
7.4 多文件编译
7.4.1 准备工作
7.4.2 编译运行
7.5 gcc 与 g++ 的区别
7.5.1 代码编译阶段(第二个阶段)
- 后缀为
.c的文件,gcc 会把它当作是 C 程序,而 g++ 会把它当作是 C++ 程序 - 后缀为
.cpp的文件,两者都会认为是 C++ 程序(C++ 的语法规则更加严谨) - 在编译阶段,两者都是使用的 gcc 完成工作(g++ 会在底层调用 gcc),对于 C++ 代码,两者是等价的,即 gcc 和 g++ 都可以编译 C/C++ 代码
7.5.2 链接阶段(最后一个阶段)
- gcc 和 g++ 都可以自动链接到标准 C 库
- g++ 可以自动链接到标准 C++ 库,gcc 如果要链接到标准 C++ 库还需要指定
-lstdc++(如:gcc xxx.cpp -lstdc++)
在链接阶段,C 程序需要链接到标准 C 库,C++ 程序需要链接到标准 C++ 库,这两个库都是 Linux 系统中的一个动态库。
-l用于指定要链接的动态库的名字。
7.5.3 关于 __cplusplus 宏的定义
- g++ 会自动定义
__cplusplus宏,但是这个不影响它去编译 C 程序 - gcc 需要根据文件后缀判断是否需要定义
__cplusplus宏
7.5.4 总结
- 不管是 gcc 还是 g++ 都可以编译 C 程序,编译程序的规则和参数都相同
- g++ 可以直接编译 C++ 程序,gcc 编译 C++ 程序需要添加额外参数
-lstdc++ - 不管是 gcc 还是 g++ 都可以定义
__cplusplus宏
8 静态库和动态库
不管是 Linux 还是 Windows 中的库文件其本质和工作模式都是相同的,只不过在不同的平台上库对应的文件格式和文件后缀不同。
程序中调用的库可以分为:静态库、动态库。不管是哪种库文件,其本质上都是源文件,只不过是二进制格式(只有计算机能够识别)。
使用库的目的:
- 使程序更加简洁,不需要在项目中维护太多的源文件
- 源代码保密
当我们拿到了库文件之后,要想使用还必须要有这些库中提供的 API 函数的声明,也就是头文件,并将其添加到项目中。
8.1 静态库
在 Linux 中静态库由程序
ar生成,现在静态库已经不像之前那么普遍了,这主要是由于程序大多都在使用动态库。
- 在 Linux 中静态库以
lib作为前缀,以.a作为后缀,中间是库的名字,如:libxxx.a - 在 Windows 中静态库以
lib作为前缀,以.lib作为后缀,中间是库的名字,如:libxxx.lib
8.1.1 生成静态链接库
生成静态库,需要先对源文件进行汇编(-c)得到二进制格式的目标文件(.o),然后再通过 ar 工具将目标文件打包就可以得到静态库文件了。

制作静态库时,不需要对
.o文件进行链接。
使用 ar 工具创建静态库的时候需要 3 个参数:
c:创建一个库(不管库是否存在,都将创建)s:创建目标文件索引,这在创建较大的库时能加快时间r:在库中插入模块(替换),默认新的成员添加在库的结尾处,如果模块名已经在库中存在,则替换同名的模块
生成静态链接库的具体步骤如下:
1 | 1.汇编源文件,得到 .o 文件(参数 c 的位置没有要求) |
发布静态库:
- 提供制作出来的静态库
libxxx.a - 提供头文件
xxx.h(声明)
8.1.2 静态库制作举例
- 汇编源文件
- 打包为静态库
- 发布静态库,这里需要提供的是
libcalc.a和head.h文件

main.c:
1 |
|
head.h:
1 |
|
8.1.3 静态库的使用
错误用法:

**undefined reference(未定义的引用):**在项目中能找到对应函数的声明(即能找到对应的头文件),但是却找不到这些函数的定义。
正确用法:需要指定要使用的库及其所在的文件路径。

最后生成了可执行程序 calc。
-l:指定要使用的库。(库的名字需要去掉前缀lib和后缀.a,如:libxxx.a->xxx)-L:指定要搜索的库的路径。(可以是相对路径,也可以是绝对路径)
8.2 动态库
动态链接库是程序运行时加载的库,当动态链接库正确部署之后,运行的多个程序可以使用同一个加载到内存中的动态库,因此在 Linux 中动态链接库也可以称之为共享库。
动态链接库是目标文件的集合,目标文件在动态链接库中的组织方式是按照特殊方式形成的。库中函数和变量的地址使用的是相对地址(静态库中使用的是绝对地址),其真实地址是在应用程序加载动态库时形成的。
动态库是具有可执行权限的,而静态库没有。
动态库的命名规则如下:
- 在 Linux 中动态库以
lib作为前缀,以.so作为后缀,中间是库的名字,如:libxxx.so - 在 Windows 中动态库以
lib作为前缀,以dll作为后缀,中间是库的名字,如:libxxx.dll
8.2.1 生成动态链接库

gcc 参数:
-fPIC或-fpic:使 gcc 生成的代码是与位置无关的,即使用的是相对位置-shared:告诉编译器生成一个动态链接库
静态库代码对应的地址都是绝对地址。
制作步骤:
1 | 得到一些 .o 文件 |
发布:
- 提供动态库:
libxxx.so - 提供头文件:
xxx.h
8.2.2 动态库制作举例

需要指定头文件搜索路径:

打包为共享库:

libcalc.so 的文件权限为 -rwxrwxr-x,即所有用户都具有该动态库的执行权限。
8.2.3 动态库的使用
发布:

编译测试程序:需要指定库的文件路径和名字

8.2.4 解决动态库无法加载的问题


8.2.4.1 库的工作原理
静态库的加载:
- 在程序编译的最后一个阶段(链接阶段),提供的静态库会被打包到可执行程序中。当可执行程序被执行,静态库中的代码也会一并被加载到内存中,因此不会出现静态库找不到无法被加载的问题。
过程:*.o -> xxx.a 和 main.c -> app(包括了静态库的代码)
动态库的加载:
- 在程序编译的最后一个阶段(链接阶段),在 gcc 命令中虽然指定了库路径(
-L),但是这个路径并没有记录到可执行程序(app)中,只是检查了这个路径下的库文件是否存在。对应的动态库文件没有被打包到可执行程序中,只是在可执行程序中记录了库的名字。 - 可执行程序被执行后,会先检查需要的动态库是否可以被加载,加载不到就会提示上边的错误信息。当动态库中的函数在程序中被调用了,这个时候动态库才加载到内存,如果不被调用就不加载。动态库的检测和内存加载操作都是由动态链接器来完成的。
过程:*.o -> xxx.so 和 main.c -> app(不包括动态库的代码,只记录了动态库的名字)
8.2.4.2 动态链接器
动态链接器是一个独立于应用程序的进程,属于操作系统,当用户的程序需要加载动态库的时候,动态链接器就开始工作了。动态链接器并不知道用户通过 gcc 编译程序的时候通过参数 -L 指定的路径。
动态链接器在搜索某一个动态库时,其内部有一个默认的搜索顺序,按照优先级从高到低的顺序分别是:
- 可执行文件内部的
DT_RPATH段(无法修改) - 系统的环境变量
LD_LIBRARY_PATH - 系统动态库的缓存文件
/etc/ld.so.cache - 存储动态库/静态库的系统目录
/lib/、/usr/lib等
按照上面的顺序,依次搜索,找到之后结束遍历,最终还是没找到,动态链接器就会提示动态库找不到的错误信息。
检测能否加载到对应的动态库:

8.2.4.3 解决方案
我们只需要将动态库的路径放到对应的环境变量或者系统配置文件中,也可以将动态库拷贝到系统库目录(或者是将动态库的软链接文件放到这些系统库目录中)。
方法一:
将库路径添加到系统的环境变量 LD_LIBRARY_PATH 中。
1 | 打印系统的环境变量的值 |
$代表取值。如果不加$,打印的就是环境变量的名字,而不是其对应的值。环境变量中的不同路径之间使用
:进行间隔。
找到相关的配置文件:
- 用户级别:
~/.bashrc(设置对当前用户有效) - 系统级别:
/etc/profile(设置对当前系统有效)
使用 vim 打开配置文件,在文件最后添加:
1 | export LIBRARY_PATH=$LIBRARY_PATH:动态库的绝对路径 |
让修改的配置文件生效:
- 修改了用户级别的配置文件:关闭当前终端,打开一个新的终端配置就生效了
- 修改了系统级别的配置文件:注销或关闭系统,再开机配置就生效了
- 不进行上面的操作,也可以执行
source命令重新加载配置文件
1 | source ~/.bashrc 或 . ~/.bashrc |
前面的
.代表source,即重新加载配置文件。
方法二:
更新 /etc/ld.so.cache 文件。
找到动态库所在的绝对路径(不包括库的名字,如:/home/robin/Library)。
使用 vim 修改 /etc/ld.so.conf 这个文件,将上面的路径添加到文件中(独占一行)。
1 | 1.打开文件 |
etc 目录属于 root 用户,普通用户要想修改其中的文件,必须加上 sudo 权限。
更新 /etc/ld.so.conf 中的数据到 /etc/ld.so.cache 中。
1 | 必须使用管理员权限来执行这个命令 |
方法三:
拷贝动态库文件(或库的软链接文件)到系统库目录 /lib/ 或 /usr/lib 中。
1 | 库拷贝 |
8.3 总结
8.3.1 静态库
优点:
- 静态库被打包到应用程序中,加载速度快
- 发布程序无需提供静态库,移植方便
缺点:
- 相同的库文件数据可能在内存中被加载多份,消耗系统资源,浪费内存
- 库文件更新需要重新编译项目文件,生成新的可执行程序,浪费时间

8.3.2 动态库
优点:
- 可实现不同进程间的资源共享
- 动态库升级简单,只需替换库文件,无需重新编译应用程序
- 程序员可以控制何时加载动态库,若不调用库函数则动态库不会被加载
缺点:
- 加载速度比静态库慢(以现在计算机的性能可以忽略)
- 发布程序需要提供依赖的库文件

每一个进程都有一片属于自己的虚拟地址空间,其中就专门有一个动态库加载区。当调用动态库中的函数时,当前进程会将动态库加载到内存,并加载到自己进程的动态库加载区中。因此,不同进程间共享内存中的同一份共享库,不会出现冲突。
当库的源文件比较少时,可以制作为静态库。反之,可以制作为动态库。
库的加载速度的差别可以忽略不计。
9 makefile
9.1 基本介绍
使用 gcc 的命令进行程序编译在单个文件下是比较方便的,当工程中的文件逐渐增多时,就会变得比较麻烦,这种情况下就需要使用 make 工具来帮助我们完成项目的构建。
make 是一个命令工具,是一个解释 makefile 中指令的工具。make 在构建项目时,需要加载 makefile 文件,它关系到了整个工程的编译规则。makefile 带来的好处就是自动化编译,一旦写好,只需要一个 make 命令,整个工程就可以自动完成编译,极大地提高了软件开发的效率。
makefile 的命名方式有两种:makefile 和 Makefile。
构建项目的时候,在哪个目录下执行的 make,就会加载该目录下的 makefile,因此在一个项目中可以有多个 makefile 文件,分别位于不同的项目目录中。
示例:
1 | ls |
9.2 规则
makefile 的框架是由规则构成的,make 命令执行时会先在 makefile 文件中查找各种规则,对各种规则进行解析后运行规则。
规则的基本格式为:
1 | target1,target2,...:depend1 depend2 ... |
每条规则由三个部分组成:
- command(命令)
- 当前这条规则的动作,一般情况下这个动作就是一个 shell 命令
- 例如:通过某个命令编译文件、生成库文件、进入目录等
- 动作可以是多个,每个命令前必须有一个 Tab 缩进并且独占一行
- depend(依赖)
- 规则所必需的依赖条件,在规则的命令中可以使用这些依赖
- 例如:生成可执行文件的目标文件(
*.o)可以作为依赖使用 - 如果规则的命令中不需要任何依赖,那么规则的依赖可以为空
- 当前规则中的依赖可以是其它规则中的某个目标,这样就形成了规则之间的嵌套
- 依赖可以根据要执行的命令的实际需求,指定很多个
- target(目标)
- 规则中的目标,这个目标和规则中的命令是对应的
- 通过执行规则中的命令,可以生成一个和目标同名的文件
- 规则中可以有多个命令,因此可以通过这多条命令来生成多个目标,所以目标也可以有很多个
示例 1:将多个源文件生成可执行程序
1 | app:a.c b.c c.c |
示例 2:有多个目标,多个依赖,多个命令
1 | app,app1:a.c b.c c.c d.c |
在一条规则中,目标和依赖可以为空(不存在)。
9.3 工作原理
9.3.1 规则的执行
在调用 make 命令的时候,make 会首先找到 makefile 文件中的第一条规则,分析并执行相关的动作。但是需要注意的是,好多时候要执行的动作(命令)中使用的依赖是不存在的,如果依赖不存在,这个动作也就不会被执行。
如何解决?
我们需要先把所需的依赖生成出来。我们可以在 makefile 中添加新的规则,将不存在的依赖作为这个新规则中的目标。
这样,makefile 中的某一条规则在需要的时候,就会被其它规则调用,直到 makefile 中的第一条规则中的所有依赖都被生成了,make 就可以基于这些依赖生成对应的目标。
示例:
1 | # 第一条规则中的依赖都不存在,因此该规则的命令不会被执行,make 会继续查找其它的规则,看哪一条规则是用来生成当前规则所需的依赖的 |
9.3.2 文件的时间戳
make 命令执行的时候会根据文件的时间戳判定是否执行 makefile 文件中相关规则的命令。
- 目标是通过依赖生成的,因此一般情况下:目标的时间戳 > 依赖的时间戳。如果执行 make 命令的时候检测到规则中的目标和依赖满足这个条件,那么规则中的命令就不会被执行。
- 当依赖文件被更新了,其文件时间戳也会随之被更新,这时候就有:目标的时间戳 < 依赖的时间戳。在这种情况下,目标文件会通过规则中的命令被重新生成。
- 如果规则中的目标对应的文件根本就不存在,那么规则中的命令肯定会被执行。
示例:
1 | # 规则一 |
- 执行 make 命令
- 修改
a.c文件 - 再次执行 make 命令:此时会先执行规则二更新
a.o,然后执行规则一更新app,其余规则不会被执行
9.3.3 自动推导
make 具有自动推导的能力,不会完全依赖 makefile。
比如,使用 make 编译扩展名为 .c 的 C 语言文件时,源文件的编译规则不用明确写出。这是因为 make 进行编译的时候会使用一个默认的编译规则,按照默认规则完成对 .c 文件的编译,生成对应的 .o 文件。他使用命令 cc -c 来编译 .c 源文件。在 makefile 中只要给出需要构建的目标文件名(一个 .o 文件),make 会自动为这个 .o 文件寻找合适的依赖文件(对应的 .c 文件),并且使用默认的命令来构建这个目标文件。
示例:
本地源文件:add.c、div.c、head.h、main.c、makefile、mult.c、sub.c
makefile 1:
1 | calc:add.c div.c main.c mult.c sub.c |
缺点:每当我们修改了其中的一个源文件后,都需要重新编译所有的源文件,非常耗时,不够灵活。
makefile 2:
1 | calc:add.o div.o main.o mult.o sub.o |
缺点:下面需要补充很多的内容,当文件很多时写起来非常麻烦。
makefile 3:
依赖中所有的 .o 文件都不存在,并且也没有指定其它的规则,此时 make 将自动匹配当前目录下对应的 .c 文件。
1 | calc:add.o div.o main.o mult.o sub.o |
9.4 变量
9.4.1 自定义变量
用户可以定义自己的变量,makefile 中的变量是没有类型的,直接创建变量然后给其赋值就可以了。
语法:
1 | # 创建一个变量并给其赋值(不能不赋值) |
示例:
1 | obj=add.o div.o main.o mult.o sub.o |
9.4.2 预定义变量
在 makefile 中有一些已经定义的变量,用户可以直接使用这些变量,不用进行定义。在进行编译的时候,某些条件下 makefile 会使用这些预定义变量的值进行编译。
这些预定义变量的名字一般都是大写的,经常采用的预定义变量如下表所示:

示例:
1 | obj=add.o div.o main.o mult.o sub.o |
9.4.3 自动变量
自动变量可以用来代表规则中的目标文件和依赖文件,并且它们只能在规则的命令中使用。
下表中是一些常见的自动变量:

示例:
1 | calc:add.o div.o main.o mult.o sub.o |
9.5 模式匹配
1 | calc:add.o div.o main.o mult.o sub.o |
上面的 makefile 的写法非常冗余,我们可以将后面一系列的相同操作整理成一个模板,所有类似的操作都通过模板去匹配。
1 | # 模式匹配 |

- 优点:更精简
- 缺点:可读性下降
示例:
1 | target=calc |
缺点:当目录下的 .o 文件很多时,我们不可能依次将所有的 .o 文件手动写在 obj 的后面。
解决:使用 makefile 函数。
9.6 函数
9.6.1 基本介绍
makefile 中提供了很多函数,并且所有的函数都是有返回值的。
makefile 中函数的格式和 C/C++ 中的函数不同,其语法格式如下:
1 | $(函数名 参数1, 参数2, ...) |
下面介绍两个使用频率最高的。
9.6.2 wildcard
该函数主要用于获取指定目录下指定类型的文件名,其返回值是指定目录下所有符合条件的文件名列表(以空格进行分隔)。
函数原型:
1 | # 该函数的参数只有一个,但是这个参数可以分成若干个部分,通过空格间隔 |
- 参数:
PATTERN指的是某个或多个目录下对应的某种类型的文件,比如当前目录下的.c可以写成*.c(可以指定多个目录,以空格进行分隔) - 返回值:得到的若干个文件的文件列表,文件名之间以空格进行分隔
示例:
1 | # 分别搜索三个不同目录下的 .c 格式的源文件 |
*.c等价于./*.c。
9.6.3 patsubst
该函数主要用于按照指定的模式替换指定的文件名的后缀。
函数原型:
1 | # 有三个参数,参数之间使用逗号间隔 |
- 参数:
pattern:这是一个模式字符串,需要指定出要被替换的文件名中的后缀是什么- 文件名和路径不需要关心,因此使用
%(通配符)表示即可 - 通配符后边指定出要被替换的后缀,比如:
%.c表示.c的后缀最终要被替换掉
- 文件名和路径不需要关心,因此使用
replacement:这是一个模式字符串,指定参数pattern中的后缀要被替换为什么- 使用
%表示参数pattern中文件的路径和名字 - 在
%后边指定出新的后缀名,比如:%.o表示原来的后缀要被替换为.o
- 使用
text:该参数中存储要被替换的原始数据
- 返回值:函数返回被替换过后的字符串
示例:
1 | src = a.cpp b.cpp c.cpp |
优化 9.5 中的示例:
1 | target=calc |
%:是通配符,用来通用匹配的。
*:是全局匹配符,用来表示全局的文件。
9.7 编写
如何解决上面伪目标的问题?
在 makefile 中声明 clean 是一个伪目标(不对应任何实体),这样 make 就不会对 clean 文件的时间戳进行检测,规则中的命令也就每次都会被执行了。
在 makefile 中声明一个伪目标需要使用 .PHONY 关键字,声明方式为:.PHONY:伪文件的名字。
makefile 最终版:
1 | target=calc |
当规则中的命令有多个时,如果其中一个执行失败,则该命令后面的命令就不会再继续执行。
我们可以通过在命令前添加 - 来强制执行某一条命令,即使该命令执行失败,其后面的命令也会继续执行。
9.8 练习
10 GDB 调试
多线程
1 线程
1.1 概述
线程是轻量级的进程,在 Linux 环境下线程的本质仍是进程。
进程有自己独立的地址空间,多个线程共用同一个地址空间:
- 线程更节省系统资源
- 在一个地址空间中多个线程独享:每个线程都有属于自己的栈区、寄存器(内核中管理的)
- 在一个地址空间中多个线程共享:代码段、堆区、全局数据区、打开的文件(文件描述符表)

进程是资源分配的最小单位,线程是 CPU 调度执行(程序执行)的最小单位:
- 每个进程对应一个虚拟地址空间,一个进程只能抢一个 CPU 时间片
- 一个地址空间中可以划分出多个线程,在有效的资源基础上,能够抢更多的 CPU 时间片

CPU 的调度和切换:
- 线程的上下文切换比进程要快的多
- 上下文切换:进程/线程分时复用 CPU 时间片,在切换之前会将上一个任务的状态进行保存,下次切换回这个任务的时候,加载这个状态继续运行,任务从保存到再次加载这个过程就是一次上下文的切换
线程更加廉价,启动速度快,退出也快,对系统资源的冲击小。
在处理多任务程序时,应优先考虑多线程,而不是多进程。
线程数并不是越多越好:
- 文件 IO 操作:文件 IO 对 CPU 的使用率不高,因此可以分时复用 CPU 时间片,
线程的个数 = 2 * CPU核数,这样效率最高 - 处理复杂的算法:主要是 CPU 进行运算,压力大,
线程的个数 = CPU核数,这样效率最高
1.2 创建线程
每个线程都有一个唯一的线程 ID,ID 类型为 pthread_t,是一个 unsigned_long 类型的数据:
1 | // 返回当前线程的线程 ID |
在一个进程中调用线程创建函数,就可以得到一个子线程,和进程不同,需要给每一个创建出的线程指定一个处理函数,否则这个线程无法工作:
1 | // Linux 系统自带的线程库 |
示例:
1 |
|
注意:
- 当主线程执行完成后,会释放其对应的地址空间,因此此时子线程也就不复存在了
- 编译时需要加上对应的线程动态库(
libpthread.so、libpthread.a),需要加上-lpthread参数,否则会提示“未定义的引用”
1.3 线程退出
如果想让某一个线程退出,但是不会导致虚拟地址空间的释放(针对于主线程),我们就可以调用线程库中的线程退出函数。只要调用该函数,当前线程就会马上退出,并且不会影响到其它线程的正常运行,不管是在子线程还是主线程中都可以使用。
1 |
|
示例:
1 |
|
1.4 线程回收
子线程退出的时候,其内核资源主要由主线程回收。线程回收函数是一个阻塞函数,如果还有子线程在运行,调用该函数就会阻塞,子线程退出后,该函数解除阻塞进行资源的回收。该函数每被调用一次,只能回收一个子线程,如果有多个子线程则需要循环进行回收。
1 |
|
示例:
1 |
|
优化后代码(方式三):
1 |
|
1.5 线程分离
将指定的子线程与主线程分离,当子线程退出的时候,其占用的内核资源就被系统的其它进程接管并回收了。
1 |
|
示例:
1 |
|
1.6 其它线程函数
1.6.1 线程取消
指在一个线程中杀死另一个线程,需要分为 2 步:
- 线程 A 取消线程 B,这时候 B 还没有死
- 线程 B 中执行了系统调用(从用户区切换到内核区),否则线程 B 可以一直运行
1 |
|
系统调用:
- 直接调用 Linux 系统函数
- 标准 C 库函数,为了实现某些功能,在底层会调用相关的系统函数,比如
printf函数。
1.6.2 线程 ID 比较
在 Linux 中线程 ID 是一个无符号长整型,因此可以直接比较,但是线程库是跨平台的,在某些平台下可能并不是一个单纯的整型,因此需要用到比较函数。
1 |
|
1.7 线程类
C++11 中提供了线程类。
2 线程同步
2.1 基本概念

2.2 互斥锁
互斥锁的类型为:pthread_mutex_t
在创建的锁对象中保存了当前这把锁的状态信息(锁定、打开),记录了给这把锁加锁的线程信息(线程 ID)。
一个互斥锁变量只能被一个线程锁定,被锁定之后其它线程再对互斥量加锁就会被阻塞,直到其解锁。
一般来说,一个共享资源对应一把互斥锁,锁的个数和线程的个数无关。
1 | // 初始化互斥锁 |
注意:应保证在线程同步期间,我们创建的锁对象资源是有效的,在栈上分配时需要考虑其时效性,会不会被回收,可以考虑在全局位置或者堆空间创建锁对象。
示例:
1 |
|
2.3 死锁
2.3.1 死锁场景
(1) 加锁之后忘记解锁
1 | // 场景 1 |
(2) 重复加锁,造成死锁
1 | // 场景 1 |
(3) 在程序中有多个共享资源,因此有很多把锁,随意加锁,导致互相被阻塞

2.3.2 如何避免死锁
- 避免多次锁定,多检查
- 对共享资源访问完毕之后,一定要解锁,或者在加锁的时候使用
trylock() - 如果程序中有多把锁,可以控制对锁的访问顺序,另外也可以在对其它互斥锁加锁之前,先释放当前线程拥有的互斥锁
- 项目程序中可以引入一些专门用于死锁检测的模块
2.4 读写锁
2.5 条件变量
2.6 信号量
3 线程池
网络通信
1 Socket
1.1 基本概念
局域网和广域网:
- 局域网:只能通过代理服务器访问外网
- 广域网:又叫外网、公网,是连接不同地区局域网或城域网的远程公共网络
IP:
- IPv4:
- 32 位(4 个字节)
- ……
- IPv6:
- 128 位(16 个字节)
- ……
常用命令:
1 | 查看本机 ip |
特殊的 ip 地址 127.0.0.1 等价于本机的 ip 地址,假设当前电脑没有连网,就没有 ip 地址,可以使用 127.0.0.1 进行本地测试。
端口:
- 用于定位到主机上的某一个进程
- 是一个
unsigned short类型,是一个 16 位整形数,有效的端口范围是:0 ~ 65535 - 如果一个进程不需要进行网络通信,那么它就不需要绑定端口
- 一个端口只能给一个进程使用
OSI 网络分层模型:……
1.2 网络协议
1.3 Socket 编程
1.3.1 字节序
字节序:大于一个字节的类型(比如:int、long)的数据在内存中的存放顺序
单字符的数据(比如:字符串是单字符的集合)没有字节序的问题。
常用的字节存储机制:
- 小端(little endian):主机字节序
- 数据的低位字节存储到内存的低地址位,数据的高位字节存储到内存的高地址位
- 在我们使用的 PC 机中,数据的存储默认使用的是小端
- 大端(big endian):网络字节序
- 数据的低位字节存储到内存的高地址位,数据的高位字节存储到内存的低地址位
- 套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP 地址、端口
示例:
有一个 16 进制的数,有 32 位(4 字节,即 int 类型):0xab5c01ff
字节序的最小单位为 1 字节,因此需要将一个 int 类型的数据拆为 4 份。
假设:内存低地址位 -> 内存高地址位,则有:
- 小端:0xff 0x01 0x5c 0xab
- 大端:0xab 0x5c 0x01 0xff
字节序转换函数:
1 |
|
1.3.2 IP 地址转换
虽然 ip 地址本质上是一个 int 类型的数据,但是一般都是用一个字符串来描述,下面的函数提供了如何将一个字符串类型的 ip 地址进行大小端转换。
1 |
|
1.3.3 sockaddr

在实际使用时,一般先使用 sockaddr_in 结构体填充数据,再强转为 sockaddr 类型的指针。
1 |
|
1.4 TCP 通信
1.4.1 基本概念
TCP 协议的特点:
- 面向连接:三次握手、四次挥手
- 安全:数据包校验、发现数据丢失后会自动重传
- 流式传输:发送端和接收端处理数据的速度和量可以不一致
TCP 通信的流程:

1.4.2 服务端
1 | // 1.创建用于监听的套接字,这个套接字是一个文件描述符 |
文件描述符:
- 监听新的客户端连接(监听):
- 只需要一个
- 只负责检测客户端的连接,监测到之后调用
accept就可以建立新的连接
- 接收已建立连接的客户端数据(通信):
- 可以有多个
- 负责和建立连接的客户端进行通信,一个描述符对应一个连接
网络 IO:网络数据的读写操作,操作的是内核中的一块内存(文件 IO 操作的是磁盘上的一块内存)
文件描述符对应的内存结构:
- 一个文件描述符对应两块内存,一块是读缓冲区,一块是写缓冲区
- 读数据:通过文件描述符将内存中的数据读出,这块内存称之为读缓冲区
- 写数据:通过文件描述符将数据写入到内存中,这块内存称之为写缓冲区

注意:
accept()函数:当读缓冲区内没有来自客户端的连接请求时,函数就会被阻塞- 接收数据:当读缓冲区内没有数据时,函数就会被阻塞
- 发送数据:当写缓冲区内的数据已经写满时,函数就会被阻塞
1.4.3 客户端
1 | // 1.创建一个通信的套接字 |
1.4.4 套接字函数
1 | /* |
1.4.5 实际案例
(1) 服务端
1 |
|
(2) 客户端
1 |
|
1.5 扩展
Windows 平台的套接字函数
2 服务器并发
2.1 单线程
问题:服务器同时只能与一个客户端建立连接
解决:
- 多线程
- 多进程
- IO 多路转接(复用)
- IO 多路转接 + 多线程
2.2 多线程

代码:
1 |
|
2.3 线程池
2.4 TCP 粘包
2.4.1 问题分析
TCP 粘包:多个数据包(N 个或半个等)粘连到一起无法拆分,这是程序员需要解决的问题,而不是 TCP 协议本身就有问题
解决方案:
- 使用标准的应用层协议(如:http、https)来封装要传输的不定长的数据包
- 在每条数据的尾部添加特殊字符(不推荐)
- 缺点:效率低,需要按字节接收(并且判断),数据不能按块接收
- 在数据块前添加固定大小的数据头(推荐)
- 数据头:存储当前数据包的总字节数,接收端先接收数据头,然后再根据数据头接收对应大小的字节
- 数据块:数据包的内容
2.4.2 解决方案
数据包的包头大小可以根据自己的实际需求进行设定,一般规定为 4 个字节,用于存储当前数据块的总字节数。

(1) 发送端
流程:
- 根据待发送的数据长度 N 动态申请一块固定大小的内存:N + 4(4 是包头占用的字节数)
- 将待发送数据的总长度写入申请的内存的前 4 个字节中,这里需要将其转换为网络字节序(大端)
- 将待发送的数据拷贝到包头后的地址空间中,将完整的数据包发送出去(字符串没有字节序问题)
- 释放申请的堆内存
由于发送端每次都需要将这个数据包完整的发送出去,因此可以设计一个发送函数,如果当前数据包中的数据没有发送完就让它一直发送。
1 | // 发送指定长度的字符串 |
通信(发送端):
1 | ... |
(2) 接收端
流程:
- 首先接收 4 个字节的数据,并转换为主机字节序,这样就得到了即将要接收的数据的总长度
- 根据得到的长度申请固定大小的堆内存,用于存储待接收的数据
- 根据得到的数据块长度接收固定数目的数据保存到申请的堆内存中
- 处理接收的数据
- 释放存储数据的堆内存
接收函数:
1 | // 接收指定字节数的字符串 |
通信(接收端):
1 | while(1) |
二级指针:
在函数体中,想修改形参指针指向的地址,即将指针当作变量,修改指针,则需要将形参上的指针指定为二级指针。
若在函数的形参上使用一级指针,则传入的实参指针的指向不会改变。
改进:指针引用(如:
int* &p),引用的本质就是指针常量(即:int* const p)。若实参为
int a = ...; int* p = &a,则无论形参是指针引用(int* &p)还是二级指针(int** p),一旦对p进行修改,会导致p与a脱钩,即指针p不再指向a。参考资料:
3 IO 多路复用
3.1 基本概念
IO 多路复用/转接:
- IO 多路复用是指在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其进行读写操作
- 使用 IO 多路复用函数委托内核帮我们检测服务端通信过程中所有的文件描述符(监听和通信)的状态,这个过程会导致进程/线程阻塞,如果检测到已就绪的文件描述符,就解除阻塞并将这些已就绪的文件描述符传出。
- 与使用多线程技术相比,IO 多路复用的优点是:系统开销小,系统不必创建与维护线程。
IO 多路复用函数:
select:跨平台,底层是线性表,效率低,检测连接数的上限为 1024(掌握)poll:Linux,底层是线性表,效率低,检测连接数的上限取决于当前操作系统的配置epoll:Linux,底层是红黑树,效率高,检测连接数的上限取决于当前操作系统的配置(掌握)
文件描述符表:
- 每启动一个进程就会得到一个对应的虚拟地址空间,这个虚拟地址空间分为两大部分,在内核区有专门用于进程管理的模块。Linux 的进程控制块 PCB 本质是一个
task_struct的结构体,里面包括管理进程所需的各种信息,其中有一个结构体叫做file,叫做文件描述符表,里面有一个整型索引表,用于存储文件描述符。 - 内核为每一个进程维护了一个文件描述符表,索引表中的值都是从 0 开始的,所以在不同的进程中你会看到相同的文件描述符,但是它们指向的不一定是同一个磁盘文件。

3.2 select
3.2.1 函数说明
1 |
|
文件描述符的就绪状态(满足条件):
- 读缓冲区:有数据可读
- 写缓冲区:数据还未写满
- 异常集合:文件描述符有异常
3.2.2 fd_set
fd_set 是一个 int[32] 数组,其内存占 1024 个标志位(文件描述符),即 128 字节,即 32 个 int。实际按 bit 为单位处理这块内存。
下图中的 fd_set 中存储了要委托内核检测读缓冲区的文件描述符集合:
- 集合中标志位为 0 代表不检测这个文件描述符状态
- 集合中标志位为 1 代表检测这个文件描述符状态

操作 fd_set 类型的参数还需要使用相关的一系列函数:
1 | // 将文件描述符 fd 从 set 集合中删除 |
3.2.3 并发处理

(1) 单线程
1 |
|
(2) 多线程
线程分工情况:
- 主线程:不断调用
select函数进行检测 - 子线程:接受客户端的连接、进行通信
需要考虑线程同步的共享资源:
- 最大文件描述符
maxfd - 原始读集合
readset
代码:
1 |
|
问题:客户端每次发送数据,都会在服务端建立一个子线程,完成本次数据的收发后,就会退出该子线程,因此会造成服务端线程频繁的创建和销毁
3.2.4 总结
虽然使用 select 可以降低系统开销,提高程序效率,但是它也有局限性:
- 待检测集合需要频繁地在用户区(堆、栈等)和内核区之间进行数据的拷贝,效率低
- 内核对于
select传递进来的待检测集合的检测方式是线性的,如果集合内待检测的文件描述符很多,检测效率会比较低 - 使用
select能够检测的最大文件描述符个数有上限,默认是 1024,这是在内核中被写死了的
3.3 poll
1 |
|
仅限于 Linux 平台下使用,是线性检测,效率低,一般不使用该方式。
3.4 epoll
3.4.1 基本概念
epoll(event poll)是 select 和 poll 的升级版,它改进了工作方式,更加高效。(仅 Linux 平台)
对比 select 和 poll:
- 对于待检测的集合
select和poll是基于线性方式处理的,而epoll基于红黑树来管理待检测集合 select和poll每次都会线性扫描整个待检测的集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是共享内存(基于 mmap 内存映射区实现),省去了不必要的内存拷贝- 程序员需要对
select和poll返回的集合进行判断才能知道哪些文件描述符是就绪的,通过epoll可以直接得到已就绪的文件描述符集合,无需再次检测
select 和 poll 效率低的原因之一就是将“添加/维护待检测任务”和“阻塞进程/线程”两个步骤合二为一,每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 个数相对固定,并不需要每次都修改。epoll 将这两个操作分开,先用 epoll_ctl() 维护等待队列,再调用 epoll_wait() 阻塞进程(解耦),效率明显提升。

总结:当多路复用的文件数量大、IO 流量频繁的时候,一般不太适合使用 select 和 poll,而是推荐使用 epoll。
3.4.2 函数说明
1 |
|
epoll_event 事件定义:
1 | // 联合体:多个成员共用同一块内存 |
常用的 events 事件类型:
| 事件类型 | 含义 |
|---|---|
EPOLLIN |
检测该文件描述符的读缓冲区中是否有数据 |
EPOLLOUT |
检测该文件描述符的写缓冲区中是否可写 |
EPOLLERR |
检测该文件描述符是否出现异常 |
EPOLLET |
将 epoll 设置为边缘触发模式(默认为水平触发模式) |
3.4.3 操作步骤
1 | // 1.创建监听的套接字 |
3.4.4 实际案例
1 |
|
看完:P14