文章目录
- 前言
- 一、STL库介绍
- 二、标准库中的string类
- 1、string类介绍
- 2、string类使用
- 3.1 string类的构造函数
- 3.2 string类对象的容量操作
- 3.3 string类对象的遍历操作
- 3.4 string类对象的访问操作
- 3.5 string类对象的修改操作
- 3.6 string类对象的字符串操作
- 三、模拟实现string类
- 四、windows下的VS中的string类和Linxu下的g++中的string类。
前言
一、STL库介绍
STL(standard template libaray-标准模板库):是C++标准库的重要组成部分,不仅是一个可复用的组件库,而且
是一个包罗数据结构与算法的软件框架。
STL的版本:
(1). 原始版本
Alexander Stepanov、Meng Lee 在惠普实验室完成的原始版本,本着开源精神,他们声明允许任何人任意运用、拷贝、修改、传播、商业使用这些代码,无需付费。唯一的条件就是也需要向原始版本一样做开源使用。 HP 版本–所有STL实现版本的始祖。
(2). P. J. 版本
由P. J. Plauger开发,继承自HP版本,被Windows Visual C++采用,不能公开或修改,缺陷:可读性比较低,符号命名比较怪异。
(3). RW版本
由Rouge Wage公司开发,继承自HP版本,被C+ + Builder 采用,不能公开或修改,可读性一般。
(4). SGI版本
由Silicon Graphics Computer Systems,Inc公司开发,继承自HP版 本。被GCC(Linux)采用,可移植性好,可公开、修改甚至贩卖,从命名风格和编程 风格上看,阅读性非常高。
STL的六大组件:
二、标准库中的string类
1、string类介绍
当我们查看c++文档中关于string类的定义时,可以发现string类是依靠basic_string类模板显示实例化出来的一个类。
我们可以查看basic_string这个类模板是这样定义的。并且根据basic_string这个类模板生成了4个模板实例。string类只是其中一个。
那么为什么要根据该模板生成4个模板实例呢?
这是因为在计算机刚发明时,因为计算机只能存储1010的二进制位数据,而美国人使用的字母和符号是无法直接存储到计算机内的。所以美国人就想到了一套ASCII码方式。
在计算机中,所有的数据在存储和运算时都要使用二进制数表示(因为计算机用高电平和低电平分别表示1和0),例如,像a、b、c、d这样的52个字母(包括大写)以及0、1等数字还有一些常用的符号(例如*、#、@等)在计算机中存储时也要使用二进制数来表示,而具体用哪些二进制数字表示哪个符号,当然每个人都可以约定自己的一套(这就叫编码),而大家如果要想互相通信而不造成混乱,那么大家就必须使用相同的编码规则,于是美国有关的标准化组织就出台了ASCII编码,统一规定了上述常用符号用哪些二进制数来表示。
这样使用0-127就将全部字符都表示出来了,即还不到一个字节,所以ASCII码中每个字符占一个字节。
但是随着计算机在全世界范围被使用,计算机中只显示这127个欧美字符是不行的,还需要显示其它国家的语言。那么像我们中国使用的是象形文字,ASCII编码那一套对我们就不适用了。所以又出现了一种unicode码的编码方式。
统一码(Unicode),也叫万国码、单一码,由统一码联盟开发,是计算机科学领域里的一项业界标准,包括字符集、编码方案等。
统一码是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。
如果把各种文字编码形容为各地的方言,那么统一码就是世界各国合作开发的一种语言。在这种语言环境下,不会再有语言的编码冲突,在同屏下,可以显示任何语言的内容,这就是统一码的最大好处。就是将世界上所有的文字用2个字节统一进行编码。那样,像这样统一编码,2个字节就已经足够容纳世界上所有语言的大部分文字了。
在统一码中,汉字“字”对应的数字是23383。在统一码中,我们有很多方式将数字23383表示成程序中的数据,包括:UTF-8、UTF-16、UTF-32。UTF是“UCS Transformation Format”的缩写,可以翻译成统一码字符集转换格式,即怎样将统一码定义的数字转换成程序数据。所以在c++中为了应对不同的编码格式,才根据basic_string模板实例化了4中类。
在string类中一个字符占1个字节,在wstring类中一个字符占2个字节,在u16string类中一个字符占2个字节,在u32string类中一个字符占4个字节。
2、string类使用
string类有对应的构造函数和析构函数,还有赋值运算符的重载函数。
3.1 string类的构造函数
int main()
{
//会调用string类的默认构造函数string()
string s1;
//会调用string类的string(const char* s)构造函数,因为参数不为string对象的引用,而是字符串地址。
string s2("hello world");
//下面的对于s3的初始化,应该先将"hello string"字符串隐式转换为临时string对象,然后调用构造函数将该临时对象初始化
//然后再调用拷贝构造将临时对象拷贝给s3对象。但是编译器会直接优化为调用构造函数。
// 构造 + 拷贝构造 -> 优化为构造
//调用的也是string(const char* s)构造函数
string s3 = "hello string";
//会调用拷贝构造函数string (const string& str)
string s4(s3);
//此时会调用string (const string& str, size_t pos, size_t len = npos)这个构造函数,因为第一个参数为string对象的引用
//意思为从s3字符的下标为6的位置开始拷贝,拷贝的长度为6。
string s5(s3, 6, 6);
cout << s5 << endl;
//当拷贝的长度大于s3的长度时,此时会只拷贝到s3末尾就停止了。
//该构造函数的第三个参数缺省值npos为-1,而-1的补码按无符号整数解析就是int表示的最大的无符号整数
//所以如果第三个参数不给的话就是默认将s3字符串拷贝到结尾再停止。
string s6(s3, 6, 12);
cout << s6 << endl;
//此时会调用string (const char* s,size_t n)这个构造函数,因为第一个参数为字符串地址。
//该函数表示的意思是将"hello world"字符串的前5个字符拷贝到s7对象中。
string s7("hello world", 5);
cout << s7 << endl;
//当第二个参数n大于字符串长度时,会拷贝到'\0'就停止了
string s8("hello worlda", 20);
cout << s8 << endl;
//此时会调用string (size_t n,char c)这个构造函数。
//会将s9中初始化为10个*字符
string s9(10, '*');
cout << s9 << endl;
return 0;
}
3.2 string类对象的容量操作
注意:
(1). size()与length()方法底层实现原理完全相同,引入size()的原因是为了与其他容器的接口保持一致,一般情况下基本都是用size()。
(2). clear()只是将string中有效字符清空,不改变底层空间大小。
int main()
{
string s1("hello world");
string s2("");
//返回字符串有效字符长度
cout << s1.size() << endl;
//返回字符串有效字符长度
cout << s1.length() << endl;
//返回容器可以容纳的最大元素数。
cout << s1.max_size() << endl;
//返回空间总大小
cout << s1.capacity() << endl;
//检测字符串是否为空串
cout << s1.empty() << endl;
//清空有效字符
s1.clear();
cout << s1.empty() << endl;
//缩容,将s1对象的capacity缩到和size一样大
//但是缩容的开销很大,一般不会使用
s1.shrink_to_fit();
cout << s1.capacity() << endl;
return 0;
}
resize和reserve区别
当使用string存储字符串时,如果字符串长度太大时,string会自动进行扩容。如果字符串过大,但是string自动扩容每次扩的都很少时,此时想要直接申请够存储字符串的空间,就可以使用reserve和resize。reserve和resize这两个成员函数可以预先开辟指定大小的空间。当需要知道开辟多少空间时,可以使用reserve提前将空间开好,减少扩容,提高效率。因为要考虑对齐等因素,开的空间会比实际要求的大,但是绝对不会比实际要求的少。
注意:
(1). resize(size_t n) 与 resize(size_t n, char c)都是将字符串中有效字符个数改变到n个,不同的是当字符个数增多时:resize(n)用0来填充多出的元素空间,resize(size_t n, char c)用字符c来填充多出的元素空间。注意:resize在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变。
(2). reserve(size_t res_arg=0):为string预留空间,不改变有效元素个数,当reserve的参数小于string的底层空间总大小时,reserver不会改变容量大小。即reserve只会改capacity,不会改size。而resize会将capacity和size都改变。
int main()
{
//扩容
string s1("hello world");
s1.reserve(100);
cout << s1.size() << endl;
cout << s1.capacity() << endl;
//扩容+初始化
//如果resize不给第二个参数则会初始化为'\0'
string s2("hello world");
s2.resize(100, 'x');
cout << s2.size() << endl;
cout << s2.capacity() << endl;
string s3("hello world");
s3.resize(100);
return 0;
}
可以看到使用resize会将预先开辟出来的空间初始化。而reserve只是将空间开辟出来,并不会进行初始化。所以reserve只会改capacity,不会改size。而resize会将capacity和size都改变。如果不给第二个参数会初始化为’\0’,如果给第二个参数则会将后面的值初始化为给定的字符。
resize还可以删除数据,当传入的第一个参数比size小时,就会删除数据,比capacity大时,就会扩容。
int main()
{
//扩容+初始化
//当resize第一个参数比capacity大时,就会扩容
string s1("hello world");
s1.resize(16,'x');
cout << s1.size() << endl;
cout << s1.capacity() << endl;
//当resize第一个参数比size小时,就会删除数据。
string s2("hello world");
s2.resize(5);
cout << s2.size() << endl;
cout << s2.capacity() << endl;
cout << s2 << endl; //hello
return 0;
}
3.3 string类对象的遍历操作
string类的迭代器。
当我们想要遍历string类中存的字符串的每一个字符时,可以使用三种方法来遍历。
循环遍历:
int main()
{
string s1("hello world");
for (int i = 0; i < s1.size(); ++i)
{
cout << s1[i] << " ";
}
cout << endl;
return 0;
}
范围for::
其实使用范围for,范围for底层还是使用迭代器,范围for只是对迭代器又封装了一层。
int main()
{
string s1("hello world");
for (auto ch : s1)
{
cout << ch << " ";
}
cout << endl;
return 0;
}
迭代器:
可以先将迭代器·想象为指针,begin就是返回字符串的起始地址,而end就是返回结束位置的下一个字符的指针。
int main()
{
string s1("hello world");
string::iterator it = s1.begin();
while (it != s1.end())
{
cout << *it << " ";
++it;
}
cout << endl;
return 0;
}
迭代器除了有正向迭代器外,还有被const修饰的正向迭代器,还有反向迭代器和被const修饰的反向迭代器。当想要遍历被const修饰的对象时,此时就需要使用const_iterator迭代器。并且const_iterator迭代器只能访问对象内容,并不能改变对象的内容。
其中begin()和end都有两种,一种是不被const修饰的,一种是被const修饰的。当创建的为iterator迭代器时,会自动去调用不被const修饰的begin(),当创建的为const_iterator迭代器时,会自动去调用被const修饰的begin()。
void func(const string& s)
{
//const 正向迭代器,只能遍历和读数据,不能写数据。
string::const_iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
//const 反向迭代器,只能遍历和读数据,不能写数据。
string::const_reverse_iterator rit = s.rbegin();
while (rit != s.rend())
{
cout << *rit << " ";
++rit;
}
cout << endl;
}
int main()
{
string s1("hello world");
//正向迭代器,可以遍历数据,也可以读写数据
string::iterator it = s1.begin();
while (it != s1.end())
{
cout << *it << " ";
++it;
}
cout << endl;
//创建一个反向迭代器,将从后向前遍历s1中的字符串。可以遍历数据,也可以读写数据
//string::reverse_iterator rit = s1.rbegin();
//此时可以使用auto来自动推断变量的类型。
//auto是根据右边的返回值来推左边的类型的,虽然可以简化代码,但是代码的可读性差了
auto rit = s1.rbegin();
while (rit != s1.rend())
{
cout << *rit << " ";
++rit;
}
cout << endl;
func(s1);
return 0;
}
3.4 string类对象的访问操作
使用operator[]和a都可以访问指定位置的元素,只不过当使用[]越界访问时会直接中止程序,而使用at越界访问时会抛异常。
int main()
{
string s1("hello world");
//使用 [] 越界访问会中止程序。
//s1[100];
//使用 at 越界访问会抛异常
try
{
s1.at(100);
}
catch (const exception& e)
{
cout << e.what() << endl;
}
//返回字符串的第一个字符,也可以修改这个字符
cout << s1.front() << endl;
s1.front() = '!';
cout << s1 << endl;
//返回字符串的最后一个字符,就是\0前面的字符,也可以修改这个字符
cout << s1.back() << endl; //返回d
s1.back() = '#';
cout << s1 << endl;
string s2("hello\0world");
cout << s2.back() << endl; //返回o
return 0;
}
3.5 string类对象的修改操作
字符串的尾插::
int main()
{
string s1("hello");
//push_back()为尾插一个字符。
s1.push_back(' ');
s1.push_back('!');
cout << s1 << endl;
//append是尾插字符串
s1.append("world");
cout << s1 << endl;
//+=可以尾插一个字符,也可以尾插字符串
//+=的底层还是调用的push_bakc和append,+=只是多了一层封装,使用起来更加简单明了
s1 += ' ';
s1 += '!';
s1 += "world";
cout << s1 << endl;
return 0;
}
当一直向string类中插入字符时,就要涉及到扩容的问题了。可以看到在VS下每次扩容为原来的1.5倍。
int main()
{
//观察扩容情况
string s;
size_t sz = s.capacity();
cout << "capacity changed: " << sz << endl;
for (int i = 0; i < 100; ++i)
{
s.push_back('c');
if (sz != s.capacity())
{
sz = s.capacity();
cout << "capacity changed: " << sz << endl;
}
}
return 0;
}
可以看到第一次扩容为两倍扩容,那么为什么呢?
这是因为在string中当字符串长度小于16时,其实并没有动态申请空间存放字符串,而是将字符串存放到string类的_buf数组内,当字符串长度大于16后,才开始进行扩容,然后动态申请空间,将字符串存到动态申请的空间中。这样做的目的是防止了每有一个小的字符串就要动态申请空间,然后申请的空间很小就会产生很多碎片空间而浪费空间。
可以看到当字符串长度小于16时,就会存在_Buf数组中。
当我们使用sizeof查看s对象大小时,可以看到大小为28,这是因为除了两个int型变量和一个指针变量外,还有_Buf数组的16个字节的大小。
string扩容在不同的编译器下是不同的,比如windows下VS1.5倍扩容,linux下g++2倍扩容。并且g++中没有buf数组。这是因为VS用的PJ版STL,g++中用了SGI版本STL。
insert的使用
int main()
{
string s1("hello world");
string s2("world");
//调用string& insert(size_t pos, const string& str)函数,在s1对象的pos位置插入s2对象的全部字符串
s1.insert(0, s2);
cout << s1 << endl;
//调用string& insert(size_t pos, const string& str, size_t subpos, size_t sublen);函数
//在s1对象的pos位置插入s2对象的从subpos位置开始长度为sublen的字符串。
//如果sublen的长度超过s2的长度,则会将s2的字符串全部插入到s1中,然后就停止。
s1.insert(0, s2, 0, 4);
cout << s1 << endl;
//调用string& insert(size_t pos, const char* s)函数,在pos位置插入字符串s
s1.insert(0, "hello");
cout << s1 << endl;
//调用string& insert(size_t pos, const char* s, size_t n)函数
//在s1对象的pos位置插入s字符串的前n个字符,如果n大于字符串s的长度,则会将字符串s全部插入s1对象的字符串中,然后就停止。
s1.insert(0, "hello", 3);
cout << s1 << endl;
//调用string& insert(size_t pos, size_t n, char c)函数
//在s1对象的pos位置插入n个c字符
s1.insert(0, 2, 'c');
cout << s1 << endl;
//调用iterator insert(const_iterator p, size_t n, char c)函数
//在s1对象的字符串的begin()向后移5个位置处插入n个字符c。
s1.insert(s1.begin() + 5, 3, 'c');
cout << s1 << endl;
//调用iterator insert(const_iterator p, char c)函数
//在s1对象的字符串的begin()向后移5个位置处插入字符c。
s1.insert(s1.begin() + 5, 'y');
cout << s1 << endl;
return 0;
}
erase使用
insert和erase不推荐经常使用,因为它们在字符串中间插入或删除数据,都会挪动数据,效率比较低,开销还很大
int main()
{
string s1("hello world");
//会调用string& erase(size_t pos = 0, size_t len = npos)函数
//将s1对象的字符串从pos位置开始,向后删除len个字符
s1.erase(5, 1);
cout << s1 << endl;
//会调用string& erase(size_t pos = 0, size_t len = npos)函数
//因为没有给第二个参数,而npos=-1,即代表无符号最大的数,所以会将s1pos位置之后的字符全部删除
s1.erase(5);
cout << s1 << endl;
//会调用iterator erase(const_iterator p)函数
//将s1.begin()向后移5个位置,然后将后面的一个字符删除
s1.erase(s1.begin() + 5);
cout << s1 << endl;
//会调用iterator erase(const_iterator first, const_iterator last)函数
//将s1.begin()向后移5个位置,然后到s1.end()之间的字符都删除
s1.erase(s1.begin() + 5, s1.end());
cout << s1 << endl;
return 0;
}
replace使用
int main()
{
string s1("hello world");
string s2("abcdefgh");
//调用string& replace(size_t pos, size_t len, const string& str)函数
//将s1对象的字符串从pos位置开始的len长度的字符替换为s2对象中的字符串
//如果len大于s1对象的字符串的长度,则会将s1的全部字符串替换为s2的字符串。
//s1.replace(0, 5, s2); //abcdefgh world
//s1.replace(0, 50, s2); //abcdefgh
cout << s1 << endl;
//调用string& replace(const_iterator i1, cosnt_iterator i2, const string& str)函数
//将s1.begin()向后移5个位置,然后到s1.end()的位置之间的字符串替换为s2对象的字符串
//s1.replace(s1.begin() + 5, s1.end(), s2);
cout << s1 << endl;
//调用string& replace(size_t pos, size_t len, const string& str, size_t subpos, size_t sublen)函数
//将s1对象的字符串从pos位置开始的len长度的字符替换为s2对象中的字符串从subpos位置开始的sublen长度的字符
//s1.replace(0, 5, s2, 0, 5);
cout << s1 << endl;
//调用string& replace(size_t pos, size_t len, const char* s)函数
//将s1对象的字符串从pos位置开始的len长度的字符替换为s字符串
//如果len大于s1对象的字符串的长度,则会将s1的全部字符串替换为s字符串。
//s1.replace(0, 5, "abcde");
cout << s1 << endl;
//调用string& replace(const_iterator i1, cosnt_iterator i2, const char* s)函数
//将s1.begin()向后移5个位置,然后到s1.end()的位置之间的字符串替换为s字符串
//s1.replace(s1.begin() + 5, s1.end(), "abcde");
cout << s1 << endl;
//调用string& replace(size_t pos, size_t len, const char* s, size_t n)函数
//将s1对象的字符串从pos位置开始的len长度的字符替换为s字符串的前n个字符
//s1.replace(0, 5, "abcde", 3);
cout << s1 << endl;
//调用string& replace(const_iterator i1, cosnt_iterator i2, const char* s, size_t n)函数
//将s1.begin()向后移5个位置,然后到s1.end()的位置之间的字符串替换为s字符串的前n个字符
//s1.replace(s1.begin() + 5, s1.end(), "abcde", 3);
cout << s1 << endl;
//调用string& replace(size_t pos, size_t len, size_t n, char c)函数
//将s1对象的字符串从pos位置向后len长度的字符替换为n个c字符
//s1.replace(0, 5, 5, 'x');
cout << s1 << endl;
//调用string& replace(const_iterator i1, cosnt_iterator i2, size_t n, char c)函数
//将s1.begin()向后移5个位置,然后到s1.end()的位置之间的字符串替换为n个c字符
s1.replace(s1.begin() + 5, s1.end(), 5, 'x');
cout << s1 << endl;
return 0;
}
assign使用
assign为调用的对象分配一个新的字符串来替代原来的内容。
int main()
{
string s1;
string s2("The quick brown fox jumps over a lazy dog.");
//调用string& assign(const string& str)函数
//将s1对象的字符串替换为s2对象的字符串
s1.assign(s2);
cout << s1 << endl;
//调用string& assign(const string& str, size_t subpos, size_t sublen)函数
//将s1对象的字符串替换为s2对象从subpos位置开始向后sublen长度的字符串
s1.assign(s2, 10, 20);
cout << s1 << endl;
//调用string& assign(const char* s)函数
//将s1对象的字符串替换为字符串s
s1.assign("hello world");
cout << s1 << endl;
//调用string& assign(const char* s, size_t n)函数
//将s1对象的字符串替换为字符串s的前n个字符
s1.assign("hello world", 5);
cout << s1 << endl;
//调用string& assign(size_t n, char c)函数
//将s1对象的字符串替换为n个c字符
s1.assign(10, 'x');
cout << s1 << endl;
return 0;
}
swap使用
我们知道在std库中有一个swap的函数模板,该模板实例的swap为浅拷贝,即会将string类的内容都互换,而string类里面的swap函数只是将两个string类类型对象的指向字符串的指针互换一下,即完成了内容的互换,所以string类里面的swap函数效率更高。
int main()
{
string s1("hello world");
string s2("world hello");
cout << s1 << endl;
cout << s2 << endl;
//将s1和s2的字符串内容互换
s1.swap(s2);
cout << s1 << endl;
cout << s2 << endl;
return 0;
}
3.6 string类对象的字符串操作
find使用
int main()
{
string s1("The quick brown fox jumps over a lazy dog.");
string s2("quick");
//调用size_t find(const string& str, size_t pos = 0) const noexcept;函数
//从s1对象的字符串的起始位置开始找s2对象的字符串,并返回该字符串在s1对象中第一次出现的位置。
//如果找不到就返回npos.
size_t pos = s1.find(s2,0);
cout << pos << endl;
//调用size_t find(const char* s, size_t pos = 0) const;函数
//从s1对象的字符串的起始位置开始找s字符串,并返回该字符串在s1对象中第一次出现的位置。
//如果找不到就返回npos.
size_t pos1 = s1.find("quick", 0);
cout << pos1 << endl;
//调用size_t find(const char* s, size_t pos, size_type n) const;函数
//从s1对象的字符串的起始位置开始找s字符串的前n个字符组成的字符串,并返回该字符串在s1对象中第一次出现的位置。
size_t pos2 = s1.find("br", 0, 2);
cout << pos2 << endl;
//调用size_t find(char c, size_t pos = 0) const noexcept;函数
//从s1对象的字符串的起始位置开始找字符c,并返回该字符在s1对象中第一次出现的位置。
size_t pos3 = s1.find('b', 0);
cout << pos3 << endl;
return 0;
}
find和replace练习
将字符串中的空格都替换为%20。
下面的代码可以完成替换,但是使用replace将1个字符替换为3个字符时,可能s1对象的容量不够,会需要扩容,此时会增加开销。
int main()
{
string s1("The quick brown fox jumps over a lazy dog.");
//练习,将s1字符串中的空格都换为%20
size_t pos = s1.find(' ');
while (pos != string::npos)
{
s1.replace(pos, 1, "%20");
pos = s1.find(' ', pos + 3);
}
cout << s1 << endl;
return 0;
}
所以可以先提前遍历s1对象的字符串中有多少空格,然后提前将s1的空间开好,避免replace时频繁扩容增加开销。
int main()
{
string s1("The quick brown fox jumps over a lazy dog.");
size_t num = 0;
for (auto ch : s1)
{
if (ch == ' ')
{
++num;
}
}
//提前开空间,避免replace时频繁开空间
s1.reserve(s1.size() + 2 * num);
//练习,将s1字符串中的空格都换为%20
size_t pos = s1.find(' ');
while (pos != string::npos)
{
s1.replace(pos, 1, "%20");
pos = s1.find(' ', pos + 3);
}
cout << s1 << endl;
return 0;
}
虽然我们提前将s1的空间开好,可以提高一些效率,但是replace方法替换都会移动数据,移动数据的开销更大。所以我们可以重新创建一个string类类型对象,用追加的形式将转换好的字符都尾插到新对象中。然后将新对象赋值给s1。
这样以空间换时间,可以使程序的效率提高一些。
int main()
{
string s1("The quick brown fox jumps over a lazy dog.");
string newStr;
size_t num = 0;
for (auto ch : s1)
{
if (ch == ' ')
{
++num;
}
}
//提前将newStr的空间开好
newStr.reserve(s1.size() + 2 * num);
for (auto ch : s1)
{
if (ch != ' ')
{
newStr += ch;
}
else
{
newStr += "%20";
}
}
s1 = newStr;
cout << newStr << endl;
return 0;
}
c_str()使用
c_str是返回一个以’\0’符结尾的字符串。直接打印s1遇到\0不会停止,因为<<符号的重载函数是按s1.size打印的,size有多大,就打印多少个字符。但是c_str返回的是一个字符串的地址,即一个const char类型的指针,所以遇到\0会停止。即c_str返回的是c语言中的字符串。c_str返回c语言中的字符串,就是为了兼容c语言的一些接口,因为有一些c语言的函数需要传入以’\0\结尾的字符串,即const char类型的指针。而不是传入c++中的string类类型的对象。
int main()
{
string s1("hello world");
cout << s1 << endl;
cout << s1.c_str() << endl;
cout << (void*)s1.c_str() << endl;
s1 += '\0';
s1 += '\0';
s1 += "xxxxxxxx";
//直接打印s1对象,遇到\0不会停止打印
cout << s1 << endl;
//而s1.c_str()返回的是一个c语言中的字符串,所以遇到\0就会结束
cout << s1.c_str() << endl;
return 0;
}
substr使用
substr为截取字符串的作用。
int main()
{
string s1("hello world");
//substr函数为截取字符串,即从pos位置截取len长度的字符串返回。
//如果第二个参数没有,则默认从pos位置将字符串全部截取然后返回。
string tmp = s1.substr(0,5);
cout << tmp << endl;
//练习,截取文件名的后缀
string file("string.cpp");
size_t pos = file.find('.');
if (pos != string::npos)
{
string suffix = file.substr(pos);
cout << suffix << endl;
}
return 0;
}
rfind使用
int main()
{
string s1("hello world hello");
string s2("hello");
//调用size_t rfind(const string& str, size_t pos = npos)const noexcept;函数
//从s1对象的字符串的pos位置开始向前查找对象s2的字符串,找到了就返回字符串在s1中的位置
//如果不传第二个参数就默认从s1字符串的末尾开始查找。
size_t pos1 = s1.rfind(s2, 0);
cout << pos1 << endl;
string file("string.cpp.tar.zip");
//从后向前找字符'.'
size_t pos = file.rfind('.');
if (pos != string::npos)
{
string suffix = file.substr(pos);
cout << suffix << endl;
}
return 0;
}
find配合substr查找字符串练习
查找url路径中的地址。
int main()
{
string url("https://legacy.cplusplus.com/reference/string/string/rfind/");
cout << url << endl;
size_t start = url.find("://");
if (start == string::npos)
{
cout << "invalid url" << endl;
}
start += 3;
size_t finish = url.find('/', start);
//打印://到第一个/之间的字符串
string address = url.substr(start, finish - start);
cout << address << endl;
return 0;
}
find_first_of使用
int main()
{
//将s1字符串中的abcd都换为*
string s1("The quick brown fox jumps over a lazy dog.");
//在s1对象的字符串中查找"abcd"字符串中的字符第一个出现的位置,并且将该位置返回
size_t found = s1.find_first_of("abcd");
while (found != string::npos)
{
s1[found] = '*';
//继续向后找s1对象的字符串中的"abcd"字符
found = s1.find_first_of("abcd", found + 1);
}
cout << s1 << endl;
return 0;
}
find_last_of使用
find_first_of为从前向后查找,而find_last_of就是从后向前查找。
int main()
{
//将s1字符串中的abcd都换为*
string s1("The quick brown fox jumps over a lazy dog.");
//在s1对象的字符串中查找"abcd"字符串中的字符最后一个出现的位置,并且将该位置返回
size_t found = s1.find_last_of("abcd");
while (found != string::npos)
{
s1[found] = '*';
//继续向前找s1对象的字符串中的"abcd"字符
found = s1.find_last_of("abcd", found + 1);
}
cout << s1 << endl;
return 0;
}
find_first_not_of使用
find_first_of为查找s1中含有字符串中字符的位置返回,而find_first_not_of是查找s1中不是这样字符串中字符的位置返回。
int main()
{
//将s1字符串中除了abcd的字符都换为*
string s1("The quick brown fox jumps over a lazy dog.");
//在s1对象的字符串中查找不是"abcd"字符串中的字符的第一个出现的位置,并且将该位置返回
size_t found = s1.find_first_not_of("abcd");
while (found != string::npos)
{
s1[found] = '*';
//继续向后找s1对象的字符串中不是"abcd"的字符
found = s1.find_first_not_of("abcd", found + 1);
}
cout << s1 << endl;
return 0;
}
find_last_not_of使用
find_first_not_of是从前向后找,而find_last_not_of为从后向前找。
三、模拟实现string类
当我们知道了string类的大致操作后,我们也可以模拟实现一下string类。
当我们写到有参构造函数时,会发现形参str在初始化列表赋值给_str时发生了错误。这是因为str为const char类型,而_str为char类型,将const char类型赋值给char,发生了权限放大,所以会出现报错。
那么上面这个问题该怎么解决呢?
我们可以将_str也改为const char类型,但是将_str改为const char类型后,虽然上面的情况不报错了,但是又遇到了新的问题。当我们对[]操作符进行重载时,会看到发生了错误,因为函数返回的是一个char类型的引用,而_str[pos]是一个const char的类型的字符,所以又发生了权限放大。
并且如果将_str设为const char类型,以后就不可以修改_str字符串的值了,这是肯定不可以的,所以我们不能将_str设为const char类型。那么我们的有参构造函数就不能像上面那样在初始化列表中将成员变量全部初始化了,我们需要像下面那样,将_str在构造函数内初始化。先使用new申请空间,然后使用strcpy函数进行拷贝,这样就完成了_str的初始化。
然后我们测试时发现报出了异常,这是因为当创建对象使用默认的无参构造函数时,此时_str为nullptr空指针,然后s1.c_str()返回的是一个nullptr,当使用cout<<nullptr时,因为cout<<有自动识别类型的特性,而s1.c_str()返回的是一个const char类型的指针,但是cout<<不会直接将这个指针打印出来,它会打印出来这个指针指向的字符串,即将这个指针解引用,找到指针指向的字符串进行打印,当遇到’\0’就停止。而此时s1.c_str()返回的是nullptr,所以cout<<就会对nullptr进行解引用,然后就会造成对空指针解引用。就会出现异常了。
所以在无参构造函数中不能将_str初始化为nullptr。我们可以在无参构造函数中将_str赋值为’\0’,这样当打印时遇到’\0’就停止打印,而第一个就是’\0’,就会打印空字符串了。
然后我们将析构函数也写出来。
然后我们可以将有参构造函数和无参构造函数和为一个全缺省构造函数。此时需要注意的时缺省参数给的缺省值,不能给str的值为nullptr空指针,而要使str指向’\0’字符,即str的值为’\0’字符所在的空间,而不是str的值为0。
然后我们再来看拷贝构造函数。我们知道编译器默认生成的拷贝构造函数为浅拷贝,即如果使用编译器自带的默认拷贝构造函数,那么会出现两个string类类型对象指向同一片空间的情况,例如下面的图片中的情况。并且在s2对象销毁时调用析构函数delete[] _str后,在s3对象销毁时,也会调用析构函数再次delete[] _str,而此时的_str已经被s2对象的析构函数给delete[]过了,所以对s2的_str进行了两次delete[],故出现了错误。并且使用浅拷贝,当改变s2对象中_str的值时,s3对象中_str的值也会改变。
所以我们需要自己重写深拷贝的拷贝构造函数。拷贝构造函数也有初始化列表,所以我们在初始化列表中将_size和_capacity直接初始化为要拷贝对象的_size和_capacity。然后在拷贝构造函数内重新申请_capacity+1大小的空间给新对象,并且使用strcpy将s对象的_str内容拷贝给新对象的_str。此时可以看到s2对象的_str指向的空间和s3对象的_str指向的空间不是一片空间。但是s2对象和s3对象的内容都相同,并且修改s2对象的内容时不会影响s3对象的内容。
接下来我们实现=赋值运算符的重载函数。该函数就需要分如下三种情况。这样我们实现赋值运算符的重载函数时还需要进行判断。但是我们也可以不进行判断,直接将s1对象的_capacity设置为和s2的_capacity一样,然后将s1对象_str的空间释放,重新为s1的_str开辟一个大小为_capacity的新空间,然后将s2的_str的内容拷贝给s1的_str。
下面的代码可以实现s1=s2,即将s2赋值给s1,但是当将自己赋值给自己时就会打印乱码。这是因为在赋值运算符重载函数中刚开始就将s1对象的_str的空间释放了,然后这个空间的值就为随机值了,然后strcpy(_str,s._str)将随机值拷贝过来给s1的_str,所以s1的_str就为随机值了。
所以我们要先判断一下,如果是s1=s1的情况时,就不需要做任何操作。并且我们提前释放s1对象的_str的空间也有安全隐患。例如如果我们申请空间不成功时,此时s1对象的_str的数据就造成丢失了。所以我们应该先申请空间,然后再释放s1的_str的空间。
然后我们再实现size()函数用来返回对象的字符串长度。
当我们实现了size()函数后,我们实现一个Print函数用来遍历对象的字符串。然后我们会发现在测试时可以调用size()函数和使用[]操作符的重载函数,但是在Print函数中使用size()函数和[]操作符的重载函数发生了错误。这是因为在Print中使用size()函数和[]操作符的重载函数发生了权限放大。因为在Print函数中s被const修饰,所以当调用成员函数size()和[]操作符的重载函数时传入的this指针为const string类型,而成员函数size()和[]操作符的重载函数的this为string*类型,所以发生了权限放大。
此时就应该将size()函数和[]操作符的重载函数使用const修饰,这样Print函数中就可以调用这两个函数了。此时虽然Print函数可以调用这两个函数了,但是[]操作符的重载函数被const修饰了,那么就不能使用[]操作符来改变string类类型对象中_str的值了,例如s[1]++就不可以了,这肯定是不合理的。
所以此时我们就需要写两个[]操作符的重载函数,一个[]操作符的重载函数被const修饰,这个函数给被const修饰的对象调用。另一个[]操作符的重载函数不使用const修饰,这个函数给那些需要改变对象的值的情况调用。因为编译器会去调用最匹配的[]操作符的重载函数,所以这样的设计就合理了。
然后我们再使用指针来实现string类的迭代器。使用迭代器可以读数据,也可以修改数据。
我们在前面说过范围for在底层就是使用迭代器来完成的,其实范围for就类似于宏的替换,当遇到范围for的语法就在底层替换为迭代器。所以支持了迭代器就支持了范围for。范围for会在底层傻瓜式的调用迭代器的begin、end等方法。所以只要实现了begin、end方法就可以使用范围for了。但是如果我们将begin改为Begin(),此时再使用范围for就会出错,也为范围for只能傻瓜式的调用begin和end等方法,如果没有名字为这些的函数,就会使用不了范围for。
当我们在测试时调用begin和end函数可以调用,但是在Print函数中调用begin和end时就会发生错误,这是因为此时又发生了权限放大,在Print函数中,对象s被const修饰,而begin()和end()的隐藏形参为string this,没有被const修饰,所以调用begin()和end()时发生了权限放大。
所以此时我们就需要再写一个被const修饰的begin和end方法,并且这两个方法返回的类型都为const char* 类型的指针,因为当创建const_iterator的迭代器就说明不希望通过这个迭代器改变对象的值,所以返回一个const char类型的指针就不能通过解引用这个指针来改变指针指向的值。但是这个指针的值是可以改变的,因为还需要通过这个指针++然后访问后面的元素。例如const char str,不能str = “abc”,但是可以str++。
此时Print函数中被const修饰的对象s可以调用被const修饰的begin和end函数,并且此时只能通过迭代器来读取对象s的数据了,并不能通过迭代器来改变对象s的值了。因为被const修饰的begin()函数返回的是const char类型的指针,不能通过解引用来修改这个指针指向的内容。
简单的实现了迭代器后,我们再来实现一下string类类型对象的比较,即实现比较符的重载函数。
上面的这些比较运算符的重载函数当里面的this和const修饰的s对象位置交换一下时,我们发现就会出错,这是因为又发生了权限放大。s对象被const修饰,当s对象调用==比较运算符的重载函数时,这个函数的隐藏参数为string * this ,而s对象被const修饰,即传入的是一个const string 类型的指针,所以发生了权限放大。
此时我们就需要将上面这些比较运算符的重载函数都使用const修饰。这也使我们明白了,那些string类的成员函数中,内部不修改成员变量的值的成员函数都建议加上const修饰,这样才能减少代码出错的概率。
像c_str()这样的函数使用const修饰后,那些被const修饰的对象也可以调用这些函数了。
而那些有时候需要修改成员变量数据的成员函数,有时候又需要被const修饰的对象调用的函数。就需要写两个版本,一个不被const修饰的版本用于修改成员变量数据时调用。一个被const修饰的版本用于给被const修饰的对象调用。
然后我们再来实现尾插字符和尾插字符串的函数。但是当尾插字符和字符串时可能会遇到容量不够的情况,此时就需要进行扩容。
所以我们要先实现reserve扩容函数,然后再实现尾插字符和尾插字符串的函数。当我们像下面这样实现了reserve函数后,我们测试时会发现这个reserve函数会进行缩容,即当我们传入的值n比s1对象的字符串长度小时,此时也会开辟一个n+1大小的新空间,然后将原来的数据拷贝过去,此时因为使用的strcpy拷贝,即strcpy拷贝是遇到’\0’才停止,所以tmp中的数据还是原来的_str,但是因为n小于_str字符串的长度,开辟的空间为n+1大小,根本不够放原来的_str字符串的,所以就会出现错了。
所以我们就需要在扩容时先判断一下,如果n<_capacity时,就不进行缩容操作了,只有当n>_capacity时才进行增容操作。
然后我们再实现push_back函数,此时我们测试时会发现给s1的字符串尾插一个字符后,打印的字符串出现了乱码。这是因为在push_back()中我们将_str原来的’\0’字符变为了ch字符,所以插入ch字符后,_str字符串就没有了’\0’字符串结束符。
所以我们需要在尾插字符ch后,重新将_str的最后一个字符后设置一个’\0’字符串结束符。
此时当我们测试时,又会发现一个新的问题,即当我们给一个_capacity为0的对象调用push_back()函数进行尾插时,因为我们的扩容写的为_capacity2,所以当_capacity为0时,_capacity2还是0,即我们没有给对象扩容成功。
此时我们有两种方法可以解决这个问题,第一个办法是在构造函数中不让_capacity的起始为0,即在构造函数初始化时就开辟少量的空间,然后使_capacity不为0。第二个办法就是在push_back函数调用reserve()函数进行扩容时先判断_capacity的值,如果_capacity为0,就不给reserve函数传参为_capacity*2,而传入一个具体的值。
第一种办法:
第二种办法:
然后我们再实现append()函数。因为append函数是尾插一个字符串,所以开辟的空间为_size+len大小。然后将字符串插入到原字符串的后面,即_str+_size的位置。因为_str为字符串首字符的位置,而_str+_size为字符串的最后一个字符的后面的位置,即为’\0’的位置。而尾插str就是将str插入到这个位置。并且因为strcpy函数中会在最后将目标字符串的后面加上’\0’字符,所以我们不需要关系乱码问题。
将strcpy换成strcat也可以实现,但是使用strcat还需要自己去找\0的位置,然后在\0的位置后追加str字符串,但是本来就知道\0的位置为 _ str + _ size,所以使用strcat会多执行不必要的代码。增加开销。
实现了尾插字符和尾插字符串,+=字符相当于上面这两个函数的结合,所以接下来我们就实现+=运算符的重载函数。因为+=运算符的重载函数可能需要处理单个字符或一个字符串,所以我们对+=进行了重载,当+=一个字符时在底层调用push_back()函数,当+=一个字符串时在底层调用append()函数。
我们知道c++中的string类中,reserve函数为扩容函数,并且不会对扩容的空间进行初始化。而resize函数会对扩容的空间进行初始化,如果调用resize函数时只传入一个参数,则会将扩容的空间初始化为’\0’,如果传入了第二个参数,则会将扩容的空间初始化为第二个参数。所以我们可以尝试来实现一下resize函数。c++中是对resize函数进行了重载,写了两个版本,一个是一个参数的版本,还有一个是两个参数的版本。而我们可以尝试使用半缺省参数的方法将这两个函数和为一个函数。可以看到在测试代码中,对象s1的最后面加上了’\0’字符,并且s1和s2的_size和_capacity都被改变了。而且官方实现的resize中,当n小于_str字符串的长度时,会将字符串直接删除为长度为n,并且也不会实现缩容,因为缩容需要很大的代价,缩容也是开辟新空间,再将值拷贝到新的空间中,也会有很大的开销。所以我们自己在实现时也不能缩容,需要像实现reserve一样进行判断。
然后我们接着实现在某位置插入字符或字符串,还有在某位置删除字符串。我们知道在c++官方的string库中有一个size_t的变量npos,这个变量的值为-1,但是该变量为无符号整型,即其实npos表示int型整数表示的最大值。我们自己实现string类时可以将npos定义为静态成员变量,因为有很多成员函数都使用npos来作为缺省值。当我们定义静态成员变量npos时,发现不可以给静态成员变量缺省值。这是因为普通成员变量给缺省值是在初始化列表中对成员变量进行初始化,而静态成员变量并没有初始化列表进行初始化,所以不可以给静态成员变量缺省值。
但是c++语法中允许被const修饰的静态成员变量在定义时进行初始化,并且这种语法只支持整型。double类型的静态成员变量使用const修饰时就不能在在定义时被初始化了。
可能这种语法是为了下面的情况时更方便。
但是我们自己在写代码时还是使用正常的写法,将静态成员变量在类中定义,在全局域中进行初始化。
然后我们来实现在某位置插入n个字符。我们想到的是当end>=pos时,就将end位置的字符向后移n位,这样移到最后就会在pos位置空出n个位置用来插入n个字符,但是当pos为0时,因为end为size_t类型,为无符号整型,所以当end=-1时会按最大的int型整数来算end值,所以此时end还是大于pos的,就还会执行while后面的语句,这样就会出现越界访问了,所以会报异常。
此时我们可以将end=_size+1,然后当pos为0时,就不会再出现越界的现象了。
当实现了某位置插入字符后,我们再写insert的重载函数来实现某位置插入字符串。其基本思路和上面插入n个字符类似,只不过在拷贝数据时需要使用strncpy函数,因为strcpy函数遇到’\0’就停止拷贝,如果str=“abc\0defg”,则使用strcpy只会拷贝abc,就会少拷贝字符。
当insert函数实现了,其实前面写的push_back函数和append函数就可以复用insert函数来实现了。
下面我们再来实现erase函数,在c++库中的erase函数在删除字符串时也不会进行缩容,所以我们实现erase时也不需要进行缩容处理。
接下来我们实现string库里面的swap函数,我们知道在std库中有一个swap函数的模板,而这个模板的调换两个对象是先创建一个临时对象,然后再调用对象的赋值运算符等进行交换,这个开销是很大的。而string类中的swap函数只是将三个成员变量交换一下即可,所有string类中的swap函数效率更高。
然后我们再实现find函数进行字符或字符串的查找。下面实现的是查找字符,如果找到了就返回该字符第一次出现的下标,如果没有出现该字符就将npos返回。
然后我们再实现查找子串,在内部使用strstr函数来查找子串,strstr返回的是子串的地址,而find函数返回的是子串第一次出现的下标,所以我们可以使用子串的地址减_str的头地址,就得到了子串的下标。
然后我们再来实现<<流插入符的重载函数和>>流提取符的重载函数。我们在实现日期类的流插入符重载函数和流提取符重载函数时,使用了友元函数的概念,那么我们实现这两个函数必须要实现成友元函数吗?当然不需要,因为对于对象的<<流插入我们不需要访问对象的_str成员变量,我们可以使用迭代器或者范围for来遍历对象的字符串。
然后我们使用同样的思路来实现流提取符重载函数。但是下面的代码在测试时我们发现当输入为空格或换行符时,并没有结束输入,还是一直让我们输入。这是因为c或c++中规定多个字符之间的间隔是空格或者换行,所以使用in>>ch时,会将空格或者换行当作字符之间的间隔,然后不会提取到缓冲区里面去,这样就会一直没有结束符,而一直循环输入。
此时我们可以使用get()函数来提取数据,get()函数会将输入的所有字符都放入缓冲区中。此时就可以正常输入了。但是我们发现当测试时,又出现了乱码,这是因为在函数中为s+=ch,当结束时没有’\0’字符串结束符了。并且库里面的流提取符重载函数会清除对象中原来字符串的内容。
所以我们实现的流提取符重载函数也要先清除原来的字符串内容,我们需要写一个clear成员函数,在每次使用流提取符重载函数时先将对象的字符串清空。
这样每次输入时都会将之前的字符串先清空。我们使用+=来给对象s每次追加一个字符,当我们输入的字符串比较长时,+=就会频繁的进行扩容,所以我们可以进行一下优化,库里面是使用了一个buff数组,每当buff数组满了之后就进行一次扩容。这样可以减少扩容的频率。
到此我们模拟实现string类就基本完成了,库里面还有很多方法,我们只模拟实现了string类比较常见的一些方法。
四、windows下的VS中的string类和Linxu下的g++中的string类。
在windows下的VS编译器中,string类中有一个大小为16的Buf数组,但是只能存15个有效字符,因为最后一个空间要留个’\0’,当string类中的字符串大小小于15时,字符串的内容就存到_Buf这个数组中,当string类中的字符串大小大于15时,就会存在动态申请的堆区中了。使用_Buf数据可以使string类的字符串很短时就不需要动态申请内存了,这样也减少了内存碎片的产生。而每个string类大小为28个字节,因为_Buf占16个字节,size_t类型的size和capacity共占8字节,还有一个指针指向动态申请的空间,也占4个字节。
在Linux下的g++编译器中,string类类型对象的大小在32位电脑下为4字节,在64位电脑下为8字节,即只有一个指针的大小。在g++中string是通过写时拷贝实现的,string对象内部只包含了一个指针,该指针将来指向一块堆空间,内部包含了如下字段:
- 空间总大小
- 字符串有效长度
- 引用计数
- 指向堆空间的指针,用来存储字符串。
在Linux下的g++中创建一个string类类型的对象是这样实现的。
当通过拷贝构造创建s2时,g++中这样处理。即先将s2中的_ptr指针也指向s1的空间,s1和s2先同时访问这一片空间,并且此时引用计数_refcount为2,引用计数是为了防止重复析构的,因为如果s1和s2都指向一个空间的话,那么会调用两次析构函数来释放这片空间,但是有了_refcount的概念后,只有当_refcount1时,才调用析构函数,而如果_refcount不为1时,如果有一个指向这片空间的对象销毁了,_refcount就 -1。
如果向s2对象中插入数据或删除数据时,即对这片空间进行写操作时,此时会新开辟一片空间,然后将s1指向的空间的内容都拷贝到新空间去,这样s2修改的就是新空间的内容了,就不会影响s1指向的空间里的内容。而如果只对s2对象进行读操作时,就不需要进行上面的拷贝操作,这样就减少了一次深拷贝。这就是写时拷贝的大致思想。