学习笔记:C++初阶【C++入门、类和对象、C/C++内存管理、模板初阶、STL简介、string、vector、list、stack、queueu、模板进阶、C++的IO流】

文章目录


前言

这篇是学习笔记:记录C++和数据结构初阶学习过程中的要点疑点和难点。 笔记简介:这篇笔记写于2022/09/03,完结于2022/12/27。完结后多次增删内容和修正。

一、C++入门

1. C++关键字

C++总计63个关键字,C语言只有32个关键字
C++关键字包含了C语言关键字,C++兼容C语言的绝大多数语言特性
在这里插入图片描述

2.命名空间

2.1 C语言缺点之一,没办法很好地解决命名冲突问题

常见情况:
1.我们自己定义的变量、函数可能跟库里面重名冲突
2.进入公司项目组以后,做的项目比较大,多人协作,两个同事写的代码命名冲突

2.2 C++提出了一个新语法——命名空间

2.2.1 命名空间概念

在C/C++中,变量、函数和后面要学到的类都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染,namespace关键字的出现就是针对这种问题的。

用到的关键字:namespace和域作用限定符:“::”

2.2.2 命名空间的定义(语法)

一个命名空间就定义了一个新的作用域,命名空间中的所有内容都局限于该命名空间中

//定义了一个叫grid的命名空间--定义出来的是一个域
namespace grid {
	//变量
	int rand = 0;//域空间里面的全局变量
	//函数
	void ADD(){};
	//结构体
	struct Node{};
	//命名空间(命名空间嵌套)
	namespace grid2{
		int a = 1;
	}
	......
}
namespace grid{ //同一个工程存在多个同名的命名空间,会合并成一个
	int abc = 10;
}
2.2.3 命名空间的使用有三种方式
//加命名空间名称及域作用限定符,隔离效果最好
printf("%d\n", grid::rand);//在grid域空间内找rand
printf("%d\n", ::rand);//在全局域空间内找rand
printf("%d\n", rand);//现在局部域空间找rand,局部域空间没有再去全局域
grid::ADD();//访问grid域空间内的函数
struct grid::Node node;//访问grid域空间内的结构体
grid::grid2::a = 10; //访问grid域空间内嵌套命名空间grid2里的对象

//使用using将命名空间中某个成员引入
using grid::rand;//单独展开某一个
printf("%d\n", rand);

//使用using namespace 命名空间名称引入
using namespace grid;//把整个命名空间展开,隔离就失效了
printf("%d\n", rand);

3.C++输入&输出:cout标准输出(控制台)和cin标准输入(键盘)

说明:
(1)使用cout标准输出(控制台)和cin标准输入(键盘)时,必须包含< iostream >头文件以及std标准命名空间。
注意:早期标准库将所有功能在全局域中实现,声明在.h后缀的头文件中,使用时只需包含对应头文件即可,后来将其实现在std命名空间下,为了和C头文件区分,也为了正确使用命名空间,规定C++头文件不带.h;旧编译器(vc 6.0)中还支持<iostream.h>格式,后续编译器已不支持,因此推荐使用+std的方式。
(2)使用C++输入输出更方便,不需增加数据格式控制,比如:整形–%d,字符–%c

	int a;
	double b;
	char c;
	//>>流提取运算符
	cin >> a;
	cin >> b >> c;
	//<<流插入运算符
	cout << a << endl;
	cout << b << " " << c << endl;

4.缺省参数

4.1 缺省参数概念:

缺省参数是声明或定义函数时为函数的参数指定一个默认值。在调用该函数时,如果没有指定实参则采用该默认值,否则使用指定的实参。

void Func(int a = 0){};//定义一个函数,给形参赋上一个初始值(默认值)
Func();	//函数调用的时候如果不给函数传值,那么函数就默认使用初始值

4.2 缺省参数分类:

4.2.1 全缺省:

全部形参都有初始值

void Func(int a = 1, int b = 2, int c = 3){};
4.2.2 半缺省:

部分形参有初始值

void Func(int a, int b = 2, int c = 3){};

注意:
(1)半缺省参数必须从右往左依次来给出,不能间隔着给
(2)缺省参数不能在函数声明和定义中同时出现,可以二选一,推荐写到声明
(3)缺省值必须是常量或者全局变量
(4)C语言不支持(编译器不支持)

5.函数重载

5.1 函数重载概念:

函数重载是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 顺序)必须不同,常用来处理实现功能类似数据类型不同的问题

//参数的类型不同的函数重载
int ADD(int a, int b){ //int型加法运算
	return a+b;
}
double ADD(double a, double b){//double型加法运算
	return a+b;
}
//调用函数时,看似是同一个函数,其实根据参数的类型所调用的函数也是不同的
ADD(1,2);
ADD(1.1,2.2);

5.2 函数重载原理:

5.2.1 C语言为什么不支持函数重载:

回顾一下编译器编译程序的过程:
(1)预处理:头文件展开、宏替换、条件编译、去掉注释,生成.i 的文件。
(2)编译:检查语法、生产汇编代码,生产.s文件。
(3)汇编:把汇编代码转换成二进制机器码,生产.o文件。
(4)链接:合并段表;符号表的合并符号表的重定位。

在linux下,采用gcc编译完成后,函数名字的修饰没有发生改变。编译的时候生成的两个重载函数,函数名相同,在.o的符号表中存在歧义和冲突,其次链接的时候也存在歧义和冲突,因为他们都是直接使用函数名去标识和查找,而重载函数的函数名相同!
采用C语言编译器编译后结果:
在这里插入图片描述

5.2.2 C++为什么支持函数重载:

c++的目标文件符号表中不是直接用函数名来标识和查找函数的。
(1)引入了函数名修饰规则(name Mangling),但是这个修饰规则,不同的编译器下面不同,修饰规则是由写编译器的人制定的。
(2)有了函数名修饰规则,只要参数不同,.o符号表里面重载的函数就不存在歧义和冲突。
(3)链接的时候,.o的main函数里面去调用两个重载的函数,查找地址时也是明确的。

5.2.3 C++是如何支持函数重载的:

在linux下,采用g++编译完成后,函数名字的修饰发生改变,编译器将函数参数类型信息添加到修改后的名字中。g++的函数修饰后变成【_Z+函数长度+函数名+类型首字母】。
采用C++编译器编译后结果:
在这里插入图片描述

5.3 extern"C"

有时候在C++工程中可能需要将某些函数按照C的风格来编译,在函数前加extern “C”,意思是告诉编译器,将该函数按照C语言规则来编译。比如:tcmalloc是google用C++实现的一个项目,他提供tcmallc()和tcfree两个接口来使用,但如果是C项目就没办法使用,那么他就使用extern “C”来解决。

extern "C" int Add(int left, int right);
int main(){
	 Add(1,2);
	 return 0; 
}

链接时报错:error LNK2019: 无法解析的外部符号_Add,该符号在函数 _main 中被引用

5.3.1 C++调C的库

简述:当C++项目中调用C的库时,一般要引用C的头文件和库文件,在编译的时候因为C++识别不了C语言的函数名修饰规则,这个时候就需要在C++项目中,使用extern "C"来告诉编译器,在C++项目中那些调用的是C的库。
extern "C"的使用方式有两个:
(1)可以加在函数声明之前。
(2)可以使用花括号,把多条函数声明或者头文件包起来。

extern "C" {
	#include <C.h>
}
5.3.2 C调C++的库

简述:当C项目中调用C++的库时,在调用C++的头文件和库文件时,C语言同样也识别不了C++的函数名修饰规则,同时C语言也无法使用extern “C”,这个时候需要在C++的库文件编译之前,在C++头文件中做一些条件编译,让extern "C"总是在C++中,具体做法:

//方法1:
#ifdef __cplusplus //__cplusplus是C++的宏标识
#define EXTERN_C extern "C"
#else
#define EXTERN_C
#endif

EXTERN_C 函数声明1;
EXTERN_C 函数声明2;
......

//方法2:
#ifdef __cplusplus
extern "C" {
#endif

EXTERN_C 函数声明1;
EXTERN_C 函数声明2;
......

#ifdef __cplusplus
}
#endif
......

总结: C++程序调用C的库,在C++项目中加extern "C"处理;C程序调用C++的库,也是在C++库中加extern "C"处理;

6.引用

6.1 引用的概念

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

引用的价值: 减少拷贝,提高效率、修改返回变量,大多数情况可以替换指针,比指针简单。

6.2 引用的语法

int a = 10;
int& b = a;//b是a的引用,此时b和a是一样的,指向的是同一个空间

6.3 引用的特性

(1)引用必须在定义的时候初始化

int a = 10;
int& b;//未初始化,错误
int& b = a;//正确的语法

(2)一个变量可以用多个引用

//四个变量名指向的都是同一个空间
int a = 10;
int& b = a;
int& c = a;
int& d = b;

(3)引用一旦引用了一个实体,再不能引用其他实体

int a = 10;
int& b = a;

int c = 20;
//1.这里可能是让b变成c的别名呢? 否
//2.还是把c赋值给b?           是
b = c;

6.4 常引用(const引用)

//权限放大,不可以
const int a = 10;
int& b = a;

//权限不变,可以
const int a = 10;
const int& b = a;

//权限缩小,可以
int a = 10;
const int& b = a;

//
double d = 11.11;
int i1 = d; //可以,隐式类型的转换,生成一个int类型的临时变量接收d(截断提取int部分后接收)然后复制给i1
int& i2 = d; //不可以,i2是临时变量的别名(临时变量是用来储存截断的d),临时变量具有常性,临时变量是右值,不可以修改,权限放大不可以
const int& i3 = d; //可以,i3是临时变量的别名(临时变量是用来储存截断的d),临时变量具有常性,临时变量是右值,不可以修改,权限不变可以

结论:const Type&可以接收各种类型的对象。
建议:如果要传的值是一个大对象或者是深拷贝,尽量使用引用传参,减少拷贝。如果不改变实参,尽量用const引用传参。

6.5 引用的使用场景

6.5.1 引用做参数
Swap(int& r1, int& r2){ //函数的参数是引用
	int tmp = r1;
	r1 = r2;
	r2 = tmp;
}

int x = 0, y = 1;
Swap(x,y);//函数形参类型为引用,不需要传地址,直接传变量名字

//可以给指针取别名,这样传参就不需要用二级指针了
int* p1 = NULL;
int*& p2 = p1; 
6.5.2 引用做返回值
//非引用返回值
int Add(int a, int b) {
	int c = a + b;
	return c;
}
//Add执行结束返回c的值,其实是c的拷贝,因为Add结束以后其作用域已经销毁,c也不复存在,只能在Add返回前,对返回值c拷贝一份!
int ret = Add(1, 2);
 
//引用返回值-错误用法
int& Add(int a, int b) {
	int c = a + b;
	return c;
}

int& ret = Add(1, 2);//Add执行结束后不会生成c的拷贝反击,而是直接返回c的引用,相当于ret就是Add返回值c的引用
//当前代码存在的问题:
//(1)存在非法访问,因为Add返回值是c的引用,所有Add栈帧销毁了以后,回去访问c位置空间
//(2)如果Add函数栈帧销毁,清理空间,那么取c值的时候取到的就是随机值,给ret的就是随机值,是否清理这个取决于编译器
6.5.3 引用传值的正确使用场景

(1) 如果函数返回时,出了函数作用域,如果返回对象还未还给系统(比如static修饰的变量或者是全局变量或者malloc出来的空间),则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。
(2)类和对象。

6.6 传值、传引用效率比较

以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。

6.7 引用和指针的区别

6.7.1 引用和指针的不同点

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

6.8 引用的底层实现

7.内联函数

7.1 内联函数的概念

以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数提升程序运行的效率。

内联函数的价值: 有了inline,就不需要C的宏,因为宏很复杂,很容易出错。

7.2 内联函数的使用场景

调用函数需要建立栈帧,栈帧需要保存一些寄存器,结束后又要恢复,这些都是有消耗的,对于频繁调用的小函数,可以用inline优化。
在这里插入图片描述
如果在上述函数前增加inline关键字将其改成内联函数,在编译期间编译器会用函数体替换函数的调用。
查看方式:
(1)在release模式下,查看编译器生成的汇编代码中是否存在call Add
(2)在debug模式下,需要对编译器进行设置,否则不会展开(因为debug模式下,编译器默认不会对代码进行优化,以下给出vs2013的设置方式)
在这里插入图片描述

7.3 内联函数的特性

(1)inline是一种以空间换时间的做法,省去调用函数额开销。所以代码很长或者有循环/递归的函数不适宜使用作为内联函数。
(2)inline对于编译器而言只是一个建议,编译器会自动优化,如果定义为inline的函数体内有循环/递归等等,编译器优化时会忽略掉内联。
(3)inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。

// F.h
#include <iostream>
using namespace std;

inline void f(int i);

// F.cpp
#include "F.h"
void f(int i) {
	 cout << i << endl; 
 }
 
// main.cpp
#include "F.h"
int main(){
	 f(10);
	 return 0;
}
// 链接错误:main.obj : error LNK2019: 无法解析的外部符号 "void __cdecl f(int)" (?f@@YAXH@Z),该符号在函数 _main 中被引用

总结: 短小,频繁调用的函数建议定义成inline。

7.4 宏的优缺点?

优点:
(1)增强代码的复用性。
(2)提高性能。
缺点:
(1)不方便调试宏。(因为预编译阶段进行了替换)
(2)导致代码可读性差,可维护性差,容易误用。
(3)没有类型安全的检查 。
C++有哪些技术替代宏?
(1) 常量定义 换用const
(2)函数定义 换用内联函数

8.auto关键字(C++11)

8.1 auto简介

在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它,大家可思考下为什么?

C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。

int TestAuto(){
	return 10;
}
int main(){
	 int a = 10;
	 
	 //auto自动推导变量的类型
	 auto b = a;
	 auto c = 'a';
	 auto d = TestAuto();
	 
 	//typeid可以打印变量的类型
	 cout << typeid(b).name() << endl;
	 cout << typeid(c).name() << endl;
	 cout << typeid(d).name() << endl;
 
	 //auto e; 无法通过编译,使用auto定义变量时必须对其进行初始化
	 return 0;
}

注意:
使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。

8.2 auto的使用细则

(1)auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&

int main(){
	int x = 10;
	
	auto a = &x;
	auto* b = &x;
	auto& c = x;
	
	cout << typeid(a).name() << endl;
	cout << typeid(b).name() << endl;
	cout << typeid(c).name() << endl;
	
	*a = 20;
	*b = 30;
	c = 40;
	return 0;
}

(2) 在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。

void TestAuto()
{
	auto a = 1, b = 2;
	auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}

8.3 auto不能推导的场景

(1)auto不能作为函数参数
(2)auto不能直接用来声明数组
(3)为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法
(4) auto在实际中最常见的优势用法就是跟以后会讲到的C++11提供的新式for循环,还有lambda表达式等进行配合使用。

9.基于范围的for循环(C++11)

9.1 范围for的语法

对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号“ :”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。

void TestFor(){
	int array[] = { 1, 2, 3, 4, 5 };
	for (auto& e : array) //这里的auto也可以直接使用int,使用auto是很方便的,所以也叫 -- 语法糖
		e *= 2;

	for (auto e : array)
		cout << e << " ";

	return 0;
}

注意: 与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环。

9.2 范围for的使用条件

(1) for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
注意:以下代码就有问题,因为for的范围不确定

void TestFor(int array[]){
	for (auto& e : array)
		cout << e << endl;
}

(2)迭代的对象要实现++和==的操作。(关于迭代器这个问题,以后会讲,现在大家了解一下就可以了)

10. 指针空值nullptr(C++11)

在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们一般指向NULL。
NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:

#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif

可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:

void f(int) {
	cout << "f(int)" << endl;
}
void f(int*) {
	cout << "f(int*)" << endl;
}
int main() {
	f(0);
	f(NULL);
	f((int*)NULL);
	return 0;
}

程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的初衷相悖。

在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void *)0。

注意:
(1)在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
(2)在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
(3)为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。

二、类和对象(上)

1. 面向过程和面向对象初步认识

C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。

2. 类的引用

C++对struct进行了升级,在C语言中,结构体中只能定义变量,在C++中,结构体不仅可以丁定义变量,还可以定义函数。

3. 类的定义

class className{
	// 类体:由成员函数和成员变量组成

}; // 一定要注意后面的分号

class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号。
类中的元素称为类的成员:类中的数据称为类的属性或者成员变量; 类中的函数称为类的方法或者成员函数。
类的两种定义方式:
(1) 声明和定义全部放在类体中,需要注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
在这里插入图片描述
(2)声明放在.h文件中,类的定义放在.cpp文件中
在这里插入图片描述
一般情况下,更期望采用第二种方式。

4. 类的访问限定符及封装

4.1 访问限定符

C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
在这里插入图片描述
访问限定符说明:
(1)public修饰的成员在类外可以直接被访问。
(2)protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)。
(3)访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止。
(4)class的默认访问权限为private,struct为public(因为struct要兼容C)。
(5)一般在定义类的时候,建议明确定义访问限定符,不要用class/struct默认限定。

注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
C++中struct和class的区别是什么?
C++需要兼容C语言,所以C++中struct可以当成结构体去使用。另外C++中struct还可以用来定义类。和class是定义类是一样的,区别是struct的成员默认访问方式是public,class是struct的成员默认访问方式是private。

4.2 封装

面向对象的三大特性:封装、继承、多态。

在类和对象阶段,我们只研究类的封装特性,那什么是封装呢?
封装: 将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。

封装本质上是一种管理: 我们如何管理兵马俑呢?比如如果什么都不管,兵马俑就被随意破坏了。那么我们首先建了一座房子把兵马俑给封装起来。但是我们目的全封装起来,不让别人看。所以我们开放了售票通道,可以买票突破封装在合理的监管机制下进去参观。类也是一样,我们使用类数据和方法都封装到一下。不想给别人看到的,我们使用protected/private把成员封装起来。开放一些共有的成员函数对成员合理的访问。所以封装本质是一种管理。

5. 类的作用域

类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员,需要使用 “::” 作用域解析符指明成员属于哪个类域。
提醒:在C++中“{}”括起来的基本上都是域。

class Person{
public:
	void PrintPersonInfo();
private:
	char _name[20];
	char _gender[3];
	int _age;
};
// 这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo(){
	cout << _name << " "_gender << " " << _age << endl;
}

6. 类的实例化

用类类型创建对象的过程,称为类的实例化。
(1) 类只是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它。
(2) 一个类可以实例化出多个对象,实例化出的对象 占用实际的物理空间,存储类成员变量。
(3)做个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间。
在这里插入图片描述
在这里插入图片描述

7. 类对象模型

7.1 计算类对象的大小

类对象储存方式猜测①:对象中包含类的各个成员
在这里插入图片描述
缺陷:每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。那么如何解决呢?
类对象储存方式猜测②:只保存成员变量,成员函数存放在公共的代码段
在这里插入图片描述
在这里插入图片描述
问题: 对于上述两种存储方式,那计算机到底是按照那种方式来存储的?
我们再通过对下面的不同对象分别获取大小来分析看下:

// 类中既有成员变量,又有成员函数
class A1 {
public:
	void f1() {}
private:
	int _a;
};
// 类中仅有成员函数
class A2 {
public:
	void f2() {}
};
// 类中什么都没有---空类
class A3
{};

sizeof(A1) :4;sizeof(A2) : 1;sizeof(A3) :1 ;
结论: 一个类的大小,实际就是该类中”成员变量”之和,当然也要进行内存对齐,注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类。

7.2 结构体内存对齐规则

(1)第一个成员在与结构体偏移量为0的地址处。
(2)其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
VS中默认的对齐数为8
(3)结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
(4)如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

8. this指针

8.1 this指针的引出

我们先来定义一个日期类Date

class Date {
public:
	void Init(int year, int month, int day) {
		_year = year;
		_month = month;
		_day = day;
	}
	void Print() {
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;

};
 
int main() {
	Date d1;
	d1.Init(2022, 9, 18);
	d1.Print(); //编译器处理成这样子:d1.Print(&d1);

	Date d2;
	d2.Init(2022, 1, 1);
	d2.Print();//编译器处理成这样子:d2.Print(&d2);


	return 0;
}

对于上述类,有这样的一个问题:

Date类中有SetDate与Display两个成员函数,函数体中没有关于不同对象的区分,那当s1调用SetDate函数时,该函数是如何知道应该设置s1对象,而不是设置s2对象呢?

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

8.2 this指针的特性

(1) this指针的类型:类类型* const
(2)只能在“成员函数”的内部使用,比如:例子中,Date成员函数Print的第二种写法:

void Print() {
		cout << _year << "-" << _month << "-" << _day << endl;
		//一般情况,不建议下面这种写法
		//cout << this-> _year << "-" << this-> _month << "-" << this-> _day << endl;
	}

(3) this指针本质上其实是一个成员函数的形参,是对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
(4) this指针是成员函数第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
在这里插入图片描述
问题:
(1)this指针存哪里?
一般情况下存在栈里,因为this是形参。 有些编译器会放到寄存器中,如VS2013,放到ecx(寄存器)。
(2)this可以为空吗?
看一段代码:

class A{
public:
	void PrintA() //这里this隐含的形参,接收的是p空指针
	{
		cout << _a << endl; //这里对空指针解引用了,运行会崩溃
	}

	void Show()//这里this隐含的形参,接收的是p空指针
	{
		cout << "Show()" << endl; //这里没有对空指针解引用,正常运行
	}
private:
	int _a;
};

int main(){
	Date* p = nullptr;
	p->PrintA();
	p->Show();
}

总结:this能不能为空,要看实际使用的情况是否在this为空时还要对其解引用。

三、类和对象(中)

1.类的6个默认成员函数

如果一个类中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数。
在这里插入图片描述

2. 构造函数

2.1 构造函数概念

对下面日期类

class Date {
public:
	void SetDate(int year, int month, int day) {
		_year = year;
		_month = month;
		_day = day;
	}
	void Print() {
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;

};
 
int main() {
	Date d1;
	d1.SetDate(2022, 9, 18);
	d1.Print(); //编译器处理成这样子:d1.Print(&d1);

	Date d2;
	d2.SetDate(2022, 1, 1);
	d2.Print();//编译器处理成这样子:d2.Print(&d2);


	return 0;
}

对于Date类,可以通过SetDate公有的方法给对象设置内容,但是如果每次创建对象都调用该方法设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,保证每个数据成员都有 一个合适的初始值,并且在对象的生命周期内只调用一次。

2.2 构造函数特性

构造函数是特殊的成员函数,需要注意的是,构造函数的虽然名称叫构造,但是需要注意的是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
(1)函数名与类名相同。
(2)无返回值。
(3)对象实例化时编译器自动调用对应的构造函数。
(4)构造函数可以重载。

class Date {
private:
	int _year;
	int _month;
	int _day;
public:
	Date(){ //无参数构造函数
		_year = 0;
		_month = 1;
		_day = 1;
	}
	Date(int year, int month, int day) { //有参数构造函数
		_year = year;
		_month = month;
		_day = day;
	}

	//可以使用缺省函数把上面两个函数合在一起
	/*
	*实际使用时,推荐使用缺省函数
	Date(int year=0, int month=1, int day=1) { //有参数构造函数
		_year = year;
		_month = month;
		_day = day;
	}
	*问题:如果全缺省和无参构造函数同时存在时,在无参调用时,就会存在二义性
	*/
};

int main() {

	Date d1; //调用无参数构造函数
	Date d2(2022,9,18); //调用有参数构造函数

	// 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
	// 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
	Date d3();

	return 0;
}

(5)如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
(6)无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认成员函数。

class Date {
private:
	int _year;
	int _month;
	int _day;
public:
	
	/*
	如果用户显式定义了构造函数,编译器将不再生成
	如果只定义非全缺省的有参数构造函数,定义对象时选择无参定义,编译就会出错
	Date(int year, int month, int day) { //有参数构造函数
		_year = year;
		_month = month;
		_day = day;
	}
	*/

};

int main() {
	/*
	* 没有定义构造函数,对象也可以创建成功,因此此处调用的是编译器生成的默认构造函数
	* C++里面把类型分为两类:内置类型(基本类型),自定义类型
	* 内置类型: int/char/double/指针/内置类型数组等等
	* 自定义类型: struct/class定义的类型
	* 我们不写构造函数时,编译器默认生成构造函数,对于内置类型不做初始化处理
	* 对于自定类型会去调用它的默认构造函数(不用参数就可以调的)初始化
	* 如果没有默认构造函数,编译就会出错
	*/
	Date d1; 
	return 0;
}

总结: C+ +我们不写编译器默认生成构造函数,这点设计的不好,没有对内置类型和自定义类型统一处理,不处理内置类型成员变量,只处理自定义类型成员变量。

(7) 关于编译器生成的默认成员函数,很多童鞋会有疑惑:在我们不实现构造函数的情况下,编译器会生成默认的构造函数。但是看起来默认构造函数又没什么用?d对象调用了编译器生成的默认构造函数,但是d对象_year/_month/_day,依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么卵用??

解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语法已经定义好的类型:如int/char…,自定义类型就是我们使用class/struct/union自己定义的类型,看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员函数

class Time{
public:
	Time()
	{
		cout << "Time()" << endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}
private:
	int _hour;
	int _minute;
	int _second;
};
class Date{
private:
	// 基本类型(内置类型)
	int _year;
	int _month;
	int _day;
	// 自定义类型
	Time _t;
};
int main()
{
	Date d;
	return 0;
}

(8) 成员变量的命名风格
为避免成员变量和形参同名,建议在成员变量前面加个“_”。

3. 析构函数

3.1 析构函数概念

前面通过构造函数的学习,我们知道一个对象时怎么来的,那一个对象又是怎么没呢的?
析构函数:与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象的一些资源清理工作。

3.2 析构函数特性

析构函数是特殊的成员函数。
(1) 析构函数名是在类名前加上字符 “~”。
(2)无参数无返回值。
(3)一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
(4)对象生命周期结束时,C++编译系统系统自动调用析构函数。

typedef int DataType;
class SeqList{
public:
	SeqList(int capacity = 10)
	{
		_pData = (DataType*)malloc(capacity * sizeof(DataType));
		assert(_pData);

		_size = 0;
		_capacity = capacity;

	}
	//如果我们不写默认生成的析构函数和构造函数类似
	//对内置类型的成员变量不做处理
	//对于自定义类型的成员变量会去调用它的析构函数
	~SeqList(){
		if (_pData)
		{
			free(_pData); // 释放堆上的空间
			_pData = NULL; // 将指针置为空
			_capacity = 0;
			_size = 0;
		}
	}

private:
	int* _pData;
	size_t _size;
	size_t _capacity;
};

(5)关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对会自定类型成员调用它的析构函数。

class String{
public:
	String(const char* str = "jack"){
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
	~String(){
		cout << "~String()" << endl;
		free(_str);
	}
private:
	char* _str;
};

class Person{
private:
	String _name;
	int _age;
};

int main(){
	Person p;
	return 0;
}

4. 拷贝构造函数

4.1 拷贝构造函数概念

拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。

拷贝构造函数是对一个已经存在的对象拷贝初始化另一个马上创建的实例化对象

4.2 拷贝构造函数特性

拷贝构造函数也是特殊的成员函数。
(1)拷贝构造函数是构造函数的一个重载形式。
(2)拷贝构造函数的参数只有一个且必须使用引用 传参,使用传值方式会引发无穷递归调用。

class Date{
public:
	Date(int year = 1900, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}
	Date(const Date& d){
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main(){
	Date d1;
	//拷贝构造
	Date d2(d1);
	return 0;
}

调用拷贝构造,需要先传值,传值传参又是一个拷贝构造。

(3)若未显示定义,系统生成默认的拷贝构造函数。
默认生成的拷贝构造:
①对内置类型成员会完成按字节序的拷贝(浅拷贝)。
②自定义类型成员,会调用他的拷贝构造。

class Date{
public:
	Date(int year = 1900, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main(){
	Date d1;
	// 这里d2调用的默认拷贝构造完成拷贝,d2和d1的值也是一样的。
	Date d2(d1);
	return 0;
}

(4)那么编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?

// 这里会发现下面的程序会崩溃掉?这里就需要我们以后讲的深拷贝去解决。
class String{
public:
	String(const char* str = "jack"){
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
	~String(){
		cout << "~String()" << endl;
		free(_str);
	}
private:
	char* _str;
};
int main(){
	String s1("hello");
	String s2(s1);
	/*
	* 这里崩掉的原因主要是,s2拷贝s1后,他们两个指向同一块空间
	* 析构的时候先析构s2这个时候,已经把s2指向的空间析构掉了
	* 再对s1析构的时候,就找不到那块空间了
	*/
}

总结: 拷贝构造我们不写生成的默认拷贝构造函数,对于内置类型和自定义类型都会拷贝处理,但是处理细节是不一样的,这个跟构造和析构是不一样的。

5. 赋值运算符重载

5.1 运算符重载

C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

函数名字为:关键字operator后面接需要重载的运算符符号。

函数原型:返回值类型 operator操作符(参数列表)
注意:
(1)不能通过连接其他符号来创建新的操作符:比如operator@ 。
(2)重载操作符必须有一个类类型或者枚举类型的操作数。
(3)用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不 能改变其含义。
(4)作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的操作符有一个默认的形参this,限定为第一个形参。
(5 ).* 、:: 、sizeof 、?: 、. 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。

// 全局的operator==
class Date{
public:
	Date(int year = 1900, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}
	//private:
	int _year;
	int _month;
	int _day;
};
// 这里会发现运算符重载成全局的就需要成员变量是共有的,那么问题来了,封装性如何保证?
// 这里其实可以用我们后面学习的友元解决,或者干脆重载成成员函数。
bool operator==(const Date& d1, const Date& d2) {
	return d1._year == d2._year
	&& d1._month == d2._month
		&& d1._day == d2._day;
}
void Test(){
	Date d1(2018, 9, 26);
	Date d2(2018, 9, 27);
	cout << (d1 == d2) << endl;
}
//新方法,类成员operator==
class Date{
public:
	Date(int year = 1900, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}

	// bool operator==(Date* this, const Date& d2)
	// 这里需要注意的是,左操作数是this指向的调用函数的对象
	bool operator==(const Date& d2){
		return _year == d2._year
		&& _month == d2._month
			&& _day == d2._day;
	}
private:
	int _year;
	int _month;
	int _day;
};
void Test(){
	Date d1(2018, 9, 26);
	Date d2(2018, 9, 27);
	cout << (d1 == d2) << endl;
}

5.2 赋值运算符重载

赋值运算符重载也是特殊的成员函数。

赋值运算符重载是对两个已经存在的对象之间的赋值拷贝。

class Date{
public:
	Date(int year = 1900, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}

	Date(const Date& d){
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	Date& operator=(const Date& d){
		//极端情况下:自己给自己赋值,就不处理了。
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		rerurn *this;
	}
private:
	int _year;
	int _month;
	int _day;
};
5.2.1 赋值运算符主要有四点

(1)参数类型
(2)返回值
(3)检测是否自己给自己赋值
(4)返回*this
(5)一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。
编译器默认生成赋值重载,跟拷贝构造做的事情完全类似:
①对内置类型成员会完成按字节序的拷贝(浅拷贝)。
②自定义类型成员,会调用他的赋值重载。

class Date{
public:
	Date(int year = 1900, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main(){
	Date d1;
	Date d2(2018101);

	// 这里d1调用的编译器生成operator=完成拷贝,d2和d1的值也是一样的。
	d1 = d2;
		return 0;
}

那么编译器生成的默认赋值重载函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?

// 这里会发现下面的程序会崩溃掉?这里就需要以后学习深拷贝去解决。
class String{
public:
	String(const char* str = ""){
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
	~String(){
		cout << "~String()" << endl;
		free(_str);
	}
private:
	char* _str;
};
int main(){
	String s1("hello");
	String s2("world");

	s1 = s2;
}

总结:构造、析构、拷贝构造、赋值运算符重载这四个默认成员函数。构造和析构处理机制是基本类似的;拷贝构造和赋值运算符重载是基本类似的。

6. 日期类的实现

内容后续补充

7. const成员

7.1 const修饰类的成员函数

将const修饰的类成员函数称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
在这里插入图片描述
我们来看看下面的代码:

class Date{
public:
	void Display(){
		cout << "Display ()" << endl;
		cout << "year:" << _year << endl;
		cout << "month:" << _month << endl;
		cout << "day:" << _day << endl << endl;
	}
	void Display() const{
		cout << "Display () const" << endl;
		cout << "year:" << _year << endl;
		cout << "month:" << _month << endl;
		cout << "day:" << _day << endl << endl;
	}
private:
	int _year; // 年
	int _month; // 月
	int _day; // 日
};
void Test(){
	Date d1;
	d1.Display();

	const Date d2;
	d2.Display();
}

请思考下面的几个问题:

  1. const对象可以调用非const成员函数吗?
  2. 非const对象可以调用const成员函数吗?
  3. const成员函数内可以调用其它的非const成员函数吗?
  4. 非const成员函数内可以调用其它的const成员函数吗?

8. 取地址及const取地址操作符重载

这两个默认成员函数一般不用重新定义 ,编译器默认会生成。

class Date{
public:
	Date* operator&(){
		return this;
	}

	const Date* operator&()const{
		return this;
	}
private:
	int _year; // 年
	int _month; // 月
	int _day; // 日
};

这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!

四、类和对象(下)

1. 再谈构造函数

1.1 构造函数体赋值

在创建对象时,编译器通过调用构造函数,给对象中各个成员变量一个合适的初始值。

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};

虽然上述构造函数调用之后,对象中已经有了一个初始值,但是不能将其称作为类对象成员的初始化,构造函数体中的语句只能将其称作为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。

1.2 初始化列表

初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。

class Date
{
public:
	Date(int year, int month, int day)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

private:
	int _year;
	int _month;
	int _day;
};

【注意】
(1) 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
(2)类中包含以下成员,必须放在初始化列表位置进行初始化:
①引用成员变量
②const成员变量
③自定义类型成员(该类没有默认构造函数)

class A {
public:
	A(int a)
		:_a(a)
	{}
private:
	int _a;
};
class B {
public:
	B(int a, int ref)
		:_aobj(a)
		, _ref(ref)
		, _n(10)
	{}
private:
	A _aobj; // 没有默认构造函数
	int& _ref; // 引用
	const int _n; // const 
};

(3)尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化。

class Time
{
public:
	Time(int hour = 0)
		:_hour(hour)
	{
		cout << "Time()" << endl;
	}
private:
	int _hour;
};
class Date
{
public:
	Date(int day)
	{}
private:
	int _day;
	Time _t;
};
int main()
{
	Date d(1);
}

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

class A {
public:
	A(int a)
		:_a1(a)
		, _a2(_a1)
	{}

	void Print() {
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a2;
	int _a1;
}
int main() {
	A aa(1);
	aa.Print();
	//实际输出 1 和 随机值
}

总结:
(1)初始化列表就是成员变量定义的地方。
(2)const、引用、没有默认构造函数的自定义类型成员变量必须在初始化列表初始化,因为他们都必须在定义的时候初始化。
(3)对于像其他类型成员变量,如:int _year、int _month,在哪初始化都可以。

1.3 explicit关键字

构造函数不仅可以构造与初始化对象,对于单个参数的构造函数,还具有类型转换的作用。

class Date
{
public:
	Date(int year)
		:_year(year)
	{}

	explicit Date(int year)
		:_year(year)
	{}

private:
	int _year;
	int _month;
	int _day;
}void TestDate()
{
	Date d1(2018);

	// 用一个整形变量给日期类型对象赋值
	// 注意:构造函数时单个参数的构造函数
	// 实际编译器背后会用2019构造一个无名对象,最后用无名对象给d1对象进行赋值
	d1 = 2019; //隐式类型转换,加上explicit,编译将不会通过
}

上述代码可读性不是很好,用explicit修饰构造函数,将会禁止单参构造函数的隐式转换。

2. static成员

2.1 概念

声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。静态的成员变量一定要在类外进行初始化。

面试题:实现一个类,计算中程序中创建出了多少个类对象。

class A {
public:
	A() { ++_scount; }
	A(const A& t) { ++_scount; }
	static int GetACount() { return _scount; }
private:
	static int _scount;
};
int A::_count = 0;
void TestA()
{
	cout << A::GetACount() << endl;
	A a1, a2;
	A a3(a1);
	cout << A::GetACount() << endl;
}

2.2 特性

(1) 静态成员为所有类对象所共享,不属于某个具体的实例。
(2)静态成员变量必须在类外定义,定义时不添加static关键字。
(3)类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问。
(4)静态成员函数没有隐藏的this指针,只能访问静态成员变量和成员函数,不能访问任何非静态成员。
(5)静态成员和类的普通成员一样,也有public、protected、private 3种访问级别,也可以具有返回值。

3. C++11的成员初始化新玩法

C++11支持非静态成员变量在声明时进行初始化赋值,但是要注意这里不是初始化,这里是给声明的成员变量缺省值。

class B {
public:
	B(int b = 0)
		:_b(b)
	{}
	int _b;
};
class A {
public:
	void Print()
	{
		cout << a << endl;
		cout << b._b << endl;
		cout << p << endl;
	}
private:
	// 非静态成员变量,可以在成员声明时给缺省值。
	int a = 10;
	B b = 20;
	int* p = (int*)malloc(4);
	static int n; //静态不能给缺省值,必须在类外面全局位置定义初始化
};
int A::n = 10;

int main()
{
	A a;
	a.Print();
	return 0;
}

4. 友元

友元分为:友元函数和友元类

友元提供了一种突破封装的方式,有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。

4.1 友元函数

问题:现在我们尝试去重载operator<<,然后发现我们没办法将operator<<重载成成员函数。因为cout的输出流对象和隐含的this指针在抢占第一个参数的位置。this指针默认是第一个参数也就是左操作数了。但是实际使用中cout需要是第一个形参对象,才能正常使用。所以我们要将operator<<重载成全局函数。但是这样的话,又会导致类外没办法访问成员,那么这里就需要友元来解决。operator>>同理。

class Date
{
public:
	Date(int year, int month, int day)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

	ostream& operator<<(ostream& _cout)
	{
		_cout << d._year << "-" << d._month << "-" << d._day;
		return _cout;
	}

prvate:
	int _year;
	int _month;
	int _day
};
int main()
{
	Date d(2017, 12, 24);
	d << cout;
	return 0;
}

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

class Date
{
	friend ostream& operator<<(ostream& _cout, const Date& d);
	friend istream& operator>>(istream& _cin, Date& d);
public:
	Date(int year, int month, int day)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

private:
	int _year;
	int _month;
	int _day;
};
ostream& operator<<(ostream& _cout, const Date& d) {
	_cout << d._year << "-" << d._month << "-" << d._day;

	return _cout;
}
istream& operator>>(istream& _cin, Date& d) {
	_cin >> d._year;
	_cin >> d._month;
	_cin >> d._day;

	return _cin;
}
int main()
{
	Date d;
	cin >> d;
	cout << d << endl;
	return 0;
}

说明:
(1)友元函数可访问类的私有和保护成员,但不是类的成员函数
(2)友元函数不能用const修饰
(3)友元函数可以在类定义的任何地方声明,不受类访问限定符限制
(4)一个函数可以是多个类的友元函数
(5)友元函数的调用与普通函数的调用和原理相同

4.2 友元类

友元类的所有成员函数都可以是另一个类的友元函数,都可以访问另一个类中的非公有成员。

(1)友元关系是单向的,不具有交换性。
比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行。

(2)友元关系不能传递
如果B是A的友元,C是B的友元,则不能说明C时A的友元。

class Date; // 前置声明
class Time
{
	friend class Date; // 声明日期类为时间类的友元类,则在日期类中就直接访问Time类中的私有成员变量
public:
	Time(int hour, int minute, int second)
		: _hour(hour)
		, _minute(minute)
		, _second(second)
	{}

private:
	int _hour;
	int _minute;
	int _second;
};
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

	void SetTimeOfDate(int hour, int minute, int second)
	{
		// 直接访问时间类私有的成员变量
		_t._hour = hour;
		_t._minute = minute;
		_t.second = second;
	}

private:
	int _year;
	int _month;
	int _day;
	Time _t;
};

5. 内部类

5.1 概念

概念:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。注意此时这个内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去调用内部类。外部类对内部类没有任何优越的访问权限。

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

5.2 特性

(1)内部类可以定义在外部类的public、protected、private都是可以的。
(2)注意内部类可以直接访问外部类中的static、枚举成员,不需要外部类的对象/类名。
(3)sizeof(外部类)=外部类,和内部类没有任何关系。

class A {
private:
	static int k;
	int h;
public:
	class B
	{
	public:
		void foo(const A& a)
		{
			cout << k << endl;//OK
			cout << a.h << endl;//OK
		}
	};
};
int A::k = 1;
int main()
{
	A::B b;
	b.foo(A());

	return 0;
}

6. 练习题

课件里!

7. 再次理解封装

8. 再次理解面向对象

五、C/C++内存管理

1. C/C++内存分布

为了C/C++内存分布理解通透,先看些题:

int globalVar = 1;
static int staticGlobalVar = 1;
void Test()
{
    static int staticVar = 1;
    int localVar = 1;

    int num1[10] = { 1, 2, 3, 4 };
    char char2[] = "abcd";
    char* pChar3 = "abcd";
    int* ptr1 = (int*)malloc(sizeof(int) * 4);
    int* ptr2 = (int*)calloc(4, sizeof(int));
    int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
    free(ptr1);
    free(ptr3);
}

(1) 选择题:
选项: A.栈 B.堆 C.数据段(静态区) D.代码段
globalVar在哪里?____ C 简单
staticGlobalVar在哪里?____ C 简单
staticVar在哪里?____ C 简单
localVar在哪里?____ A 简单
num1 在哪里?____ A 简单

char2在哪里?____ A
*char2在哪里?___ A *char2指的就是数组首元素

pChar3在哪里?____ A
*pChar3在哪里?____ D pChar3是常量字符串的地址,*pChar3就是指的常量字符串

ptr1在哪里?____ A
*ptr1在哪里?____ B *ptr1是推上的空间
(2)填空题:
sizeof(num1) = ____ 40
sizeof(char2) = ____ 5
strlen(char2) = ____ 4
sizeof(pChar3) = ____ 4
strlen(pChar3) = ____ 4
sizeof(ptr1) = ____ 4
图示分析:
在这里插入图片描述
说明:
(1)栈又叫堆栈,非静态局部变量/函数参数/返回值等等,栈是向下增长的。
(2)内存映射段是高效的I/O映射方式,用于装载一个共享的动态内存库。用户可使用系统接口创建共享共享内存,做进程间通信。(Linux课程如果没学到这块,现在只需要了解一下)
(3)堆用于程序运行时动态内存分配,堆是可以上增长的。
(4)数据段–存储全局数据和静态数据。
(5)代码段–可执行的代码/只读常量。

2. C语言中的动态内存管理方式

2.1 malloc、calloc、realloc和free

void Test()
{
    int* p1 = (int*)malloc(sizeof(int));
    int* p2 = (int*)calloc(4, sizeof(int));
    int* p3 = (int*)realloc(p2, sizeof(int) * 10);
    free(p1);
	free(p2);
    free(p3);
}

malloc/calloc/realloc的区别?(复习C语言知识)
(1)calloc 会初始化,相当于malloc+memset,按字节初始化,空间每个字节都初始化为0。
(2)realloc 扩容,有原地扩或异地扩。

3. C++内存管理方式

(1) C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力而且使用起来比较麻烦,因此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。

3.1 new/delete操作内置类型

void Test()
{
    // 动态申请一个int类型的空间
    int* ptr4 = new int;

    // 动态申请一个int类型的空间并初始化为10
    int* ptr5 = new int(10);

    // 动态申请10个int类型的空间
    int* ptr6 = new int[3];

    delete ptr4;
    delete ptr5;
    delete[] ptr6;
}

在这里插入图片描述
注意:
(1)申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用new[]和delete[]、
(2)malloc/free和new/delete对于内置类型没有本质区别,只有用法上的区别。
(3)C++98不支持初始化new数组,C++11支持用{}列表初始化。

3.2 new和delete操作自定义类型

class Test
{
public:
    Test()
        : _data(0)
    {
        cout << "Test():" << this << endl;
    }
    ~Test()
    {
        cout << "~Test():" << this << endl;
    }

private:
    int _data;
};
void Test2()
{
    // 申请单个Test类型的空间
    Test* p1 = (Test*)malloc(sizeof(Test));
    free(p1);

    // 申请10个Test类型的空间
    Test* p2 = (Test*)malloc(sizoef(Test) * 10);
    free(p2);
}
void Test2()
{
    // 申请单个Test类型的对象
    Test* p1 = new Test;
    delete p1;

    // 申请10个Test类型的对象
    Test* p2 = new Test[10];
    delete[] p2;
}

注意:在申请自定义类型的空间时,new会调用构造函数,delete会调用析构函数,而malloc与free不会。

3.3 C++提出new和delete主要解决两个问题:

①自定义类型和对象自动申请的时候,初始化和清理问题,new/delete会调用构造函数和析构函数。
②new失败了以后要求抛异常,这样才符合面向对象语言的出错处理机制。

ps:
delete和free一般不会失败,如果失败了,都是释放空间上存在越界或者释放指针位置不对。
面向对象的语言,处理错误的方式一般是抛异常;
面向过程的语言,处理错误的方式是返回值+错误码解决。

4. operator new与operator delete函数(重点)

4.1 operator new与operator delete函数

new和delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过operator delete全局函数来释放空间。

/*
operator new:该函数实际通过malloc来申请空间,当malloc申请空间成功时直接返回;申请空间失败,
尝试执行空 间不足应对措施,如果改应对措施用户设置了,则继续申请,否则抛异常。
*/
void* __CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc) {
    // try to allocate size bytes
    void* p;
    while ((p = malloc(size)) == 0)
        if (_callnewh(size) == 0)
        {
            // report no memory
            // 如果申请内存失败了,这里会抛出bad_alloc 类型异常
            static const std::bad_alloc nomem;
            _RAISE(nomem);
        }
    return (p);
}
/*
operator delete: 该函数最终是通过free来释放空间的
*/
void operator delete(void* pUserData) {
    _CrtMemBlockHeader* pHead;
    RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));
    if (pUserData == NULL)
        return;
    _mlock(_HEAP_LOCK); /* block other threads */
    __TRY
        /* get a pointer to memory block header */
        pHead = pHdr(pUserData);
    /* verify block type */
    _ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));
    _free_dbg(pUserData, pHead->nBlockUse);
    __FINALLY
        _munlock(_HEAP_LOCK); /* release other threads */
    __END_TRY_FINALLY
        return;
}
/*
free的实现
*/
#define free(p) _free_dbg(p, _NORMAL_BLOCK)

通过上述两个全局函数的实现知道,operator new 实际也是通过malloc来申请空间,如果malloc申请空间成功就直接返回,否则执行用户提供的空间不足应对措施,如果用户提供该措施就继续申请,否则就抛异常。operator delete 最终是通过free来释放空间的。

4.2 operator new与operator delete的类专属重载(了解)

下面代码演示了,针对链表的节点ListNode通过重载类专属 operator new/ operator delete,实现链表节点使用内存池申请和释放内存,提高效率。

struct ListNode
{
    ListNode* _next;
    ListNode* _prev;
    int _data;
    void* operator new(size_t n)
    {
        void* p = nullptr;
        p = allocator<ListNode>().allocate(1); //STL中的内存池--空间配置器
        cout << "memory pool allocate" << endl;
        return p;
    }
    void operator delete(void* p)
    {
        allocator<ListNode>().deallocate((ListNode*)p, 1);
        cout << "memory pool deallocate" << endl;
    }
};
class List
{
public:
    List()
    {
        _head = new ListNode;
        _head->_next = _head;
        _head->_prev = _head;
    }
    ~List()
    {
        ListNode* cur = _head->_next;
        while (cur != _head)
        {
            ListNode* next = cur->_next;
            delete cur;
            cur = next;
        }
        delete _head;
        _head = nullptr;
    }
private:
    ListNode* _head;
};
int main()
{
    List l;
    return 0;
}

5. new和delete的实现原理

5.1 内置类型

如果申请的是内置类型的空间,new和malloc,delete和free基本类似,不同的地方是:new/delete申请和释放的是单个元素的空间,new[]和delete[]申请的是连续空间,而且new在申请空间失败时会抛异常,malloc会返回NULL。

5.2 自定义类型

(1)new的原理
①调用operator new函数申请空间
②在申请的空间上执行构造函数,完成对象的构造
(2)delete的原理
①在空间上执行析构函数,完成对象中资源的清理工作
②调用operator delete函数释放对象的空间
(3)new T[N]的原理
①调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请
②在申请的空间上执行N次构造函数
(4)delete[]的原理
①在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理
②调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间

6. 定位new表达式(placement-new)(了解)

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

new (place_address) type
或者
new (place_address) type(initializer-list)
place_address必须是一个指针,initializer-list是类型的初始化列表。

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

class Test
{
public:
    Test()
        : _data(0)
    {
        cout << "Test():" << this << endl;
    }
    ~Test()
    {
        cout << "~Test():" << this << endl;
    }

private:
    int _data;
};
void Test()
{
    // pt现在指向的只不过是与Test对象相同大小的一段空间,还不能算是一个对象,因为构造函数没有执行
    Test* pt = (Test*)malloc(sizeof(Test));

    new(pt) Test; // 注意:如果Test类的构造函数有参数时,此处需要传参
}

7. 常见面试题

7.1 malloc/free和new/delete的区别

malloc/free和new/delete的共同点是:都是从堆上申请空间,并且需要用户手动释放。不同的地方是:
(1)malloc和free是函数,new和delete是操作符
(2)malloc申请的空间不会初始化,new可以初始化
(3)malloc申请空间时,需要手动计算空间大小并传递,new只需在其后跟上空间的类型即可
(4)malloc的返回值为void*, 在使用时必须强转,new不需要,因为new后跟的是空间的类型
(5)malloc申请空间失败时,返回的是NULL,因此使用时必须判空,new不需要,但是new需要捕获异常
(6)申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数与析构函数,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理

7.2 内存泄漏

7.2.1 什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

void MemoryLeaks()
{
    // 1.内存申请了忘记释放
    int* p1 = (int*)malloc(sizeof(int));
    int* p2 = new int;

    // 2.异常安全问题
    int* p3 = new int[10];

    Func(); // 这里Func函数抛异常导致 delete[] p3未执行,p3没被释放.

    delete[] p3;
}
7.2.2 内存泄漏的分类

C/C++程序中一般我们关心两种方面的内存泄漏:

堆内存泄漏(Heap leak)
堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。

系统资源泄漏
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。

7.2.3 如何检查内存泄漏

Linux下几款C++程序中的内存泄露检查工具
VS编程内存泄漏:VLD(Visual LeakDetector)内存泄露库
内存泄露检测工具比较

7.2.4 如何避免内存泄漏

(1)工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps:这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
(2)采用RAII思想或者智能指针来管理资源。
(3)有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
(4)靠谱,或者收费昂贵。

总结一下:
内存泄漏非常常见,解决方案分为两种:
1、事前预防型。如智能指针等。
2、事后查错型。如泄漏检测工具。

7.3 如何一次在堆上申请4G内存

// 将程序编译成x64的进程,运行下面的程序试试?
#include <iostream>
using namespace std;
int main()
{
    void* p = new char[0xfffffffful];
    cout << "new:" << p << endl;
    return 0;
}

六、模板初阶

1. 泛型编程

void Swap(int& left, int& right) {
	int temp = left;
	left = right;
	right = temp;
}
void Swap(double& left, double& right) {
	double temp = left;
	left = right;
	right = temp;
}
void Swap(char& left, char& right) {
	char temp = left;
	left = right;
	right = temp;
}

实现一个通用的交换函数,使用函数重载虽然可以实现,但是有一下几个不好的地方:
(1)重载的函数仅仅只是类型不同,代码的复用率比较低,只要有新类型出现时,就需要增加对应的函数
(2)代码的可维护性比较低,一个出错可能所有的重载均出错
为了解决好这些问题,于是诞生了泛型编程!
泛型编程:
编写与类型无关的通用代码,是代码复用的一种手段。模板是泛型编程的基础。
在这里插入图片描述

2. 函数模板

2.1 函数模板概念

函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本。

2.2 函数模板格式

template<typename T1, typename T2,…,typename Tn>
返回值类型 函数名(参数列表){}

template<typename T>
void Swap(T& left, T& right) {
    T temp = left;
    left = right;
    right = temp;
}

注意:
typename是用来定义模板参数关键字,也可以使用class(切记:不能使用struct代替class)

2.3 函数模板的原理

函数模板是一个蓝图,它本身并不是函数,是编译器用使用方式产生特定具体类型函数的模具。所以其实模板就是将本来应该我们做的重复的事情交给了编译器。
在这里插入图片描述
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。比如:当用double类型使用函数模板时,编译器通过对实参类型的推演,将T确定为double类型,然后产生一份专门处理double类型的代码,对于字符类型也是如此。

2.4 函数模板的实例化

用不同类型的参数使用函数模板时,称为函数模板的实例化。模板参数实例化分为:隐式实例化和显式实例化。
(1) 隐式实例化:让编译器根据实参推演模板参数的实际类型

template<class T> 
T Add(const T& left, const T& right) {
    return left + right;
}
int main()
{
    int a1 = 10, a2 = 20;
    double d1 = 10.0, d2 = 20.0;
    Add(a1, a2);
    Add(d1, d2);

    /*
    该语句不能通过编译,因为在编译期间,当编译器看到该实例化时,需要推演其实参类型
    通过实参a1将T推演为int,通过实参d1将T推演为double类型,但模板参数列表中只有一个T,
    编译器无法确定此处到底该将T确定为int 或者 double类型而报错
    注意:在模板中,编译器一般不会进行类型转换操作,因为一旦转化出问题,编译器就需要背黑锅
    Add(a1, d1);
    */

    // 此时有两种处理方式:1. 用户自己来强制转化 2. 使用显式实例化
    Add(a, (int)d);
    return 0;
}

(2)显式实例化:在函数名后的<>中指定模板参数的实际类型

int main(void) {
    int a = 10;
    double b = 20.0;

    // 显式实例化
    Add<int>(a, b);
    return 0;
}

如果类型不匹配,编译器会尝试进行隐式类型转换,如果无法转换成功编译器将会报错。

2.5 模板参数的匹配原则

(1). 一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数

// 专门处理int的加法函数
int Add(int left, int right) {
    return left + right;
}
// 通用加法函数
template<class T> 
T Add(T left, T right) {
    return left + right;
}
void Test()
{
    Add(1, 2); // 与非模板函数匹配,编译器不需要特化
    Add<int>(1, 2); // 调用编译器特化的Add版本
}

(2)对于非模板函数和同名函数模板,如果其他条件都相同,在调动时会优先调用非模板函数而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配的函数, 那么将选择模板

// 专门处理int的加法函数
int Add(int left, int right) {
    return left + right;
}
// 通用加法函数
template<class T1, class T2>
T1 Add(T1 left, T2 right) {
    return left + right;
}
void Test()
{
    Add(1, 2); // 与非函数模板类型完全匹配,不需要函数模板实例化
    Add(1, 2.0); // 模板函数可以生成更加匹配的版本,编译器根据实参生成更加匹配的Add函数
}

(3)模板函数不允许自动类型转换,但普通函数可以进行自动类型转换

3.类模板

3.1 类模板的定义格式

template<class T1, class T2, …, class Tn>
class 类模板名{
// 类内成员定义
};

// 动态顺序表
// 注意:Vector不是具体的类,是编译器根据被实例化的类型生成具体类的模具
template<class T>
class Vector
{
public:
    Vector(size_t capacity = 10)
        : _pData(new T[capacity])
        , _size(0)
        , _capacity(capacity)
    {}

    // 使用析构函数演示:在类中声明,在类外定义。
    ~Vector();

    void PushBack(const T& data)void PopBack()// ...

        size_t Size() { return _size; }

    T& operator[](size_t pos)
    {
        assert(pos < _size);
        return _pData[pos];
    }

private:
    T* _pData;
    size_t _size;
    size_t _capacity;
};
// 注意:类模板中函数放在类外进行定义时,需要加模板参数列表
template <class T>
Vector<T>::~Vector()
{
    if (_pData)
        delete[] _pData;
    _size = _capacity = 0;
}

3.2 类模板的实例化

类模板实例化与函数模板实例化不同,类模板实例化需要在类模板名字后跟<>,然后将实例化的类型放在<>中即可,类模板名字不是真正的类,而实例化的结果才是真正的类。

// Vector类名,Vector<int>才是类型
Vector<int> s1;
Vector<double> s2;

七、STL简介

推荐书籍:《STL源码剖析》《effcrive C++》《高质量 C++》

1. 什么是STL

STL(standard template libaray-标准模板库):是C++标准库的重要组成部分,不仅是一个可复用的组件库,而且是一个包罗数据结构与算法的软件框架。

2. STL的版本

原始版本:
Alexander Stepanov、Meng Lee 在惠普实验室完成的原始版本,本着开源精神,他们声明允许任何人任意运用、拷贝、修改、传播、商业使用这些代码,无需付费。唯一的条件就是也需要向原始版本一样做开源使用。 HP 版本–所有STL实现版本的始祖。
P.J.版本:
由P. J. Plauger开发,继承自HP版本,被Windows Visual C++采用,不能公开或修改,缺陷:可读性比较低,符号命名比较怪异。
RW版本:
由Rouge Wage公司开发,继承自HP版本,被C+ + Builder 采用,不能公开或修改,可读性一般。
SGI版本:
由Silicon Graphics Computer Systems,Inc公司开发,继承自HP版 本。被GCC(Linux)采用,可移植性好,可公开、修改甚至贩卖,从命名风格和编程 风格上看,阅读性非常高。我们后面学习STL要阅读部分源代码,主要参考的就是这个版本。

3. STL的六大组件

在这里插入图片描述

4. STL的重要性

非常重要!非常重要!!非常重要!!!
学习C++无非就是学两大类,第一类是基础语法,第二类就是STL
做题使用相当方便,求职面试要求高,工作中流行着一句话:“不懂STL,不要说你会C++”。
STL是C++中的优秀作品,有了它的陪伴,许多底层的数据结构以及算法都不需要自己重新造轮子,站在前人的肩膀上,健步如飞的快速开发。

5. 如何学习STL

截取一本书的内容:
在这里插入图片描述
简单总结一下:学习STL的三个境界:能用,明理,能扩展 。

6. STL的缺陷

(1)STL库的更新太慢了。这个得严重吐槽,上一版靠谱是C++98,中间的C++03基本一些修订。C++11出来已经相隔了13年,STL才进一步更新。
(2)STL现在都没有支持线程安全。并发环境下需要我们自己加锁。且锁的粒度是比较大的。
(3)STL极度的追求效率,导致内部比较复杂。比如类型萃取,迭代器萃取。
(4)STL的使用会有代码膨胀的问题,比如使用vector/vector/vector这样会生成多份代码,当然这是模板语法本身导致的。

八、string

1. string简介

1.1 C语言中的字符串

C语言中,字符串是以’\0’结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数,但是这些库函数与字符串是分离开的,不太符合OOP的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。

编码: 早起的计算机只显示英文,只需要128个字符就可以了,一个值和符号建立映射关系,这就是编码表;这个 时候就出现了ASCII编码表,来表示英文。后来计算机要全球化,就出现了unicode编码变,可以表示全世界文字的编码表,其中就有UTF-8、UTF-16、UTF-32等。中文自己量身定做的编码表是GBK。

1.2迭代器的意义:

所有的容器都可以使用迭代器这种方式访问修改。
对于string类,无论是正着遍历还是倒着遍历,下标+[]都足够好用,确实可以不用迭代器。但是对于list、map/set不支持下标+[]遍历,就需要迭代器。
迭代器是通用的遍历方式。
在C++中,凡是使用迭代器区间,都是左闭右开[)。

2. 标准库中的string类

2.1 string的介绍

(1)字符串是表示字符序列的类
(2)标准的字符串类提供了对此类对象的支持,其接口类似于标准字符容器的接口,但添加了专门用于操作单字节字符字符串的设计特性。
(3)string类是使用char(即作为它的字符类型,使用它的默认char_traits和分配器类型(关于模板的更多信息,请参阅basic_string)。
(4)string类是basic_string模板类的一个实例,它使用char来实例化basic_string模板类,并用char_traits和allocator作为basic_string的默认参数(根于更多的模板信息请参考basic_string)。
(5)注意,这个类独立于所使用的编码来处理字节:如果用来处理多字节或变长字符(如UTF-8)的序列,这个类的所有成员(如长度或大小)以及它的迭代器,将仍然按照字节(而不是实际编码的字符)来操作。
总结:
(1)string是表示字符串的字符串类
(2)该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
(3)string在底层实际是:basic_string模板类的别名,typedef basic_string<char, char_traits, allocator> string;
(4)不能操作多字节或者变长字符的序列。

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

2.2 string类的常用接口说明

2.2.1 string类对象的常见构造
(constructor)函数名称功能说明
string() (重点)构造空的string类对象,即空字符串
string(const char* s) (重点)用C-string来构造string类对象
string(size_t n, char c)string类对象中包含n个字符c
string(const string&s) (重点)拷贝构造函数
void Teststring()
{
    string s1; // 构造空的string类对象s1
    string s2("hello bit"); // 用C格式字符串构造string类对象s2
    string s3(s2); // 拷贝构造s3
}
2.2.2 string类对象的容量操作
函数名称功能说明
size返回字符串有效字符长度
length返回字符串有效字符长度
capacity返回空间总大小
empty检测字符串释放为空串,是返回true,否则返回false
clear清空有效字符
reserve为字符串预留空间
resize将有效字符的个数该成n个,多出的空间用字符c填充
// size/clear/resize
void Teststring1()
{
    // 注意:string类对象支持直接用cin和cout进行输入和输出
    string s("hello, bit!!!");
    cout << s.size() << endl;
    cout << s.length() << endl;
    cout << s.capacity() << endl;
    cout << s << endl;

    // 将s中的字符串清空,注意清空时只是将size清0,不改变底层空间的大小
    s.clear();
    cout << s.size() << endl;
    cout << s.capacity() << endl;
    // 将s中有效字符个数增加到10个,多出位置用'a'进行填充
    // “aaaaaaaaaa”
    s.resize(10, 'a');
    cout << s.size() << endl;
    cout << s.capacity() << endl;
    // 将s中有效字符个数增加到15个,多出位置用缺省值'\0'进行填充
    // "aaaaaaaaaa\0\0\0\0\0"
    // 注意此时s中有效字符个数已经增加到15个
    s.resize(15);
    cout << s.size() << endl;
    cout << s.capacity() << endl;
    cout << s << endl;
    // 将s中有效字符个数缩小到5个
    s.resize(5);
    cout << s.size() << endl;
    cout << s.capacity() << endl;
    cout << s << endl;
}
//====================================================================================
void Teststring2()
{
    string s;
    // 测试reserve是否会改变string中有效元素个数
    s.reserve(100);
    cout << s.size() << endl;
    cout << s.capacity() << endl;
    // 测试reserve参数小于string的底层空间大小时,是否会将空间缩小
    s.reserve(50);
    cout << s.size() << endl;
    cout << s.capacity() << endl;
}
// 利用reserve提高插入数据的效率,避免增容带来的开销
//====================================================================================
void TestPushBack()
{
    string s;
    size_t sz = s.capacity();
    cout << "making s grow:\n";
    for (int i = 0; i < 100; ++i)
    {
        s.push_back('c');
        if (sz != s.capacity())
        {
            sz = s.capacity();
            cout << "capacity changed: " << sz << '\n';
        }
    }
}
void TestPushBackReserve()
{
    string s;
    s.reserve(100);
    size_t sz = s.capacity();

    cout << "making s grow:\n";
    for (int i = 0; i < 100; ++i)
    {
        s.push_back('c');
        if (sz != s.capacity())
        {
            sz = s.capacity();
            cout << "capacity changed: " << sz << '\n';
        }
    }
}

注意:
(1)size()与length()方法底层实现原理完全相同,引入size()的原因是为了与其他容器的接口保持一致,一般情况下基本都是用size()。
(2)clear()只是将string中有效字符清空,不改变底层空间大小。
(3)resize(size_t n) 与 resize(size_t n, char c)都是将字符串中有效字符个数改变到n个,不同的是当字符个数增多时:resize(n)用0来填充多出的元素空间,resize(size_t n, char c)用字符c来填充多出的元素空间。注意:resize在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变。
(4) reserve(size_t res_arg=0):为string预留空间,不改变有效元素个数,当reserve的参数小于string的底层空间总大小时,reserver不会改变容量大小。

2.2.3 string类对象的访问及遍历操作
函数名称功能说明
operator[]返回pos位置的字符,const string类对象调用
begin + endbegin获取一个字符的迭代器 + end获取最后一个字符下一个位置的迭代器
rbegin + rendbegin获取一个字符的迭代器 + end获取最后一个字符下一个位置的迭代器
范围forC++11支持更简洁的范围for的新遍历方式

void Teststring()
{
    string s1("hello Bit");
    const string s2("Hello Bit");
    cout << s1 << " " << s2 << endl;
    cout << s1[0] << " " << s2[0] << endl;

    s1[0] = 'H';
    cout << s1 << endl;

    // s2[0] = 'h'; 代码编译失败,因为const类型对象不能修改
}
void Teststring()
{
    string s("hello Bit");
    // 3种遍历方式:
    // 需要注意的以下三种方式除了遍历string对象,还可以遍历是修改string中的字符,
    // 另外以下三种方式对于string而言,第一种使用最多
    // 1. for+operator[]
    for (size_t i = 0; i < s.size(); ++i)
        cout << s[i] << endl;

    // 2.迭代器
    string::iterator it = s.begin();
    while (it != s.end())
    {
        cout << *it << endl;
        ++it;
    }

    string::reverse_iterator rit = s.rbegin();
    while (rit != s.rend())
        cout << *rit << endl;

    // 3.范围for
    for (auto ch : s)
        cout << ch << endl;
}
2.2.4 string类对象的修改操作
函数名称功能说明
push_back在字符串后尾插字符c
append在字符串后追加一个字符串
operator+=在字符串后追加字符串str
c_str返回C格式字符串
find + npos从字符串pos位置开始往后找字符c,返回该字符在字符串中的位置
rfind从字符串pos位置开始往前找字符c,返回该字符在字符串中的位置
substr在str中从pos位置开始,截取n个字符,然后将其返回
void Teststring()
{
    string str;
    str.push_back(' '); // 在str后插入空格
    str.append("hello"); // 在str后追加一个字符"hello"
    str += 'b'; // 在str后追加一个字符'b' 
    str += "it"; // 在str后追加一个字符串"it"
    cout << str << endl;
    cout << str.c_str() << endl; // 以C语言的方式打印字符串

    // 获取file的后缀
    string file1("string.cpp");
    size_t pos = file.rfind('.');
    string suffix(file.substr(pos, file.size() - pos));
    cout << suffix << endl;

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

    // 取出url中的域名
    string url("http://www.cplusplus.com/reference/string/string/find/");
    cout << url << endl;
    size_t start = url.find("://");
    if (start == string::npos)
    {
        cout << "invalid url" << endl;
        return;
    }
    start += 3;
    size_t finish = url.find('/', start);
    string address = url.substr(start, finish - start);
    cout << address << endl;

    // 删除url的协议前缀
    pos = url.find("://");
    url.erase(0, pos + 3);
    cout << url << endl;
}

注意:
(1)在string尾部追加字符时,s.push_back© / s.append(1, c) / s += 'c’三种的实现方式差不多,一般情况下string类的+=操作用的比较多,+=操作不仅可以连接单个字符,还可以连接字符串。
(2)对string操作时,如果能够大概预估到放多少字符,可以先通过reserve把空间预留好。

2.2.5 string类非成员函数
函数名称功能说明
operator+尽量少用,因为传值返回,导致深拷贝效率低
operator>>输入运算符重载
operator<<输出运算符重载
getline获取一行字符串
relational operators大小比较
2.2.6 牛刀小试(OJ题)

(1)仅仅翻转字母
(2)字符串中的第一个唯一字符
(3)字符串最后一个单词的长度
(4)验证回文串
(5)字符串相加

扩充:
stoi等类
to_string

3. string的模拟实现

实际项目实现,这里就不付上代码了。

3.1 经典string类问题

class string
{
public:
    /*string()
    :_str(new char[1])
    {*_str = '\0';}
    */
    //string(const char* str = "\0") 错误示范
    //string(const char* str = nullptr) 错误示范
    string(const char* str = "")
    {
        // 构造string类对象时,如果传递nullptr指针,认为程序非法,此处断言下
        if (nullptr == str)
        {
            assert(false);
            return;
        }

        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }

    ~string()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }

private:
    char* _str;
};
// 测试
void Teststring()
{
    string s1("hello bit!!!");
    string s2(s1);
}

在这里插入图片描述
说明:
上述string类没有显式定义其拷贝构造函数与赋值运算符重载,此时编译器会合成默认的,当用s1构造s2时,编译器会调用默认的拷贝构造。最终导致的问题是,s1、s2共用同一块内存空间,在释放时同一块空间被释放多次而引起程序崩溃,这种拷贝方式,称为浅拷贝。

3.2 浅拷贝

浅拷贝也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以 当继续对资源进项操作时,就会发生发生了访问违规。要解决浅拷贝问题,C++中引入了深拷贝

浅拷贝的问题:
(1)析构两次
(2)其中一个对象进行修改会影响另外一个

3.3 深拷贝

如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显式给出。一般情况都是按照深拷贝方式提供。
在这里插入图片描述

3.3.1 传统写法的string类
class string
{
public:
    string(const char* str = "")
    {
        // 构造string类对象时,如果传递nullptr指针,认为程序非法,此处断言下
        if (nullptr == str)
        {
            assert(false);
            return;
        }

        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }

    string(const string& s)
        : _str(new char[strlen(s._str) + 1])
    {
        strcpy(_str, s._str);
    }

    string& operator=(const string& s)
    {
        if (this != &s)
        {
            char* pStr = new char[strlen(s._str) + 1];
            strcpy(pStr, s._str);
            delete[] _str;
            _str = pStr;
        }

        return *this;
    }

    ~string()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }

private:
    char* _str;
};
3.3.2 现代写法的string类
class string
{
public:
    string(const char* str = "")
    {
        if (nullptr == str)
            str = "";
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }

    string(const string& s)
        : _str(nullptr)
    {
        string strTmp(s._str);
        swap(_str, strTmp._str);
    }

    // 对比下和上面的赋值那个实现比较好?
    string& operator=(string s)
    {
        swap(_str, s._str);
        return *this;
    }

    /*
    string& operator=(const string& s)
    {
    if(this != &s)
    {
    string strTmp(s);
    swap(_str, strTmp._str);
    }

    return *this;
    }
    */

    ~string()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }

private:
    char* _str;
};

3.4 引用计数的写时拷贝

写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。
引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。

资料:写时拷贝
资料:写时拷贝在读取时的缺陷

缺陷:引用技术存在线程安全的问题,需要加锁,在多线程环境下,要付出代价;在动态库、静态库中的有些场景会出现问题。

4. 扩展阅读

面试中string的一种正确写法
STL中的string类怎么了?

九、vector

1. vector的介绍和使用

1.1 vector的介绍

(1)vector是表示可变大小数组的序列容器。
(2)就像数组一样,vector也采用的连续存储空间来存储元素。也就是意味着可以采用下标对vector的元素进行访问,和数组一样高效。但是又不像数组,它的大小是可以动态改变的,而且它的大小会被容器自动处理。
(3)本质讲,vector使用动态分配数组来存储它的元素。当新元素插入时候,这个数组需要被重新分配大小为了增加存储空间。其做法是,分配一个新的数组,然后将全部元素移到这个数组。就时间而言,这是一个相对代价高的任务,因为每当一个新的元素加入到容器的时候,vector并不会每次都重新分配大小。
(4)vector分配空间策略:vector会分配一些额外的空间以适应可能的增长,因为存储空间比实际需要的存储空间更大。不同的库采用不同的策略权衡空间的使用和重新分配。但是无论如何,重新分配都应该是对数增长的间隔大小,以至于在末尾插入一个元素的时候是在常数时间的复杂度完成的。
(5)因此,vector占用了更多的存储空间,为了获得管理存储空间的能力,并且以一种有效的方式动态增长。
(6)与其它动态序列容器相比(deques, lists and forward_lists), vector在访问元素的时候更加高效,在末尾添加和删除元素相对高效。对于其它不在末尾的删除和插入操作,效率更低。比起lists和forward_lists统一的迭代器和引用更好。

1.2 vector的使用

1.2.1 vector的定义
(constructor)构造函数声明接口说明
vector()无参构造
vector(size_type n, const value_type& val = value_type())构造并初始化n个val
vector (const vector& x)拷贝构造
vector (InputIterator first, InputIterator last)使用迭代器进行初始化构造
// constructing vectors
#include <iostream>
#include <vector>
int main()
{
    // constructors used in the same order as described above:
    std::vector<int> first; // empty vector of ints
    std::vector<int> second(4, 100); // four ints with value 100
    std::vector<int> third(second.begin(), second.end()); // iterating through second
    std::vector<int> fourth(third); // a copy of third
    // 下面涉及迭代器初始化的部分,我们学习完迭代器再来看这部分
    // the iterator constructor can also be used to construct from arrays:
    int myints[] = { 16,2,77,29 };
    std::vector<int> fifth(myints, myints + sizeof(myints) / sizeof(int));
    std::cout << "The contents of fifth are:";
    for (std::vector<int>::iterator it = fifth.begin(); it != fifth.end(); ++it)
        std::cout << ' ' << *it;
    std::cout << '\n';
    return 0;
}
1.2.2 vector iterator的使用
iterator的使用接口说明
begin + end获取第一个数据位置的iterator/const_iterator, 获取最后一个数据的下一个位置的iterator/const_iterator
rbegin + rend获取最后一个数据位置的reverse_iterator,获取第一个数据前一个位置reverse_iterator

在这里插入图片描述
在这里插入图片描述


#include <iostream>
#include <vector>
using namespace std;
void PrintVector(const vector<int>& v) {
    // const对象使用const迭代器进行遍历打印
    vector<int>::const_iterator it = v.begin();
    while (it != v.end())
    {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
}
int main()
{
    // 使用push_back插入4个数据
    vector<int> v;
    v.push_back(1);
    v.push_back(2);
    v.push_back(3);
    v.push_back(4);
    // 使用迭代器进行遍历打印
    vector<int>::iterator it = v.begin();
    while (it != v.end())
    {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
    // 使用迭代器进行修改
    it = v.begin();
    while (it != v.end())
    {
        *it *= 2;
        ++it;
    }
    // 使用反向迭代器进行遍历再打印
    vector<int>::reverse_iterator rit = v.rbegin();
    while (rit != v.rend())
    {
        cout << *rit << " ";
        ++rit;
    }
    cout << endl;
    PrintVector(v);
    return 0;
}
1.2.3 vector空间增长问题
容量空间接口说明
size获取数据个数
capacity获取容量大小
empty判断是否为空
resize改变vector的size
reserve改变vector的capacity

(1)capacity的代码在vs和g++下分别运行会发现,vs下capacity是按1.5倍增长的,g++是按2倍增长的。这个问题经常会考察,不要固化的认为,顺序表增容都是2倍,具体增长多少是根据具体的需求定义的。vs是PJ版本STL,g++是SGI版本STL。
(2)reserve只负责开辟空间,如果确定知道需要用多少空间,reserve可以缓解vector增容的代价缺陷问题。
(3)resize在开空间的同时还会进行初始化,影响size

// vector::capacity
#include <iostream>
#include <vector>
int main()
{
    size_t sz;
    std::vector<int> foo;
    sz = foo.capacity();
    std::cout << "making foo grow:\n";
    for (int i = 0; i < 100; ++i) {
        foo.push_back(i);
        if (sz != foo.capacity()) {
            sz = foo.capacity();
            std::cout << "capacity changed: " << sz << '\n';
        }
    }
}
vs:运行结果:
making foo grow :
capacity changed : 1
capacity changed : 2
capacity changed : 3
capacity changed : 4
capacity changed : 6
capacity changed : 9
capacity changed : 13
capacity changed : 19
capacity changed : 28
capacity changed : 42
capacity changed : 63
capacity changed : 94
capacity changed : 141
g++运行结果:
making foo grow :
capacity changed : 1
capacity changed : 2
capacity changed : 4
capacity changed : 8
capacity changed : 16
capacity changed : 32
capacity changed : 64
capacity changed : 128
// vector::reserve
#include <iostream>
#include <vector>
int main()
{
    size_t sz;
    std::vector<int> foo;
    sz = foo.capacity();
    std::cout << "making foo grow:\n";
    for (int i = 0; i < 100; ++i) {
        foo.push_back(i);
        if (sz != foo.capacity()) {
            sz = foo.capacity();
            std::cout << "capacity changed: " << sz << '\n';
        }
    }
    std::vector<int> bar;
    sz = bar.capacity();
    bar.reserve(100); // this is the only difference with foo above
    std::cout << "making bar grow:\n";
    for (int i = 0; i < 100; ++i) {
        bar.push_back(i);
        if (sz != bar.capacity()) {
            sz = bar.capacity();
            std::cout << "capacity changed: " << sz << '\n';
        }
    }
    return 0;
}
// vector::resize
#include <iostream>
#include <vector>
int main()
{
    std::vector<int> myvector;
    // set some initial content:
    for (int i = 1; i < 10; i++)
        myvector.push_back(i);
    myvector.resize(5);
    myvector.resize(8, 100);
    myvector.resize(12);
    std::cout << "myvector contains:";
    for (int i = 0; i < myvector.size(); i++)
        std::cout << ' ' << myvector[i];
    std::cout << '\n';
    return 0;
}
1.2.4 vector增删查改
vector增删查改接口说明
push_back尾插
pop_back尾删
find查找。(注意这个是算法模块实现,不是vector的成员接口)
insert在position之前插入val
erase删除position位置的数据
swap交换两个vector的数据空间
operator[]像数组一样访问
// push_back/pop_back
#include <iostream>
#include <vector>
using namespace std;
int main()
{
    int a[] = { 1, 2, 3, 4 };
    vector<int> v(a, a + sizeof(a) / sizeof(int));
    vector<int>::iterator it = v.begin();
    while (it != v.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
    v.pop_back();
    v.pop_back();
    it = v.begin();
    while (it != v.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
    return 0;
}
// find / insert / erase
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main()
{
    int a[] = { 1, 2, 3, 4 };
    vector<int> v(a, a + sizeof(a) / sizeof(int));
    // 使用find查找3所在位置的iterator
    vector<int>::iterator pos = find(v.begin(), v.end(), 3);

    // 在pos位置之前插入30
    v.insert(pos, 30);
    vector<int>::iterator it = v.begin();
    while (it != v.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
    pos = find(v.begin(), v.end(), 3);
    // 删除pos位置的数据
    v.erase(pos);
    it = v.begin();
    while (it != v.end()) {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
    return 0;
}
// operator[]+index 和 C++11中vector的新式for+auto的遍历
// vector使用这两种遍历方式是比较便捷的。
#include <iostream>
#include <vector>
using namespace std;
int main()
{
    int a[] = { 1, 2, 3, 4 };
    vector<int> v(a, a + sizeof(a) / sizeof(int));
    // 通过[]读写第0个位置。
    v[0] = 10;
    cout << v[0] << endl;
    // 通过[i]的方式遍历vector
    for (size_t i = 0; i < v.size(); ++i)
        cout << v[i] << " ";
    cout << endl;
    vector<int> swapv;
    swapv.swap(v);
    cout << "v data:";
    for (size_t i = 0; i < v.size(); ++i)
        cout << v[i] << " ";
    cout << endl;
    cout << "swapv data:";
    for (size_t i = 0; i < swapv.size(); ++i)
        cout << swapv[i] << " ";
    cout << endl;

    // C++11支持的新式范围for遍历
    for (auto x : v)
        cout << x << " ";
    cout << endl;
    return 0;
}
1.2.5 vector 迭代器失效问题(重点)

迭代器的主要作用就是让算法能够不用关心底层数据结构,其底层实际就是一个指针,或者是对指针进行了封装,比如:vector的迭代器就是原生态指针T*。因此迭代器失效,实际就是迭代器底层对应指针所指向的空间被销毁了,而使用一块已经被释放的空间,造成的后果是程序崩溃(即如果继续使用已经失效的迭代器,程序可能会崩溃)。

对于vector可能会导致其迭代器失效的操作有:
(1)会引起其底层空间改变的操作,都有可能是迭代器失效,比如:resize、reserve、insert、assign、push_back等。

#include <iostream>
using namespace std;
#include <vector>
int main()
{
    vector<int> v{ 1,2,3,4,5,6 };

    auto it = v.begin();

    // 将有效元素个数增加到100个,多出的位置使用8填充,操作期间底层会扩容
    // v.resize(100, 8);

    // reserve的作用就是改变扩容大小但不改变有效元素个数,操作期间可能会引起底层容量改变
    // v.reserve(100);

    // 插入元素期间,可能会引起扩容,而导致原空间被释放
    // v.insert(v.begin(), 0);
    // v.push_back(8);

 // 给vector重新赋值,可能会引起底层容量改变
    v.assign(100, 8);

    /*
    出错原因:以上操作,都有可能会导致vector扩容,也就是说vector底层原理旧空间被释放掉,
   而在打印时,it还使用的是释放之间的旧空间,在对it迭代器操作时,实际操作的是一块已经被释放的
   空间,而引起代码运行时崩溃。
    解决方式:在以上操作完成之后,如果想要继续通过迭代器操作vector中的元素,只需给it重新
   赋值即可。
    */
    while (it != v.end())
    {
        cout << *it << " ";
        ++it;
    }
    cout << endl;
    return 0;
}

(2)指定位置元素的删除操作–erase

#include <iostream>
using namespace std;
#include <vector>
int main()
{
    int a[] = { 1, 2, 3, 4 };
    vector<int> v(a, a + sizeof(a) / sizeof(int));
    // 使用find查找3所在位置的iterator
    vector<int>::iterator pos = find(v.begin(), v.end(), 3);
    // 删除pos位置的数据,导致pos迭代器失效。
    v.erase(pos);
    cout << *pos << endl; // 此处会导致非法访问
    return 0;
}

erase删除pos位置元素后,pos位置之后的元素会往前搬移,没有导致底层空间的改变,理论上讲迭代器不应该会失效,但是:如果pos刚好是最后一个元素,删完之后pos刚好是end的位置,而end位置是没有元素的,那么pos就失效了。因此删除vector中任意位置上元素时,vs就认为该位置迭代器失效了。

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

int main() //迭代器失效例子
{
    vector<int> v{ 1, 2, 3, 4 };
    auto it = v.begin();
    while (it != v.end())
    {
        if (*it % 2 == 0)
            v.erase(it);
	        ++it;
    }

    return 0;
}
int main() //迭代器失效解决办法
{
    vector<int> v{ 1, 2, 3, 4 };
    auto it = v.begin();
    while (it != v.end())
    {
        if (*it % 2 == 0)
            it = v.erase(it);
        else
            ++it;
    }
    return 0;
}
1.2.6 vector在OJ中的使用(OJ题)

(1)只出现一次的数字i
(2)杨辉三角
(3)删除排序数组中的重复项
(4)只出现一次的数ii
(5)只出现一次的数iii
(6)数组中出现次数超过一半的数字
(7)电话号码字母组合
(8)连续子数组的最大和
总结:通过上面的练习我们发现vector常用的接口更多是插入和遍历。遍历更喜欢用数组operator[i]的形式访问,因为这样便捷。

2. vector深度剖析及模拟实现

在这里插入图片描述
在这里插入图片描述

2.1 vector的核心框架接口的模拟实现

实际项目实现,这里就不付上代码了。

2.2 使用memcpy拷贝问题

假设模拟实现的vector中的reserve接口中,使用memcpy进行的拷贝,以下代码会发生什么问题?

int main()
{
    bite::vector<bite::string> v;
    v.push_back("1111");
    v.push_back("2222");
    v.push_back("3333");
    return 0;
}

问题分析:
(1)memcpy是内存的二进制格式拷贝,将一段内存空间中内容原封不动的拷贝到另外一段内存空间中
(2)如果拷贝的是自定义类型的元素,memcpy即高效又不会出错,但如果拷贝的是自定义类型元素,并且自定义类型元素中涉及到资源管理时,就会出错,因为memcpy的拷贝实际是浅拷贝。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
结论:
如果对象中涉及到资源管理时,千万不能使用memcpy进行对象之间的拷贝,因为memcpy是浅拷贝,否则可能会引起内存泄漏甚至程序崩溃。

2.3 动态二维数组理解

// 以杨慧三角的前n行为例:假设n为5
void test5(size_t n) {
    // 使用vector定义二维数组vv,vv中的每个元素都是vector<int>
    grid::vector<bit::vector<int>> vv(n);

    // 将二维数组每一行中的vecotr<int>中的元素全部设置为1
    for (size_t i = 0; i < n; ++i)
        vv[i].resize(i + 1, 1);
    // 给杨慧三角出第一列和对角线的所有元素赋值
    for (int i = 2; i < n; ++i)
    {
        for (int j = 1; j < i; ++j)
        {
            vv[i][j] = vv[i - 1][j] + vv[i - 1][j - 1];
        }
    }
}

grid::vector<bit::vector> vv(n); 构造一个vv动态二维数组,vv中总共有n个元素,每个元素都是vector类型的,每行没有包含任何元素,如果n为5时如下所示:
在这里插入图片描述
vv中元素填充完成之后,如下图所示:
在这里插入图片描述
使用标准库中vector构建动态二维数组时与上图实际是一致的。

十、list

1. list的介绍及使用

1.1 list的介绍

(1)list是可以在常数范围内在任意位置进行插入和删除的序列式容器,并且该容器可以前后双向迭代。
(2)list的底层是双向链表结构,双向链表中每个元素存储在互不相关的独立节点中,在节点中通过指针指向其前一个元素和后一个元素。
(3)list与forward_list非常相似:最主要的不同在于forward_list是单链表,只能朝前迭代,以让其更简单高效。
(4)与其他的序列式容器相比(array,vector,deque),list通常在任意位置进行插入、移除元素的执行效率更好。
(5)与其他序列式容器相比,list和forward_list最大的缺陷是不支持任意位置的随机访问,比如:要访问list的第6个元素,必须从已知的位置(比如头部或者尾部)迭代到该位置,在这段位置上迭代需要线性的时间开销;list还需要一些额外的空间,以保存每个节点的相关联信息(对于存储类型较小元素的大list来说这可能是一个重要的因素)
在这里插入图片描述

1.2 list的使用

list中的接口比较多,此处类似,只需要掌握如何正确的使用,然后再去深入研究背后的原理,已达到可扩展的能力。以下为list中一些常见的重要接口。

1.2.1 list的构造
(constructor)构造函数接口说明
list()构造空的list
list (size_type n, const value_type& val = value_type())构造的list中包含n个值为val的元素
list (const list& x)拷贝构造函数
list (InputIterator first, InputIterator last)用[first, last)区间中的元素构造list
// constructing lists
#include <iostream>
#include <list>
int main()
{
    std::list<int> l1;                       // 构造空的l1
    std::list<int> l2(4, 100);               // l2中放4个值为100的元素
    std::list<int> l3(l2.begin(), l2.end()); // 用l2的[begin(), end())左闭右开的区间构造l3
    std::list<int> l4(l3);                   // 用l3拷贝构造l4
    // 以数组为迭代器区间构造l5
    int array[] = {16, 2, 77, 29};
    std::list<int> l5(array, array + sizeof(array) / sizeof(int));
    // 用迭代器方式打印l5中的元素
    for (std::list<int>::iterator it = l5.begin(); it != l5.end(); it++)
        std::cout << *it << " ";
    std::cout << endl;

    // C++11范围for的方式遍历
    for (auto &e : l5)
        std::cout << e << " ";

    std::cout << endl;
    return 0;
}
1.2.2 list iterator的使用
函数声明接口说明
begin + end返回第一个元素的迭代器+返回最后一个元素下一个位置的迭代器
rbegin + rend返回第一个元素的reverse_iterator,即end位置,返回最后一个元素下一个位置的reverse_iterator,即begin位置

在这里插入图片描述
【注意】
(1)begin与end为正向迭代器,对迭代器执行++操作,迭代器向后移动
(2)rbegin(end)与rend(begin)为反向迭代器,对迭代器执行++操作,迭代器向前移动

#include <iostream>
using namespace std;
#include <list>
void print_list(const list<int> &l)
{
    // 注意这里调用的是list的 begin() const,返回list的const_iterator对象
    for (list<int>::const_iterator it = l.begin(); it != l.end(); ++it)
    {
        cout << *it << " ";
        // *it = 10; 编译不通过
    }

    cout << endl;
}
int main()
{
    int array[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    list<int> l(array, array + sizeof(array) / sizeof(array[0]));
    // 使用正向迭代器正向list中的元素
    for (list<int>::iterator it = l.begin(); it != l.end(); ++it)
        cout << *it << " ";
    cout << endl;
    // 使用反向迭代器逆向打印list中的元素
    for (list<int>::reverse_iterator it = l.rbegin(); it != l.rend(); ++it)
        cout << *it << " ";
    cout << endl;
    return 0;
}
1.2.3 list capacity
函数声明接口说明
empty检测list是否为空,是返回true,否则返回false
size返回list中有效节点的个数
1.2.4 list element access
函数声明接口说明
front返回list的第一个节点中值的引用
back返回list的最后一个节点中值的引用
1.2.5 list modifiers
函数声明接口说明
push_front在list首元素前插入值为val的元素
pop_front删除list中第一个元素
push_back在list尾部插入值为val的元素
pop_back删除list中最后一个元素
insert在list position 位置中插入值为val的元素
erase删除list position位置的元素
swap交换两个list中的元素
clear清空list中的有效元素
#include <list>
void PrintList(list<int> &l)
{
    for (auto &e : l)
        cout << e << " ";
    cout << endl;
}
//======================================================================================= ==
// push_back/pop_back/push_front/pop_front
void TestList1()
{
    int array[] = {1, 2, 3};
    list<int> L(array, array + sizeof(array) / sizeof(array[0]));
    // 在list的尾部插入4,头部插入0
    L.push_back(4);
    L.push_front(0);
    PrintList(L);
    // 删除list尾部节点和头部节点
    L.pop_back();
    L.pop_front();
    PrintList(L);
}
//======================================================================================= ==
// insert /erase
void TestList3()
{
    int array1[] = {1, 2, 3};
    list<int> L(array1, array1 + sizeof(array1) / sizeof(array1[0]));
    // 获取链表中第二个节点
    auto pos = ++L.begin();
    cout << *pos << endl;
    // 在pos前插入值为4的元素
    L.insert(pos, 4);
    PrintList(L);
    // 在pos前插入5个值为5的元素
    L.insert(pos, 5, 5);
    PrintList(L);
    // 在pos前插入[v.begin(), v.end)区间中的元素
    vector<int> v{7, 8, 9};
    L.insert(pos, v.begin(), v.end());
    PrintList(L);
    // 删除pos位置上的元素
    L.erase(pos);
    PrintList(L);
    // 删除list中[begin, end)区间中的元素,即删除list中的所有元素
    L.erase(L.begin(), L.end());
    PrintList(L);
}
// resize/swap/clear
void TestList4()
{
    // 用数组来构造list
    int array1[] = {1, 2, 3};
    list<int> l1(array1, array1 + sizeof(array1) / sizeof(array1[0]));
    PrintList(l1);
    // 交换l1和l2中的元素
    l1.swap(l2);
    PrintList(l1);
    PrintList(l2);
    // 将l2中的元素清空
    l2.clear();
    cout << l2.size() << endl;
}
1.2.6 list的迭代器失效

迭代器失效即迭代器所指向的节点的无效,即该节点被删除了。因为list的底层结构为带头结点的双向循环链表,因此在list中进行插入时是不会导致list的迭代器失效的,只有在删除时才会失效,并且失效的只是指向被删除节点的迭代器,其他迭代器不会受到影响。

void TestListIterator1()
{
    int array[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    list<int> l(array, array + sizeof(array) / sizeof(array[0]));
    auto it = l.begin();
    while (it != l.end())
    {
        // erase()函数执行后,it所指向的节点已被删除,因此it无效,在下一次使用it时,必须先给
        其赋值
        l.erase(it);
        ++it;
    }
}
// 改正
void TestListIterator()
{
    int array[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    list<int> l(array, array + sizeof(array) / sizeof(array[0]));
    auto it = l.begin();
    while (it != l.end())
    {
        l.erase(it++); // it = l.erase(it);
    }
}

2. list的模拟实现

实际项目实现,这里就不付上代码了。

3. list与vector的对比

vector与list都是STL中非常重要的序列式容器,由于两个容器的底层结构不同,导致其特性以及应用场景不同,其主要不同如下:
在这里插入图片描述

十一、stack和queue

1. stack的介绍和使用

1.1 stack的介绍

(1)stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。
(2)stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。
(3)stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下操作:

empty:判空操作
back:获取尾部元素操作
push_back:尾部插入元素操作
pop_back:尾部删除元素操作

(4)标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque。
在这里插入图片描述

1.2 stack的使用

函数说明接口说明
stack()构造空的栈
empty()检测stack是否为空
size()返回stack中元素的个数
top()返回栈顶元素的引用
push()将元素val压入stack中
pop()将stack中尾部的元素弹出

1.3 stack在OJ中的使用(OJ题)

(1)最小栈
(2)栈的弹出压入序列
(3)逆波兰表达式求值
(4)用两个栈实现队列

1.4 stack的模拟实现

实际项目实现,这里就不付上代码了。

2. queue的介绍和使用

2.1 queue的介绍

(1)队列是一种容器适配器,专门用于在FIFO上下文(先进先出)中操作,其中从容器一端插入元素,另一端提取元素。
(2)队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从队尾入队列,从队头出队列。
(3)底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:

empty:检测队列是否为空
size:返回队列中有效元素的个数
front:返回队头元素的引用
back:返回队尾元素的引用
push_back:在队列尾部入队列
pop_front:在队列头部出队列

(4)标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。
在这里插入图片描述

2.2 queue的使用

函数说明接口说明
queue()构造空的队列
empty()检测队列是否为空,是返回true,否则返回false
size()返回队列中有效元素的个数
front()返回队头元素的引用
back()返回队尾元素的引用
push()在队尾将元素val入队列
pop()将队头元素出队列

2.3 queue在OJ中的使用(OJ题)

(1)用队列实现栈

2.4 queue的模拟实现

实际项目实现,这里就不付上代码了。

3. priority_queue的介绍和使用

3.1 priority_queue的介绍

(1)优先队列是一种容器适配器,根据严格的弱排序标准,它的第一个元素总是它所包含的元素中最大的。
(2)此上下文类似于堆,在堆中可以随时插入元素,并且只能检索最大堆元素(优先队列中位于顶部的元素)。
(3)优先队列被实现为容器适配器,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从特定容器的“尾部”弹出,其称为优先队列的顶部。
(4)底层容器可以是任何标准容器类模板,也可以是其他特定设计的容器类。容器应该可以通过随机访问迭代器访问,并支持以下操作:

empty():检测容器是否为空
size():返回容器中有效元素个数
front():返回容器中第一个元素的引用
push_back():在容器尾部插入元素
pop_back():删除容器尾部元素

(5)标准容器类vector和deque满足这些需求。默认情况下,如果没有为特定的priority_queue类实例化指定容器类,则使用vector。
(6)需要支持随机访问迭代器,以便始终在内部保持堆结构。容器适配器通过在需要时自动调用算法函数make_heap、push_heap和pop_heap来自动完成此操作。

3.2 priority_queue的使用

函数声明接口说明
priority_queue()/priority_queue(first,last)构造一个空的优先级队列
empty( )检测优先级队列是否为空,是返回true,否则返回false
top( )返回优先级队列中最大(最小元素),即堆顶元素
push(x)在优先级队列中插入元素x
pop()删除优先级队列中最大(最小)元素,即堆顶元素

注意:
(1)默认情况下,priority_queue是大堆。

#include <vector>
#include <queue>
#include <functional> // greater算法的头文件
void TestPriorityQueue()
{
    // 默认情况下,创建的是大堆,其底层按照小于号比较
    vector<int> v{3, 2, 7, 6, 0, 4, 1, 9, 8, 5};
    priority_queue<int> q1;
    for (auto &e : v)
        q1.push(e);
    cout << q1.top() << endl;
    // 如果要创建小堆,将第三个模板参数换成greater比较方式
    priority_queue<int, vector<int>, greater<int>> q2(v.begin(), v.end());
    cout << q2.top() << endl;
}

(2)如果在priority_queue中放自定义类型的数据,用户需要在自定义类型中提供> 或者< 的重载。

class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
        : _year(year), _month(month), _day(day)
    {
    }
    bool operator<(const Date &d) const
    {
        return (_year < d._year) ||
               (_year == d._year && _month < d._month) ||
               (_year == d._year && _month == d._month && _day < d._day);
    }
    bool operator>(const Date &d) const
    {
        return (_year > d._year) ||
               (_year == d._year && _month > d._month) ||
               (_year == d._year && _month == d._month && _day > d._day);
    }
    friend ostream &operator<<(ostream &_cout, const Date &d)
    {
        _cout << d._year << "-" << d._month << "-" << d._day;
        return _cout;
    }

private:
    int _year;
    int _month;
    int _day;
};
void TestPriorityQueue()
{
    // 大堆,需要用户在自定义类型中提供<的重载
    priority_queue<Date> q1;
    q1.push(Date(2018, 10, 29));
    q1.push(Date(2018, 10, 28));
    q1.push(Date(2018, 10, 30));
    cout << q1.top() << endl;
    // 如果要创建小堆,需要用户提供>的重载
    priority_queue<Date, vector<Date>, greater<Date>> q2;
    q2.push(Date(2018, 10, 29));
    q2.push(Date(2018, 10, 28));
    q2.push(Date(2018, 10, 30));
    cout << q2.top() << endl;
}

3.3 priority_queue在OJ中的使用(OJ题)

(1)数组中第K个大的元素

3.4 priority_queue的模拟实现

实际项目实现,这里就不付上代码了。

4. 容器适配器

4.1 什么是适配器

适配器是一种设计模式(设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结),该种模式是将一个类的接口转换成客户希望的另外一个接口。
在这里插入图片描述

4.2 STL标准库中stack和queue的底层结构

虽然stack和queue中也可以存放元素,但在STL中并没有将其划分在容器的行列,而是将其称为容器适配器,这是因为stack和队列只是对其他容器的接口进行了包装,STL中stack和queue默认使用deque,比如:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

4.3 deque的简单介绍(了解)

4.3.1 deque的原理介绍

deque(双端队列):是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1),与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高。

在这里插入图片描述
deque并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际deque类似于一个动态的二维数组,其底层结构如下图所示:
在这里插入图片描述
双端队列底层是一段假象的连续空间,实际是分段连续的,为了维护其“整体连续”以及随机访问的假象,落在了deque的迭代器身上,因此deque的迭代器设计就比较复杂,如下图所示:
在这里插入图片描述
那deque是如何借助其迭代器维护其假想连续的结构呢?
在这里插入图片描述

4.3.2 deque的缺陷

(1)与vector比较,deque的优势是:头部插入和删除时,不需要搬移元素,效率特别高,而且在扩容时,也不需要搬移大量的元素,因此其效率是必vector高的。
(2)与list比较,其底层是连续空间,空间利用率比较高,不需要存储额外字段。
(3)但是,deque有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历,因此在实际中,需要线性结构时,大多数情况下优先考虑vector和list,deque的应用并不多,而目前能看到的一个应用就是,STL用其作为stack和queue的底层数据结构。

4.4 为什么选择deque作为stack和queue的底层默认容器

stack是一种后进先出的特殊线性数据结构,因此只要具有push_back()和pop_back()操作的线性结构,都可以作为stack的底层容器,比如vector和list都可以;queue是先进先出的特殊线性数据结构,只要具有push_back和pop_front操作的线性结构,都可以作为queue的底层容器,比如list。但是STL中对stack和queue默认选择deque作为其底层容器,主要是因为:
(1)stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作。
(2)在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据);queue中的元素增长时,deque不仅效率高,而且内存使用率高。

结合了deque的优点,而完美的避开了其缺陷。

4.5 STL标准库中对于stack和queue的模拟实现

实际项目实现,这里就不付上代码了。

十二、模板进阶

1. 非类型模板函数

模板参数分类类型形参与非类型形参。
类型形参: 出现在模板参数列表中,跟在class或者typename之类的参数类型名称。
非类型形参: 就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。

namespace grid
{
    // 定义一个模板类型的静态数组
    template <class T, size_t N = 10>
    class array
    {
    public:
        T &operator[](size_t index) { return _array[index]; }
        const T &operator[](size_t index) const { return _array[index]; }

        size_t size() const { return _size; }
        bool empty() const { return 0 == _size; }

    private:
        T _array[N];
        size_t _size;
    }}

注意:
(1)浮点数、类对象以及字符串是不允许作为非类型模板参数的。
(2)非类型的模板参数必须在编译期就能确认结果。

2. 模板的特化

2.1 概念

通常情况下,使用模板可以实现一些与类型无关的代码,但对于一些特殊类型的可能会得到一些错误的结果,比如:

template <class T>
bool IsEqual(T &left, T &right)
{
    return left == right;
}
void Test()
{
    char *p1 = "hello";
    char *p2 = "world";
    if (IsEqual(p1, p2))
        cout << p1 << endl;
    else
        cout << p2 << endl;
}

此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化。

2.2函数模板特化

函数模板的特化步骤:
(1)必须要先有一个基础的函数模板
(2)关键字template后面接一对空的尖括号<>
(3)函数名后跟一对尖括号,尖括号中指定需要特化的类型
(4)函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。

template <>
bool IsEqual<char *>(char *&left, char *&right)
{
    if (strcmp(left, right) > 0)
        return true;

    return false;
}

注意:
一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出。

bool IsEqual(char *left, char *right)
{
    if (strcmp(left, right) > 0)
        return true;

    return false;
}

2.3 类模板特化

2.3.1 全特化

全特化即是将模板参数列表中所有的参数都确定化。

template <class T1, class T2>
class Data
{
public:
    Data() { cout << "Data<T1, T2>" << endl; }

private:
    T1 _d1;
    T2 _d2;
};
template <>
class Data<int, char>
{
public:
    Data() { cout << "Data<int, char>" << endl; }

private:
    int _d1;
    char _d2;
};
void TestVector()
{
    Data<int, int> d1;
    Data<int, char> d2;
}
2.3.2 偏特化

偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。比如对于以下模板类:

template <class T1, class T2>
class Data
{
public:
    Data() { cout << "Data<T1, T2>" << endl; }

private:
    T1 _d1;
    T2 _d2;
};

偏特化有以下两种表现方式:
(1)部分特化:将模板参数类表中的一部分参数特化。

// 将第二个参数特化为int
template <class T1>
class Data<T1, int>
{
public:
    Data() { cout << "Data<T1, int>" << endl; }

private:
    T1 _d1;
    int _d2;
};

(2)参数更进一步的限制:偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一个特化版本。

// 两个参数偏特化为指针类型
template <typename T1, typename T2>
class Data<T1 *, T2 *>
{
public:
    Data() { cout << "Data<T1*, T2*>" << endl; }

private:
    T1 _d1;
    T2 _d2;
};
// 两个参数偏特化为引用类型
template <typename T1, typename T2>
class Data<T1 &, T2 &>
{
public:
    Data(const T1 &d1, const T2 &d2)
        : _d1(d1), _d2(d2)
    {
        cout << "Data<T1&, T2&>" << endl;
    }

private:
    const T1 &_d1;
    const T2 &_d2;
};
void test2()
{
    Data<double, int> d1;        // 调用特化的int版本
    Data<int, double> d2;        // 调用基础的模板
    Data<int *, int *> d3;       // 调用特化的指针版本
    Data<int &, int &> d4(1, 2); // 调用特化的指针版本
}

3. 模板分离编译

3.1 什么是分离编译

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

3.2 模板的分离编译

假如有以下场景,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义:

// a.h
template <class T>
T Add(const T &left, const T &right);
// a.cpp
template <class T>
T Add(const T &left, const T &right)
{
    return left + right;
}
// main.cpp
#include "a.h"
int main()
{
    Add(1, 2);
    Add(1.0, 2.0);

    return 0;
}

分析:
在这里插入图片描述

3.3 解决办法

(1)将声明和定义放到一个文件 “xxx.hpp” 里面或者xxx.h其实也是可以的。推荐使用这种。
(2)模板定义的位置显式实例化。这种方法不实用,不推荐使用。

3.4 分离编译扩展阅读

为什么C++编译器不能支持对模板的分离式编译?

4. 模板总结

【优点】
(1)准模板库(STL)因此而产生
(2)增强了代码的灵活性
【缺陷】
(1)模板会导致代码膨胀问题,也会导致编译时间变长
(2)出现模板编译错误时,错误信息非常凌乱,不易定位错误

十三、C++的IO流

1. C语言的输入与输出

C语言中我们用到的最频繁的输入输出方式就是scanf ()与printf()。
scanf():从标准输入设备(键盘)读取数据,并将值存放在变量中。
printf(): 将指定的文字/字符串输出到标准输出设备(屏幕)。注意宽度输出和精度输出控制。C语言借助了相应的缓冲区来进行输入与输出。如下图所示:
在这里插入图片描述
对输入输出缓冲区的理解:
(1)可以屏蔽掉低级I/O的实现,低级I/O的实现依赖操作系统本身内核的实现,所以如果能够屏蔽这部分的差异,可以很容易写出可移植的程序。
(2)可以使用这部分的内容实现“行”读取的行为,对于计算机而言是没有“行”这个概念,有了这部分,就可以定义“行”的概念,然后解析缓冲区的内容,返回一个“行”。

2. 流是什么

“流”即是流动的意思,是物质从一处向另一处流动的过程,是对一种有序连续且具有方向性的数据( 其单位可以是bit,byte,packet )的抽象描述。 C++流是指信息从外部输入设备(如键盘)向计算机内部(如内存)
输入和从内存向外部输出设备(显示器)输出的过程。这种输入输出的过程被形象的比喻为“流”。

它的特性是:有序连续、具有方向性

为了实现这种流动,C++定义了I/O标准类库,这些每个类都称为流/流类,用以完成某方面的功能

3. C++IO流

C++系统实现了一个庞大的类库,其中ios为基类,其他类都是直接或间接派生自ios类
在这里插入图片描述

3.1 C++标准IO流

C++标准库提供了4个全局流对象cin、cout、cerr、clog,使用cout进行标准输出,即数据从内存流向控制台(显示器)。使用cin进行标准输入即数据通过键盘输入到程序中,同时C++标准库还提供了cerr用来进行标准错误的输出,以及clog进行日志的输出,从上图可以看出,cout、cerr、clog是ostream类的三个不同的对象,因此这三个对象现在基本没有区别,只是应用场景不同。

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

注意:
(1)cin为缓冲流。键盘输入的数据保存在缓冲区中,当要提取时,是从缓冲区中拿。如果一次输入过多,会留在那儿慢慢用,如果输入错了,必须在回车之前修改,如果回车键按下就无法挽回了。只有把输入缓冲区中的数据取完后,才要求输入新的数据。
(2)输入的数据类型必须与要提取的数据类型一致,否则出错。出错只是在流的状态字state中对应位置位(置1),程序继续。
(3)空格和回车都可以作为数据之间的分格符,所以多个数据可以在一行输入,也可以分行输入。但如果是字符型和字符串,则空格(ASCII码为32)无法用cin输入,字符串中也不能有空格。回车符也无法读入。
(4)cin和cout可以直接输入和输出内置类型数据,原因:标准库已经将所有内置类型的输入和输出全部重载了:
在这里插入图片描述
在这里插入图片描述
(5)对于自定义类型,如果要支持cin和cout的标准输入输出,需要对<<和>>进行重载。
(6)在线OJ中的输入和输出:
对于IO类型的算法,一般都需要循环输入:

// 单个元素循环输入
while (cin >> a)
{
    // ...
}
// 多个元素循环输入
while (c >> a >> b >> c)
{
    // ...
}
// 整行接收
while (cin >> str)
{
    // ...
}

输出:严格按照题目的要求进行,多一个少一个空格都不行。

3.2 C++文件IO流

C++根据文件内容的数据格式分为二进制文件和文本文件。采用文件流对象操作文件的一般步骤:
(1)定义一个文件流对象

ifstream ifile(只输入用)
ofstream ofile(只输出用)
fstream iofile(既输入又输出用)

(2)使用文件流对象的成员函数打开一个磁盘文件,使得文件流对象和磁盘文件之间建立联系
(3)使用提取和插入运算符对文件进行读写操作,或使用成员函数进行读写
(4)关闭文件

// 使用文件IO流用文本及二进制方式演示读写配置文件
struct ServerInfo
{
    char _ip[32]; // ip
    int _port;    // 端口
};
struct ConfigManager
{
public:
    ConfigManager(const char *configfile = "bitserver.config")
        : _configfile(configfile)
    {
    }
    void WriteBin(const ServerInfo &info)
    {
        // 这里注意使用二进制方式打开写
        ofstream ofs(_configfile, ifstream::out | ifstream::binary);
        ofs.write((const char *)&info, sizeof(ServerInfo));
        ofs.close();
    }
    void ReadBin(ServerInfo &info)
    {
        // 这里注意使用二进制方式打开读
        ifstream ifs(_configfile, ifstream::in | ifstream::binary);
        ifs.read((char *)&info, sizeof(ServerInfo));
        ifs.close();
    }
    void WriteText(const ServerInfo &info)
    {
        // 这里会发现IO流写整形比C语言那套就简单多了,
        // C 语言得先把整形itoa再写
        ofstream ofs(_configfile);
        ofs << info._ip << endl;
        ofs << info._port << endl;
        ofs.close();
    }
    void ReadText(ServerInfo &info)
    {
        // 这里会发现IO流读整形比C语言那套就简单多了,
        // C 语言得先读字符串,再atoi
        ifstream ifs(_configfile);
        ifs >> info._ip;
        ifs >> info._port;
        ifs.close();
    }

private:
    string _configfile; // 配置文件
};
int main()
{
    ConfigManager cfgMgr;
    ServerInfo wtinfo;
    ServerInfo rdinfo;
    strcpy(wtinfo._ip, "127.0.0.1");
    wtinfo._port = 80;
    // 二进制读写
    cfgMgr.WriteBin(wtinfo);
    cfgMgr.ReadBin(rdinfo);
    cout << rdinfo._ip << endl;
    cout << rdinfo._port << endl;
    // 文本读写
    cfgMgr.WriteText(wtinfo);
    cfgMgr.ReadText(rdinfo);
    cout << rdinfo._ip << endl;
    cout << rdinfo._port << endl;
    return 0;
}

4. stringstream的简单介绍

在C语言中,如果想要将一个整形变量的数据转化为字符串格式,如何去做?
(1)使用itoa()函数
(2)使用sprintf()函数
但是两个函数在转化时,都得需要先给出保存结果的空间,那空间要给多大呢,就不太好界定,而且转化格式不匹配时,可能还会得到错误的结果甚至程序崩溃。

int main()
{
    int n = 123456789;
    char s1[32];
    _itoa(n, s1, 10);
    char s2[32];
    sprintf(s2, "%d", n);
    char s3[32];
    sprintf(s3, "%f", n);
    return 0;
}

在C++中,可以使用stringstream类对象来避开此问题。
在程序中如果想要使用stringstream,必须要包含头文件。在该头文件下,标准库三个类:
istringstream、ostringstream 和 stringstream,分别用来进行流的输入、输出和输入输出操作,本文主要介绍stringstream。
stringstream主要可以用来:
(1)将数值类型数据格式化为字符串

#include <sstream>
int main()
{
    int a = 12345678;
    string sa;
    // 将一个整形变量转化为字符串,存储到string类对象中
    stringstream s;
    s << a;
    s >> sa;
    // clear()
    // 注意多次转换时,必须使用clear将上次转换状态清空掉
    // stringstreams在转换结尾时(即最后一个转换后),会将其内部状态设置为badbit
    // 因此下一次转换是必须调用clear()将状态重置为goodbit才可以转换
    // 但是clear()不会将stringstreams底层字符串清空掉

    // s.str("");
    // 将stringstream底层管理string对象设置成"",
    // 否则多次转换时,会将结果全部累积在底层string对象中

    s.str("");
    s.clear(); // 清空s, 不清空会转化失败
    double d = 12.34;
    s << d;
    s >> sa;
    string sValue;
    sValue = s.str(); // str()方法:返回stringsteam中管理的string类型
    cout << sValue << endl;
    return 0;
}

(2)字符串拼接

int main()
{
    stringstream sstream;
    // 将多个字符串放入 sstream 中
    sstream << "first"
            << " "
            << "string,";
    sstream << " second string";
    cout << "strResult is: " << sstream.str() << endl;
    // 清空 sstream
    sstream.str("");
    sstream << "third string";
    cout << "After clear, strResult is: " << sstream.str() << endl;
    return 0;
}

注意:
(1)stringstream实际是在其底层维护了一个string类型的对象用来保存结果。
(2)多次数据类型转化时,一定要用clear()来清空,才能正确转化,但clear()不会将stringstream底层的
string对象清空。
(3)可以使用s. str(“”)方法将底层string对象设置为""空字符串。
(4)可以使用s.str()将让stringstream返回其底层的string对象。
(5)stringstream使用string类对象代替字符数组,可以避免缓冲区溢出的危险,而且其会对参数类型进
行推演,不需要格式化控制,也不会出现格式化失败的风险,因此使用更方便,更安全。

完结!

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值