目录
前言
路漫漫其修远兮,吾将上下而求索;
一、认识string
由于编码的原因,string 其实是一个类模板;
注:我们在下面模拟实现string 的时候,不以模板的形式呈现;
首先需要我们明确三个知识点:
- 1、在多个文件中相同的命名空间会被合并为一个
- 2、内联函数的声明和定义不能分离,缺省参数只能给一个;
- 3、普通函数不可以在 .h 文件中进行定义,因为 .cpp 文件中会包含 .h 文件,本质就是 .h 文件会在 .cpp 文件中展开;而多个 .cpp 文件经过遍历会形成多个 .o 文件,最后和链接库链接之后形成可执行程序;而至于普通函数:方式一是让该函数的声明与定义分离,方式二是让此普通函数变为静态的,静态函数只能在当前文件可见(静态函数失去了函数的外部链接属性);
string 实际上是一个叫做 basic_string 的模板类,如下:
Q:为什么string 是一个basic_string 的类呢?
- 以前我们所理解的串都是char* 的字符串,而实际上由于编码的原因还有几种串:char、wchar_t(宽字符)、char16_t(UTF-16)、char32_t(UTF-32);
其中类模板base_string 模板参数类型为char 的类 ,称为 string , 模板参数类型为 char16_t 的类称为u16string, 模板参数类型为 char32_t 的类称为 u32string, 模板参数类型为 wchar_t 的类型称为 wstring;
Q:什么是编码?
- 计算机底层存储的是 0、1, 它是如何表示、存储我们的文字信息?0、1 与我们的文字之间存在映射关系;此时便提出了一个概念:ASCII(American Standard Code For Information Interchange),如下:
ASSIC码值实际上是计算机实际的存储值与该符号的映射编码表;在内存中存储的是ASSIC码值,而在打印的过程中会查ASSIC表,打印出该ASSIC码值对应的字符;
老美的文字很简单,由字母构成单词,而单词便可以表达他们的文字;但是不同的国家的文字不同,对于汉字来说并不能用一个字符(1byte) 去编码对应的汉字,因为汉字有很多(大概有十几万个),而1byte 只能表示 256 种状态;
所以基于这一系列的原因,对全世界的文字进行了一种标准的编码:
编码,是指以固定的顺序排列字符。并以此作为记录、存储、传递、交换的统一内部特征,这个字符排序序列被称为“编码”;
字符集 | 定义 | 简介 |
ASCII | 美国信息交换标准码 (American Standard Code for Information Interchange) | 7位编码,二进制取值范围 0000000~1111111(共128个字符) 存储时仍然是以 8 位存储 包含控制字符和图形字符 重要字符:0-48、A-65、a-97(大小写字母相差32) |
GB类 | 先后出现GB2312、GBK、GB18030的等字符集 | 双字节定长编码 汉字的字符编码集,从GBK开始支持繁体字 |
Big5 | 五大码 | 最早是在台湾省/港澳使用的繁体字编码表,与GB类不兼容 |
ANSI | 美国国家标准学会 (American National Standards Institude) | ANSI并不是某种特定的字符编码,而是在不同的系统中,ANSI表示不同的字符编码 在英文体系之中,默认为ANSI 在简体中文系统中,默认为GB类 在繁体中文系统中,默认为Big-5 命令行使用chcp 命令,可显示或者设置当前系统ANSI的编码方式(936简体中文 950繁体中文) |
USC | (Universal Coded Character Set)1991年10月,两个团队决定合并字符集,后来两个字符集合并为一,UCS的码点将与 Unicode 完全一致 | 国际标准化组织(ISO)创建; UCS-2:两个字节定长编码,包含 65536 个编码空间(可以为全世界最常用的 63K 字符编码,为了兼容 Unicode , 0xD800-0xDFFF之间的码位并未使用)USC-2 时 UTF-16的一个子集; UCS-4: 4个字节定长编码,编码空间为 0x00000000-0x7FFFFFFF (可以转换成20多亿个字符),为了兼容Unicode 标准,只不过其编码空间被限定在了 0~0x10FFFF之间,UTF-32是USC-4的一个子集; |
Unicode | 统一化字符编码标准(The Unicode Standard),或称统一码、万国码 | 统一码联盟创建 统一包含了世界上所有的字符。为每一种语言的每一个字符均设定了唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求; 三种编码方式:UTF-8、UTF-16、UTF-32 |
其中最常用的就是Unicode,“万国码”,其目的是为了将全世界各个国家的文字均编入;而在Unicode 之中又有几个较为主流的方案:UTF-8、UTF-16、UTF-32;
在统一码中,汉字“字”对应的数字是23383。在统一码中,我们又很多种方式将数字 23383 表示成程序中的数据,包括:UTF-8、UTF-16、UTF-32。UTF是“USC Transformation Formal” 的缩写,可以翻译成统一码字集转换格式,即怎样将统一码定义的数字转换成程序数据。例如,“汉字”对应的数字是 0x6c49 和 0x5b57 ,而编码的程序数据是:
BYTE data_utf8[] = {0XE6 , 0XB1,0X89,0XE5,0XAD,0X97};//UTF-8编码
WORD data_utf16[] = {0x6c49 , 0x5b57};//UTF-16编码
DWORD data_utf32[] = {0x6c49 , 0x5b57};//UTF-32编码
此处使用BYTE、WORD、DWORD分别表示无符号8位整数、无符号16位整数、无符号32位整数。UTF-8、UTF-16、UTF-32分别以BYTE、WORD、DWORD 作为编码单位;“汉字”的UTF-8编码需要6个字节;“汉字”的UTF-16 编码需要两个WORD,大小是4字节;“汉字”的UTF-32编码需要两个DWORD,大小是8个字节。根据字节序的不同,UTF-16可以被实现位UTF-16LE或者UTF-16BE,UTF-32 可以被实现为UTF-32LE或者UTF-32BE。
单拿汉字来说,汉字有十几万个,而编码需要让值和符号一一对应;2byte 有 16个比特位,即 2^16 可以表示 1024*10^6 个字符(2^16 为 65535)基本上就可以表示常用的汉字,但是如果要加上一些生僻字,2byte 也编不下,但在大多数的地方上考量的编码方案:用2byte 去编译一个汉字;
在这些编码之中,用得最多的是UTF-8;UTF-8并不是以8bit 去编码的,而是以 8bit 为单位去编码;其本质是一个变长编码(UTF-8在编码大多数文字的时候是以1byte、2byte 为主,但也有可能有3byte、4byte ),UTF-8最大的特点就是兼容ASCII码值(也就是说明ASCII可以作为UTF-8的一部分),那也就意味着在UTF-8中该符号究竟是用几个字节进行编码,会用前缀对此进行区分;
UTF-8 是以字节为单位对统一码进行编码,从统一码到UTF-8的编码方式如下:
字节 | 格式 | 实际编码位 | 码点范围 |
1byte | 0xxxxxxx | 7 | 0~127 |
2byte | 110xxxxx 10xxxxxx | 11 | 128~2047 |
3byte | 1110xxxx 10xxxxxx 10xxxxxx | 16 | 2048~65535 |
4byte | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 21 | 65536~2097151 |
UTF-8的特点是对不同范围的字符使用不同长度的编码;对于0x00-0x7F之间的字符,UTF-8 编码与ASCII编码完全相同,UTF-8编码的最大长度是8byte ;从上表中可以看出,4字节模板有21个x ,即可以容纳 21位二进制数字。统一码(Unicode)的最大码位 0x10FFFF也只有21位;
- eg.1. “汉”字的统一码编码是 0x6C49。0x6C49在0x0800-0xFFFF之间,使用三字节模板:1110xxxx 10xxxxxx 10xxxxxx ,将0x6C49写成二进制是: 0110 1100 0100 1001 ,用这个比特流依次替代模板中的x,就会得到:1110 0110 1011 0001 1000 1001, 即E6 B1 89
- eg.2. 统一码编码 0x20C30 在0x010000-0x10FFFF之间,使用4字节模板:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 。将0x20C30 写成21位二进制数字(不足的就在千米爱你补0):0 0010 0000 1100 0011 0000 ,用这个比特流依次替代模板中的x ,就会得到: 1111 0000 1010 0000 1011 0000 1011 0000, 即F0 C0 B0 B0;
当编码是UTF-8 的时候,用string 便用得很多,也会很方便,故而我们最后学习string:
一个字符通常是由2byte 来编写的:
如上述例子中,2+2+1=5;其后的'\0' 是ASCII码,占1byte;
Q: 汉字的底层存储的仍然是二进制,如果我们稍加修改其底层存储的数据会怎样呢?
- 同时这也说明了,汉字编码表中,同音字是被编在一起的;
在我们的应用当中,有一些地方便会去了解编码表,以及做一些净网行动;
Q:什么是净网行动?
- 屏蔽掉一些不好、负面的词;例如,国粹;
Q: 即使屏蔽了,但还可以用同音词去代替、拼音去表达,此时又该如何解决?
- 编码表中的同音字是在一起的。eg.我草;当这两个发音的字组合在一起的时候,需将“我”同音的字给屏蔽掉,并且将与“草”同音的字也屏蔽掉;
而由于历史的原因,有些地方还有其他的编码方案;
eg. UTF-8 是以1byte 为单位进行编码的;UTF-16是以2byte 为单位进行编码的,但是UTF-16 并不兼容ASCII;而对于UTF-32 来说是以 4byte 为单位进行编码;
UTF-16与UTF-32的好处:编码长度比较统一,在读的时候速度可能会更快一些;这样看来的话,其实UTF-8是有问题的,因为需要通过投几个比特位来判断当前符号用多少个字节来表示,而UTF-16与UTF-32不存在这样的问题;
倘若你的编码表设置为UTF-16,那么就不该使用string ,而是应该使用u16string:
Q: 为什么还有一个宽字符的概念?
- 在早期的时候,并没有考虑到会有编码表这些东西;eg.C++11 (2011年之前)就只搞了char 和 wchar;wstring 的功能实际上与u16string 的功能是重叠的;
直接 wchar_t a[] = "加薪"; 是编译不过的,因为写常量字符串编译器会默认为char 类型的字符串;我们需要在常量字符串前面加一个 L 作为前缀,以表示该字符串为 wchar_t 类型;而对于char16_t 来说,需要在字符串前面加 u 作为前缀:对于char32_t,需要在字符串前面增加 U 作为前缀;
void test3()
{
wchar_t a1[] = L"加薪";
char16_t a2[] = u"自由";
char32_t a3[] = U"太阳";
cout << sizeof(a1) << endl;
cout << sizeof(a2) << endl;
cout << sizeof(a3) << endl;
}
其中wchar_t 和 char16_t 是以 2 byte 为单位来编码的,char32_t 是以4byte 为单位进行编码;wchar_t 、char16_t 与 char32_t 均不兼容ASCII,所以最后的 '\0' 对于wchar_t 和 char16_t 来说占了2byte , 而对于 char32_t 来说占了 4byte;
wchar_t 在C++11 之前产生的,char16_t 和char32_t 在C++11 时产生的;char16_6 和char32_t 用来涉及 Unicode ,而wchar_t 下有些地方进行编程便会使用wcahr_t ,并且在wchar_t 与 char16_t 之间还会涉及相互转换的问题;
基于以上问题就可以明白,为什么string 需要写成模板;因为有不同编码的字符串,只有写成模板才可以用来表示各种各样的字符;
除了以上的编码,我们还需要再了解一种:GB系列;
凡是以GB开头,后面加数字的均可以统称为GBK;GB其实就是“国标”拼音的缩写,既然都用拼音首字母命名了,显然这是中国人自己搞的编码;
和中文相关的编码标准有:国标GB码、GBK码、港台BIG-5码等,不同编码的汉字字库都与汉字的应用有密切的联系;
很多人在使用过程中,发现字不够用,因为大家使用的主要是GB编码字库,此编码标准只收录了6763 个常用汉字,而GB字库外的大量的汉字,只能通过方正女娲补字软件拼字或者其他造字程序补字。尽管补出来的字在字形上满足需求,但在字体风格、大小、结构方面难以统一,而采用手工贴图的方式补字,更不雅观;进而言之,如果用户建立信息系统,或需要查询新闻、出版内容的时候,靠补字是无法实现的。方正开发的GBK库,将极大地缓解缺字现象;
从GB字库扩展到GBK字库,增加了一万四千多字;北大方正从1996年投入大量人力,开始做黑、宋、仿、楷GBK字库,并于1998年4月成为第一家通过国家标准认证部门组织地GBK字库鉴定的专业厂商;北大方正已将全部字体转换为GBK字库,共46款,其中18款字数达21003个,是拥有GBK字库款数量最多的厂商;
ISO 10646 是一个包括世界上各种语言的书面形式以及附加 符号的编码体系。其中的汉字部分被称为“CJK统一汉字”(C指的是中国,J指的是日本,K指的是朝鲜);而其中中国部分,包括了源自中国大陆得 GB 2312、GB 12345、《现代汉语通用字表》等法定标准得汉字和符号,以及源自台湾得CNS 11643 标准中第1、2字面(基本等同于 BIG-5编码)、第14字面的汉字和符号;
其次GBK与UTF-8有点类似,GBK同样也兼容ASCII;
windows 上喜欢用GBK;因为微软这家公司很早就看上了中国的市场,所以微软很早便将 windows 系统汉化了,所以在windows 中有时候默认的编码用的就是GBK;
通过上述对于编码的讲述,我们又可以解释为什么如果将一块空间delete 之后,再查此空间中的内容便会发现“烫烫烫烫...” 以及“屯屯屯...” ;这是因为字符串在显示的时候会去查其对应的编码,delete 了这块空间并不是将这块空间整没了,而只是将此块空间归还给操作系统,此时这块空间中的数据会被设置为随机值,而其设置的随机值去查其对应的编码表显示的就是“烫烫...”或者“屯屯屯...”
本质上ASCII、Unicode、GBK...是为了表示文字, 计算机只能存储 0、1 , 设置好对应的编码,将存储在计算机中的值读取出来再去对仗编码表以显示对应的文字;即编码表是存储的值与符号之间的映射,一一对应;而符号是以图像的方式存储的;
C++之中的string 还可以跟整型、浮点数等内置类型进行很好的转换:
可以很好地替代C语言中的 atoi;
二、模拟实现
1、底层结构
string 其实就是顺序表的封装,其底层需要三个变量 size、capacity、str ,即利用size 来记录当前结构中有效字符的个数,用capacity 来记录当前结构的容量,用str 来存储这些字符;
namespace zjx
{
class string
{
public:
private:
//声明
char* _str;
size_t _size;
size_t _capacity;
};
}
2、构造函数
2.1 常量字符串构造
其中在string 中,最常见的是带参的构造函数;在实现的时候不能直接将带参的_str 给给 _str;这是因为在实际当中进行使用的时候,我们经常使用一个常量字符串去初始化此 _str , 如果直接让 str 初始化 _str ,那么_str 就会指向此常量字符串,那么便不可以修改 _str 了,无法进行扩容等操作;那么 _str 在初始化的时候应该确切地开辟空间,开辟 strlen(str)+1 大小的空间;
//构造
string(const char* str)
:_str(new char[strlen(str) + 1])
, _size(strlen(str))
, _capacity(strlen(str))
{
//将str 中的数据拷贝放到 _str 中
strcpy(_str, str);
}
其中 +1 的目的是为了给 '\0' 预留空间;实际上此处用 strlen 还有点蹩脚,因为strlen 本身就是一个库函数,其时间复杂度为O(N),如果单单使用strlen ,那么在初始化列表之中便会使用三次 strlen ,效率有所损失;
如何解决呢?改变一下初始化列表中的顺序?先初始化_size ,然后_str 和 _capacity 进行复用?
namespace zjx
{
class string
{
public:
//构造
string(const char* str)
:_size(strlen(str))
, _capacity(_size)
,_str(new char[_size+1])
{
//将str 中的数据拷贝放到 _str 中
strcpy(_str, str);
}
private:
//声明
char* _str;
size_t _size;
size_t _capacity;
};
}
上述代码的写法是不可行的,因为初始化列表初始化并不是按照初始化列表中所初始化的顺序去走的,而是按照声明的顺序去初始化;我们可以稍微测试一下上述代码(在x86 环境下测试):
程序崩溃了,其中一部分原因是因为编译器将_size 默认处理成了0,而根据声明的顺序会先初始化 _str , _str 中的 _size 为0,那么开辟的空间就不够,并且在使用strcpy 的时候会发生越界;
其中一种修改方式是让声明的顺序与初始化列表中的顺序相对应;
namespace zjx
{
class string
{
public:
//构造
string(const char* str)
:_size(strlen(str))
, _capacity(_size)
,_str(new char[_size+1])
{
//将str 中的数据拷贝放到 _str 中
strcpy(_str, str);
}
const char* c_str()
{
return _str;
}
private:
//声明
size_t _size;
size_t _capacity;
char* _str;
};
}
void test_string1()
{
zjx::string s("hello world");
cout << s.c_str() << endl;
}
int main()
{
test_string1();
return 0;
}
这样虽然可以解决问题,但是这样做其声明的顺序与初始化列表的顺序就耦合在一起了,一旦有人不小心修改了初始化列表中的顺序或者修改了声明中的顺序就又会出现问题;不推荐此种修改方式,更推荐下面的一种修改方式;
修改方式二:将 _size 放在初始化列表借助于 strlen(str) 进行初始化,但_capacity 与 _str 的初始化放在函数体中;
namespace zjx
{
class string
{
public:
//常量字符串构造
string(const char* str)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_size + 1];
//将str 中的数据拷贝放到 _str 中
strcpy(_str, str);
}
const char* c_str()
{
return _str;
}
private:
//声明
char* _str;
size_t _size;
size_t _capacity;
};
}
2.2 默认构造
2.2.1 无参默认构造
空对象、空串的初始化需要借助于默认构造;
namespace zjx
{
class string
{
public:
//常量字符串构造
string(const char* str)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_size + 1];
//将str 中的数据拷贝放到 _str 中
strcpy(_str, str);
}
const char* c_str()
{
return _str;
}
//无参默认构造
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
private:
//声明
char* _str;
size_t _size;
size_t _capacity;
};
}
为什么程序又崩溃了?
在构造对象 s2 的时候,如果 _str 为空,那么c_str 返回值也为 nullptr ,cout 在打印指针的时候,均会以指针的形式去打印;但对于 char* 的指针,cout 不会打印该指针本身,而是会打印该指针指向的内容,即对指针进行解引用操作;(字符串比较特殊),故而此处就对nullptr 进行了解引用,所以程序崩溃了;
我们先来看一下库中无参构造是如何实现的:
空字符串中还有一个'\0' , '\0' 并非是一个有效字符而是一个标识字符;
修改:无参的默认构造中初始化 _str 的时候需要开辟一个空间并放一个 '\0' 进去;
string()
:_str(new char[1]{'\0'})
,_size(0)
,_capacity(0)
{}
其中 new char[1]{'\0'} 还可以写做:new char('\0') ,但是写做 new char[1]{'\0'} 可以与delete[] 更好地进行匹配;此处模拟string 的底层实现,匹不匹配也没有关系,因为char 为内置类型,无需调用析构函数;但是总地来说,在写法上更推荐写成: new char[1]{'\0'};
2.2.2 缺省默认构造
同样地,str 的缺省值也不能为 nullptr , 不然 strlen(nullptr) 会导致程序崩溃;那么字符串的缺省值应该给什么?
可以给 "\0" , 但是实际上字符串 "\0" 中存在两个 "\0" , 显得有点多此一举;可以直接给空字符串;
//全缺省默认构造
string(const char* str = "")
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_size + 1];
strcpy(_str, str);
}
需要注意的是,strcpy 在拷贝的时候会将字符串最后的 '\0' 一同拷贝过去;
我们可以看一下调试:
2.3 n个char的构造
开辟 n+1 个空间,在函数体中完成初始化即可;
//n个char的构造
string(size_t n, char ch)
:_str(new char[n + 1])
, _size(n)
, _capacity(n)
{
//初始化
for (size_t i = 0; i < n; i++)
{
_str[i] = ch;
}
_str[_size] = '\0';
}
2.4 拷贝构造
传统写法:
//string 对象的构造
string(const string& s)
{
//开辟与str 一样大小的空间
_str = new char[s._capacity + 1];//+1 是留给 '\0' 的
//拷贝数据
strcpy(_str, s._str);
//处理剩余两个成员变量
_size = s._size;
_capacity = s._capacity;
}
现代写法:
不像传统写法那样自己去开辟空间然后再拷贝数据;需要我们先创建一个临时变量 tmp (会去调用构造函数,而tmp 这块空间是我们想要的,进行交换便可);
现代写法代码:
//拷贝构造函数现代写法
string::string(const string& s)
{
string tmp(s);
//交换
swap(_str, tmp._str);
swap(_size, tmp._size);
swap(_capacity, tmp._capacity);
}
测试一下:
现代写法的拷贝构造函数并不会相较于传统写法提高效率,只不过是相较于传统写法来说更加简洁;建议成员变量的声明处给上缺省值:
private:
//声明
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
给缺省值的意义在于:即使未初始化,也是一种构造;
Q:为什么给 _str 给缺省值 nullptr?
- 置为nullptr 的意义在于出了作用域 tmp 会析构,而析构 nullptr 并不会出错;
还需要注意的一个点是,在拷贝构造函数中创建临时对象 tmp 的时候只能写做:string tmp(s._str) 而不能写做 string tmp(s) ,因为string tmp(s) 的本质是去调用拷贝构造函数,而本身就在实现拷贝构造函数,又在自己调用自己就会造成无穷的递归;
此处还可以对现代写法的拷贝构造进行优化:将使用库中的swap 封装实现成string 内部的swap:
//封装swap
void string::swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
//现代写法的拷贝构造函数
string::string(const string& s)
{
string tmp(s._str);
//交换
swap(tmp);
}
测试一下:
3、swap 的相关问题
在上面实现拷贝构造函数的过程中我们实现了swap:
//封装swap
void string::swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
Q: 这两种swap 有什么区别呢?
- 调用我们自己实现的swap, 是对 s1 与 s2 的成员变量的交换,因为其成员变量均为内置类型,交换的代价就比较小;而算法库中的swap 是一个模板;
Q:为什么此处使用算法库中的swap 代价这么大呢?
- 算法库中的swap 是针对泛型的,需要借助于第三个变量来实现交换的逻辑;如果被交换数据的类型是浅拷贝类型,那么使用算法库中的swap 代价也不会很大;但是倘若被交换的数据的类型为深拷贝类型,那么使用算法库中的swap 的代价就会很大;而我们实现的string 涉及资源,就必须使用深拷贝,也就是说此处我们实现的string 倘若直接只用算法库中的swap 就会进行三次深拷贝,而深拷贝就会涉及释放旧空间、创建新空间、拷贝数据……故而代价这么大;
Q:有什么方案可以解决调用算法库的swap 而代价很大的问题?
- 在string 中单独实现一个供自己使用的swap;
因为string实现了一个全局的swap , 根据语法规则(“有现成吃现成”),会优先调用string 中实现的这个全局swap ;所以说,在string 中实现全局的swap是为了避免编译器去调用算法库中的swap,因为当模板和普通函数同时存在的时候,会优先调用普通函数,而并非实例化该模板函数;而在string 中实现的全局swap ,其内部还是调用的算法库中的swap , 基于成员对象均是内置类型不涉及深拷贝,浅拷贝的类型调用算法库中的swap 实现交换代价不大;
4、赋值运算符重载
string 的底层涉及资源,所以其赋值运算符重载函数必须我们单独实现;如果我们没有现实实现,在使用 '=' 的时候,由于编译器实现的是浅拷贝,所以就会导致多个指针指向同一份资源,导致调用多次析构,于是就报错;
赋值的逻辑并非是直接将值拷贝过去,因为this 指针指向的对象中的_str空间不一定足够;此处有两种方法可以解决:
- 方法一:考虑this->_str 的三种状态:空间足够、空间一样大、空间不够 ;针对这三种情况分别实现即可,这种实现方式比较复杂;
- 方法二:直接粗暴地将 this->_str 的空间释放掉,开辟与被赋值对象一样大小的空间、拷贝数据;eg: s1 = s2 --> 释放 s1 的空间,让后让s1 开辟与 s2 一样大小的空间;
使用方法二实现的时候,还有一个点需要注意:不能自己给自己赋值;实现的时候需要提前判断一下,如果是自己给自己赋值的情况,不做处理直接返回即可;
因为赋值存在连续赋值的情况,所以我们实现的operator= 的返回值应该为 string& ;
参考代码:
//operator=
string& string::operator=(const string& s)
{
if (this != &s)
{
//释放掉旧空间
delete[] _str;
//开辟与 s 一样大小的空间
_str = new char[s._capacity + 1];//记得加上 '\0' 的空间
strcpy(_str, s._str);//拷贝数据
//处理剩下两个成员变量
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
测试一下:
上述实现的赋值运算符重载函数是传统写法:将旧空间释放,在开辟与被拷贝对象一样大的空间、拷贝数据;而现代写法:我们需要完成拷贝,但是我不会自己去实现,而是借助于编译器帮我去实现拷贝;既然拷贝构造函数都能用现代写法实现,那么赋值运算符重载函数也可以使用现代写法实现;
s1 = s3;
- 传统写法:先将s1 的空间释放,再按照s3 的空间大小为 s1 开辟空间,再将s3 中的数据拷贝放入s1 中,最后处理 s1 中的 _size 与 _capacity;
- 现代写法:利用构造函数根据s3 创建的临时对象 tmp ,再让s1 与 tmp 进行交换就可以了;临时变量tmp 会在当前函数栈帧结束的时候自动调用析构函数释放空间;
现代写法代码:
//赋值运算符重载 - 现代写法
string& string::operator=(const string& s)
{
if (this != &s)
{
string tmp(s._str);
swap(tmp);
}
return *this;
}
实际上在此现代写法的基础上还可以再优化:利用传值传参自动调用拷贝构造函数,这样我们就没有必要自己再创建一个临时对象 tmp:
现代写法的优化版本:
//赋值运算符重载 - 现代写法的优化版本
string& string::operator=(string s)
{
if (this != &s)
{
swap(s);
}
return *this;
}
测试一下:
图解:
5、析构函数
//析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
6、reserve扩容
reserve 有可能会缩容,但是一定会扩容;我们此处实现地简单些,只扩容而不缩容;
Q: 如何实现扩容,能否使用realloc ?
- 不能直接使用realloc , 因为realloc 是与 malloc 、calloc、free 配套使用的;而在实现构造函数时,使用的是 new[] , 故而此处最好也要对应起来配套地写;我们只有用new[] 模拟realloc 的逻辑来实现扩容;
//reserve
void reserve(size_t n)
{
if (n > _capacity)
{
//模拟实现realloc
char* tmp = new char[n + 1];//需要为 '\0' 开辟一个空间
strcpy(tmp, _str);//将_str 中的数据拷贝放到 tmp 中
delete[] _str;//释放旧空间
//处理细节
_str = tmp;
_capacity = n;
}
}
7、push_back 和 append
如果你看了博主上一篇讲解 string 的使用的博文,你会有疑问?明明实践中更常用 operator+= ,为什么不先实现 operator+= 呢?在功能上来说,我们实现了 push_back 和 append , operator+= 自然也就实现了;
我们先来看一下push_back:
push_back 即尾插一个字符,插入的时候首先就应该考虑容量是否足够即扩容与否的问题,其次就是插入字符并处理细节;
对于扩容,只要 _size == _capacity 便就扩容;在插入一个字符的情况之下,对于 push_back 的扩容条件还可以写成: _size+1>_capacity ;直接复用 reserve 就可以了;
push_back 的参考代码:
//push_back
void push_back(char ch)
{
//确保空间足够以放下所要插入的字符
if (_size + 1 > _capacity)
{
//扩容,2倍
reserve(_capacity == 0 ? 4 : 2 * _capacity);
}
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
需要注意的是,push_back 将原本 '\0' 的位置上插入了一个字符,而我们完成尾插之后需要手动加上一个 '\0';
而至于 append:
append 可以在字符串的结尾追加,string 对象、字符串、n个字符以及迭代器区间中的字符;此处我们实现 append 插入字符串;
Q: 首先我们需要处理的是扩容逻辑,扩1.5 倍、2倍,其空间的大小可能放不下一个字符串,又该如何扩容呢?
- append 的扩容应该根据所输入的字符串来进行扩容,而不是死板地遵循2倍、1.5倍扩容;我们可以利用 strlen 求出所要插入的字符串的长度len ;当满足扩容条件 ( _size + len > _capacity) ,先判断2倍扩容能否满足使用,如果按照2倍扩容空间足够就可以2倍扩容,若不够用则按 len 进行扩容; 这样做,不仅可以减少扩容次数,还可以“因地制宜”要多少空间开辟多少空间;
append 插入字符串的参考代码:
//append
void append(const char* str)
{
//先求len
size_t len = strlen(str);
//是否扩容
if (_size + len > _capacity)
{
//看2倍扩容能否满足需求
size_t newCapacity = 2 * _capacity;
if (_size + len > newCapacity)
{
newCapacity = _size + len;//按需求扩容
}
//复用reserve 进行扩容
reserve(newCapacity);
}
//将str 中的数据拷贝放入当前字符串尾
strcpy(_str + _size, str);//strcpy 会将最后的 '\0' 也拷贝过去
_size += len;
}
此处的插入最好不要去直接复用C语言中的库函数 strcat ,因为 strcat 的效率非常低下,因为每次追加拼接均需要从该字符串的头来查找 '\0' ;而使用 strcpy 就很合适,strcpy 还会将所要拷贝的字符串末尾的'\0' 拷贝过去;
将上述实现测试一下:
void test_string2()
{
zjx::string s1("hello world!");
s1.push_back('x');
s1.append("hello freedom!");
cout << s1.c_str() << endl;
}
8、operator+= (+=重载函数)
相较于 push_back 和 append ,实践当中更喜欢使用 operator+= , 因为使用operator+= 代码的可读性更高;实际上,operator+= 的底层就是 push_back 和 append ,我们实现的时候直接复用即可;
operator+= 尾插一个字符参考代码:
//operator+= 一个字符
string& operator+=(char ch)
{
//直接复用push_back
push_back(ch);
return *this;
}
operator+= 尾插字符串参考代码:
//operator+= 尾插字符串
string& operator+=(const char* str)
{
//直接复用 append
append(str);
return *this;
}
测试一下:
void test_string3()
{
zjx::string s1("hello world!");
s1 += ' ';
s1 += '~';
s1 += "hello freedom!";
s1 += "1111111111111111111111111111111111111111111";//测试插入长一点的字符串
cout << s1.c_str() << endl;
}
9、insert 插入
insert :在 pos 位置之前插入字符、字符串... 版本有很多,此处只实现最核心:插入 n 个字符以及插入一个字符串;
//insert 插入 n 个字符
void insert(size_t pos, size_t n, char ch);
//insert插入一个字符串
void insert(size_t pos, const char* str);
在此之前,我们需要向我们自己实现的类 string 之中再增加一个静态成员变量 npos:
class zjx
{
class string
{
public:
private:
//声明
char* _str;
size_t _size;
size_t _capacity;
//静态成员变量
const static size_t npos;
};
}
需要注意的是, 静态成员变量在类中声明类外定义;(其静态的含义指的是修饰其生命周期和存储的位置);并且静态成员变脸不能定义在 .h 之中;全局变量、全局函数均不可以定义在 .h 之中,只要定义在 .h 当中便会在多个 .cpp 中包含,而倘若在多个 .cpp 中出现了就会报“重定义”的错误;
插入数据的时候首先需要考虑所知道的 pos 是否合法;判断 pos 是否合法: pos <= _size 范围之中就是合法的, pos 可以等于 _size ,相当于尾插;其次就是扩容的问题;同样地,扩容可以参考 append 的扩容思想;先看按照2倍扩容能否满足需求,如果满足需求则就2倍扩容;如果2倍扩容不满足需求则就按照实际情况进行扩容;最后就是处理数据,处理数据有两种方法,一是挪动数据,二是再创建一个空间;
如果采用方式一,通过挪动数据来进行插入:
有两种挪动数据的方式,一种是先挪动前面的数据,一种是先挪动后面的数据;用我们聪明的小脑袋想一想就很了然了,如果先挪动前面的数据会造成数据覆盖,而倘如先挪动后面的数据就不会出现数据覆盖的问题;
insert 插入 n 个字符:
//insert 插入 n 个字符
void string::insert(size_t pos, size_t n, char ch)
{
//首先需要保障 pos 是合法的
assert(pos <= _size);
assert(n > 0);
//判断是否需要扩容
if (_size + n > _capacity)
{
//先看2倍扩容是否符合要求
size_t newCapacity = 2 * _capacity;
if (_size + n > newCapacity)
{
newCapacity = _size + n;
}
//复用reserve 进行扩容
reserve(newCapacity);
}
//挪动数据
size_t end = _size;
while (end >= pos)//在pos 之前进行插入
{
//先挪动后买的数据
_str[end + n] = _str[end];
--end;
}
//拷贝
for (size_t i = 0; i < n; i++)
{
_str[pos + i] = ch;
}
//处理细节
_size += n;
}
在上述代码中,assert(n>0); , n 虽然是 size_t 类型的数据,并不会小于0.但是但当 n ==0 的时候,该接口函数就没有什么意义了;
上述的代码实现的逻辑很简单:确保 pos 合法--> 判断是否要进行扩容(是2倍还是按需) --> 挪动数据 --> 拷贝数据 --> 处理 _size;我们先来简单测试一下:
看似很完美没有逻辑破洞,但是实际上上述的代码是存在问题的;我们要冒烟测试,接下来测试一下边界情况:当 pos 为 0 或者为 _size 进行头插、尾插的时候:
可以清楚地看到,进行头插的时候程序崩溃了;
注:冒烟测试:早期计算机的最初形态为硬件(电路连接),早期的电脑在运行程序的时候很容器冒烟……而冒烟测试的含义就是将程序跑通;
Q:为什么程序会崩溃呢?
步骤无非就是:确保 pos 合法--> 判断是否要进行扩容(是2倍还是按需) --> 挪动数据 --> 拷贝数据 --> 处理 _size;
前两个一定没有问题,我们先来看一下头插挪动数据的逻辑:
循环能够结束吗?不能,end 的类型为size_t 即无符号整型,永远都不可能小于0;当头插的时候,pos 为0,所以陷入了死循环而导致程序崩溃;
如何解决这个问题呢?实际上,我们可以将 end 的类型修改为 int, 但是程序依旧会崩溃;因为pos 的类型为 size_t ,在走while (end >= pos) 这条语句的时候,会发生隐式类型转换;在一个运算符或一个操作符中,其两个操作数的类型不一定相同,首先是会进行隐式类型转换,通常是范围小的向范围大的进行转换,若是有符号和无符号,则是有符号向无符号进行转换;
针对这个问题,此处我们有三种解决方案;
方案一:将pos 的类型修改为int ,并不推荐这么做,因为我们模拟实现本质上是跟着库中的实现走的,下标不可能存在负数的情况,所以设计成 size_t 的类型非常合理;
方案二:end 使用int 类型,但是在使用 end 与 pos 的地方将 pos 进行强制类型转换以避免发生隐式类型转换;
//挪动数据
int end = _size;
while (end >= (int)pos)//在pos 之前进行插入
{
//先挪动后买的数据
_str[end + n] = _str[end];
--end;
}
方案三:前面两种解决方法的核心:数据从后面开始移动,让循环条件 end >= pos 合法;而当 end 在 '\0' 的位置上,向后挪动数据此时便会导致一个问题:当pos 为0的时候一定是永远满足 end>=pos , 而导致了死循环;所以就对 pos 或者 end 的类型进行了修改,有点治标不治本;换种挪动数据实现的方式呢?将 end 放到 _size+n 的位置上。然后挪动数据写为: _str[end] = _str[end-n]; 那么循环条件为:end-n>=pos --> end >= pos + n;
//修改方式三:
//挪动数据
size_t end = _size + n;
while (end >= pos + n)//在pos 之前进行插入
{
//先挪动后面的数据
_str[end] = _str[end-n];
--end;
}
insert 插入 n 个字符的参考代码:
//insert 插入 n 个字符
void string::insert(size_t pos, size_t n, char ch)
{
//首先需要保障 pos 是合法的
assert(pos <= _size);
assert(n > 0);
//判断是否需要扩容
if (_size + n > _capacity)
{
//先看2倍扩容是否符合要求
size_t newCapacity = 2 * _capacity;
if (_size + n > newCapacity)
{
newCapacity = _size + n;
}
//复用reserve 进行扩容
reserve(newCapacity);
}
//修改方式2:
挪动数据
//int end = _size;
//while (end >= (int)pos)//在pos 之前进行插入
//{
// //先挪动后买的数据
// _str[end + n] = _str[end];
// --end;
//}
//修改方式三:
//挪动数据
size_t end = _size + n;
while (end >= pos + n)//在pos 之前进行插入
{
//先挪动后买的数据
_str[end] = _str[end-n];
--end;
}
//拷贝
for (size_t i = 0; i < n; i++)
{
_str[pos + i] = ch;
}
//处理细节
_size += n;
}
对于insert 插入一个字符串的实现,与 insert 插入n 个 char 的实现大差不差;
insert 插入一个字符串参考代码:
//insert插入一个字符串
void string::insert(size_t pos, const char* str)
{
//确保pos 合法
assert(pos <= _size);
size_t len = strlen(str);
//判断是否需要扩容
if (_size + len > _capacity)
{
//看2倍扩容是否满足需求
size_t newCapacity = 2 * _capacity;
if (_size + len > newCapacity)
{
newCapacity = _size + len;
}
//复用reserve 进行扩容
reserve(newCapacity);
}
//挪动数据
size_t end = _size + len;
while (end >= pos + len)
{
_str[end] = _str[end - len];
--end;
}
//拷贝数据
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = str[i];
}
//处理细节
_size += len;
}
}
测试:
Q:insert 插入 n 个字符和插入一个字符串的整体逻辑十分相似,有没有什么办法可以将上述两个所实现的代码进行合并?
- 复用;有两种复用的方式,一是让插入n个字符的接口函数复用插入字符串的接口函数;这样的话,我们可以new 一个数组,但是需要进行初始化(需要自己记得释放空间),用数组实现起来有点繁琐;还可以使用string 调用insert 全初始化当前字符,或者直接使用string n个字符初始化的功能;
此处采用使用string 利用n个char 的构造:
//复用,插入n个字符复用插入一个字符串
void string::insert(size_t pos, size_t n, char ch)
{
//确保pos 合法
assert(pos <= _size);
assert(n > 0);
//复用
string tmp(n, ch);
this->insert(pos, tmp.c_str());
}
测试一下:
这种复用方式不是很好,因为当你插入n 个字符的时候,每次均要去创建一个string 对象,然后拷贝进入,效率上来看有点差;
复用方式二:让插入一个字符串复用插入n个字符
首先我们要排除一个字符一个字符地进行插入,每插入一个字符均需要挪动数据,插入 n 个字符需要挪动 n 个数据,效率十分低下。我们最好一次性地挪动好数据,然后再去处理所要插入什么数据的问题;
//复用 - 插入一个字符串复用插入n个字符
void string::insert(size_t pos, const char* str)
{
size_t len = strlen(str);
this->insert(pos, len, 'x');
//处理数据
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = str[i];
}
}
测试一下:
10、erase 删除
此处我们实现erase 的第一个函数,从 pos 位置开始向后删除 len 个字符,其第二个形参为缺省参数,其缺省值为 npos, 即默认不传第二个参数时,pos 位置后面有多少个字符就删除多少个字符;
那么该函数的就存在两种情况:
情况一:pos 位置后的字符全删完
_size - pos 为 pos 以及其后面有效字符的个数; 但是第二个形参 len 不一定为pos 以及其后有效字符的个数;
因为pos 是结束的位置,结束的位置存放 '\0' ,那么当前下标为此字符串有效字符的个数;
情况2:只删除pos 位置后面的部分字符
eg.删除pos 位置后面3个字符
处理挪动数据,还可以使用 strcpy;
int end = pos + len; 将 end 位置往后的字符整体向前挪动 len ;循环的判断条件为:end <= _size
注:在类中声明,类外实现;
声明:
//erase
void erase(size_t pos = 0, size_t len = npos);
参考代码:
//erase
void string::erase(size_t pos, size_t len)
{
//判断有没有数据可以删除
assert(_size);
assert(pos < _size);/确保pos 合法
//首先判断是不是删除pos 后面所有的字符
if (len >= _size - pos)
{
//将 \0 放在pos 的位置上
_str[pos] = '\0';
_size = pos;
}
else//只删除pos 位置后面的部分字符
{
//挪动数据
size_t end = pos + len;
while (end <= _size)
{
_str[end - len] = _str[end];
++end;
}
_size -= len;
}
}
测试一下:
11、find 查找
find 的实现同样是类中声明类外定义;此处我们实现 find 从 pos位置开始查找字符,从pos 位置查找字符串;
声明:
//find
size_t find(char ch, size_t pod = 0);
size_t find(const char* str, size_t pos = 0);
当没有传位置的时候默认是从下标为0的地方开始查找;
找字符:
//find 找字符
size_t string::find(char ch, size_t pos)
{
//检查pos 是否合法
assert(pos < _size);
//查找
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch) return i;
}
//没找到就返回npos
return npos;
}
找字符串:
//find 查找字符串
size_t string::find(const char* str, size_t pos)
{
//检查pos 是否合法
assert(pos < _size);
//查找,使用strstr
const char* p = strstr(_str + pos, str);
if (p == nullptr)//没有找到
{
return npos;
}
else
{
return p - _str;//指针相减就是下标
}
}
测试一下:
12、获取string 类中成员变量的函数
直接放在string 类中,实现为inline 内联函数:
//获取string 类中的成员变量
size_t size() const
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
测试一下:
13、operator[] ([] 重载函数)
重载实现,两个版本;const 修饰的和不被const 修饰的;
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
测试一下:
14、迭代器
迭代器,可以理解为是一个像指针一样的东西,但是不一定为指针;
在string 当中实现迭代器有多种可以实现的方式,此处可以参考用指针实现,因为string 的底层是字符数组;
我们在string 类中将指针 typedef一下:
typedef char* iterator;
普通迭代器:
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
测试一下:
只要支持迭代器就一定支持范围for ;
const 迭代器:
typedef const char* const_iterator;
需要注意的是,const_iterator 本身就单单只是一个命名; const char* 修饰的不是迭代器本身,修饰的是迭代器const_iterator 指向的内容;
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
测试一下:
需要注意的是,string 对象支持迭代器就是原生指针,但是实际上在真正的string 的实现中其迭代器不一定是原生指针;库中由于各种原因(此处不做过多扩展), 将string 的迭代器用类进行了封装,或者又去重载实现了……弄得比较复杂…)
15、substr
substr,从pos 位置开始获取len 长度的字符串。返回一个新的包含该字符串的string 对象;
其中第一个参数 pos 的缺省值为0, 没传参时默认从下标为0处开始;第二个参数 len 的缺省值为 npos, 有多少取多少;
首先我们需要处理len ,因为len 可能不会为有效长度;然后就是创建一个string 对象,循环遍历并且使用operator+= 获取对应的字符;
参考代码:
//substr
string substr(size_t pos = 0, size_t len = npos)
{
assert(pos<_size);
size_t leftlen = _size - pos;
if (len > leftlen) len = leftlen;
//获取子串
string tmp;
tmp.reserve(len);
for (size_t i = 0; i < len; i++)
{
tmp += _str[pos + i];
}
return tmp;
}
需要注意的是,substr 只能使用传值返回而不能使用引用返回,因为tmp 是局部变量;
测试一下:
16、比较大小
在库中将“判断大小”的函数均实现为全局函数;主要是因为成员函数的第一个参数必为this 指针,而函数重载的参数与操作数的位置有关,而如若想实现为多种比较形式,比较大小的函数就应该实现为全局函数;
比较的两个参数,可以是两个string 对象,也可以是char*字符串与string 对象、还可以是 string对象与char* 字符串进行比较;
我们在此处模拟实现就不搞这么复杂,在类中实现string 对象之间的大小比较即可;
此处的实现与之前所述日期类的大小比较相似;
类中声明:
//比较大小
bool operator==(const string& s) const;
bool operator!=(const string& s) const;
bool operator<(const string& s) const;
bool operator<=(const string& s) const;
bool operator>(const string& s) const;
bool operator>=(const string& s) const;
此处我们实现的时候会用到 strcmp 来实现:
当str1 < str2 的时候, strcmp 的返回值 <0 ;当 str1 == str2 的时候, strcmp 的返回值等于0;当 str1 > str2 的时候, strcmp 的返回值大于0;
参考代码:
//比较大小
bool string::operator==(const string& s) const
{
return strcmp(_str, s._str) == 0;
}
bool string::operator!=(const string& s) const
{
return !((*this) == s);
}
bool string::operator<(const string& s) const
{
return strcmp(_str, s._str) < 0;
}
bool string::operator<=(const string& s) const
{
//复用
return (*this == s || *this < s);
}
bool string::operator>(const string& s) const
{
return !(*this <= s);
}
bool string::operator>=(const string& s) const
{
return (*this > s || *this == s);
}
17、流插入、流提取的重载
需要注意的是,流插入、流提取函数必须重载为全局函数;因为流插入的左操作数一定是 cin , 流提取的左操作数一定是 cout;
Q: 将 流插入、流提取重载函数写成了全局函数又该如何获取该类的私有成员变量?
- 我们可以将这两个重载函数写为该类的友元函数;也可以专门为其提供获取私有成员变量的公有成员函数;
需要注意的是,流插入、流提取的重载必须写成全局函数且写成该类的友元函数,这种说法是错误的;流插入、流提取的重载函数一定是全局函数,但是并不代表它一定是该类的友元函数,因为我们还可以通过Get 函数(公有成员函数)获取该类的私有成员变量;
scanf 和 cin 的特点:对于任何类型的数据均拿不到空格或者换行符;所以我们流插入从流中读取数据的时候不可能使用 scanf 或者 cin ,而应该使用C语言的 getc 或者C++中的get ,这两个接口函数均可以用来读取每一个字符(包括空格和换行);
流插入声明:
//流插入
ostream& operator<<(ostream& out, const string& s);
operator<< 的参考代码:
//流插入
ostream& operator<<(ostream& out, const string& s)
{
//将s中的数据写入 out 中
for (auto ch : s)
{
out << ch;
}
return out;
}
流插入声明:
//流提取
istream& operator>>(istream& in, string& s);
代码;
//流提取
istream& operator>>(istream& in, string& s)
{
//将流 in 中的数据放入s 中
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
//in >> ch;//不能使用 cin 或者 scanf
ch = in.get();
}
return in;
}
需要注意的是,为了支持连续的流插入、流提取,流插入重载函数的返回类型为 ostream& , 流提取重载函数的返回类型为 istream&;
测试一下:
重载的流提取没什么问题,但是流提取的实现似乎与库中的流提取有所区别,我们自己实现的 operator>> 是将输入的字符加载了 s1(原本空间) 的后面;我们可以大致看一下库中流提取的实现:
库中的 >> 会将输入的字符串从下标0的位置开始将s 的空间进行覆盖;所以我们实现的流提取的重载进行额外的处理:在读取内容之前,需要将被覆盖的对象进行 clear;
clear 就是将这个string 对象中的 _size 置为0,_str 第一个存放 '\0' ;
clear 的实现代码1:
void clear()
{
_str[0] = '\0';
_size = 0;
}
当然也可以复用erase,当我们什么参数都不给erase 的时候,erase 就是将string 中的数据全部删除clear 的实现代码2:
void clear()
{
erase;
}
流提取重载函数修改之后:
//流提取,加上clear 之后
istream& operator>>(istream& in, string& s)
{
s.clear();//先清理 s 中的数据
//将流 in 中的数据放入s 中
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
//in >> ch;//不能使用 cin 或者 scanf
ch = in.get();
}
return in;
}
测试一下:
修改之后的operator>> 的代码本身没有太大的问题,但是在极端情况下(输入的字符串特别长)效率会不够;因为当输入的字符串特别长的时候,一个字符一个字符地插入会不断地扩容而导致效率低下;此处有两种解决方案:
解决方案一:用reserve 预留空间
代码如下:
//优化:利用reserve 预留空间
istream& operator>>(istream& in, string& s)
{
s.clear();//先清理 s 中的数据
s.reserve(1024);
//将流 in 中的数据放入s 中
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
//in >> ch;//不能使用 cin 或者 scanf
ch = in.get();
}
return in;
}
直接用reserve 开辟一块空间,是可以解决当输入一个较长字符串而导致频繁扩容效率低下的问题;但是这种解决方式仍然存在两个问题:1、输入的字符串特别特别长,1024byte 的空间可能都不够用;那么这种解决方案仍然不能解决频繁扩容而导致效率低下的问题;2、当输入的字符串很短的时候,这种解决方案就会导致空间的浪费;所以,这种方案非常死板,不可行;
解决方案二:利用 buff 数组
buff数组是一个局部变量,出了作用域就会销毁;而对于 s 对象来说,reserve 其空间只有当s对象的生命周期结束才会销毁;
每次插入数据先不往对象s 中存储,而是先将数据存放在 buff 数组中;假设输入的字符很长,当 i 为1023 的时候(意味着下标0~1022 均放满了,在下标为1023 的空间存放 '\0' 即可),然后让对象s += buff 数组……然后再继续向buff 数组中放数据……重复上述操作,直到提取到空格或者换行符的时候,提取结束,并让对象s += buff 数组;
这样做可以有效地较少 s 对象扩容地次数,从而题干代码的效率;
Q:这样做的优势?
- buff数组的存在,让短串避免开空间太大,让长串避免了不断地扩容;
如果输入地字符串很短(小于1023个),放入 buff 数组之中,遇到空格或换行符便就结束了;那么一次性便可以将buff 数组中地内容 += 到s 对象之中;
当输入较长的字符串的时候,也完全没有问题;相当于用该 buff 数组去暂存该空间,而在栈上开辟空间是很快的;
扩容的的代价是很大的,需要开辟新的空间、拷贝数据、释放旧空间……
Q:为什么在栈上开辟空间很快?
- 因为栈上的空间由寄存器 ebp 和esp 来维护;在栈上开空间,让 ebp 之中保存 ebp 中的地址,再让ebp 走到esp 指向的位置,最后让esp 向下减所要开辟的空间大小即可;在栈上开辟空间很快,比在任何地方都要快;虽然在栈上空间很快,但栈不大,在Linux中栈只有 8M;
operator>> 的参考代码:
//使用buff 数组进行优化,灵活版本
istream& operator>>(istream& in, string& s)
{
s.clear();//清理s中的数据
const size_t N = 1024;
//创建buff 数组来暂存从缓冲区读取到的字符
char buff[N];
int i = 0;
char ch = in.get();//使用get,get 可以读取到所有字符
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
//如果i = N-1,需要将buff 中的数放入s 中
if(i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
//当读取结束的时候,在buff 数组中可能还存在字符
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
我们在reserve 中增加一个扩容小提示,然后再测试一下:
//reserve
void reserve(size_t n)
{
if (n > _capacity)
{
cout << "reserve :" << n << endl;//增加一个扩容提示
//模拟实现realloc
char* tmp = new char[n + 1];//需要为 '\0' 开辟一个空间
strcpy(tmp, _str);//将_str 中的数据拷贝放到 tmp 中
delete[] _str;//释放旧空间
//处理细节
_str = tmp;
_capacity = n;
}
}
void test_string14()
{
zjx::string s1("hello world!");
zjx::string s2("hello freedom!");
cout << s1 << endl;
cout << s2 << endl;
//连续插入
cin >> s1 >> s2;
cout << s1 << '\n' << s2 << endl;
}
我们输入了约五六千个2,也只扩容了3次;
18、getline 读一行
getline 的逻辑与 cin 的逻辑高度相似,所以此处getline 的实现与我们重载的流提取十分相似;但getline 是默认遇见 '\n' 读取结束,而 operator>> 默认遇见 ' ' 或者 '\n' 读取结束;
声明:
//getline
istream& getline(istream& in, string& s, char delim = '\n');
参考代码:
//getline
istream& getline(istream& in, string& s, char delim )
{
s.clear();//将s清理
const size_t N = 1024;
char buff[N];
int i = 0;
char ch = in.get();
while (ch != delim)
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
//buff 中有可能还有字符
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
测试一下:
三、代码汇总
string.h 中的代码
#pragma once
#include<iostream>
#include<assert.h>
#include<string.h>
#include<stdlib.h>
using namespace std;
namespace zjx
{
class string
{
public:
//迭代器
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin()const
{
return _str;
}
const_iterator end()const
{
return _str + _size;
}
const char* c_str()
{
return _str;
}
//构造函数
//无参默认构造
//string()
// :_str(nullptr)
// ,_size(0)
// ,_capacity(0)
//{}
//缺省默认构造
string(const char* str = "")
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_size + 1];
strcpy(_str, str);
}
//字符串构造
//string(const char* str)
// :_size(strlen(str)
//{
// _capacity = _size;
// _str = new char[_size + 1];
// strcpy(_str.str);
//}
//n个char 的构造
string(size_t n, char ch)
:_str(new char[n+1])
,_size(n)
,_capacity(n)
{
//初始化
for (size_t i = 0; i < n; i++)
{
_str[i] = ch;
}
_str[n] = '\0';//记得 '\0'!
}
//拷贝构造
string(const string& s)
{
_str = new char[s._capacity + 1];
//拷贝
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
//析构
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
//swap 封装
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
//赋值运算符重载 - 为了保证可以连续赋值,返回值为string&
//传统写法
//string& operator=(const string& s)
//{
// //还要避免自己给自己赋值
// if (this != &s)
// {
// //释放旧空间
// delete[] _str;
// //根据被赋值对象的大小开辟空间
// _str = new char[s._capacity + 1];
// //拷贝数据
// strcpy(_str, s._str);
// //处理
// _size = s._size;
// _capacity = s._capacity;
// }
// return *this;
//}
//现代写法
//string& operator=(const string& s)
//{
// if (this != &s)
// {
// string tmp(s._str);
// swap(tmp);
// }
// return *this;
//}
string& operator=(string s)
{
//swap(s);//直接交换也没有问题
if (this != &s)
{
swap(s);//判断一下再交换也可以
}
return *this;
}
//reserve - 一定会扩容,由于编译器的不同实现,不一定会缩容
//此处我们实现的reserve 只扩容而不缩容
void reserve(size_t n);
//push_back
void push_back(char c);
//append
void append(const char* str);
//operator+=
string& operator+=(char ch);
string& operator+=(const char* str);
//insert
void insert(size_t pos ,size_t n, char ch);
void insert(size_t pos, const char* str);
//erase
void erase(size_t pos = 0, size_t len = npos);
//find
size_t find(char ch, size_t pos = 0);
size_t find(const char* str, size_t pos = 0);
//获取成员变量的函数
size_t size()const
{
return _size;
}
size_t capacity()const
{
return _capacity;
}
//operator[]
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos)const
{
assert(pos < _size);
return _str[pos];
}
//获取子串
string substr(size_t pos = 0, size_t len = npos);
//比较大小
bool operator==(const string& s) const;
bool operator!=(const string& s) const;
bool operator<(const string& s) const;
bool operator<=(const string& s) const;
bool operator>(const string& s) const;
bool operator>=(const string& s) const;
//清理数据
void clear()
{
//erase();
_str[0] = '\0';
_size = 0;
}
private:
char* _str;
size_t _size;
size_t _capacity;
static const size_t npos;
};
//流插入
ostream& operator<<(ostream& out, const string& s);
//流提取
istream& operator>>(istream& in, string& s);
//获取一行的数据
istream& getline(istream& in, string& s , char delim = '\n');
}
string.cpp 中的代码:
#define _CRT_SECURE_NO_WARNINGS 1
#include"string.h"
namespace zjx
{
const size_t string::npos = -1;
void string::reserve(size_t n)
{
//手动实现realloc
if (n > _capacity)
{
char* tmp = new char[n + 1];//加上 '\0' 的位置
//拷贝数据
strcpy(tmp, _str);
//释放旧空间
delete[] _str;
_str = tmp;
_capacity = n;
}
}
//push_back
void string::push_back(char ch)
{
//首先判断空间是否足够
if (_size + 1 > _capacity)
{
reserve(_capacity ==0 ? 4 :2 * _capacity);//还是以2倍进行扩容,但 _capacity 也有可能为0
}
//尾插
_str[_size++] = ch;
_str[_size] = '\0';
}
//append
void string::append(const char* str)
{
int len = strlen(str);//strlen 不会计算 \0
//首先就是判断空间是否足够
if (_size + len > _capacity)
{
size_t newCapacity = 2 * _capacity;
//先看2倍扩容是否足够
if (_size + len > newCapacity)
{
newCapacity = _size + len ;//无需考虑 '\0'
}
reserve(newCapacity);//reserve 会帮我们处理
}
//尾插,可以利用strcpy, 也可以利用循环
strcpy(_str + _size, str);//strcpy 会将 \0 拷贝
_size += len;
}
//operator+=
string& string::operator+=(char ch)
{
//复用
push_back(ch);
return *this;
}
string& string::operator+=(const char* str)
{
append(str);
return *this;
}
//insert
void string::insert(size_t pos, size_t n, char ch)
{
//保证pos 合法
assert(pos <= _size);
assert(n > 0);
//首先判断空间是否足够
if (_size + n > _capacity)
{
//先2倍扩容,不够才按需求来
size_t newCapacity = 2 * _capacity;
if (_size + n > newCapacity)
{
newCapacity = _size + n;
}
reserve(newCapacity);
}
//挪动数据
size_t end = _size + n;
while (end >= pos + n)
{
_str[end] = _str[end - n];
end--;
}
//放数据
for (size_t i = 0; i < n; i++)
{
_str[pos + i] = ch;
}
_size += n;
}
void string::insert(size_t pos, const char* str)
{
//可以复用
size_t len = strlen(str);
//先占用空间
insert(pos, len, 'x');
//处理数据
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = str[i];
}
}
//erase
void string::erase(size_t pos, size_t len)
{
//判断有没有数据可以删除
assert(_size);
assert(pos < _size);
//看pos 之后的字符串长度与len 相比较
if (len >= _size - pos)//pos 之后的全部删除
{
_str[pos] = '\0';
_size = pos;
}
else//只删除部分
{
//挪动数据
size_t end = pos + len;
while (end <= _size)
{
_str[end-len] = _str[end];
++end;
}
_size -= len;
}
}
//find
size_t string::find(char ch, size_t pos)
{
assert(pos < _size);
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch) return i;
}
//没有找到
return npos;
}
size_t string::find(const char* str, size_t pos)
{
assert(pos < _size);
//利用strstr
const char* p = strstr(_str+pos , str);
if (p == nullptr)
{
return npos;
}
return p - _str;
}
//获取子串
string string::substr(size_t pos, size_t len)
{
assert(pos < _size);
size_t leftlen = _size - pos;
if (len >= leftlen) len = leftlen;
//获取子串
string tmp;
for (size_t i = 0; i < len; i++)
{
tmp += _str[pos + i];
}
return tmp;
}
//比较大小
bool string::operator==(const string& s) const
{
//使用strcmp
return strcmp(_str, s._str) == 0;
}
bool string::operator!=(const string& s) const
{
return !(*this == s);
}
bool string::operator<(const string& s) const
{
return strcmp(_str, s._str) < 0;
}
bool string::operator<=(const string& s) const
{
return (*this < s || *this == s);
}
bool string::operator>(const string& s) const
{
return !(*this <= s);
}
bool string::operator>=(const string& s) const
{
return (*this > s || *this == s);
}
//流插入
ostream& operator<<(ostream& out, const string& s)
{
//将字符写入out之中
for (size_t i = 0; i < s.size(); i++)
{
out << s[i];
}
return out;
}
//流提取
istream& operator>>(istream& in, string& s)
{
s.clear();//清理数据
const int N = 1024;
char buff[N];
int i = 0;
//从 in 中获取字符
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
//判断i
if (i == N-1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
//buff之中可能还有字符
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
istream& getline(istream& in, string& s, char delim)
{
s.clear();
const size_t N = 1024;
char buff[N];
int i = 0;
char ch = in.get();
while(ch != delim)
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
}
总结
1、string 实际上是一个叫做 basic_string 的模板类;以前我们所理解的串都是char* 的字符串,而实际上由于编码的原因还有几种串:char、wchar_t(宽字符)、char16_t(UTF16)、char32_t(UTF32);
2、熟悉使用string 中的函数,自己动手敲一遍~