STL&string&模拟实现
STL简介
STL(standard template library-标准模板库):是C++标准库的重要组成部分,不仅是一个可以复用的库,而且是一个包罗数据结构与算法的软件框架。
STL六大组件
string解析
string严格来说不属于STL,它的创建时间比STL更早
头文件&命名空间
头文件#include <string>
但是有引入头文件<iostream>
的时候,不引用头文件<string>
也可以
命名空间std
构造函数string::string
三种常用的构造函数
1 | string();//default |
1 | char ch[] = "hello" |
成员变量string::nops
1 | static const size_t npos = -1; //C+中的定义 |
这是一个值为-1的size_t
类型的静态常变量。很明显,size_t
不可能取负值,因此这个变量常表示用于一些特殊的表示:
- 表示
string
这个类型的最大容量(大约4亿多字节),是max_size()
的返回值。 - 常用来作为
string一些成员函数
的返回值,表示“未找到”“不存在”等。例如find()
查找字符/字符串查找无果时。
运算符重载
string::operator[],访问string中的字符
1 | char& operator[] (size_t pos); |
pos为字符的下标
通过[]获取到的字符就是string的字符的引用,可读可写
通常标准库对于一些函数都会提供两个版本,一个是非const版本,一个是const版本,供不同情况使用。
1 | const string str_const = "hello China"; |
权限是不允许放大的,如果str_const
调用的是非const类型的成员函数,那么就属于权限放大了。
- 非const类型的成员函数并未指明自己所属的对象是一个const类型的对象,则认定为this对象为非cosnt。因此属于通过非const成员函数将自己的对象权限放大了
- 非const类型的成员函数并未指定返回值为const类型,而返回值又使用了引用,因此可以通过返回值更改string对象的元素,对一个只有读权限的const对象来说,这也是权限放大,是不合法的。
传统的数组的越界检查是一个抽查行为,不一定每次程序编译都会检查出来
而string越界一定会检查出来,因此[]重载时给出了越界断言检查assert(pos<size)
string::opeartor=,string对象赋值
1 | string s1; |
string::operator+=,拼接字符/字符串
1 | string s1 = "hello"; |
opeartor<<,流插入运算符重载
这个重载运算符并非string的成员函数
实现了可以将string对象插入到IO流中
string::c_str
获取string的C类型的字符串,本质就是返回该string的char*
意义就是可以很好的跟C语言的一些接口配合
operator<<的重载就运用了这个函数,获取到string的C字符串,即可实现重载
迭代器iterator的使用
迭代器iterator是一个额外的、独立数据结构,存在于STL库中。专门用于访问STL中各个数据结构中的元素。
(可以朴素地认为迭代器就是指针)
使用迭代器访问元素,和使用方括号[]加下标的效果一样,都是获取元素的引用,可读可写
但是方括号是对象本身的数据结构自带的(通过重构),而迭代器是不属于被访问的对象的,一个单独的数据结构
当一个对象为const时,为只可读的,此时还是可以通过方括号下标访问(因为通常会重构一个const类型的方括号),只要不对访问到的元素进行修改即可
但是已经不能使用普通迭代器访问了,因此使用迭代器访问元素,本质上是使用一个数据结构A(iterator)访问另一个数据结构B(被访问的对象)中的元素,而非数据结构B直接调用自己的成员函数访问自己因此就算数据结构B设置为const,但是外部的迭代器仍有写的权限,这是不合理的。此处应使用const_iterator
迭代器的使用方法
- 使用迭代器的时候要指明被访问的数据结构类型
1 | //此处以string对象为例 |
- STL中的数据结构,都具有相关的成员函数,获取到自己元素的迭代器
以string
为例:
string::begin()
获取首字符的迭代器
string::end()
获取最后一个有效字符的下一个字符(即结束字符,也就是’\0’)的迭代器
STL的各个数据结构都有
begin()
和end()
函数,而且都是左闭右开即begin()获取首元素的迭代器,end()获取最后一个有效元素的下一个元素的迭代器
这样便于遍历
c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 string s1 = "helle world";//即h、e、l、l、o、 、w、o、r、l、d、\0
string::iterator it_left = s1.begin();//获取的是h的迭代器
string::iterator it_right = s1.end();//获取的是\0的迭代器
//遍历方式1
while(it_left != it_right)
{
cout<<*it_left<<" ";//访问迭代器对应的元素,就是解引用
it_left++;//迭代器可以加减,就是后移/前移
}
//遍历方式2
while(it_left != s1.end())
{
cout<<*it_left<<" ";
it_left++;
}
//遍历方式3
for(; it_left != it_right; it_left++;)
{
cout<<*it_left<<" ";
}
//遍历方式4
for(; it_left != s1.end(); it_left++;)
{........}
注意!迭代器遍历要使用!=,不能使用<,因为地址空间不一定连续
顺序存储类型的数据结构,地址空间连续,如string/vector出了使用迭代器访问元素,还可以通过方括号[]结合下标来访问。
但非顺序存储类型的数据结构,地址空间不连续,如list,则只能使用迭代器访问
但是iterator++
或itertor+=n
意味着迭代器指向下一个/后面第n个元素,是逻辑上的指向下一个
四大常用迭代器
普通正向迭代器 iterator
1 | string::iterator it_left = s1.begin();//获取首元素 |
普通反向迭代器 reverse_iterator
与正向迭代器的起点、终点、移动方向正好相反
1 | string::reverse_iterator re_it_left = s1.rbegin();//获取最后一个有效元素 |
const正向迭代器
1 | string::const_iterator con_it_left = s1.begin();//还使用begin()获取,因为string中对此重载了 |
const反向迭代器
1 | string::const_reverse_iterator con_re_it_left = s1.rbegin(); |
不知道对象是不是const的?auto登场
string::size / string::length
获取string的有效长度,即有效字符的数量。不包括结束字符\0
1 | size_t size() const;//C++98中是这样定义的 |
注意返回值类型是size_t
,因此最小值就是0
c++
1
2
3
4
5
6 size_t _size = s1.size();
while(_size >= 0)
{
_size--;
}
//会造成无限循环。因为size_t类型,最小值就是0,即使已经等于0了,--之后还是0。所以会无尽循环
string::capacity
获取string的容量,即已开辟的string的总空间
一般情况下,容量capacity肯定是比大小/长度size大的,因为要预留一部分空间
1 | size_t capacity() const;//C++98中是这样定义的 |
string::empty
1 | bool empty() const;//C++定义,判断string是非为空串 |
string的三种遍历方式
[下标]
迭代器
范围for
[下标]
1 | string s = "hello world"; |
迭代器
1 | string s = "hello world"; |
范围for,C++11新引入,原理:替换成迭代器
1 | string s = "hello world"; |
范围for的实现逻辑实际上就是调用了迭代器iterator,通过查看汇编就可以看出来
范围for是遍历STL中的每一个元素
这里不要和迭代器搞混,迭代器是访问的元素的地址,然后再解引用迭代器,访问到的元素
范围for使用时变量直接就是获取到的元素(也就是包含了用迭代器获取地址+迭代器解引用)
string扩容
string会自动扩容,每当string被填满(size == capacity)的时候,就会自动进行扩容
扩容:开新空间,拷贝,释放旧空间
出了初始化时(第一次扩容):Windows的vs下每次扩容约为原来的1.5倍,Linux下约为2倍
创建一个空string也是有容量的,因为要存放’\0’
string::reserve
扩大容量
1 | void reserve (size_t n = 0);//C++定义 |
只能扩,不能缩
只增加
capacity
,不更改size/length
可以提前扩容(增加单次扩容的空间),减少单次扩容的次数(因为扩容也是费时间的)
1 | string s1; |
string::resize
更改大小/长度
1 | void resize (size_t n); |
更改的是
size
,既能扩,也能缩,并且会进行初始化扩容的时候,可能会间接影响capacity,例如如果当前capacity小于n,则capacity也会被扩充
本质是通过影响
size(长度)
来影响capacity(容量)
,因为capacity
始终要略大于size
1 | string s1.= "a";//size=1,capacity>1(因为保存'\0',再加上要内存对齐) |
二者的区别与联系
reserve
:开空间。本质只影响capacity(容量)
resize
:开空间+初始化。本质是通过影响size(长度)
来影响capacity(容量)
扩容时:
如果string中没有数据,resize
会出初始化,填上指定字符或者’\0’
如果string已经存在数据了,reserve
只扩容,不改变字符串;resize
会在原有字符串后面填上指定字符或者’\0’
缩小时:reserve
不会缩小容量、大小,只能扩,不能缩resize
不会缩小容量,只会减小长度。将多余的字符删掉。因为resize影响的是size
二者都不会缩小capacity(注意,是一般情况下、某一版本的STL下。VS下是这样的,其他版本下,不好说)
二者的常规用途就是:扩容
不初始化,就用
reserve
初始化,就用
resize
扩容都会多扩出一点,因为内存是要对齐的
尾插
string::push_back尾插字符
1 | void push_back (char c);//C++定义 |
string::append尾插字符串/字符串对象
1 | //C++98定义 |
+=既可以尾插字符,也可以尾插字符串!
string::insert
从任意位置插入字符/字符串
1 | //C++98定义 |
1 | string s = "hello world"; |
string::earse
删除string内的某段子串
1 | string& erase (size_t pos = 0, size_t len = npos); |
1 | string s = "hello world"; |
string::clear
清空字符串,size=0,capacity不变
swap 交换
string::swap
1 | void swap (string& str);//C++定义 |
实现方法,交换两个string的指针
STL的全局函数std::swap
STL库中存在一个全局函数swap,在命名空间std中,支持任意两个相同类型的对象进行交换
实现方法,深拷贝
区别
string::swap效率高(交换指针)
swap效率低(深拷贝交换)
string::find()
查找字符/字符串,从前向后正向找,找到最先匹配的字符/字符串,返回size_t
类型的
被查找的(字符串的首)字符的下标 【找到了】
npos 【没找到】
string::rfind()
从后向前找,或者说找到最后匹配的字符/字符串,返回size_t
类型的
被查找的(字符串的首)字符的下标 【找到了】
npos 【没找到】
string::substr
1 | string substr (size_t pos = 0, size_t len = npos) const;//C++定义 |
从某个位置(pos)开始,取出长度为len的子串。不会影响远来的字符串,因为有const *this
,规定了当前对象为const。
模拟实现
模拟实现拷贝构造函数
默认的拷贝构造函数是浅拷贝(值拷贝),会出现的问题是:1. 同一块空间会析构两次 2.其中一个改变会影响另外一个
一块空间只能析构一次
因此应完成深拷贝
1 | //s2(s1) |
为什么要
strlen(s._str) + 1
,因为strlen只获取字符串的有效字符个数,不获取字符串结尾符号\0
但是strcpy
函数会把被拷贝的字符串s._str
全部字符拷贝到_str
,包括\0
,因此要多开一位,避免造成_str
容量不够,无法接纳\0
同理,赋值=
的重定义也应该使用深拷贝
模拟实现赋值=运算符重载
1 | //s1("hello world"); |
为什么选择引用返回:
传值返回:拷贝要返回的变量的值,返回拷贝出的临时变量。并且出了函数作用域,这个变量就失效了。引用返回则是直接返回原本的这个变量本身,简单。
此处引用返回的是*this
,this是指向当前对象的指针,*this就是当前对象,返回的是当前对象本身。
如果此处使用传值返回,那么需要先拷贝一个string对象,这个值是自定义的string类型,而自定义的拷贝是深拷贝,代价太大。
使用引用拷贝相当于直接对本对象进行修改然后返回本对象,不需要经过修改-拷贝一个临时对象-将临时对象赋值给当前对象的过程。当然,返回类型应该也可以是void,不需要返回值,直接修改完当前对象即可。
与malloc不同,new动态开辟空间后不需要手动检查开辟是否成功,失败时new会自动抛出异常
清空_str
写在了在开辟新空间之前,此处有一个小问题,如果new开辟空间失败,不仅无法成功拷贝,反而还先把原来的字符串s1
清空了
针对这个问题,有人提出了改进,更改了一下代码的顺序,先new新对象并赋值给一个中间变量p
,将被拷贝的字符串s._str
拷贝给中间变量p
,再清空原来的_str
,最后将中间变量赋值给_str
这样如果开空间失败,会抛出异常终止程序执行,这一步会赶在清空原字符串之前
1 | string& operator = (const string& s) |
标准库里面resize()扩容的时候,capacity会多扩一些,因为涉及到内存对齐,比如扩容之后内存应该是是2的整数倍,则capacity为这个值-1(因为capacity是有效字符存储空间容量,不包含\0,而内存最后一个为\0)
模拟实现范围for
范围for本质就是底层被替换为迭代器以及其中的begin()和end()函数
就算是自己模拟实现的迭代器也是可以的。只要容器支持迭代器,就支持范围for
范围for在遍历的时候,如果不指明获取的元素为引用,则默认是迭代器的解引用的拷贝,即原string里面的元素的拷贝,更改这个值不影响原字符串
如果指明获取的元素为引用,则获取到的则是迭代器解引用的引用,更改这个值影响原字符串
1 | s1 = "hello world"; |
C++传参如果没有特殊需求,尽量使用引用传参,减少拷贝,如果要防止参数被修改,就加上const
权限只能缩小或保持不变,不能放大
比如一个函数定义时形参写的是const,那么调用传参的时候,实参可以是加了const的也可以是不加const的
但是如果一个函数定义时形参写的是不加const的,调用的时候,实参就不能是const类型的,因为权限放大了c++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 void fun1(const char s)//参数要求是const类型的
{
cout << "const in" << endl;
}
void fun2(char s)//参数没要求是const类型的
{
}
int main()
{
char s1 = '1';
const char s2 = '2';
fun1(s1);//正确,权限缩小
fun1(s2);//正确,权限保持不变
fun2(s1);//正确,权限保持不变
fun2(s2);//错误,权限由const变成了一般,权限放大了
return 0;
}
所以有一些函数会提供两个版本,一个是const版本的,一个是没有const版本的。供不同情况下调用。
例如STL的string的标准库中,运算符[]
重载函数就提供了两个版本:
1 | char& operator[] (size_t pos); |
前一个const指明返回值类型为const,后一个const指明此函数所在的对象是一个const类型的对象
相当于const char& operator[](size_t pos, const string& *this)
比如当创建了一个const类型的string对象时,因为该对象不能被修改,因此在使用重载运算符[]的时候,就只能使用const版本的,否则会发生权限放大
const对象不能调用非const的成员函数
模拟实现<<
流插入运算符重载
1 | ostream& operator<<(ostream& out, const string& s) |
第一中写法是获取string的char*格式的字符串,然后打印
第二种写法是遍历整个string,然后逐个打印
第一种方式在遍历C格式的字符串的时候,遇到\0
就会终止,认为字符串已经结束
第二种方式会遍历整个string
1 | string s = "hello world"; |
如果是第一种写法的话,打印出来只能打印
hello world
,因为后面遇到了\0
,C字符串认定为终结符第二种写法则会打印出
hello world x
,因为是对于string整体做了一个遍历
模拟实现>>
流提取运算符重载
1 | istream& operator>>(istream& in, string& s) |
此处从缓冲区获取字符的时候,使用的是
in.get()
而不是in>>
,因为字符的流提取符>>
将空格和换行认定为终结符,因此如果从通过in>>
读取到缓冲区中读取到终结符,就终止读取了,ch获取不到这个终结符。
而in.get()
是获取缓冲区中的(任何)一个字符,无论是不是终结符。这样就能确保ch拿到缓冲区里面的每一个字符,然后再判断时候终止循环。
1 | cin >> s1; |
优化1:
如果键入的字符太多,当字符串s满了的时候,s+=每次都要扩一下容,效率不高
创建一个字符数组buff,先把获取到的字符放到字符数组中,等字符数组满了或者字符获取结束后,再将字符数组(其实就是C字符串) += 到字符串s里面去。如果字符数组满了,将内容放到字符串s之后,清空或重新初始化自己的内容,准备继续承接字符。
1 | istream& operator>> (istream& in, string& s) |
这样就避免了string字符串s频繁进行
+=
操作,减少了扩容次数,提高了效率
模拟实现getline()
就是将上面while循环里面的空格判断删除,只让换行符作为终结符
优化2:
STL中的string在流提取时,如果原string有内容,则会被新获取的内容覆盖掉
1 | std::string s = "hello"; |
因此模拟实现中也要先将原来的字符串清空一下才可以
1 | void clear() |