【C++】STL介绍 + string类使用介绍 + 模拟实现string类

news2025/1/30 15:15:31

  • 目录

    前言

    一、STL简介

    二、string类

    1.为什么学习string类

    2.标准库中的string类

    3.auto和范围for

    4.迭代器

    5.string类的常用接口说明

    三、模拟实现 string类



前言

        本文带大家入坑STL,学习第一个容器string。


一、STL简介

在学习C++数据结构和算法前,我们需要先了解C++的STL,方便后续学习其他数据结构


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的六大组件

        以上内容后续都会了解到,总之我们要明白STL的重要性。STL在C++的笔试和面试中占比很大,在工作上更是“不懂STL,不要说你会C++”。STL是C++中的优秀作品,有了它的陪伴,许多底层的数据结构以及算法都不需要自己重新造轮子,站在前人的肩膀上,健步如飞的快速开发。


二、string类

1.为什么学习string类

  1. 原字符串的缺陷:C语言中,字符串是以'\0'结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数,但是这些库函数与字符串是分离开的,不太符合OOP的思想(封装、继承和多态),而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
  2. 实用性:在OJ中,有关字符串的题目基本以string类的形式出现,而且在常规工作中,为了简单、方便、 快捷,基本都使用string类,很少有人去使用C库中的字符串操作函数。

2.标准库中的string类

在使用string类时,必须包含#include <string>头文件,平时学习可使用using namespace std;展开命名空间方便使用

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

int main()
{
	string s1("hello world");//定义对象s1并初始化
	cout << s1 << endl;//打印输出

	return 0;
}

简单说明下命名空间std 头文件 还有 STL 之间的联系:

  • 命名空间std 是C++标准库的命名空间,也就是C++编程的重要组成部分,它不仅包含了STL的所有组件,也包括了更多的东西。例如输入输出流(cin,cout)、容器(string,vector,list,map等)、迭代器、智能指针、内存管理工具、算法等
  • 所以我们平时使用库函数和容器等,如果不使用using namespace std;展开这个命名空间的话,就需要在前面指定命名空间std::。
  • 头文件的包含,比如<iostream>和<strintg>,你实际上是在告诉编译器你想要使用该头文件中定义的功能,这些功能都是 std 这个命名空间的一部分,因此可以说,我们是通过包含不同的头文件来解锁和访问 std 命名空间中不同部分的内容。
  • 不过需要注意的是,C++语言本身不仅仅是由 std 命名空间组成。C++的核心内容还包括:语言语法、基础数据类型、内存模型和管理、面向对象编程特性、模版和泛型编程、异常处理。
  • 切记,关键字不是 std 中定义的,C++关键字是语言本身的一部分,它们不是由标准库提供的,而是直接由编译器识别的。

继续了解string类

其实严格意义上,string不属于容器,在下图容器分类中就没有看到string

这是因为string在STL之前就已经有了,因为在设计上与STL中容器很相似,因此就有 串 这么一个数据结构,后面使用方法中就可以看出 string 设计的方法非常冗余,因为要照顾旧方法同时又要融入STL。

基础串

string类其实是一个类模版,它的原模版就叫 basic_string(基础串)

    

    

在基础串模版中,后两个参数有默认的模版参数,string 的定义中给基础串的第一个参数传递了 char 并进行了重命名,所以我们创建 string 模版类时没有给定模版参数

当然,除了经常使用的 string 类外,还有另外两个不同的string类:

    

这两个不同的string类,一个是一个字符占2个字节,另一个是4个字节,这里大小不同的原因是因为编码不同。编码在下文学习完string的使用后会讲到。这里主要是讲为什么要搞一个基础串的模版,原因就在这里。

不管怎样,我们最常用的还是前面的string,主要因为它兼容的编码多。


深入学习string前先学习两个语法糖

3.auto和范围for

1. auto关键字

#include <iostream>
using namespace std;

int main()
{
	int a = 1;
	auto b = a;//根据表达式右边自动推导出b的类型
	cout << b << endl;
	cout << sizeof(b) << endl;

	return 0;
}

运行结果: 


auto使用注意事项:(C++11)

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

例如:

(1)推导指针类型

#include <iostream>
using namespace std;

int main()
{
	//自动推导指针
	int a = 10;
	auto b = &a;
	cout << b << endl << &a << endl;

	return 0;
}

运行结果:


(2)推导引用类型

#include <iostream>
using namespace std;

int main()
{
	//引用类型推导
	int a = 10;
	int& b = a;
	auto& c = b;
	cout << &a << endl;
	cout << &c << endl << endl;

	//不加&推导出来的不是引用类型,而是原数据类型
	auto d = b;
	cout << &d << endl;

	return 0;
}

运行结果:


(3)可做函数返回值,但不能做参数

//auto做返回值
auto func1()
{
	int a = 10;
	return a;
}

//auto做参数
//报错:error C3533: 参数不能为包含“auto”的类型
//int func2(auto x)
//{
//	int a = x;
//	return a;
//}

但auto作为返回值有时候会是个坑,因为如果代码复杂,维护时会导致无法快速判断该函数返回值,写了函数注释还好,没写就会大大增加代码维护成本,所以慎用。


2.范围for

语法:

for(类型 e : 容器)

{

        //每循环一次e自动指向下一个数据

        //直到容器遍历完成

}

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

int main()
{
	//范围for用于遍历容器
	string s1("hello world");
	for (char c : s1)
	{
		cout << c << " ";
	}
	cout << endl;

	//可配合auto使用
	//如果想改变容器内容,需要使用引用类型
	for (auto& c : s1)
	{
		++c;
	}
	cout << s1 << endl;

	return 0;
}

运行结果:


范围for用处总结:

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

4.迭代器

  • 迭代器,STL六大组件之一,关于迭代器的介绍,C++迭代器是一种用于遍历容器(如数组、链表、向量等)中元素的工具。它们提供了统一的接口,使得不同类型的容器可以以相似的方式进行访问和操作。
  • 我们现阶段可以先理解迭代器为一种指针,但本质上不是指针,我们先学会使用

常见迭代器:

  • iterator
  • 常量迭代器 const_iterator
  • 反向迭代器 reverse_iterator
  • 常量反向迭代器 const_reverse_iterator

 声明迭代器时,一般是 std::容器名(如果是模版需要模版参数):: iterator 对象名。

展开了命名空间std就可省略,因为不同容器迭代器底层实现不同,因此需要指定容器

例如:利用迭代器遍历string

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

int main()
{
	string s1("hello world");

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

	return 0;
}

运行结果:

解释:

首先 string类的接口 begin()和end():

  • 我们发现,它们的返回值就是迭代器,它们的作用就是返回容器的头部与尾部的迭代器,对于string来说,end()指向的就是'\0',begin()指向的就是下标为0的字符。

在使用上,用法和指针相似:

  • 使用*运算符解引用迭代器以访问它所指向的元素。
  • 使用->运算符访问指向的对象的成员(如果该对象是一个类或结构体)。
  • 可以使用++或--来前进或后退迭代器。

注意:判断迭代器是否走到容器的结尾是使用 != 容器.end() 来判断,而不是其他关系判断,另外,一般使用while循环遍历,for循环虽然也行,但写法上相较复杂点。


迭代器的全部接口:

r开头的就是支持反向迭代器,c开头的就是就是常量迭代器,但是前面我们注意到了,begin 和 end 都有重载const版本的,在 rbegin 和 rend 中一样都有重载 const 版本的(注意,这种成员函数重载是根据 const 区分的,不是参数)。因此,我们一般不使用c开头的以及cr开头的迭代器接口。

  • rend() 指向的是第一个元素前一个位置
  • rbegin() 指向的是最后一个元素的位置,对于string来说,就是'\0'前一个字符
  • 因为倒着遍历还是从 rbegin 开始,一直到 != rend() 结束,因此这种安排合理

剩余三种迭代器遍历演示:

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

int main()
{
	//1.反向迭代器
	string s1("hello world");
	string::reverse_iterator rit = s1.rbegin();
	while (rit != s1.rend())
	{
		cout << *rit << " ";
		++rit;
	}
	cout << endl;

	//2.const迭代器
	const string s2("hello world");//string::const_iterator cit = s1.begin();
	auto cit = s2.begin();//使用前面所学的auto自动识别类型更加方便
	while (cit != s2.end())
	{
		cout << *cit << " ";
		++cit;
	}
	cout << endl;

	//3.const反向迭代器
	auto crit = s2.rbegin();
	while (crit != s2.rend())
	{
		cout << *crit << " ";
		++crit;
	}
	cout << endl;

	return 0;
}

运行结果:

需要注意的是:反向迭代器虽是倒着遍历,但依旧是使用++使迭代器指向下一个元素。因为对于反向迭代器来说,它正方向就是从右往左。

另外,迭代器与指针类似,当然也可以修改非const容器对象的内容

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

int main()
{
	string s1("hello world");
	auto it = s1.begin();
	while (it != s1.end())
	{
		++(*it);
		++it;
	}
	cout << s1 << endl;

	return 0;
}

运行结果:

小结:

        迭代器的是所有的STL容器通用的一种元素访问方式,不同的容器,迭代器底层会有些不同,但是用法是一样的,因此学好迭代器很重要


5.string类的常用接口说明

强调,C++为了适配C语言,因此 string 类对象的末尾也是存在 '\0'

1.string类的常见构造

上图中:

  • (1)就是不传参的默认构造
  • (2)就是拷贝构造
  • (3)从string对象 str 的 pos(下标) 位置开始,拷贝 len(默认 nops)个字符进行构造
  • (4)使用字符串进行构造
  • (5)使用字符串 s 的前 n 个字符进行构造
  • (6)用 n 个相同字符 c 进行构造
  • (7)使用迭代器进行构造

下面演示一下(3)(4)(5)(6)(7)

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

int main()
{
	string s1("hello world");//使用字符串初始化构造
	cout << "s1:" << s1 << endl;

	string s2(s1, 6, 5);//利用string对象的下标+长度进行构造
	cout << "s2:" << s2 << endl;

	string s3("xxxxxxxxxxxx", 4);//使用字符串的前4个字符进行构造
	cout << "s3:" << s3 << endl;

	string s4(5, 'y');//用n个相同字符进行构造
	cout << "s4:" << s4 << endl;

	string s5(s1.begin(), s1.end() - 5);//利用迭代器进行构造
	cout << "s2:" << s5 << endl;

	return 0;
}

运行结果:


赋值运算符重载:

演示:

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

int main()
{
	string s1("111");
	string s2("222");

	//(1)
	s1 = s2;
	cout << s1 << endl;

	//(2)
	s1 = "333";
	cout << s1 << endl;

	//(3)
	s1 = '*';
	cout << s1 << endl;

	return 0;
}

运行结果:


补充:npos和析构

(1)npos

  • npos 是 string 类的一个静态成员变量,无符号整形并且等于-1,因此就是整形的最大值(2进制位全是1),常用作缺省参数,表示最大值。

(2)string 类的析构

类的析构函数会在对象作用域结束时自动调用,用于销毁对象


2.string类对象的容量操作

函数名称简要功能说明
size返回字符串有效字符长度
length返回字符串有效字符长度

max_size

返回字符串可以达到的最大长度。
resize将有效字符的个数该成n个,多出的空间用字符c填充
capacity返回空间总大小
reserve为字符串预开辟空间
clear清空有效字符
empty检测字符串释放为空串,是返回true,否则返回false
shrink_to_fit缩容,减小字符串容量以适应其大小

(1)size和length

  • size和length的功能相同,都是返回字符串有效字符个数,而这样设计的原因是历史原因导致的,主要就是STL出来之前,string已经存在了。为了保留原string接口,同时为了和STL其余容器保持通用性,因此设计了size,其余STL容器都是size返回有效元素个数。对于string,我们平时也基本是使用size,而不是length。

演示:


(2)max_size

  • 这个用处不大,编译器也开不了这么大的空间。

演示:(64位)


 (3)capacity

返回空间容量,无需多言

演示:


(4)resize

将字符串大小调整为 n 个字符的长度,那么这里就有3中情况:

  1. 如果 n 小于当前字符串长度,则当前值将缩短为其前 n 个字符,并删除第 个字符以外的字符。
  2. 如果 n 大于当前字符串长度却又小于当前空间容量,则将当前字符串大小调整为 n,指定了c,则新元素将初始化为 c ,未指定则初始化为'\0'
  3. 如果 n 大于当前空间容量,则需要扩容,然后初始化新元素,新元素处理同上

演示:

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

int main()
{
	string s1("11111111111111111111");
	cout << s1 << endl;
	cout << "size: " << s1.size() << endl;
	cout << "capacity: " << s1.capacity() << endl;
	cout << endl;

	//resize
	//1.n < size
	s1.resize(10);
	cout << s1 << endl;
	cout << "size: " << s1.size() << endl;
	cout << "capacity: " << s1.capacity() << endl;
	cout << endl;

	//2.size < n < capacity
	s1.resize(25,'x');
	cout << s1 << endl;
	cout << "size: " << s1.size() << endl;
	cout << "capacity: " << s1.capacity() << endl;
	cout << endl;

	//3.n > capacity
	s1.resize(40, 'y');
	cout << s1 << endl;
	cout << "size: " << s1.size() << endl;
	cout << "capacity: " << s1.capacity() << endl;
	cout << endl;

	return 0;
}

运行结果:


(5)reserve

  • 更改空间容量,如果 n 大于当前字符串容量,则该函数会导致容器将其容量增加到 n 个字符(或更大)。此函数对字符串长度没有影响,也无法更改其内容。因此reserve不能缩小容量,也就是 n 小于当前字符串容量,没有什么实际效果。
  • 适用场景:提前知道大概需要多少空间,提前开辟可以避免多次扩容,提升效率。

演示:


拓展:

我们观察string类对象每次扩容的大小:

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

int main()
{
	string s1;
	size_t old = s1.capacity();
	cout << "capacity: " << old << endl;

	for (size_t i = 0; i < 100; i++)
	{
		s1 += 'x';
		if (s1.capacity() != old)
		{
			cout << "capacity: " << s1.capacity() << endl;
			old = s1.capacity();
		}
	}

	return 0;
}

 运行结果:

  • 我们发现:除了第一次到第二次是2倍扩容以外,31以后就是 1.5 倍扩容了。
  • 首先,这个底层扩容倍率每个平台是不一定一样的,以上是vs2022的结果。
  • 然后为啥第一次不是1.5倍扩容的原因:string 底层还存在一个类似 char buff[16] 大小的字符数组,如果数据小于16的话就会存在这个数组里面,大于16就储存在堆上开的空间中。这样做是为了避免存储数据小时频繁开辟空间。所以第一次的容量 15 不算是扩容。

我们可以通过计算空间大小验证一下:(32位)

  • 28的由来:底层字符串指针 4 字节、底层 size 和 capacity 记录大小和容量的无符号整形一共占 8 字节、剩下的 16 个字节就是 char buff[16] 数组。

我们在调试窗口中也能观察到该数组:


(5)clear

清空有效字符,对应字符串来说,就是将'\0'移动到第一位


(6)empty

  • 判空,为空返回ture(1),反之返回false(0)。


(7)shrink_to_fit

  • 缩容,将容量缩小与有效字符一样大的空间,注意,该函数不是任意情况下都会进行缩容,而是当capacity 与 size 相差过大时才会缩容。


3.string类对象的访问接口


 (1)operator[ ]

  • 运算符重载函数,返回对应下标的引用(越界会直接报错)
  • 最常用的元素访问接口

演示:

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

int main()
{
	string s1("hello world");
	cout << s1[4] << endl;
	s1[4] = 'x';//因为返回的是引用类型,因此修改可直接影响原对象
	cout << s1[4] << endl;

	return 0;
}

运行结果:

配合 size()接口,可以实现遍历string类对象:

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

int main()
{
	string s1("hello world");
	for (size_t i = 0; i < s1.size(); i++)
	{
		cout << s1[i] << " ";
	}
	cout << endl;

	return 0;
}

运行结果:


(2)at

  • at 功能大致与 operator[ ] 相同,区别是 at 访问失败会抛出异常,而 [ ] 是直接断言报错

演示:关于捕获异常的知识,我会在后续篇章中单独讲解 


(3)back 和 front

  • back 和 front 分别是返回字符串第一个字符和最后一个字符,因为这些 [ ] 也可以轻松做到,所以这两个接口用的不多,访问元素用的最多的就是 [ ]。

  演示:


4.string类对象的修改操作

函数名称功能说明

operator+=

在字符串后追加字符或字符串

append

在字符串后追加一个字符串

push_back

尾插一个字符

assign

为字符串分配一个新值,替换其当前内容

insert

在指定位置前插入字符或字符串

erase

删除指定位置字符

replace

替换指定位置字符

swap

交换两个字符串

pop_back

尾删一个字符

(1)operator+=

  • 我们可以直接尾插一个string类对象,或者一个字符串,或者一个字符
  • += 是字符串尾插中运用最多的接口

演示:

  • 除了+=以外,string 也重载了 + 运算符,区别就是不会修改本身,返回值为 + 的结果:


(2)push_back

  • 尾插一个字符

演示:


(3)append

append 重载了许多函数,功能都是尾插一段字符串:

  • (1)尾插一个 string 类对象
  • (2)从待尾插 string 对象的 subpos 位置开始,尾插 sublen 个字符
  • (3)尾插一段字符串
  • (4)尾插一段字符串的前 n 个字符
  • (5)尾插 n 个相同的字符 c
  • (6)以迭代器的方式,尾插一段字符串

演示:

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

int main()
{
	string s1("111");
	string s2("xxxx");
	string s3("hello world");

	//(1)
	s1.append(s2);
	cout << s1 << endl;

	//(2)
	s1.append(s3, 0, 5);
	cout << s1 << endl;

	//(3)
	s1.append("world");
	cout << s1 << endl;

	//(4)
	s1.append("yyyyyyyyyyy", 3);
	cout << s1 << endl;

	//(5)
	s1.append(2, 'a');
	cout << s1 << endl;

	//(6)
	s1.append(s3.begin() + 5, s3.end());
	cout << s1 << endl;

	return 0;
}

运行结果:


(4)insert

  • insert 支持头插以及中间指定位置之前插入元素,重载了很多函数,类比构造和append函数,其实不难看出每种重载函数的用法,以下不一一列举了
  • 提醒:insert 进行头插和中间插入时,需要挪动数据,因此效率低下,不建议多次使用。

演示:

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

int main()
{
	string s1("111");
	string s2("22");
	string s3("xxxxxx");
	string s4("orld");

	//(1)
	s1.insert(0, s2);
	cout << s1 << endl;

	//(2)
	s1.insert(3, s3, 0, 2);
	cout << s1 << endl;

	//(3)
	s1.insert(s1.size(), "hello");
	cout << s1 << endl;

	//(4)
	s1.insert(0, "yyyyyyyyyyyyy", 3);
	cout << s1 << endl;

	//(5)
	s1.insert(0, 4, 'm');
	cout << s1 << endl;

	//(6)
	s1.insert(s1.end(), 'w');
	cout << s1 << endl;

	//(7)
	s1.insert(s1.end(), s4.begin(), s4.end());
	cout << s1 << endl;

	return 0;
}

运行结果:


(5)erase

erase 用于删除字符:

  • (1)缺省参数 0 和 npos,npos前面说过是整形最大值,也就是说什么都不传,默认全部删除(效果和 clear 一样),传参则按照指定位置大小删除。
  • (2)删除迭代器位置的字符
  • (3)删除迭代器区间的字符

演示:

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

int main()
{
	string s1("I want to be a C++ master");
	
	//(1)
	s1.erase(0, 1);
	cout << s1 << endl;

	//(2)
	s1.erase(s1.begin());
	cout << s1 << endl;

	//(3)
	s1.erase(s1.begin(), s1.begin() + 5);
	cout << s1 << endl;

	//(1)
	s1.erase();
	cout << s1 << endl;

	return 0;
}

运行结果:


(6)assign

  • 该函数主要功能是对字符串进行重新赋值
  • 相比重载的赋值运算符,功能上有重合,虽然assign更灵活,但用的更多的还是重载的赋值运算符函数。

演示:(根据前面函数的参数,很容易判断每种重载函数的功能,因此不再详细演示)


(7)replace

  • replace 主要功能就是替换,也提供了一大堆重载函数,我们不用一个个去记忆,需要的时候查阅就行,前面我们已经判断了很多重载函数的功能,根据参数就大致能判断出每种重载函数的用法。
  • 另外在替换过程中,如果是平替(替换与被替换字符数相等)则效率高,如果不是平替,少替多,多替少,替换次数多了时,效率就会很低,因此除了平替或者替换次数少,不建议经常使用

演示:(只演示一个)


(8)pop_back

  • 尾删一个字符

演示:


(9)swap

关于 swap,string类提供了一个,还有一个全局的,算法库里面也有一个,这么设计的原因是什么?

原因:

  • 第一个 swap 是 string类 的成员函数,例如两个string对象s1、s2,使用 s1.swap(s2) 即可调用到该函数完成交换,该交换是直接交换两个字符串的地址,因此效率高。
  • 而我们平时习惯性写成 swap(s1,s2),这样就会调用到算法库里的swap,也就是第三个swap,该swap是一个函数模版,其内部对于 string 对象来说是深拷贝,深拷贝效率没有直接交换两个字符串地址效率高。因此为了避免调用到第三个swap,就创造了第二个全局的swap函数。
  • 第二个 swap 函数内部就是调用第一个swap,直接交换两字符串地址,因此效率比第三个swap高,第三个swap是函数模版,对于函数模版来说,有现成的就会直接使用现成的,不会再实例化一份。因此写成 swap(s1,s2)不会调用到第三个swap,而是调用第二个swap。
  • 关于这样的设计,其它容器也是如此,都是为了方便调用到成员函数的swap。


5.string类对象的其它常见操作

函数名称函数功能
c_str将string类对象的数据以C语言字符串的格式返回
copy、substr相比copy,substr用的更多,用于截取当前字符串的子串
find系列用于查找字符或字符串

关系运算符重载、compare

因为string重载了关系运算符,所以一般很少使用compare判断两字符串关系
operator<<、operator>>重载的流插入、流提取运算符
getline从输入流中读取字符

(1)c_str

  • 获取C语言格式的字符串,因为C++兼容C语言,所以有时候需要混合编程,但是C语言中关于字符串的库函数是不支持string类对象的,因此使用该函数就能解决这些问题

演示:

#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <string>
using namespace std;

int main()
{
	//C++使用C语言的文件操作
	string s1("test.cpp");
	FILE* pf = fopen(s1.c_str(), "r");//c_str返回C格式的字符串
	char ch = fgetc(pf);
	while (ch != EOF)
	{
		cout << ch;
		ch = fgetc(pf);
	}
	cout << endl;

	return 0;
}

运行结果:


(2)substr

  • 返回当前字符串 pos 位置开始,len 长的子串。
  • 因为都有缺省参数,所以默认返回整个字符串

演示:

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

int main()
{
	string s1("****hello world****");
	string s2 = s1.substr(4, 11);
	cout << s2 << endl;

	return 0;
}

运行结果:


(3)find系列

1. find

​​​

  • 第一个参数 str、s、c 就是需要查找的字符串或字符
  • 参数 pos 是查找的起始位置,默认0则从头开始找
  • 第三个重载函数的参数 n 是指定需查找的字符串 s 的长度
  • find 查找成功会返回匹配的第一个字符的下标,查找失败则返回 string::npos

演示:

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

int main()
{
	//将下面字符串的空格全部替换为 '#' 号
	string s1("There are two needles in this haystack with needles.");
	size_t pos = s1.find(' ');
	while (pos != string::npos)
	{
		s1[pos] = '#';
		pos = s1.find(' ', pos + 1);
	}
	cout << s1 << endl;

	return 0;
}

运行结果:


2.rfind

  • rfind 就是倒着找,其他的和 find 一样
  • 适用于找后缀的场景

演示:

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

int main()
{
	//指出下面文件的后缀名
	string s1("test.cpp.zip");
	size_t pos = s1.rfind('.');

	cout << s1.substr(pos) << endl;

	return 0;
}

运行结果:


3.find_first_of

  • 作用:在字符串中搜索与其参数中指定的任何字符匹配的第一个字符
  • 简单点说:find是查找单一字符或字符串,find_first_of是查找一个集合,只要查找的字符串中出现了这个集合中的字符,那么它就会返回该下标
  • 成功返回下标,失败返回 string::npos

演示:

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

int main()
{
	//屏蔽5个元音字母
	string s1("qwertyuiopasdfghjklzxcvbnm");
	size_t pos = s1.find_first_of("aeiou");
	while (pos != string::npos)
	{
		s1[pos] = '*';
		pos = s1.find_first_of("aeiou", pos + 1);
	}
	cout << s1 << endl;

	return 0;
}

运行结果:


4.find_first_not_of

  • 该函数与 find_first_not_of 相反,它是找出所有不在匹配串中的字符位置

演示:

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

int main()
{
	//屏蔽5个元音字母以外的字母
	string s1("qwertyuiopasdfghjklzxcvbnm");
	size_t pos = s1.find_first_not_of("aeiou");
	while (pos != string::npos)
	{
		s1[pos] = '*';
		pos = s1.find_first_not_of("aeiou", pos + 1);
	}
	cout << s1 << endl;

	return 0;
}

运行结果:


5.find_last_of 与 find_last_not_of

  • 相比 find_first_of 和 find_first_not_of,区别就是倒着找,这里不再赘述和演示

(4)关系运算符重载

  • 注意是全局函数,不是成员函数
  • 模拟时会详细说明

(5)operator<<、operator>>

  • 模拟时会详细说明

(6)getline

  • 解决流提取时,无法读取空格和换行符等问题
  • 参数 delim 是自定义读取结束符,不传参默认读到换行符结束

你是否遇到以下困扰?cin流提取时遇到空格或者换行符会自动截断,导致赋值不完整。

而getline就是专门解决这个问题的:


三、模拟实现 string类

了解完string类的使用,接下来就是自己模拟实现出string类

为了避免太复杂,我们不使用模版实现,还是按照声明和定义分离的方式来实现string类,模拟实现的意义是让我们对string的使用更加深刻,不是完全的模拟实现,主要是对常用的接口的模拟实现。

1.string.h 头文件

#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <cassert>
using namespace std;

//为避免与std中的string冲突,因此定义一个命名空间分隔
namespace txp
{
	class string
	{
	public:
		//定义迭代器
		using iterator = char*;
		using const_iterator = const char*;
	
		//声明构造,拷贝构造
		string(const char* str = "");
		string(const string& s);

		//赋值运算符重载
		string& operator=(string s);

		//声明析构函数
		~string();

		//对于一些简短的函数,直接在头文件中定义,较长的函数则放到定义文件中
		//定义c_str成员函数
		const char* c_str() const
		{
			return _str;
		}

		//定义size成员函数
		size_t size() const
		{
			return _size;
		}

		//定义重载运算符[]
		char& operator[](size_t i)
		{
			assert(i < _size);

			return _str[i];
		}

		//const版本 []
		const char& operator[](size_t i) const
		{
			assert(i < _size);

			return _str[i];
		}

		//定义迭代器接口begin
		iterator begin()
		{
			return _str;
		}

		//迭代器接口end
		iterator end()
		{
			return _str + _size;
		}

		//const版本的begin
		const_iterator begin() const
		{
			return _str;
		}

		//const版本的end
		const_iterator end() const
		{
			return _str + _size;
		}

		//定义clear成员函数
		void clear()
		{
			_str[0] = '\n';
			_size = 0;
		}

		//声明reserve函数
		void reserve(size_t n);

		//声明push_back函数
		void push_back(char ch);

		//声明append函数
		void append(const char* str);

		//声明运算符重载函数+=
		string& operator+=(char ch);

		//声明第二个版本的+=
		string& operator+=(const char* str);

		//声明insert成员函数
		void insert(size_t pos, char ch);

		//声明重载的insert成员函数
		void insert(size_t pos, const char* str);

		//声明erase成员函数
		void erase(size_t pos, size_t len = npos);

		//声明find成员函数
		size_t find(char ch, size_t pos = 0) const;
		size_t find(const char* str, size_t pos = 0) const;

		//声明substr成员函数
		string substr(size_t pos = 0, size_t len = npos) const;

		//声明swap成员函数
		void swap(string& str);

	private:
		//底层结构
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;
	public:
		//声明静态成员变量npos
		static const size_t npos;
	};

	//声明关系运算符重载函数
	bool operator==(const string& s1, const string& s2);
	bool operator!=(const string& s1, const string& s2);
	bool operator>(const string& s1, const string& s2);
	bool operator<(const string& s1, const string& s2);
	bool operator>=(const string& s1, const string& s2);
	bool operator<=(const string& s1, const string& s2);

	//声明流插入、流提取运算符重载函数,以及getline函数
	ostream& operator<<(ostream& os, const string& str);
	istream& operator>>(istream& is, string& str);
	istream& getline(istream& is, string& str, char delim = '\n');

	//声明全局的swap函数
	void swap(string& s1, string& s2);
}

2.string.cpp 文件

因函数之间存在复用关系,因此大家直接看注释吧

#include "string.h"

namespace txp
{
	//定义全局静态变量npos
	const size_t string::npos = -1;

	//默认构造,注意只能在声明处给缺省值,因此定义时没有写缺省值
	string::string(const char* str)
		:_size(strlen(str))
	{
		_capacity = _size;
		_str = new char[_size + 1];//多开辟一个空间用于存储'\0'
		strcpy(_str, str);
	}

	//拷贝构造
	//1.传统写法:自己开空间+自己拷贝
	/*string::string(const string& s)
	{
		_str = new char[s._capacity + 1];
		strcpy(_str, s._str);
		_size = s._size;
		_capacity = s._capacity;
	}*/

	//2.现代写法:利用构造开空间+利用swap拷贝
	string::string(const string& s)
	{
		string tmp(s._str);//创建临时对象tmp用于拷贝s的_str进行构造
		swap(tmp);//交换后,this指向的对象就是拷贝构造出来的对象,而tmp出了函数就会被析构
	}

	//赋值运算符重载
	//1.传统写法
	/*string& string::operator=(const string& s)
	{
		if (this != &s)
		{
			delete[] _str;
			_str = new char[s._capacity + 1];
			strcpy(_str, s._str);
			_capacity = s._capacity;
			_size = s._size;
		}

		return *this;
	}*/

	//2.现代写法:
	string& string::operator=(string s)//利用传值传参进行拷贝构造
	{
		swap(s);//再进行交换,原this指向的空间就由s析构带走了
		return *this;
	}

	//注意:现代写法没有效率提升,只是更简洁了,本质是一种复用


	//析构
	string::~string()
	{
		delete[] _str;
		_str = nullptr;
		_size = _capacity = 0;
	}

	//reserve开空间,只考虑扩容的情况
	void string::reserve(size_t n)
	{
		if (n > _capacity)
		{
			char* tmp = new char[n + 1];
			strcpy(tmp, _str);
			delete[] _str;
			_str = tmp;
			_capacity = n;
		}
	}

	//尾插字符
	void string::push_back(char ch)
	{
		/*if (_size == _capacity)
		{
			reserve(_capacity == 0 ? 4 : _capacity * 2);
		}
		_str[_size++] = ch;*/

		insert(_size, ch);//当我们实现insert后,可以直接复用来实现push_back的效果
	}

	//尾插字符串
	void string::append(const char* str)
	{
		/*size_t len = strlen(str);
		if ((_size + len) > _capacity)
		{
			size_t newCapacity = 2 * _capacity;
			if ((len + _size) > newCapacity)
			{
				newCapacity = len + _size;
			}
			reserve(newCapacity);
		}
		strcpy(_str + _size, str);
		_size += len;*/

		insert(_size, str);//可直接复用insert
	}

	//重载运算符+=
	string& string::operator+=(char ch)
	{
		push_back(ch);//复用push_back即可
		return *this;
	}

	//重载版本
	string& string::operator+=(const char* str)
	{
		append(str);//复用append即可
		return *this;
	}

	//insert插入
	void string::insert(size_t pos, char ch)
	{
		assert(pos <= _size);

		//需扩容时按照2倍扩容
		if (_size == _capacity)
		{
			reserve(_capacity == 0 ? 4 : _capacity * 2);
		}

		//挪动数据
		size_t end = _size + 1;
		while (end > pos)
		{
			_str[end] = _str[end - 1];
			--end;
		}

		//插入
		_str[pos] = ch;
		++_size;
	}

	//insert重载
	void string::insert(size_t pos, const char* str)
	{
		assert(pos <= _size);

		//由于不确定插入的字符串大小,因此扩容时需进行2次判断
		size_t len = strlen(str);
		if ((_size + len) > _capacity)
		{
			size_t newCapacity = 2 * _capacity;
			if ((len + _size) > newCapacity)//2倍扩容不够,就需要多少开多少
			{
				newCapacity = len + _size;
			}
			reserve(newCapacity);
		}

		//挪动数据
		size_t end = _size + len;
		//对于字符串来说,停止条件不能写成end>pos,会导致越界,pos+len是最后一次挪动的位置
		//因此要保证end = pos+len时继续挪动,所以停止条件为end > (pos+len-1)
		while (end > (pos + len - 1))
		{
			_str[end] = _str[end - len];
			--end;
		}

		//插入
		for (size_t i = 0; i < len; i++)
		{
			_str[pos + i] = str[i];
		}

		_size += len;
	}

	//删除
	void string::erase(size_t pos, size_t len)
	{
		assert(pos < _size);
		//第一种情况,要删除的字符数大于剩余的字符,直接挪动'\0'所在位置即可
		if (len >= (_size - pos))
		{
			_str[pos] = '\0';
			_size = pos;
		}
		else//剩下的情况就是要手动挪动剩余数据了
		{
			size_t end = pos + len;
			while (end <= _size)
			{
				_str[end - len] = _str[end];//从后向前挪
				++end;
			}

			_size -= len;
		}
	}

	//查找
	size_t string::find(char ch, size_t pos) const
	{
		assert(pos < _size);

		for (size_t i = pos; i < _size; i++)
		{
			if (_str[i] == ch)
			{
				return i;
			}
		}

		return npos;
	}
	//字符串查找算法有很多,我们直接使用C库里的函数strstr
	size_t string::find(const char* str, size_t pos) const
	{
		assert(pos < _size);

		const char* ptr = strstr(_str + pos, str);
		if (ptr == nullptr)
		{
			return npos;
		}
		else
		{
			return ptr - _str;
		}
	}

	//取子串
	string string::substr(size_t pos, size_t len) const
	{
		assert(pos < _size);

		//len大于剩余串长度,则直接取到结尾
		if (len > (_size - pos))
		{
			len = _size - pos;
		}

		txp::string sub;
		sub.reserve(len);
		for (size_t i = 0; i < len; i++)
		{
			sub += _str[pos + i];
		}
		return sub;
	}

	//交换
	void string::swap(string& str)
	{
		//调用算法库中的swap即可
		std::swap(_str, str._str);
		std::swap(_size, str._size);
		std::swap(_capacity, str._capacity);
	}

	//关系运算符重载
	bool operator==(const string& s1, const string& s2)
	{
		return strcmp(s1.c_str(), s2.c_str()) == 0;//直接利用C的库函数
	}
	bool operator!=(const string& s1, const string& s2)
	{
		return !(s1 == s2);//复用==
	}
	bool operator>(const string& s1, const string& s2)
	{
		return strcmp(s1.c_str(), s2.c_str()) > 0;//利用C库
	}
	bool operator<(const string& s1, const string& s2)
	{
		return strcmp(s1.c_str(), s2.c_str()) < 0;//利用C库
	}
	bool operator>=(const string& s1, const string& s2)
	{
		return s1 > s2 || s1 == s2;//复用>和==
	}
	bool operator<=(const string& s1, const string& s2)
	{
		return s1 < s2 || s1 == s2;//复用<和==
	}

	//流插入
	ostream& operator<<(ostream& os, const string& str)
	{
		for (size_t i = 0; i < str.size(); i++)
		{
			os << str[i];
		}

		return os;
	}

    //流提取
	istream& operator>>(istream& is, string& str)
	{
		str.clear();//先清空数据
		int i = 0;
		char buff[256];//为避免多次扩容,选择创建一个buff数组
		char ch;

		//传统的流提取会忽略掉空格和换行符,怎么解决呢?
		ch = is.get();//get为istream类对象的一个接口,可以读取任意字符
		while (ch != ' ' && ch != '\n')
		{
			buff[i++] = ch;
			//当buff数组存满时就+=到str
			if (i == 255)
			{
				buff[i] = '\0';
				i = 0;
				str += buff;
			}

			ch = is.get();
		}
		//如果buff中还有剩余字符未处理
		if (i > 0)
		{
			buff[i] = '\0';
			str += buff;
		}

		return is;
	}

	//定义getline函数
	istream& getline(istream& is, string& str, char delim)
	{
		str.clear();
		int i = 0;
		char buff[256];
		char ch;

		ch = is.get();
		while (ch != delim)//与流提取的差别就是这里,delim控制结束符
		{
			buff[i++] = ch;

			if (i == 255)
			{
				buff[i] = '\0';
				i = 0;
				str += buff;
			}

			ch = is.get();
		}
		if (i > 0)
		{
			buff[i] = '\0';
			str += buff;
		}

		return is;
	}

	//全局交换
	void swap(string& s1, string& s2)
	{
		//调用成员函数的swap即可
		s1.swap(s2);
	}
}


总结

以上就是本文的全部内容,感谢支持,祝大家新年快乐 !

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2286478.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Redis】List 类型的介绍和常用命令

1. 介绍 Redis 中的 list 相当于顺序表&#xff0c;并且内部更接近于“双端队列”&#xff0c;所以也支持头插和尾插的操作&#xff0c;可以当做队列或者栈来使用&#xff0c;同时也存在下标的概念&#xff0c;不过和 Java 中的下标不同&#xff0c;Redis 支持负数下标&#x…

【愚公系列】《循序渐进Vue.js 3.x前端开发实践》033-响应式编程的原理及在Vue中的应用

标题详情作者简介愚公搬代码头衔华为云特约编辑&#xff0c;华为云云享专家&#xff0c;华为开发者专家&#xff0c;华为产品云测专家&#xff0c;CSDN博客专家&#xff0c;CSDN商业化专家&#xff0c;阿里云专家博主&#xff0c;阿里云签约作者&#xff0c;腾讯云优秀博主&…

PETSc源码分析: Optimization Solvers

本文结合PETSc源代码&#xff0c;分析PETSc中的优化求解器。 注1&#xff1a;限于研究水平&#xff0c;分析难免不当&#xff0c;欢迎批评指正。 注2&#xff1a;文章内容会不定期更新。 参考文献 Balay S. PETSc/TAO Users Manual, Revision 3.22. Argonne National Labora…

面向对象设计(大三上)--往年试卷题+答案

目录 1. UML以及相关概念 1.1 动态图&静态图 1.2 交互图 1.3 序列图 1.4 类图以及关联关系 1.4.1类图 1.4.2 关系类型 (1) 用例图中的包含、扩展关系(include & extend) (2) 类图中的聚合、组合关系(aggragation & composition) 1.5 图对象以及职责划…

芯片AI深度实战:进阶篇之vim内verilog实时自定义检视

本文基于Editor Integration | ast-grep&#xff0c;以及coc.nvim&#xff0c;并基于以下verilog parser(my-language.so&#xff0c;文末下载链接), 可以在vim中实时显示自定义的verilog 匹配。效果图如下&#xff1a; 需要的配置如下&#xff1a; 系列文章&#xff1a; 芯片…

几种K8s运维管理平台对比说明

目录 深入体验**结论**对比分析表格**1. 功能对比****2. 用户界面****3. 多租户支持****4. DevOps支持** 细对比分析1. **Kuboard**2. **xkube**3. **KubeSphere**4. **Dashboard****对比总结** 深入体验 KuboardxkubeKubeSphereDashboard 结论 如果您需要一个功能全面且适合…

TikTok 推出了一款 IDE,用于快速构建 AI 应用

字节跳动(TikTok 的母公司)刚刚推出了一款名为 Trae 的新集成开发环境(IDE)。 Trae 基于 Visual Studio Code(VS Code)构建,继承了这个熟悉的平台,并加入了 AI 工具,帮助开发者更快、更轻松地构建应用——有时甚至无需编写任何代码。 如果你之前使用过 Cursor AI,T…

【MySQL — 数据库增删改查操作】深入解析MySQL的 Retrieve 检索操作

Retrieve 检索 示例 1. 构造数据 创建表结构 create table exam1(id bigint, name varchar(20) comment同学姓名, Chinesedecimal(3,1) comment 语文成绩, Math decimal(3,1) comment 数学成绩, English decimal(3,1) comment 英语成绩 ); 插入测试数据 insert into ex…

强大到工业层面的软件

电脑数据删不干净&#xff0c;简直是一种让人抓狂的折磨&#xff01;明明已经把文件扔进了回收站&#xff0c;清空了&#xff0c;可那些残留的数据就像牛皮癣一样&#xff0c;怎么也除不掉。这种烦恼简直无处不在&#xff0c;让人从头到脚都感到无比烦躁。 首先&#xff0c;心…

全面解析文件包含漏洞:原理、危害与防护

目录 前言 漏洞介绍 漏洞原理 产生条件 攻击方式 造成的影响 经典漏洞介绍 防御措施 结语 前言 在当今复杂的网络安全环境中&#xff0c;文件包含漏洞就像潜藏在暗处的危险陷阱&#xff0c;随时可能对防护薄弱的 Web 应用发起致命攻击。随着互联网的迅猛发展&#xff…

基于Django的Boss直聘IT岗位可视化分析系统的设计与实现

【Django】基于Django的Boss直聘IT岗位可视化分析系统的设计与实现&#xff08;完整系统源码开发笔记详细部署教程&#xff09;✅ 目录 一、项目简介二、项目界面展示三、项目视频展示 一、项目简介 该系统采用Python作为主要开发语言&#xff0c;利用Django这一高效、安全的W…

【Rust自学】14.6. 安装二进制crate

喜欢的话别忘了点赞、收藏加关注哦&#xff0c;对接下来的教程有兴趣的可以关注专栏。谢谢喵&#xff01;(&#xff65;ω&#xff65;) 14.6.1. 从cratea.io安装二进制crate 通过cargo_install命令可以从crates.io安装二进制crate。 这并不是为了替换系统包&#xff0c;它应…

【Redis】hash 类型的介绍和常用命令

1. 介绍 Redis 中存储的 key-value 本身就是哈希表的结构&#xff0c;存储的 value 也可以是一个哈希表的结构 这里每一个 key 对应的一个 哈希类型用 field-value 来表示 2. 常用命令 命令 介绍 时间复杂度 hset key field value 用于设置哈希表 key 中字段 field 的值为…

低代码产品表单渲染架构

在React和Vue没有流行起来的时候&#xff0c;低代码产品的表单渲染设计通常会使用操作Dom的方式实现。 下面是一个表单的例子&#xff1a; 产品层 用户通过打开表单&#xff0c;使用不同业务场景业务下的表单页面&#xff0c;中间的Render层就是技术实现。 每一个不同业务的表单…

多线程-线程池的使用

1. 线程池 1.1 线程状态介绍 当线程被创建并启动以后&#xff0c;它既不是一启动就进入了执行状态&#xff0c;也不是一直处于执行状态。线程对象在不同的时期有不同的状态。那么 Java 中的线程存在哪几种状态呢&#xff1f;Java 中的线程 状态被定义在了 java.lang.Thread.…

计算机网络 IP 网络层 2 (重置版)

IP的简介&#xff1a; IP 地址是互联网协议地址&#xff08;Internet Protocol Address&#xff09;的简称&#xff0c;是分配给连接到互联网的设备的唯一标识符&#xff0c;用于在网络中定位和通信。 IP编制的历史阶段&#xff1a; 1&#xff0c;分类的IP地址&#xff1a; …

Linux学习笔记——网络管理命令

一、网络基础知识 TCP/IP四层模型 以太网地址&#xff08;MAC地址&#xff09;&#xff1a; 段16进制数据 IP地址&#xff1a; 子网掩码&#xff1a; 二、接口管命令 ip命令&#xff1a;字符终端&#xff0c;立即生效&#xff0c;重启配置会丢失 nmcli命令&#xff1a;字符…

供应链系统设计-供应链中台系统设计(十)- 清结算中心概念片篇

综述 我们之前在供应链系统设计-中台系统设计系列&#xff08;五&#xff09;- 供应链中台实践概述文章中针对中台到底是什么进行了描述&#xff0c;对于中台的范围也进行划分&#xff0c;如下图所示&#xff1a; 关于商品中心&#xff0c;我们之前用4篇文章介绍了什么是商品中…

C++,STL 简介:历史、组成、优势

文章目录 引言一、STL 的历史STL 的核心组成三、STL 的核心优势四、结语进一步学习资源&#xff1a; 引言 C 是一门强大且灵活的编程语言&#xff0c;但其真正的魅力之一在于其标准库——尤其是标准模板库&#xff08;Standard Template Library, STL&#xff09;。STL 提供了…

OpenAI-Edge-TTS:本地化 OpenAI 兼容的文本转语音 API,免费高效!

文本转语音&#xff08;TTS&#xff09;技术已经成为人工智能领域的重要一环&#xff0c;无论是语音助手、教育内容生成&#xff0c;还是音频文章创作&#xff0c;TTS 工具都能显著提高效率。今天要为大家介绍的是 OpenAI-Edge-TTS&#xff0c;一款基于 Microsoft Edge 在线文本…