C++ string 类(上)—— string类初识与常用接口详解

在这里插入图片描述

1. 为什么学习 string 类

1.1 C 语言中的字符串

C 语言中,字符串是以 '\0' 结尾的一些字符的集合,为了操作方便,C 标准库中提供了一些 str 系列的库函数,比如 strlen、strcpy、strcat、strcmp 等等。

但是这些库函数和字符串是分离开的,不太符合面向对象的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。

举个例子,你用 strcpy 拷贝字符串的时候,如果目标空间不够大,就会越界写入,编译器不会帮你检查,全靠程序员自己小心。

1.2 为什么需要 string 类

正是因为 C 语言字符串有这些问题,C++ 中就设计了 string 类。

  1. string 类把字符串和操作字符串的方法封装在了一起,符合面向对象的思想。

  2. string 类自动管理底层空间,不需要我们自己 new 和 delete,大大降低了内存泄漏和越界的风险。

  3. 在 OJ 中,有关字符串的题目基本以 string 类的形式出现,在常规工作中,为了简单、方便、快捷,基本都使用 string 类,很少有人去使用 C 库中的字符串操作函数。

2. string 类的使用

2.1 string 类的介绍

在使用 string 类时,必须包含:

#include <string>

头文件以及:

using namespace std;

string 类其实是 basic_string<char> 的 typedef,是标准库提供的一个管理字符数组的类。

2.2 补充语法:auto 和范围 for

在这里补充两个 C++11 的小语法,方便我们后面的学习。

2.2.1 auto 关键字

在早期 C/C++ 中 auto 的含义是:

使用 auto 修饰的变量,是具有自动存储器的局部变量(入函数创建,出函数销毁)。

后来这个不重要了。

C++11 中,标准委员会变废为宝赋予了 auto 全新的含义:

auto 不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto 声明的变量必须由编译器在编译时期推导而得。

#include<iostream>

using namespace std;


int func1()
{
	return 10;
}


int main()
{
	int a = 10;

	auto b = a;       // 推导为int
	auto c = 'a';     // 推导为char
	auto d = func1(); // 推导为int


	cout << typeid(b).name() << endl;
	cout << typeid(c).name() << endl;
	cout << typeid(d).name() << endl;


	return 0;
}

typeid(b).name(),typeid 运算符可以求出变量的类型。

auto 使用时的注意点:

1. 用 auto 声明指针和引用

用 auto 声明指针类型时,用 auto 和 auto* 没有任何区别。

但是用 auto 声明引用类型时则必须加 &

int x = 10;

auto y = &x;    // 推导为int*
auto* z = &x;   // 推导为int*,和上面没有区别
auto& m = x;    // 推导为int&,引用必须加&
2. 同一行声明多个变量时必须类型一致

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

因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。

错误代码:

auto cc = 3, dd = 4.0;

编译报错:

auto 必须始终推导为同一类型。

3. auto 不能作为函数参数

错误代码:

void func2(auto a)
{}

因为参数是接口,调用前必须确定类型,编译器无法推导。

4. auto 可以做函数返回值,但是建议谨慎使用
auto func3()
{
	return 3;
}

返回值用 auto 的坏处是,你可能要不断往前看才能知道返回的类型是什么,对我们不太友好。

对编译器没有什么影响,因为编译器调用完函数以后顺带推导的,倒是没有什么消耗。

5. auto 不能直接用来声明数组

错误代码:

auto array[] = { 4, 5, 6 };

因为 auto 只能推出类型,推不出类型加数量,而初始化列表也没有推导数量的作用。

6. auto 的用武之地

当类型名特别长的时候,auto 就非常好用了。

#include<iostream>
#include<string>
#include<map>

using namespace std;


int main()
{
	std::map<std::string, std::string> dict =
	{
		{ "apple", "苹果" },
		{ "orange", "橙子" },
		{ "pear", "梨" }
	};


	// 不用auto,类型名超长
	// std::map<std::string, std::string>::iterator it = dict.begin();


	// 用auto,简洁明了

	auto it = dict.begin();


	while (it != dict.end())
	{
		cout << it->first << ":" << it->second << endl;

		++it;
	}


	return 0;
}

当然凡事都有弊端的,这里也是牺牲了一部分可读性的。

2.2.2 范围 for

对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此 C++11 中引入了基于范围的 for 循环。

for 循环后的括号由冒号 : 分为两部分:

第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。

自动取数据,自动迭代,自动判断结束。

#include<iostream>
#include<string>

using namespace std;


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


	// C++98的遍历
	for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
	{
		array[i] *= 2;
	}


	// C++11的范围for遍历
	for (auto& e : array)
		e *= 2;


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


	cout << endl;


	string str("hello world");


	for (auto ch : str)
	{
		cout << ch << " ";
	}


	cout << endl;


	return 0;
}

范围 for 的注意点:

  1. 范围 for 的底层很简单,容器遍历实际就是替换为迭代器,这个从汇编层也可以看到。
  2. 迭代器可以直接修改值,但是范围 for 不是这样的,加了引用才可以修改值,否则只是拷贝。
string s2("hello");
// 不加引用,ch是拷贝,修改不影响原字符串
for (auto ch : s2)
{
	ch -= 2; // 改的是临时拷贝
}

cout << s2 << endl; // 还是hello

// 加引用,ch是引用,修改影响原字符串

for (auto& ch : s2)
{
	ch -= 2; // 改的是原字符串
}


cout << s2 << endl; // 变成了fcjjm
  1. 范围 for 可以作用到数组和容器对象上进行遍历。

2.3 string 类对象的常见构造

函数名称功能说明
string()构造空的 string 类对象,即空字符串
string(const char* s)用 C-string 来构造 string 类对象
string(size_t n, char c)string 类对象中包含 n 个字符 c
string(const string& s)拷贝构造函数
#include<iostream>
#include<string>

using namespace std;


void Teststring()
{
	string s1;                // 构造空的string类对象s1
	string s2("hello bit");   // 用C格式字符串构造string类对象s2
	string s3(10, 'x');       // 构造包含10个'x'的string对象
	string s4(s2);            // 拷贝构造s4


	cout << s1 << endl;
	cout << s2 << endl;
	cout << s3 << endl;
	cout << s4 << endl;
}


int main()
{
	Teststring();

	return 0;
}

2.4 string 类对象的容量操作

函数名称功能说明
size返回字符串有效字符长度
length返回字符串有效字符长度
capacity返回空间总大小
empty检测字符串是否为空串,是返回 true,否则返回 false
clear清空有效字符
reserve为字符串预留空间
resize将有效字符的个数改成 n 个,多出的空间用字符 c 填充
#include<iostream>
#include<string>

using namespace std;


int main()
{
	string s("hello bit");


	cout << s.size() << endl;      
	cout << s.length() << endl;    
	cout << s.capacity() << endl;  
	cout << s.empty() << endl;     


	s.clear();


	cout << s << endl;             
	cout << s.capacity() << endl;  


	s.reserve(100);


	cout << s.capacity() << endl;  


	s.resize(5, 'a');


	cout << s << endl;             


	return 0;
}

容量操作的注意点:

  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 在改变元素个数时,如果是将元素个数增多,会改变底层容量的大小;

如果是将元素个数减少,底层空间总大小不变。

  1. reserve(size_t res_arg=0):为 string 预留空间,不改变有效元素个数。

当 reserve 的参数小于 string 的底层空间总大小时,VS2022中reserve 不会改变容量大小,但是linux会改变,即缩容,就是因为文档在这种情况下写的是没有约束力的请求,即请求缩容,但是不算强制的。

对 string 操作时,如果能够大概预估到放多少字符,可以先通过 reserve 把空间预留好,避免频繁扩容。

2.5 string 类对象的访问及遍历操作

函数名称功能说明
operator[]返回 pos 位置的字符,const 和非 const 版本都有
begin + endbegin 获取第一个字符的迭代器,end 获取最后一个字符下一个位置的迭代器
rbegin + rend反向迭代器,从后往前遍历
范围 forC++11 支持更简洁的遍历方式
#include<iostream>
#include<string>

using namespace std;


int main()
{
	string s("hello bit");


	// 1. operator[] 下标访问

	for (size_t i = 0; i < s.size(); ++i)
	{
		cout << s[i] << " ";
	}

	cout << endl;



	// 2. 迭代器遍历

	string::iterator it = s.begin();

	while (it != s.end())
	{
		cout << *it << " ";

		++it;
	}

	cout << endl;



	// 3. 反向迭代器遍历

	string::reverse_iterator rit = s.rbegin();

	while (rit != s.rend())
	{
		cout << *rit << " ";

		++rit;
	}

	cout << endl;



	// 4. 范围for遍历

	for (auto ch : s)
	{
		cout << ch << " ";
	}

	cout << endl;


	return 0;
}

迭代器使用的注意点:

不能直接对 end() 进行 -- 操作,因为 end() 返回的是临时右值,不能对临时右值进行自减。

错误代码:

auto it = --container.end();

错误原因:

不能对临时右值进行自减。

正确做法:

先把 end() 拿到,存在一个变量里,此时它就变成了左值,可以安全地进行 -- 操作。

auto it = s.end(); // 先用变量存储end()迭代器

--it;              // 对变量进行自减,安全指向最后一个有效元素

cout << *it << endl;

2.6 string 类对象的修改操作

函数名称功能说明
push_back在字符串后尾插字符 c
append在字符串后追加一个字符串
operator+=在字符串后追加字符串 str(最常用)
c_str返回 C 格式字符串
data返回字符串数据
find + npos从字符串 pos 位置开始往后找,返回位置
rfind从字符串 pos 位置开始往前找,返回位置
find_first_of查找字符集合中任意一个字符的第一个匹配
find_last_of从后往前查找字符集合中任意一个字符
substr从 pos 位置开始,截取 n 个字符返回
copy把字符串中的字符拷贝到 buffer 中

2.6.1 尾插操作

在 string 尾部追加字符时:

push_back(c) / append(1, c) / += 'c'

三种的实现方式差不多。

一般情况下 string 类的 += 操作用的比较多。

+= 操作不仅可以连接单个字符,还可以连接字符串。

#include<iostream>
#include<string>

using namespace std;


int main()
{
	string s;


	s.push_back('h');      // 尾插字符

	s.append("ello");      // 追加字符串

	s += ' ';              // +=追加字符

	s += "bit";            // +=追加字符串


	cout << s << endl;     // hello bit


	return 0;
}

2.6.2 c_str 和 data

c_str() 返回 C 格式字符串,即 const char*,末尾保证有 '\0'

data()c_str() 功能类似,返回 const char*,指向字符串内部字符数组的首地址。

注意:

C++17 之前:

data()

不保证末尾带

'\0'

C++17 及以后:

data()

c_str()

完全一样,末尾保证有

'\0'

空终止符。

#include<iostream>
#include<string>

using namespace std;


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


	const char* p1 = s.c_str();  // 返回C格式字符串

	const char* p2 = s.data();   // 返回字符串数据


	cout << p1 << endl;

	cout << p2 << endl;



	// 用C语言的函数操作string

	printf("%s\n", s.c_str());


	return 0;
}

2.6.3 find 系列和 npos

find 从字符串 pos 位置开始往后找子串或字符,返回找到的位置;找不到返回 npos

npos 是 string 类的静态成员常量,值为 -1

因为是 size_t 无符号类型,所以实际表示的是最大值。

#include<iostream>
#include<string>

using namespace std;


int main()
{
	string s("hello bit hello");



	// find找子串

	size_t pos = s.find("bit");


	if (pos != string::npos)
	{
		cout << "找到了,位置是:" << pos << endl;
	}



	// rfind从后往前找

	pos = s.rfind("hello");

	cout << "rfind找到的位置:" << pos << endl;



	// find_first_of:找字符集合中任意一个字符的第一个匹配

	// 在s中找"aeiou"中任意一个元音字母第一次出现的位置

	pos = s.find_first_of("aeiou");

	cout << "第一个元音字母的位置:" << pos << endl;



	// find_last_of:从后往前找字符集合中任意一个字符

	pos = s.find_last_of("aeiou");

	cout << "最后一个元音字母的位置:" << pos << endl;


	return 0;
}

注意区分:

find

find_first_of

find 是找整个子串完全匹配。

find_first_of 是找字符集合中任意一个字符匹配,只要有一个字符匹配就算找到了。

2.6.4 substr 和 copy

substr(pos, len)

在 str 中从 pos 位置开始,截取 len 个字符,然后将其作为一个新的 string 对象返回。

copy 一般和 substr 功能类似,但是 copy 要开一个 buffer 空间,把 string 的值拷贝到 buffer 中去。

#include<iostream>
#include<string>

using namespace std;


int main()
{
	string s("hello bit");


	// substr截取子串

	string sub = s.substr(6, 3); // 从位置6开始,截取3个字符

	cout << sub << endl;         // bit



	// copy拷贝到buffer

	char buffer[10] = { 0 };

	s.copy(buffer, 5, 0); // 从位置0开始,拷贝5个字符到buffer


	cout << buffer << endl; // hello


	return 0;
}

不是迭代器接口的函数,参数都是起始位置加长度,比如:

substr(pos, len)

erase(pos, len)

copy(buf, len, pos)

等。


2.7 string 类的非成员函数

函数功能说明
operator+尽量少用,因为传值返回,导致深拷贝效率低
operator>>输入运算符重载
operator<<输出运算符重载
getline获取一行字符串
relational operators大小比较

2.7.1 operator + 尽量少用

operator+ 是传值返回,会产生深拷贝,效率低。

能使用 += 就不要用 +

string s1("hello");

string s2(" bit");


string s3 = s1 + s2; // 产生临时对象,深拷贝,效率低


s1 += s2;            // 直接在s1上追加,效率高

2.7.2 operator>> 和 getline

cin >> s 遇到空格和换行符就会终止,所以不能读取带空格的字符串。

getline(cin, s) 可以读取一整行,包括空格,遇到换行符终止,还有一种形式是自己确定是以什么为终止符getline(cin, s, '*')

#include<iostream>
#include<string>

using namespace std;


int main()
{
	string s;


	// cin >> s 遇到空格就结束

	cin >> s;

	cout << s << endl; // 输入"hello bit",只输出"hello"



	// getline可以读取一整行

	getline(cin, s);

	cout << s << endl; // 输入"hello bit",输出"hello bit"

	// 这里getline可以读取到*才截止

	getline(cin, s);

	cout << s << endl; // 输入"hello bit(回车换行)adsd*",
	//输出"hello bit(回车换行)adsd"
	
	return 0;
}

那么 cin.get() 是干什么的呢?

不管是字符还是字符串的 >> 输入,它们都不会读取到空格或者换行符。

为了解决这个问题,就有了 cin.get()

这个东西可以读取到空格和换行符。

输入字符串的时候,因为字符串是以空格和换行符作为分隔符的,所以遇到这些就会终止。

输入字符的时候,因为字符是一个一个读取的,读完一个就终止,所以空格和换行符在字符输入时不起作用的。


2.7.3 operator>> 的底层实现(如何解决频繁扩容)

我们来思考一下 operator>> 是怎么实现的。

如果每读取一个字符就 push_back 一次,那么当输入字符串很长的时候,就会频繁扩容,效率很低。

那能不能用 reserve 提前预留空间呢?

不行。

因为 reserve 小了,效率没有提升;

reserve 大了,效率提升了,但是如果输入小了就浪费了。

解决办法是:

开一个 buff 数组,先把字符读到 buff 数组中,buff 数组满了再一次性追加到 string 中。

因为字符放在 buff 数组中是没有扩容的,而且 buff 数组在栈上,随函数栈帧释放,效率高,出了operator>>就销毁了,不会一直占用空间,用reserve的话可能会有多个类同时存在很久,就导致空间一直被占用。

istream& operator>>(istream& in, string& s)
{
	s.clear();


	const int N = 256;

	char buff[N];

	int i = 0;

	char ch;


	ch = in.get(); // 用get读取,因为>>读不到空格和换行


	while (ch != ' ' && ch != '\n')
	{
		buff[i++] = ch;


		if (i == N - 1) // buff满了
		{
			buff[i] = '\0';

			s += buff;   // 一次性追加

			i = 0;
		}


		ch = in.get();
	}



	if (i > 0) // 处理最后不满buff的部分
	{
		buff[i] = '\0';

		s += buff;
	}


	return in;
}

这也是 string 类这样设计的原因之一:

通过 buff 数组减少扩容次数,同时栈上的 buff 随函数结束自动释放,不会造成长期的空间浪费。


2.7.4 关系运算符

string 类重载了关系运算符,可以直接用:

><==!=

等比较字符串大小。

比较的是字典序。

string s1("abc");

string s2("abd");


if (s1 < s2)

	cout << "s1 < s2" << endl;

2.8 string 的两个 swap

string 有两个 swap:

一个是成员函数 swap

一个是全局的 std::swap(string)

string s1("hello");

string s2("world");


s1.swap(s2);       // 成员swap,交换三个成员,高效


std::swap(s1, s2); // 全局swap,也可以用

成员 swap 的实现:

void swap(string& s)
{
	std::swap(_str, s._str);

	std::swap(_size, s._size);

	std::swap(_capacity, s._capacity);
}

为什么需要全局的 std::swap(string) 呢?

全局的是为了防止调用库里面的那个通用 swap。

库里面的通用 swap 在面对深拷贝的情况下,会产生 3 次深拷贝,导致效率骤降。

而 string 专门提供了全局的 swap 特化版本,内部调用的是成员 swap,只交换指针和整数,效率很高。

3 总结

这篇博客我们学习了:

  1. 为什么需要 string 类(C 语言字符串的痛点)
  2. string 类的常用接口(构造、容量、访问、修改、非成员函数)

预告:
下一篇博客会讲到:

  1. string 的底层结构(VS 的 SSO 和 g++ 的写时拷贝)

  2. string 的模拟实现(浅拷贝问题 → 深拷贝传统写法 → 现代写法 → 写时拷贝)

  3. 编码问题和一些细节注意点

欲知后事如何,请听下回分解!

希望这篇博客对你有帮助!

谢谢观看!

评论 3
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值