我们正式进入到C++的STL的第一课:string的学习
目录
一、为什么要学习string类?
C语言中,字符串是以'\0'结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数, 但是这些库函数与字符串是分离开的,不太方便操作,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
对于这一系列问题,C++使用了string类来补缺了这些不足。
二、标准库中的string类
2.1 string类
在我们学习string类时,先来查阅其文档:
我们通过文档可以知道string是一个基于basic_string模板类重定义的类,其中因为编码情况的不同含有四种string类的实例化:分别是string/u16string/u32string/wstring
对于我们一般情况(ASCLL、UTF-8、GBK等)使用string的string类就足够了:
• string是表示字符序列的类
• 标准的字符串类提供了对此类对象的支持,其接口类似于标准字符容器的接口,但添加了专门用于操作单字节字符字符串的设计特性。
• string类是使用char(即作为它的字符类型,使用它的默认char_traits和分配器类型(关于模板的更多信息,请参阅basic_string)。
• string类是basic_string模板类的一个实例,它使用char来实例化basic_string模板类,并用char_traits 和allocator作为basic_string的默认参数(根于更多的模板信息请参考basic_string)。
• 注意,这个类独立于所使用的编码来处理字节:如果用来处理多字节或变长字符(如UTF-8)的序列,这个类的所有成员(如长度或大小)以及它的迭代器,将仍然按照字节(而不是实际编码的字符)来操作
总结:
1. string是表示字符串的字符串类
2. 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作
3. string在底层实际是:basic_string模板类的别名,typedef basic_string string
4. 不能操作多字节或者变长字符的序列
2.2 string类的常用接口说明
2.2.1 string类构造函数接口
我们先来看到string类的构造函数:
函数名称 | 功能说明 |
---|---|
string(); | 创建一个空的string类 |
string (const string& str); | 拷贝构造传入的srt对象 |
string (const string& str, size_t pos, size_t len = npos); | 复制从pos位置开始向后len字节的str部分(如果len没有传值一直复制到str的末尾,如果传入的len值大于str的长度就一直复制到str的末尾) |
string (const char* s); | 复制s所指向的以空结尾的字符序列(C-字符串) |
string (const char* s, size_t n); | 从s指向的字符数组复制前n个字符 |
string (size_t n, char c); | 连续使用n个字符c来填充字符串 |
一共有六种常用函数接口,还有一种要涉及到迭代器,我们现在不进行讲解。
下面是各种函数接口使用的举例:
● 我们先来看到string():
int main()
{
string S1;//无参构造
cout << S1 << endl;
return 0;
}
在这里可以使用<<(流插入)是因为string类支持了<<的重载
● 再来看到string (const char* s):
string S2("Hello world");
cout << S2 << endl;
当然我们可以使用=来进行隐式类型转换来传值:
string S3 = "Hello world";
cout << S3 << endl;
● 接着我们来看到string (const string& str):
string S3 = "Hello world";
string S4(S3);
cout << S4 << endl;
● 后面我们来看到string (const string& str, size_t pos, size_t len = npos):
string S3 = "Hello world";
string S4(S3, 6, 2);
cout << S4 << endl;
string S5(S3, 6, 20);//len大于S3的pos位置后面的长度
cout << S5 << endl;
string S6(S3, 6);//第三个参数不传
cout << S6 << endl;
注意:这里的缺省值npos是一个无符号整型
无符号整型实际值并不是-1,而是一个非常大的数(42亿多)
● 我们来看到string (const char* s, size_t n):
string S7("Hello world", 6);
cout << S7 << endl;
● 最后再来看到string (size_t n, char c):
string S8(6,'*');
cout << S8 << endl;
2.2.2 string类对象的容量函数接口
函数名称 | 功能说明 |
---|---|
size(重点) | 返回字符串有效字符长度 |
length | 返回字符串有效字符长度 |
capacity | 返回空间总大小 |
empty(重点) | 检测字符串是否为空串,是返回true,否则返回false |
clear(重点) | 清空有效字符 |
reserve(重点) | 为字符串预留空间 |
resize(重点) | 将有效字符的个数改成n个,多出的空间用字符c填充 |
在这里有两个功能相同的函数length和size,那为什么会有两个功能相同的函数呢?这是因为C++的历史原因,在STL出来之前标准库中就有了string类,在那段时间是用length来描述字符串的长度,在后期STL出来了之后常用size来描述容器的大小,所以在string类中加上了size接口。在现在size作为标准,用的比较多。
下面是一些接口的举例:
● size
string S = "Hello world";
cout << S.size() << endl;
注意:在这里size函数接口并不统计字符串结尾的‘/0’,所以字符串实际大小是12字节
● length
string S = "Hello world";
cout << S.length() << endl;
注意:在这里length函数接口并不统计字符串结尾的‘/0’,所以字符串实际大小是12字节
● capacity
string S = "Hello world";
cout << S.capacity() << endl;
注意:在这里capacity函数接口并不统计字符串结尾的‘/0’,所以字符串实际容量大小是16字节
在这里我们深入讲解一下string类扩容的过程:
//观察扩容过程
void TestPushBack()
{
string s;
size_t sz = s.capacity();
cout << "init capacity:" << sz << endl;
cout << "making s grow:\n";
for (int i = 0; i < 100; ++i)
{
s.push_back('c');//每次向s中加入一个字符
if (sz != s.capacity())//当s扩容时打印扩容后的容量
{
sz = s.capacity();
cout << "capacity changed: " << sz << '\n';
}
}
}
上面这段代码可以让我们观察到string所创建的对象扩容的过程:
我们可以看到在最初时字符串的实际容量是16字节,再一下子扩容2倍到32字节,接下来以1.5倍的大小再进行扩容,这是为什么呢?为什么不按1.5倍匀速扩容呢?
这是因为在字符串数据大小小于等于16字节时,string会将数据存在一个叫_Buf的字符数组中,该数组大小为16字节:
当字符串数据大小大于16字节时,string会将数据存在一个叫_Ptr指向的动态开辟的空间中,之后扩容时以1.5倍进行:
但是上述结论仅在VS环境中适用!在Linux的g++中测试结果是一个经典的二倍扩容:
● reserve
对于上面不断的扩容开辟空间的情况会造成效率上的低下,如果我们知道我们想要的空间大小可以使用reserve函数来预留一些空间:
void Test1()
{
string s;
s.reserve(100);//预留100字节的空间
size_t sz = s.capacity();
cout << "init capacity:" << sz << endl;
for (int i = 0; i < 100; ++i)
{
s.push_back('c');
if (sz != s.capacity())
{
sz = s.capacity();
cout << "capacity changed: " << sz << '\n';
}
}
}
这样子在程序运行时,下面就没有继续开辟空间了。
注意:在VS环境下由于底层实现原因,可能会将预留的空间放大一点点
● resize
该函数有两种重载形式:
只传参数n:
int main()
{
string S = "Hello";
cout << S.size() << endl;
cout << S.capacity() << endl;
S.resize(100);//将字符串有效数据个数改为100字节
cout << S.size() << endl;
cout << S.capacity() << endl;
return 0;
}
我们可以看到只传参数n时,如果n大于字符串的容量,该函数会自动扩容,并且会将未初始化的字符修改为‘/0’,并不会去修改已经被初始化的字符。
传入参数n和c:
int main()
{
string S = "Hello";
cout << S.size() << endl;
cout << S.capacity() << endl;
S.resize(100,'c');//将字符串有效数据个数改为100字节,并且将未被初始化的字符初始化为字符'c'
cout << S.size() << endl;
cout << S.capacity() << endl;
return 0;
}
我们可以看到传入参数n和c时,如果n大于字符串的容量,该函数会自动扩容,并且会将未初始化的字符修改为字符c,并不会去修改已经被初始化的字符。
那如果n小于字符串的有效字符的个数呢?
int main()
{
string S = "Hello";
cout << S.size() << endl;
cout << S.capacity() << endl;
cout << S << endl;
S.resize(1,'c');//传入1小于有效字符个数
cout << S.size() << endl;
cout << S.capacity() << endl;
cout << S << endl;
return 0;
}
我们可以看到如果传入的n小于字符串的有效字符的个数,函数会删除n个字符之后的数据(将第n个的后面一个字符改为'/0')
2.2.3 string类对象的修改函数接口
函数名称 | 功能说明 |
---|---|
push_back | 在字符串后尾插字符c |
append | 在字符串后追加一个字符串 |
operator+=(重点) | 在字符串后追加字符串 |
insert(不推荐使用) | 在字符串某个位置插入字符(字符串) |
erase(不推荐使用) | 在字符串某个位置向后删除字符 |
swap | 交换两个字符串的值 |
下面是一些接口的举例讲解:
● push_back
string S = "Hello";
S.push_back(' ');
S.push_back('!');
cout << S << endl;
● append
string S = "Hello";
S.append(" world !");
cout << S<< endl;
另外append函数还有很多重载,这里不再一一举例
● operator+=
string S = "Hello";
S += " ";
S += "world !";
cout << S<< endl;
注意:在这里operator+=只是一个封装,里面实现还是会调用push_back等接口的,但是我们只需要使用一个+=就可以向字符串添加字符比push_back、append函数更加方便,推荐使用!
● insert
insert函数有以下重载:
这里举几个常用的:
int main()
{
string S = "wrld";
S.insert(1, 1, 'o');
cout << S << endl;
S.insert(0, "Hello");
cout << S << endl;
S.insert(S.begin() + 5, ' ');
cout << S << endl;
return 0;
}
注意:频繁使用insert函数会导致程序效率下降(插入字符可能需要整体移动字符串)
● erase
该函数有三种重载方式:
int main()
{
string S = "Hello world !";
S.erase(5, 1);
cout << S << endl;
S.erase(S.begin() + 5);
cout << S << endl;
S.erase(5);
cout << S << endl;
return 0;
}
注意:频繁使用erase函数会导致程序效率下降(删除字符可能需要整体移动字符串)
● swap
int main()
{
string s1 = "Hello";
string s2 = "world";
s1.swap(s2);
cout << s1 << endl;
cout << s2 << endl;
return 0;
}
string类的swap函数可以交换两个字符串的值,那库函数中也有swap函数,它们到底有什么区别呢?
我们来看看库函数中的swap:
我们看看到对于任意类型的a,b参数,库中的swap函数会另外开辟一个空间c来拷贝a,再将a修改为b,b修改为c。在这个过程中需要扩容,效率不高。
而string类型内置的函数swap只需要更改两个string类对象的内部指针指向的存储空间即可,不需要另外开辟空间,效率较高。
2.2.4 string类的功能函数接口
函数名称 | 函数功能 |
---|---|
c_str | 返回一个指向字符串的指针,该字符串包含一个以空结尾的字符序列(即一个C-字符串) |
find | 顺序在string中查找内容(字符或字符串),找到返回字符返回字符在string中的位置,没找到返回npos |
rfind | 逆序在string中查找内容(字符或字符串),找到返回字符返回字符在string中的位置,没找到返回npos |
find_first_of | 顺序查找传入要查找字符串中的任何一个字符,找到返回字符返回字符在string中的位置,没找到返回npos |
find_last_of | 逆序查找传入要查找字符串中的任何一个字符,找到返回字符返回字符在string中的位置,没找到返回npos |
find_first_not_of | 顺序在string中搜索与其参数中指定的字符串中任何字符不匹配的第一个字符,不匹配返回该字符位置,匹配继续向后找,没找到不匹配的返回npos |
find_last_not_of | 逆序在string中搜索与其参数中指定的字符串中任何字符不匹配的第一个字符,不匹配返回该字符位置,匹配继续向前找,没找到不匹配的返回npos |
下面是一些接口的举例讲解:
● c_str
int main()
{
string s = "Hello world";
cout << s.c_str() << endl;
cout << s << endl;
return 0;
}
我们可以看到使用c_str函数和用string创建的对象进行流输出的结果是一样的,那下面这个例子呢?
int main()
{
string s = "Hello world";
cout << s.c_str() << endl;
cout << s << endl;
s.push_back('\0');
s.push_back('\0');
s += "cccccccc";
cout << s.c_str() << endl;
cout << s << endl;
return 0;
}
我们可以清楚的看到使用c_str函数进行流输出时会受到\0字符的影响,而string类的流输出重载不会(根据size来进行输出)
● find
int main()
{
string s = "https://legacy.cplusplus.com/reference/string/string/find/";
size_t st = s.find("t");
while (st != string::npos)
{
s[st] = '*';
st = s.find('t', st + 1);
}
cout << s << endl;
return 0;
}
上面的例子是寻找字符‘t’并替换为‘*’的过程
● find_frist_of
int main()
{
string s = "https://legacy.cplusplus.com/reference/string/string/find/";
size_t st = s.find_first_of("abcd");
while (st != string::npos)
{
s[st] = '*';
st = s.find_first_of("abcd", st + 1);
}
cout << s << endl;
return 0;
}
上面的例子是寻找字符串“abcd”并将字符串中任何一个找到的字符替换为‘*’的过程
● find_frist_not_of
int main()
{
string s = "https://legacy.cplusplus.com/reference/string/string/find/";
size_t st = s.find_first_not_of("/");
while (st != string::npos)
{
s[st] = '*';
st = s.find_first_not_of("/", st + 1);
}
cout << s << endl;
return 0;
}
上面的例子是将非“/”字符替换为‘*’的过程
2.3 三种访问string类创建的对象的元素的方式
2.3.1 使用operator[ ]
string支持[]重载,我们可以在创建的string类的对象名后面使用[ ]来直接访问其内部有效元素:
int main()
{
string S = "Hello world !";
for (int i = 0; i < S.size(); ++i)
{
cout << S[i];//访问S的第i个元素
}
cout << endl;
return 0;
}
2.3.2 使用迭代器
由于我们现在还未深入学习迭代器,可以暂且将其理解成一种类似于指针的东西
对于迭代器有四种类型,分别为正向迭代器(iterator)、正向常量迭代器(const_iterator)、反向迭代器(reverse_iterator)和反向常量迭代器(const_reverse_iterator)
● 正向迭代器演示:
int main()
{
string S = "Hello world !";
string::iterator it = S.begin();//创建一个正向迭代器it,并将S对象的首元素地址传给it
while (it != S.end())//当it未指向S对象的最后元素后一位地址时进入循环
{
cout << *it;//打印it所指向的元素信息
++it;//将it向右移动指向下一个元素
}
cout << endl;
return 0;
}
我们来理解一下上面的代码:一开始调用begin函数(string类的begin函数可以返回一个首元素地址的正向迭代器)将首元素地址交给迭代器it,it拿到地址后每次向右移动一个元素的单位,并将指向的元素信息打印出来,一直到it指向S对象的最后元素后一位地址时停止打印(string类的end函数可以返回一个最后一个元素的后一位地址的正向迭代器):
● 反向迭代器演示:
正向迭代器++向右移动,反向迭代器反之,++向左移动:
int main()
{
string S = "Hello world !";
string::reverse_iterator rit = S.rbegin();//创建一个反向迭代器rit,并将S对象的最后一个元素地址传给rit
while (rit != S.rend())//当rit未指向S对象的首元素前一位地址时进入循环
{
cout << *rit;//打印rit所指向的元素信息
++rit;//将it向左移动指向下一个元素
}
cout << endl;
return 0;
}
我们来理解一下上面的代码:一开始调用rbegin函数(string类的rbegin函数可以返回一个最后元素地址的反向迭代器)将末尾元素地址交给迭代器rit,rit拿到地址后每次向左移动一个元素的单位,并将指向的元素信息打印出来,一直到rit指向S对象的首元素前一位地址时停止打印(string类的rend函数可以返回一个首元素前一位地址的反向迭代器):
● 正向常量迭代器和反向常量迭代器演示:
当我们创建了一个const的string类的对象时,并不能正常使用正向迭代器和反向迭代器了:
这时就只能使用正向常量迭代器和反向常量迭代器搭配cbegin/cend(begin/end)和crbegin/crend(rbegin/rend)函数了:
void Func(const string& s)
{
string::const_iterator cit = s.cbegin();//创建一个常量反向迭代器cit,并将S对象的首元素地址传给cit
while (cit != s.cend())//当cit未指向S对象的最后元素后一位地址时进入循环
{
cout << *cit;//打印cit所指向的元素信息
++cit;//将cit向右移动指向下一个元素
}
cout << endl;
string::const_reverse_iterator crit = s.crbegin();//创建一个常量反向迭代器crit,并将S对象的最后一个元素地址传给crit
while (crit != s.crend())//当crit未指向S对象的首元素前一位地址时进入循环
{
cout << *crit;//打印crit所指向的元素信息
++crit;//将crit向左移动指向下一个元素
}
cout << endl;
}
普通迭代器和常量迭代器唯一区别就是:普通迭代器可以修改数据而常量迭代器不可以
当然如果嫌迭代器的命名太烦杂我们也可以使用auto来自动推导(不过会降低程序的可读性):
//string::iterator it = S.begin();
auto it = S.begin();
//string::reverse_iterator rit = S.rbegin();
auto rit = S.rbegin();
//string::const_iterator cit = s.cbegin();
auto cit = S.cbegin();
//string::const_reverse_iterator crit = s.crbegin();
auto crit = S.crbegin();
2.3.3 使用范围for
int main()
{
string S = "Hello world !";
for (auto ch : S)
{
cout << ch;
}
cout << endl;
return 0;
}
在这里我们使用了一下基于范围for循环,也可以很好的访问string类的内部元素(但是底层原理依然会使用迭代器),对于范围for不熟悉的同学可以看下面这篇文章:
本期博客到这又要结束了,欢迎大家在评论区指正
下期见啦~