提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
提示:这里可以添加本文要记录的大概内容:
学习c++,不能只满足于简单的使用,理解底层原理至关重要,今天这篇文章就简述一下string、vector、list的简单使用和模拟实现
提示:以下是本篇文章正文内容,下面案例可供参考
一、string
1、为何使用string
在我的印象中,c语言也有字符串,标准库中也含有关于字符串的一些操作,那为何又要大费周章的搞一个string类呢。其实这些操作看似好用,其实危机四伏,str等函数是和字符串相分离的,极易造成越界访问等问题。
使用了string后,利用c++一些重载等特性,可以很舒服的,安全的对字符串进行操作。
2、简述string类
c++标准库中的string类是一种经过各种封装后得到的表示字符序列的类
它是basic_string的char类型实例化得到的
为何需要用一个模板来泛型表示出来呢,这就牵扯到不同编码规则的问题了,ascii编码中,一个byte可以表示256个英文字符,但在unicode中,所以字符都用两字节表示,这就说明了字符并不一定是char类型。
3、string的简单使用
(1)、构造,析构和运算符重载
构造函数最主要的有下面几个
构造一个空串
用c中的字符串来构造
拷贝构造:用一个已知的string对象去构造
int main()
{
string s("123");
string s2(s);
cout << s << endl;
cout << s2 << endl;
return 0;
}
其他的构造函数都是比较少见的,需要用时查询相关资料即可,这里就不多赘述
析构函数
单纯的释放掉string占用的那一块空间即可
重载
类似相同的*++、- -、-=等*操作均可通过直观感受快速使用出来
对于[]操作符也进行了重载,可读可写,因为重载时采取的是引用返回,当然,如果用const修饰以后就只能读了
(2)其他接口
首先就是size()和capacity()
这里推荐使用size()而不是length(),因为后续比如二叉树等就只能使用size(),尽量保持一致,size()的作用是求出字符串的长度,capacity()的作用是求出容量,也就是能存多少字符
resize()和reserve()
resize()的作用是改变字符的数量,如果是增加数量,可以用c来进行填充,如果没给c,则会用**‘\0’**来补,如果n的数量比capacty还要多,会直接报错吗?其实resize()还有扩容的能力,改变capacity的值,这就有点强买强卖的感觉了(狗头),其次还有n比size小的情况,为偷懒而生的c++会直接改变size。
reserve()就比较老实了,会兢兢业业扩容,当然如果给的n小于capacity,老实人会不予理会。
clear()和empty()
这两个就比较简单了,一个是清空字符串(注意:容量是不变的,变得是size)
另一个是判断字符串是否为空串
append和push_back
append用于追加字符串,push_back后面追加一个字符
+=可以简单,完美的替代这两位,因为+=的底层就是以它们为基础的
c_str,find和substr
c_str会返回一个c中const类型的字符串(const对象可以调用)
find会重给的pos位置开始开始寻找,寻找到字符c的位置,并返回,如果给的是字符串,则返回字符串第一个字符的位置
substr是从pos位置开始,截取n个字符,并返回
operator<<,operator>>和getline
string类不是内置类型,所以我们需要用重载来实现输入和输出
而这里为了不让输出变成str<<cout这中情况,我们一般使用类外函数来表示,或者是使用友元。
而字符串的输入遇到’ ‘(空格)和’\n’会跳出,从而不能输入一整串字符。这里我们引用了一个函数getline
(3)、迭代器
在string对象里面,可以很方便的用[]和下标准确访问到需要的地方,但迭代器的重要性是毋庸置疑的,它是一个所以容器统一的访问接口,对于无法使用下表访问的(类如list)等至关重要,这里就不说了,下面和你们讲哈。
小提一句
在不同的平台上,string的实现也大不相同。
在vs中,string站28个字节,包括除了自己的size,capacity以及一个char类型的指针外,还有一个16字节的数组,在使用时,优先放在数组里面,超过容量再在堆上开辟出对应的空间,好处就是以空间换时间
而g++里面的string类只占四个字节的空间,只有一个指针,指向一个堆空间
里面包括:
空间总大小
字符串有效长度
引用计数
指向堆上字符串的指针
二、vector
1、vector的简单介绍
1、vector是一个可变大小的类似数组的容器,在使用时,借用了泛型的模板来增加数组的兼容性。
2、由于大小可变的缘故,在使用时不免出现容量不够的情况发生,针对这种情况,底层使用的方法是开辟一个更大的空间,然后将数据移到这个新空间里。
3、在扩容时,每个编译器处理时开辟的新空间大小都会有所不同,但都会预留一些空间以便再添加元素进来,而这个预留空间的尺度就要具体编译器具体拿捏了。
4、由于它类似数组的这一特性,在访问元素时很轻松,尾删,尾插也很容易,但要是不在尾部进行删除时,由于vector是连续的,那么就需要将后面的元素往前面移动,“覆盖住”被删掉的元素,
2.vector的使用
(1)构造
文档中给出了包括无参构造,拷贝构造等四种构造方式,按需取用
(2)迭代器的使用
由于vector也通string一样可以通过[]和下标访问内容,但还是简单介绍一下它的迭代器
由于泛型的需求,迭代器的定义也不止止局限在int*、char*,模板T的使用是很重要的。
迭代器的几个函数:begin()、end()、rbegin()、rend()。
需要注意的有两点:
1、end()返回的是最后一个的下一个的迭代器
2、rbegin()是最后一个元素,rend()是第一个元素的前面一个。这里写到后面的反向迭代器时会重点强调。
(3)空间相关的接口
前面string的学习已经让我们对于一些简单的接口有了了解,下面列举出来就不多赘述了
size 数据的长度
capacity 容器的容量
empty 判空
resize 改变数据的长度
reserve 改变容量大小
对于扩容有几点需要注意一下:
1、扩容怎么扩?vs的底层设计是1.5倍的扩容,g++下是2倍的扩容。
2、resize扩容后会初始化为0。
(4)vector的增删查改
push_back、pop_back尾插和尾删
insert和erase
这里要注意一个问题,平时我们弄删除和插入操作,都不会设置函数返回值,但这两个函数都返回了一个迭代器,这是为什么呢?这和我们下文会谈的迭代器失效有关
vector类内的swap函数
为什么vector类不用库里的,而是自己在类内定义了一个呢?有大佬路过可以评论区探讨一下。
operator[]
既然可以将vector看作数组使用,那数组的访问方式当然是必不可少的。
它可以直接有效的帮助我们不用迭代器的方式来访问容器内的元素
3、迭代器失效问题
迭代器的作用是提供一个统一的方法让我们去访问容器内的元素,其本质上就是一种指针,只不过有些容器的迭代器进行了封装,vector的迭代器底层其实就是一个T*。在进行某些操作时,会将某一块内存释放掉,但迭代器还指向了那一块内存,没有进行更改,从而导致程序崩溃的问题就是迭代器失效问题。下面这几种情况就有可能会造成迭代器失效
1、对底层内存进行操作,如resize,push_back等。
比如对v进行resize(100)会导致扩容,而扩容的底层就是放弃原有空间,开辟了一块更大的空间
2、使用了erase函数
按理说,erase函数删除对于元素后,其余元素会向前移动,不应该出现原空间释放的情况,但如果pos指向最后一个元素,删除后pos就指向了end,end位置没有元素,所以认为pos失效。基于此,vs上规定,删除任何位置后,erase都会失效。,所以我们回到上面的问题,为什么insert和erase会返回一个迭代器。查询相关资料,insert返回的是新地址的插入元素的迭代器,而erase返回的是删除元素的下一元素的迭代器
注:看到这里有人就开始疑惑,为什么不研究string的迭代器失效呢,这两容器不差不多嘛,这其实和他们给的函数参数有关,可以查一下文档看看,string给的参数都是什么,和vector有什么不同。
三、list
都坐稳了(狗头),文章较长,可以分次阅读
1、list的简单介绍
list可以看作双向循环链表,在插入和删除上具有很高的效率,代价呢就是无法进行高效的随机访问,必须从头节点开始慢慢往后挪动。
2、list的使用
构造方法同上面两种相似
empty和size
一个是判空,一个是节点数量
front和back
返回第一个节点和最后一个节点
使用方式和前面的vector基本相同。
3、list的迭代器
不同的来了。
我们前面讲到:迭代器的本质就是指针,只是有些进行了封装,比如list的迭代器,我们不能直接将其写成某种类型的指针,我们需要对这一个指针进行对应的封装,比如要重载它的**++运算符,解引用运算符**等,这个我们会在它的模拟实现上进行具体的实现和说明。
四、模拟实现,了解底层
为了更好的去观察底层,我们将三个容器放一块对比学习(坐稳了哦)
1、成员变量
我们在模拟底层时可以根据这些容器的能力来类似出应该含有的成员变量
(1)string
string和c中的字符串异曲同工,所以可以用一个指向字符串的指针来表示具体内容,为了更好的进行封装等功能,加上表示长度和容量的变量
char* _str;
size_t _size;
size_t _capacity;
(2)vector
相信很多人第一印象和我一样,开个T* 的指针指向数组元素,然后再加上长度和容量,但stl库中没有这样给(有知道为什么的大佬可以再评论区探讨一下)
库中给的是三个指针,分别指向字符串开始,结束和总容器容量的结束位置
typedef T* iterator;
typedef const T* const_iterator;
iterator _start;
iterator _finish;
iterator _end_of_storage;
(3)list
list的成员函数很有趣,针对双向循环链表的功能,我们只给一个哨兵位的头节点,而节点位置就放在头节点的后面,利用指针来找到节点(这个节点的内容再用结构体来定义),最后再加上表示容量的变量
struct List_Node
{
List_Node<T>* _prev;
List_Node<T>* _next;
T _data;
List_Node(const T& x=T())
:_prev(nullptr)
,_next(nullptr)
,_data(x)
{}
};
typedef List_Node<T> node;
node* _head;
size_t _size;
2、构造,析构和赋值重载
(1)string
string这需要注意的一个点就是用缺省参数来讲空初始化和字符串初始化结合起来
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
然后就是简单的拷贝构造和析构函数
string(const string& str)
{
_str = new char[str._capacity + 1];
_size = str._size;
_capacity = str._capacity;
strcpy(_str, str._str);
}
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
注意这里的拷贝构造是深拷贝,即**_str和str._str的元素相同,但是在堆上开辟的空间不同**,互不干扰。
由于前面开辟空间时用的是new[],析构时,也要用[]来delete。
赋值重载也相同,注意避免浅拷贝的问题,还要注意释放掉原先的空间。当s1==s2时,我们直接返回s1。
string& operator=(const string& s)
{
if (this != &s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
以上的构造等等写法,还有更加“资本主义”的写法,就是利用已有的函数,创造一个中间人,然后讲中间人的东西都抢过来自己用,这里就拷贝构造举个例子
string(const string& str)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
string tmp(str._str);
swap(tmp);
}
这里的打工人虽然老实,但也不能随意欺负,所以在初始化列表位置给*this赋了一些值,来保证tmp不会生气,导致程序崩溃。
(2)vector
构造函数还是很容易的
vector()
:_start(nullptr)
,_finish(nullptr)
,_end_of_storage(nullptr)
{}
vector(size_t n,const T& val=T())
:_start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
reserve(n);
for (size_t i = 0; i < n; i++)
{
push_back(val);
}
}
析构也不难
~vector()
{
delete[] _start;
_start = _finish = _end_of_storage = nullptr;
}
赋值重载呢这里用一种比较新的写法
//v1 = v2;
vector<T> operator=(vector<T> v)
{
swap(v);
return *this;
}
这种写法的艺术成分很高啊!交换后的v由于是传值进来的,函数结束会直接调用析构删掉,活活“资本家”行为啊!!
下面再说一个有意思的拷贝构造
还在用常规的写法,创造一个新的vector,将元素一个个放进去?这里还有打工人可以用!
也是一种新的初始化方式:迭代器区间初始化
template<class InputIterator>
vector(InputIterator first, InputIterator last)
:_start(nullptr)
,_finish(nullptr)
,_end_of_storage(nullptr)
{
while (first != last)
{
push_back(*first);
first++;
}
}
其原理就是利用了迭代器本质是指针的特性,将一段数据放进vector中,至于用模板来泛型化的原因呢。。
c++比起c语言的提升就是能够兼容,易于操作,这里的迭代器可以是任意容器的迭代器放进来,只要元素类型和vector相同
利用这种构造,一种很新的拷贝构造诞生了
void swap(vector<T>& v)
{
std::swap(_start, v._start);
std::swap(_finish, v._finish);
std::swap(_end_of_storage, v._end_of_storage);
}
vector(const vector<T>& v)
:_start(nullptr)
, _finish(nullptr)
, _end_of_storage(nullptr)
{
vector<T> tmp(v.begin(), v.end());
swap(tmp);
}
这里的swap函数是为了深拷贝做考虑的。
(3)list
由于list的构造常常用到这串代码
_head = new node;
_head->_next = _head;
_head->_prev = _head;
_size = 0;
所以我们将其放在一个函数中,这样调用就很方便
构造,拷贝构造和析构函数都和vector相差不大
list()
{
empty_initialize();
}
template <class InputIterator>
list(InputIterator first, InputIterator last)
{
empty_initialize();
while (first != last)
{
push_back(*first);
++first;
}
}
void swap(list<T>& it)
{
std::swap(_head, it._head);
std::swap(_size, it._size);
}
list(const list<T>& it)
{
empty_initialize();
list<T> tmp(it.begin(), it.end());
swap(tmp);
}
由于list类内只存了_head,所以删除时需要遍历整个链表,删除每一个节点
void clear()
{
iterator it = begin();
while (it != end())
{
it = erase(it);
}
}
~list()
{
clear();
delete _head;
_head = nullptr;
}
3、迭代器
string和vector的迭代器本身就是指针,分别是char* 和T*,原因是,它们本身顺序表的结构,简单的++和- -就能找到下一个元素。
那么list呢,它的本质是链表啊,数据的存储不是连续的。这里就要提到上面说过的封装问题。下面献上代码。
template<class T, class Ref, class Ptr>
struct _list_iterator
{
typedef List_Node<T> node;
typedef _list_iterator<T, Ref, Ptr> Self;
node* _pnode;
_list_iterator(node* p)
:_pnode(p)
{}
Ref operator*()
{
return _pnode->_data;
}
Ptr operator->()
{
return &_pnode->_data;
}
//前置++和--;
Self& operator++()
{
_pnode = _pnode->_next;
return *this;
}
Self& operator--()
{
_pnode=_pnode->_prev;
return *this;
}
bool operator==(const Self& it) const
{
return _pnode == it._pnode;
}
bool operator!=(const Self& it) const
{
return _pnode != it._pnode;
}
};
可以看到模板给的**三个参数可以分别对应到<T ,T& ,T*>(引用可以改变值)*只要在用的时候传入进去就行
并且这里的成员函数是node _pnode,是一个指向节点的指针,进行简单的封装后,就可以和其他迭代器一样使用了。
4、reserve和resize函数
这两个函数是vector和string这种顺序表结构所独有的,
string中
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
开空间(n+1的原因是预留一个位置存放’\0’),将原先的元素拷贝过去,删除原先的空间,更新capacity,一气呵成
void resize(size_t n, char ch = '\0')
{
if (n > _size)
{
reserve(n);
for (size_t i = _size; i < n; ++i)
{
_str[i] = ch;
}
_size = n;
_str[_size] = '\0';
}
else
{
_str[n] = '\0';
_size = n;
}
}
resize需要注意几个点就行
1、n和size的大小比较,这里复用了reserve将n与capacity的比较放到函数中去了。
2、扩充的位置用ch来补充。
对于vector呢,整体思路和string大同小异
void reserve(size_t n)
{
if (n > capacity())
{
size_t Old_Size = size();
T* tmp = new T[n];
if (_start)//如果原来的不是空
{
for (size_t i = 0; i < size(); ++i)
{
tmp[i] = _start[i];
}
delete[] _start;
}
_start = tmp;
_finish = _start + Old_Size;
_end_of_storage = _start + n;
}
}
由于后面有删除和修改_start的步骤,开始就先记录下size大小
void resize(size_t n, T val = T())
{
if (n > capacity())
{
reserve(n);
}
if (n > size())
{
while (_finish < _start + n)
{
*_finish = val;
++_finish;
}
}
else
{
_finish = _start + n;
}
}
需要注意的是是否需要扩容,在空间足够的情况下,只需要一个一个往后放val就行。
5、“查”操作
string的查操作有通过[]访问,通过find函数找到对应的元素,这些都可以利用底层是字符串来解决
char& operator[](const size_t pos)
{
assert(pos < _size);
return _str[pos];
}
//常对象调用
const char& operator[](const size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
size_t find(const char ch, size_t pos)
{
assert(pos < _size);
while (pos < _size)
{
if (_str[pos] == ch)
{
return pos;
}
pos++;
}
return npos;
}
};
npos的值可以在类成员中定义一下
vector也可以通过迭代器和[]两种方法进行访问操作
T& operator[](size_t x)
{
assert(x < size());
return _start[x];
}
iterator begin()
{
return _start;
}
iterator end()
{
return _finish;
}
注意这里的_finish是指向最后一个元素的下一个
而list呢,由于自身结构问题,只能通过迭代器进行访问
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
6、插入和删除操作
三个容器都是需要对数据进行这两个操作,只是操作的方式略有不同。
首先是string和vector的插入操作
void insert(size_t pos,const char* str)
{
size_t len = strlen(str);
if (len + _size > _capacity)
{
reserve(len + _size);
}
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
end--;
}
strncpy(_str + pos, str, len);
_size += len;
}
iterator insert(iterator pos, const T& x)
{
assert(pos < _finish);
assert(pos >= _start);
if (_finish == _end_of_storage)
{
size_t len = pos - _start;
size_t new_capacity = capacity() == 0 ? 4 : capacity() * 2;
reserve(new_capacity);
pos = _start + len;//在扩容后迭代器会失效
}
iterator end = _finish;
while (end > pos)
{
*end = *(end - 1);
--end;
}
*pos = x;
_finish++;
return pos;
}
判断插入位置,如果空间不够需要扩容,将位置后的元素往后移,在更新一下成员变量
这边说一下string的插入中的strncpy函数,将str中的len个数据从_str+pos的位置开始复制上去,和strcpy的功能类似。
其次是删除操作
void erase(size_t pos)
{
size_t start = pos;
while (start < _size)
{
_str[start] = _str[start + 1];
start++;
}
}
iterator erase(iterator pos)
{
assert(pos < _finish);
assert(pos >= _start);
iterator begin = pos;
while (begin < _finish - 1)
{
*begin = *(begin + 1);
++begin;
}
_finish--;
return pos;
}
需要移位置,所以中间和头部的插入删除的代价还是比较大的。
list本身是双向循环链表,插入和删除操作只需要对一两个节点做出改变即可。
iterator insert(iterator pos, const T& val)
{
node* new_node = new node(val);
node* cur = pos._pnode;
node* prev = pos._pnode->_prev;
new_node->_prev = prev;
new_node->_next = cur;
cur->_prev = new_node;
prev->_next = new_node;
_size++;
return iterator(new_node);
}
iterator erase(iterator pos)
{
node* prev = pos._pnode->_prev;
node* next = pos._pnode->_next;
prev->_next = pos._pnode->_next;
next->_prev = prev;
delete pos._pnode;
--_size;
return iterator(next);
}
那么有了insert和erase后,push_back,pop_back这些函数都很简单了,对已有的进行复用即可。
7、最后一点:反向迭代器
反向迭代器是迭代器的一种在从后开始遍历较好时产生的一种类。没错,反向迭代器是将普通迭代器封装而产生的类。
首先,为了兼容性,模板中的参数第一个便是放迭代器的,而后需要获得数据和对应的地址,我们在增加两个参数
在这个类里面,我们要实现反向迭代器++可以使得指向的元素向前移,- -向前移。
template<class Iterator, class Ref, class Ptr>
class reverse_iterator
{
private:
Iterator _it;//对应类型的迭代器
typedef reverse_iterator<Iterator, Ref, Ptr> Self;
public:
reverse_iterator(Iterator it)
:_it(it)
{}
Self& operator++()
{
--_it;
return *this;
}
Self& operator--()
{
++_it;
return *this;
}
Ref operator*()
{
Iterator tmp = _it;
return *(--tmp);
}
Ptr operator->()
{
return &(operator*());
}
bool operator!=(const Self& it1) const
{
return _it != it1._it;
}
};
很多人会好奇,为什么解引用后,返回的是指向位置的前一个位置。这里要从begin,rbegin,end,rend里面讨论了。
当开始时,反向迭代器指向最后一个元素的下一个位置,这是库里面规定的,我们只需要去了解并使用就行,不用可以造出不一样的东西。
总结
这就是本文的全部内容啦,本人能力有限,非常欢迎路过的大佬指出错误
最后,用一句话激励一下自己:只要我们能善用时间,就永远不愁时间不够用