C++ - string 的使用 #auto #范围for #访问及遍历操作 #容量操作 #修改操作 #其他操作 #非成员函数

发布于:2025-06-09 ⋅ 阅读:(14) ⋅ 点赞:(0)

文章目录

目录

文章目录

前言

一、为什么要学习string 类?

二、标准库中的string 类

1、auto 和 范围 for

1.1、auto 关键字:

知识点汇总:

详细理解:

1.2、范围for

知识点汇总:

详细理解:

2、string 中的常用接口

2.2 string 对象访问以及遍历操作

2.2.1 下标+[]

2.2.2 迭代器

2.2.2.1 普通正向迭代器:

2.2.2.2 普通反向迭代器

2.2.2.3 const 迭代器

2.2.3 范围for

2.3 string 对象的容量操作

2.3.1 size 和 length

2.3.2 max_size

2.3.3 capacity

2.3.4 clear 

2.3.5 empty

2.3.6 reserve 

2.3.7 resize

2.3.8 shrink_to_fit

2.4 string 类对象的修改操作

2.4.1 push_back

3.4.2 append

3.4.3 operator+=

3.4.4 insert

3.4.5 pop_back

3.4.6 erase

2.5 不同平台下string 的扩容机制

2.6 find和replace 替换指定字符

2.7 其他操作

2.7.1 c_str 

2.7.2 substr

2.8 非成员函数

2.8.1 getline

总结


前言

路漫漫其修远兮,吾将上下而求索;


一、为什么要学习string 类?

在C语言中,字符串以'\0' 为结尾的一些字符的集合,为了方便操作,C标准库中提供了一些str 系列的库函数,但是这些库函数与字符串是分离的,不太符合OOP的思想(OOP--> Object Oriented Programming 面向对象),而且字符串的底层空间需要用户自己去管理(开辟、销毁),如果稍有不注意很容易越界访问;

并且在OJ之中,有关字符串的题目基本上是以string 类的形式出现,在常规工作中,为了简单、方便、快捷,基本上都使用string 类,而很少去使用C语言库中的字符串来操作函数;

二、标准库中的string 类

1、auto 和 范围 for

1.1、auto 关键字:

知识点汇总:
  • 在早期C/C++ 中auto 的含义:使用auto 修饰的变量,是具有自动存储器的局部变量,后来这个就不重要了;在C++11 中,标准委员会变废为宝赋予了auto全新的含义,即:auto 不再是一个存储类型的指示符,而是作为一个新的类型指示符来指示编译器,auto 声明的变量必须由编译器在编译时期推导而得
  • auto 声明指针类型的时候,用auto 和 auto* 没有任何区别,但用auto 声明引用类型时必须加 & 
  • 当用auto 声明同一行多个变量时,这些变量必须是相同类型否则编译器就会报错;因为编译器实际只对第一个类型进行推导,然后用推导出来的类型去定义其他变量
  • C++11 中 auto 不能作为函数的参数,可以作为返回值,但是建议谨慎使用;但在C++20 中又支持auto 作为函数的参数类型
  • auto 不能直接用来声明数组
详细理解:

auto 只会推导第一个类型,即推导aa ,由于aa 的数据为整形,所以推导auto 为int 类型,但是bb 的数据为double 类型,类型不符,所以编译器报错了;所以当用auto 在同一行声明多个变量的时候,这些变量必须是相同类型,如下:

int main()
{
	//auto aa = 1, bb = 1.0;//错误的写法
	auto aa = 1, bb = 2;//正确的用法

	return 0;
}

还需要注意的是,不可以使用auto 来推导数组的类型

Q:为什么 C++ 11 中,auto 不可以用来推导数组类型?

先了解一下数组类型的特殊性以及auto 的推导规则;

在C++11中,auto 关键字用于自动推导变量的类型,但它不能直接用于推导数组的类型。原因在于数组类型在C++中具有一些独特的性质,而 auto 的设计和行为在处理数组时存在一些限制。

1. 数组类型的特殊性

在C++中,数组类型包含两个重要信息:元素类型和数组大小。例如, int[5] 是一个与 int[10] 不同的类型。数组名在大多数情况下会退化为指向其首元素的指针(例如,当作为函数参数传递时),但数组类型本身是存在的。

2.  auto 的推导规则

先来看一下模板的推导规则:

模板参数推导中,数组名会被推导为指针类型(除非使用引用)。例如:

template<typename T>
void f(T param);

int main()
{
    f(arr); // T被推导为int*
    return 0;
}

auto 使用模板参数推导规则。当使用 auto 声明一个变量并用数组初始化时,会发生什么?

例子代码如下:

int arr[5] = {1,2,3,4,5};

auto x = arr; // x的类型是什么?

这里, arr 是一个 int[5] 类型的数组。但是,根据C++的规则,在表达式中使用数组名时(除了作为 sizeof 或 typeid 等操作符的操作数,或者用于初始化引用,数组名表示类型)数组名会退化为指针。因此,auto x = arr; 实际上会将auto 推导为 int* ,而不是数组类型。

即便可以auto 推导为数组类型,表达式 auto x = arr; 合理吗?

auto x = arr; // 如果推导为数组类型,那么x应该是数组?但数组不能直接赋值

如果auto 是数组类型,那么该表达式就是赋值表达式,即 x 将是一个数组;而在C++中,数组是不能被赋值的(即不能将一个数组赋值给另一个数组),数组的拷贝只能通过逐元素拷贝。所以 x 就不能是数组,即auto 推导出来的不是数组类型;

我们可以测试一下:

int main()
{
	int array[] = { 1, 2,3 };
	auto y = array;//auto 推导出来的类型是 int* 

	cout << typeid(y).name() << endl;

	return 0;
}

Q:那究竟是否可以使用auto 推导出数组的类型呢?

  • 如果我们想要用auto 推导出数组类型,那么我们需要一种机制来保留数组的大小信息,可以使用引用
int main()
{
	//auto 不可以用来推到数组的类型
	int array[] = { 1, 2,3 };
	//但是可以使用引用
	auto& y =  array;

	cout << typeid(y).name() << endl;

	return 0;
}

运行结果如下:

注:此处我们使用了typeid 用于获取对象或类型的运行时类型信息;

这样,y 被推导为数组的引用,从而保留了数组的大小信息。

还需要注意的是,C++11中,auto 不能作为函数的参数,但是可以做其返回值;

Q:为什么auto 不可以作为函数的参数类型,却可以是其返回类型?

我们先来解释,为什么auto 不可以作为函数的参数类型:

如果允许 auto 作为参数类型,函数定义可能看起来像这样:

auto add(auto a, auto b) 
{ 
    return a + b;
}

这种写法在C++11中会引起歧义,因为 auto 在函数参数中无法明确表示参数类型是独立推导还是需要一致的类型。此外,这种语法与函数模板的语法非常相似,但函数模板需要写模板参数。上述代码等价于:

template <typename T, typename U>
auto add(T a, U b) 
{ 
    //... 
}

在C++11中,设计委员会认为引入这种简写形式会增加语言复杂性,而使用模板语法已经足够清晰。并且如果用auto 作为函数的参数,与模板函数的行为一致,但可能会引起链接和重载解析的混淆。

C++11引入 auto 的主要目的是简化局部变量的类型声明函数参数的类型推导已经可以通过函数模板实现,因此没有迫切的需求在C++11中支持 auto 作为参数类型。

 auto 的应用场景:

	//auto 的用处:简化类型声明
	//std::map<std::string, std::string>::iterator it = dict.begin();//迭代器的类型很长
	auto it = dict.begin();
	while (it != dict.end())
	{
		cout << it->first << ":" << it->second << endl;
		++it;
	}

但其实在C++20 中,又支持使用auto 作为函数参数了;

auto 可以作为函数的返回值,但是使用auto 作为函数的返回值,会导致程序不够清晰,如下:

auto func()
{
	return 1;
}

auto func1()
{
	auto x = func();
	return x;
}

auto func2()
{
	auto y = func1();
	return y;
}

int main()
{
	auto ret = func2();
	return 0;
}

由于函数的多层调用,而又使用auto ,想要知道变量ret 的类型就非常麻烦,所以用auto 作为函数的返回值需要谨慎使用

1.2、范围for

知识点汇总:
  • 对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,并且有时候还容易犯错;因此C++11中引入基于范围的for 循环。for循环后面的括号由冒号分为两部分,第一部分是范围内用于迭代的变量,第二部分则是表示被迭代的范围自动迭代,启动取数据,自动判断结束
  • 范围for 可以作用到数组容器对象上进行遍历
  • 范围for 的底层很简单,容器遍历实际上就是替换成迭代器,从汇编层可以明显地看出来;
详细理解:

范围for 的使用代码如下:

int main()
{
	int array[] = { 1,2,3,4,5,6,7 };
	//普通的for 循环
	for (int i = 0; i < sizeof(array) / sizeof(array[0]); i++)
	{
		cout << array[i] << " ";
	}
	cout << endl;

	//使用范围for
    //范围for 常与auto 一起使用,非常香~
	for (auto e : array)
	{
		cout << e << " ";
	}
	cout << endl;

	return 0;
}

运行结果如下:

在上述代码中,自动取数组array 中的数据赋值给e ,自动++,依次取数组中的数据并自动判断结束;意味着e 是数组中的数据的拷贝,想要改变数组中的数据就得使用引用传参;代码如下:

int main()
{
	int array[] = { 1,2,3,4,5,6,7 };
	//普通的for 循环
	for (int i = 0; i < sizeof(array) / sizeof(array[0]); i++)
	{
		cout << array[i] << " ";
	}
	cout << endl;

	//使用范围for
	for (auto e : array)
	{
		cout << e << " ";
	}
	cout << endl;

	//改变数组中的数据就得使用引用传参
	for (auto& e : array)
	{
	e *= 2;
	}
	for (auto e : array)
	{
	cout << e << " ";
	}
	cout << endl;

	return 0;
}

运行结果如下:

改进:倘若数组中的数据很大,直接赋值给e 会调用拷贝构造函数,会消耗并付出代价;使用引用接收;如果害怕更改数据,还可以加上const 进行修饰,代码如下:

int main()
{
	int array[] = { 1,2,3,4,5,6,7 };

	//使用范围for
	//引用接收,如果怕更改数据可以加上const 进行修饰
	for (const auto& e : array)
	{
		cout << e << " ";
	}
	cout << endl;

	return 0;
}

运行结果如下:

2、string 中的常用接口

string 的本质是一个 basic_string 的类的char 类型的模板,相较于其他类型的模板实现,string 是最常用的;

2.1 构造函数

int main()
{
	string s0("hello world!");//利用c_string(字符串)构造

	string s1;//默认构造 - 构造空字符串
	
	string s2(s0);//拷贝构造
	
	string s3(s0 ,5 , 6);//拷贝构造
	
	string s4(10, 'x');//n个char 进行构造

	string s5(s0.begin(), s0.end());//迭代器区间初始化

	cout << s0 << endl;
	cout << s1 << endl;
	cout << s2 << endl;
	cout << s3 << endl;
	cout << s4 << endl;
	cout << s5 << endl;

	return 0;
}

需要注意的是,如果我们要构造空字符串,不能在对象后面增加一个 (),即不能写做:而应该写做:写成string s1(); 编译器会将s1 当作一个函数!

2.2 string 对象访问以及遍历操作

三种遍历访问的方式,方法一:下标+[] ; 方法二:迭代器 ; 方法三:范围for ,本质上是两种访问方式,下标+[] 与迭代器访问

2.2.1 下标+[]
int main()
{
	string s("hello world!");
	//下标+[] 访问
	for (int i = 0; i < s.size(); i++)
	{
		cout << s[i] << " ";
	}
	cout << endl;

	return 0;
}

此处的s[i] 其实是函数重载实现的,即 operator[];s[i] 的原型为 s.operator[](i);

普通的string 对象使用operator[] 会去调用普通版本的operator[] , const 对象使用operator[] 会去调用const 版本的operator[];

2.2.2 迭代器

string 中的迭代器分为四种:普通正向迭代器、普通反向迭代器、const 正向迭代器、const 反向迭代器,如下:

需要注意的是,迭代器 iterator 是一个左闭右开的区间;

2.2.2.1 普通正向迭代器:
int main()
{
	string s("hello world!");
	//迭代器
	//string::iterator it = s.begin();
    //此处使用auto 就很方便
    auto it = s.begin();
	while (it != s.end())
	{
		cout << *it << " ";
		it++;
	}
	cout << endl;

	return 0;
}

字符串末尾的 \0 并非是有效的字符,而是一个标识字符;可以将迭代器想象成向指针一样的东西   ,迭代器作为六大组件之一,是一个通用的访问方式  ;

2.2.2.2 普通反向迭代器

如果想要倒序遍历,又该如何实现呢?普通迭代器分为正向迭代器iterator 与反向迭代器 reverse_iterator ,反向迭代器可以实现倒序遍历

而专门为反向迭代器专门提供了;两个接口:

int main()
{
	string s("hello world!");
	//反向迭代器
	string::reverse_iterator it = s.rbegin();
	while (it != s.rend())
	{
		cout << *it << " ";
		it++;//同样要使用++
	}
	cout << endl;
	return 0;
}

2.2.2.3 const 迭代器

const 对象不能使用普通迭代器,必须使用const迭代器;因为const 对象不可修改,权限只能平移、缩小,而不可以放大;对于const 迭代器来说也分为cost 正向迭代器 (const_iterator)与const 反向迭代器 (const_reverse_iterator);

同样地,也专门为const 迭代器提供了专门的接口函数:

int main()
{
	string s("hello world!");
	//const 正向迭代器
	string::const_iterator it1 = s.cbegin();
	while (it1 != s.cend())
	{
		cout << *it1 << " ";
		++it1;
	}
	cout << endl;
	//const 反向迭代器
	string::const_reverse_iterator it2 = s.crbegin();
	while (it2 != s.crend())
	{
		cout << *it2 << " ";
		++it2;//同样可使用++
	}
	cout << endl;
	return 0;
}

小结:

容器中一定会有迭代器,但是不一定会有反向迭代器;例如单链表便不会右反向迭代器

在string 中迭代器有四种,itreator、const_iterator、reverse_itreator、const_reverse_iterator

2.2.3 范围for

范围for 除了可以访问数组还可以访问容器

需要注意的是,范围for 的底层就是迭代器,也就是说当编译器在编译的时候齐底层根本就没有范围for,而是在编译的前置阶段便将范围for 的这段代码替换成了迭代器的访问方式;范围for 就是迭代器,只不过是编译器在干活;由于编码的原因,string 是一个模板,其底层可以看作是一个顺序表;

只要支持迭代器就一定支持范围for!并且,范围for 常与 auto 一起使用,非常“语法糖”~

int main()
{
	string s("hello world!");
	//范围for
	for (auto e : s)
	{
		cout << e << " ";
	}
	cout << endl;

	return 0;
}

2.3 string 对象的容量操作

2.3.1 size 和 length

size length 均是获取string 对象中有效字符的个数(不包含 '\0')的成员函数

int main()
{
	string s("hello world!");
	//length
	int len1 = s.length();
	//size
	int len2 = s.size();
	
	cout << len1 << " " << len2 << endl;

	return 0;
}

Q:既然size和length的功能相同,为什么要都实现出来呢?

  • 这是缘于历史发展的原因,string设计地比STL要早,而string最开始设计“获取有效字符个数“功能的函数是length;后来在STL地容器中将其统一命名为size,所以在string 中引入size 的原因是为了与其他容器的接口保持一致,在string 中 length 与 size 的功能是一致的,但是更推荐使用size;
2.3.2 max_size

max_size 表示该string 对象的最大能开多大的空间;而又因为max_size 的底层是写死的,并且取决于平台,所以max_size 在实际当中的使用意义不大;

int main()
{
	string s("hello world!");
	cout << s.max_size() << endl;

	return 0;
}

x86 平台下运行结果:

x64 平台下运行结果:

就单单拿 x86 下的运行结果来说,实际中string 能开这么大的空间吗?21亿字符就会占用大概2GB大小的空间,如果真的有这么大的字符串需要存储就直接用文件了而非string , 所以max_size 实际当中没什么用;

2.3.3 capacity

capacity 返回string 底层数组的容量即string对象中可以存储的有效字符的个数,capacity 中也不包含\0 的空间,故而在底层开辟空间的时候需要额外多开辟一个空间,这一点与C语言保持一致

int main()
{
	string s("hello world!");
	cout << s.capacity() << endl;

	return 0;
}

虽然此处为 15 byte,但实际上s 可以存储的实际容量为16byte ,最后一个是 '\0';

2.3.4 clear 

clear 会将所有的数据清除,但是并不会释放空间,单纯地清除数据将size 变为0

清除数据,一般不清除空间数据,只修改size,然后首位置改为\0,具体的实现更为复杂。

int main()
{
	string s("hello world!");
	cout <<  "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;

	s.clear();
	cout << "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;
	
	return 0;
}

从运行结果就可得知,clear 只会清除数据,即修改 size;

2.3.5 empty

检测字符串是否为空串,是空串的话返回true ,否则返回false;

int main()
{
	string s("hello world!");
	if (s.empty()) cout << "为空" << endl;
	else cout << "不为空" << endl;
	
	return 0;
}

2.3.6 reserve 

为字符串预留空间,一般用来扩容,但是不一定缩容;

int main()
{
	string s("hello world!");
	cout << "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;
	cout << "-----------------" << endl;

	//当 n 小于当前的capacity 
	s.reserve(5);
	cout << "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;
	cout << "-----------------" << endl;

	//当 n  大于当前的capacity
	s.reserve(20);
	cout << "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;

	return 0;
}

reserve(size_t n 0) : 为 string 预留空间,不改变有效元素个数,当reserve 的参数小于string 底层空间大小的时候,reserve 不会改变容器的大小

Linux 运行如下:

Q:为什么不可以缩容?

  • 性能。减少容量需要重新分配内存并复制数据,这是具有极大消耗的操作。标准库设计者认为,主动要求增加容量(预分配)是常见的优化手段,而要求减少容量则较少见,并且可以由程序员通过其他方式(如 shrink_to_fit )显式请求;
2.3.7 resize

将有效字符的个数改成 n  个,多出来的空间用 '\0' (没有传参就用'\0' 进行填充 , 传了字符c 就是用字符 c)来填充;

需要注意的是,当  n < size ,会进行删除数据,修改 size ,但是不会影响 capacity;当 size<n<capacity 的时候,会进行填充, 修改 size , 同样也不会影响capacity ;当 n >capacity 的时候,扩容 + 填充, 影响 size 和 capacity

int main()
{
	string s("hello world!");
	// n < size 会删除数据,即将size 修改为n ,但不会影响capacity 
	s.resize(5);
	cout << "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;
	cout << "-----------------" << endl;

	// size < n < capacity 填充数据,修改size 为 n ,不会影响capacity
	s.resize(12, 'x');//没有给resize第二个参数,那么就是用 '/0' 进行填充,给了什么字符就用哪一个字符进行填充
	cout << "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;
	cout << "-----------------" << endl;

	// n > capacity  扩容 + 填充
	s.resize(20, 'y');
	cout << "s:" << s << endl;
	cout << "size:" << s.size() << endl;
	cout << "capacity:" << s.capacity() << endl;
	cout << "-----------------" << endl;

	return 0;
}

需要注意的是,如果没有设置插入的字符,默认就是‘\0’,并且只有最后一个‘\0’会被认定为是终结符。

resize(size_t n)resize(size_t n , char c) 都是将字符串中有效字符的个数改变到 n 个不同的是,当字符个数增多的时候:resize(n) 使用 0 来填充多出来的元素空间,resize(size_t n , char c) 用字符c 来填充多出来的元素空间。注意,resize 在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变;

2.3.8 shrink_to_fit

shrink_to_fit 缩容,即对capacity 进行缩小以达到合适的值(size)

倘若你想用时间换空间的,插入了很多的数据又删除很多的数据的时候;便可以使用shrink_to_fit;建议少使用这个接口,因为这个接口实现缩容的代价非常大——异地缩容;之所以采用异地缩容是因为动态开辟的空间不能只释放部分,必须要全部释放,所以此处的缩容只能是异地缩容;

shrink_to_fit 缩容的原理异地申请一块空间,将旧空间中的数据拷贝放入到新空间中,释放旧空间;

2.4 string 类对象的修改操作

2.4.1 push_back

尾插一个字符;

int main()
{
	string s("hello world!");

	//尾插
	cout << s << endl;
	s.push_back('x');
	cout << s << endl;

	return 0;
}

3.4.2 append

在我们之前实现的顺序表、链表提供一个尾插push_back 就可以了,但是string 与其他容器不同;string 中可以插入一个字符也可以插入一个字符串,也可以插入多个字符——append;

在当前字符串的结尾进行追加;

int main()
{
	string s("hello world!");
	string s1("1234567");
	cout << s << endl;

	//append
	s.append(s1);
	cout << s << endl;
	s.append("xxx");
	cout << s << endl;
	s.append('y', 5);
	cout << s << endl;
	s.append(s1.begin() + 3, s1.end());
	cout << s << endl;

	return 0;
}

3.4.3 operator+=

+= 运算符重载函数常常可以替代 push_back、appned 使用;在平时的实践当中其实不太常用push_back 和append ,更加常用的是operator+=

operator+= 可以在字符串结尾追加string 对象、字符串、单个字符;

int main()
{
	string s("hello world!");
	string s1("1234567");
	cout << s << endl;

	//operaror++
	s += s1;
	cout << s << endl;
	s += "xxxxxxx";
	cout << s << endl;
	s += 'y';
	cout << s << endl;

	return 0;
}

使用operator+= 写起来简单并且代码的可读性很高;从底层来说 += 类似于C语言的一个接口:strcat ,但是实际上strcat 是一个效率很低的接口函数;

strcat 的效率低下体现在两个方面:

  • 1、需要遍历找到'\0' 的位置
  • 2、strcat 并不会对原始空间进行扩容处理;在使用strcat 的时候,使用者需要保证原始空间足够的可以放下所要拼接的字符串,否则追加便会越界;

而在c++之中有了类,将空间等问题结合在一起无需考虑其他关系,若要使用+=,直接使用便可,倘若空间不够会自动扩容;并且operator+= 的效率很高,直接_start + size() 便可以找到 \0 的位置,无需遍历查找,直接进行追加;

3.4.4 insert

pos 位置之前进行插入;

int main()
{
	string s("hello world!");
	string s1("1234567");
	cout << s << endl;

	//insert
	s.insert(5, s1);
	cout << s << endl;
	s.insert(12, "ooooo");
	cout << s << endl;
	s.insert(0, 3, 'x');
	cout << s << endl;
	s.insert(s.begin(), s1.begin(), s1.end());
	cout << s << endl;

	return 0;
}

3.4.5 pop_back

尾删;

int main()
{
	string s("hello world!");
	cout << s << endl;

	//pop_back
	s.pop_back();
	s.pop_back();
	s.pop_back();
	cout << s << endl;

	return 0;
}

3.4.6 erase

int main()
{
	string s("hello world!");
	cout << s << endl;

	//erase
	s.erase(5);
	cout << s << endl;
	s.erase(s.begin(), s.end() - 3);
	cout << s << endl;

	return 0;
}

erase 要谨慎使用,erase有时使用的效率极低,尤其是删除一部分数据的时候,因为会挪动数据;

3.4.7 assign

assign 转让,分配,会覆盖其原来的数据;

赋值的功能不够多样化,只能把整个对象赋值过去,故而又设计出来了一个assign;

void test3()
{
	string s1("hello world!");
	string s2("123456");
	cout << s1 << endl;
	//assign
	s1.assign(s2);
	cout << s1 << endl;

	s1.assign("xxxxxxxx");
	cout << s1 << endl;

	s1.assign(5, 'y');
	cout << s1 << endl;

	s1.assign(s2.begin() + 2, s2.end());
	cout << s1 << endl;
}

int main()
{
	test3();
	return 0;
}

2.5 不同平台下string 的扩容机制

我们先来观察一下 vs 中string 扩容的情况:

//观察vs 中string 扩容的情况
void test2()
{
	string s;
	//旧容量
	size_t old = s.capacity();
	cout << "容量变化:" << old << endl;
	cout << "扩容:" << endl;
	for (int i = 0; i < 100; i++)
	{
		s += 'x';//一个字符一个字符地增长
		if (old != s.capacity())
		{
			old = s.capacity();
			cout << "容量变化:" << old << endl;
		}
	}
}

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

通过运行之后屏幕上显示的可以知道,在vs 中的string 最开始的一下是以2倍扩容,而后是以 1.5 倍扩容,为什么?

  • 实际上string 设计得比较特殊, string的底层还有一个_Buf数组;因为编译器认为向系统频繁地申请小块的空间会存在一些内存碎片等问题,故而设计成了两段存储当我们的字符个数小于16的时候(实际存储15个字符,外加一个 \0 ),便会直接存放在_Buf数组中,并不会去堆上开辟空间,相当于此_buf数组本身是放在string 对象之中的;而当所要存储的字符个数大于15的时候,便会在堆上重新开辟空间,即将数据存放在_str 之中并不会在_Buf中存放一截然后又在_str 中存放一截,分开存储会很麻烦;也就是说,当所要存放的字符多余15的时候,_Buf数组的空间是会被浪费掉的;

在Linux 系统中g++ 编译器始终是以2倍进行扩容的;而VS这种特有的优化方式,是想要用空间换时间;

在Linux 下测试上述代码:

g++ 是以2倍进行扩容的,所以说,不同编译器其底层实现不同;

如若我们知道所要使用的空间为多少的时候,此时可以使用 reserve 来预留空间;可以减少扩容以提高效率;

//观察vs 中string 扩容的情况
void test2()
{
	string s;
	//如果要开辟500个空间,就直接使用reserse 
	s.reserve(500);
	//旧容量
	size_t old = s.capacity();
	cout << "容量变化:" << old << endl;
	cout << "扩容:" << endl;
	for (int i = 0; i < 100; i++)
	{
		s += 'x';//一个字符一个字符地增长
		if (old != s.capacity())
		{
			old = s.capacity();
			cout << "容量变化:" << old << endl;
		}
	}
}

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

Q: 为什么想要开辟500个空间,而实际上是511(比预计的要多) 呢?

  • 因为VS 下的一些对齐机制造成的;

2.6 find和replace 替换指定字符

find: 查找字符串中由其参数指定的序列的第一个出现。 当指定pos时,查找仅包括位置pos之后或该位置的字符,忽略任何可能包含位置pos之前字符的出现。find 找到了会返回找到的第一个字符的位置,如果没有找到就返回npos;返回npos 意味着没有找到的原因是因为一个字符串不可能有npos 这么长,由于npos 是公有的静态成员变量,所以npos可以在类外使用;

npos 是size_t 的最大值;

replace :替换字符串的一部分

利用replace 和find 解决之前做过的一道题:将空格替换成%%

void test5()
{
	string s1("hello world,i am zjx!");
	size_t pos = s1.find(' ');
	while (pos != string::npos)//find 没有找到会返回npos
	{
		s1.replace(pos, 1, "%%");
		pos = s1.find(' ', pos += 2);//因为将空格变成了%%,要从%%的后一个字符开始查找
	}
	cout << s1 << endl;
}

int main()
{
	test5();
	return 0;
}

replace 本质上有点像插入,仅仅只是像;有可能多替换少,也有可能是删除;无论是assign 还是replace 其功能都是高度相似的;要谨慎使用replace ,因为replace在大多数情况下是需要挪动数据的,效率极其低下;

前面的空格越多此算法的效率越低,因为使用replace 将一个字符换成两个字符,会挪动数据;越在前面,所要挪动的数据就越多,故而效率越低;

优化:

  • 方法一:操作起来比较麻烦,需要先将空间开辟,然后去数一下有多少个空格,然后从后往前从空格的地方开始挪动数据,替换了空格便减少了挪动的次数;
  • 方法二:空间换时间;再创建一个新串,遍历原串拷贝放入到新串之中遇到空格便将空格改为%%拷贝放入

优化二代码:

void test6()
{
	string s1("hello world,i am zjx!");
	//建立新串
	string s2;
	//遍历原串
	for (auto ch : s1)
	{
		if (ch == ' ')
			s2 += "%%";
		else
			s2 += ch;
	}
	//cout << s2 << endl;
	//s1 = s2;//可以赋值,也可以调用swap 函数
	s1.swap(s2);
	cout << s1 << endl;
}

int main()
{
	test6();
	return 0;
}

rfind 就是倒着查找;

使用rfind 的样例:假设此处有个文件要拿到其文件后缀;先是查找利用rfind 查找 . ,然后通过substr 拿到后缀

eg. 拿到test.cpp 的后缀:

用 find 也可以实现:

void test7()
{
	string s("test.cpp");
	size_t pos = s.find('.');
	if (pos != string::npos)//当find 找不到就会返回 npos
	{
		string str = s.substr(pos);//将pos 位置以及其后面的字符拿到,缺省 npos
		cout << str << endl;
	}
}

看似似乎可以用find 就可以解决,rfind 的用处呢?在Linux 中,文件的后缀名很长,eg. test.cpp.tar.zip ,此时如果使用find 从前往后查找,那么得到的后缀名为 .cpp.tar.zip ,而显然它的文件后缀名是为 .zip ;对于这个例子来说,就只能利用 rfind 从后往前查找:

void test8()
{
	string s("test.cpp.tar.zip");
	//从后往前找
	size_t pos = s.rfind(".");
	if (pos != string::npos)
	{
		string str = s.substr(pos);
		cout << str << endl;
	}
}

注:substr的使用 可以见下文的讲解;

而至于剩下的“find” : find_first_of 、find_last_of、find_first_not_of、find_last_not_of 都是早期设计而遗留下来的坑:

这些函数来源于早期的C++标准(甚至可追溯到C语言的字符串处理思想),在C++98之前就存在。当时,字符串处理功能相对简单,这些函数提供了一种便捷的方式。然而,随着C++的发展,更现代的字符串处理方式(如正则表达式、范围库)出现,但这些函数仍然保留以保持向后兼容。是特定历史背景下的产物。它们的设计初衷是为了提供一种简单的方法来查找字符集合的出现。然而,由于命名容易引起误解,以及与现代字符串处理需求的不完全匹配,它们有时会被开发者误用或避免。

函数名中的 of 可能让人误解为查找子串。实际上,它们查找的是字符集合(即参数中的任何一个字符)。例如:str.find_first_of("abc"); 并不是查找子串"abc",而是查找'a'、'b'或'c'任意一个字符首次出现的位置。

这些函数是为基于字符集合(而非完整子串)的搜索设计的

例如:查找字符串中第一个数字、最后一个标点等场景:

// 查找第一个数字
size_t pos = str.find_first_of("0123456789");

// 查找最后一个非字母字符
size_t pos = str.find_last_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
void test9()
{
	string s("Please, replace the vowels in this sentence by asterisks.");
	//将字符 aeux 替换成 *
	size_t pos = s.find_first_of("aeux" , 0);
	while(pos != string::npos)
	{
		s[pos] = '*';
		pos = s.find_first_of("aeux" , pos+1);
	}
	cout << s << endl;
}

函数名 实际行为 用户直觉误解
find_first_of 查找任意匹配字符首次出现 误以为查找子串首次出现
find_last_of 查找任意匹配字符最后一次出现 误以为查找子串最后一次出现
find_first_not_of 查找首个不匹配字符 命名相对准确
find_last_not_of 查找最后不匹配字符 命名相对准确

需要注意这四个函数都是查找所给参数字符串中任意一个字符:

// 查找数字的两种写法看起来相似,行为完全不同
str.find_first_of("123");   // 匹配'1','2','3'中任意字符
str.find_first_of(123);     // 匹配ASCII码为123的字符('{')

而find 和 rfind ,如果给的参数是字符串,那么就是去查找整个字符串:

函数 搜索目标 返回位置
find("abc") 完整子串 子串起始位置
find_first_of("abc") 任意字符 单个字符位置

2.7 其他操作

2.7.1 c_str 

返回c格式的字符串

void test10()
{
	string s("hello world!");
	cout << s << endl;
	cout << s.c_str() << endl;
}

C++已经实现了流插入运算符的函数重载,可以打印出C格式的字符串,为什么还有提供这个接口呢?

  • 有些软件并不会提供C++的接口。因为C++兼容C,c_str()可以保证C和C++混合编程。
2.7.2 substr

在 str 中从 pos 位置开始,截取 n 个字符,然后将其返回; 第二个参数为缺省值,默认不传,则获取 pos 以及 pos 位置之后的字符,有多少获取多少;即使所传的 n 大于 pos 之后的字符个数也没有关系,还是有多少取多少;

void test11()
{
	string s("hello world!");
	string str1 = s.substr(6);
	string str2 = s.substr(6, 20);
	string str3 = s.substr(6,3);

	cout << s << endl;
	cout << str1 << endl;
	cout << str2 << endl;
	cout << str3 << endl;
}

2.8 非成员函数

2.8.1 getline

使用getline 默认是以 '\n' 作为读取的结束标志;但是我们还可以传第三个参数来自定义结束标志;

int main()
{
	string name;
	cout << "Please , enter your fail name: ";
	getline(cin, name);
	cout << "Hello, " << name << "!\n";

	return 0;
}


总结

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

2、基于范围的for 循环。for循环后面的括号由冒号分为两部分,第一部分是范围内用于迭代的变量,第二部分则是表示被迭代的范围自动迭代,启动取数据,自动判断结束

3、size length 均是获取string 对象中有效字符的个数(不包含 '\0')的成员函数max_size 表示该string 对象的最大能开多大的空间(在实际当中的使用意义不大); capacity 返回string 底层数组的容量即string对象中可以存储的有效字符的个数;clear 会将所有的数据清除,但是并不会释放空间,单纯地清除数据将size 变为0;emply,检测字符串是否为空串,是空串的话返回true ,否则返回false;reserve,为字符串预留空间,一般用来扩容,但是不一定缩容;resize,将有效字符的个数改成 n  个,多出来的空间用 '\0' (没有传参就用'\0' 进行填充 , 传了字符c 就是用字符 c)来填充;shrink_to_fit 缩容,即对capacity 进行缩小以达到合适的值(size)

4、push_back 尾插一个字符;append ,在当前字符串的结尾进行追加(字符、string 对象、字符串);operator+=, 在当前字符串的结尾进行追加(字符、字符串、string 对象均可);insert , 任意位置进行插入;

5、c_str ,返回c格式字符串;getline 默认是以 '\n' 作为读取的结束标志;