这一讲开始,我们正式进入STL篇章的学习,STL是相当重要的,首先我先介绍一下什么是STL。
STL意为标准模板库,是C++标准库的重要组成部分,可以理解为一个包含数据结构和算法的软件框架。
STL有很多版本,PJ、SGI都是很出名的版本,版本之多也印证其重要性之大。
STL包含六大组件——仿函数、算法、迭代器、空间配置器、容器、配接器。像容器里就有我们熟悉的链表等等。
仅仅是说到这一步,你肯定都会认为STL很重要很方便了,C语言我们实现一个数据结构还要手搓,C++都给你现成的了,那你有没有想过一个问题,为什么C语言没有实现这种库呢?结合上一讲提到的模板知识,我们可以发现,当你C语言实现一个栈的时候,针对不同的数据类型,你要写不同的结构体,C语言没有模板这个概念,就不能很好的完成通用这个功能,自然实现起来就会非常麻烦,这就是C语言没有这个库的原因之一。
那么在简单介绍STL之后,我们正式开始STL的学习。
不过比较尴尬的是,我们第一个学习的是string虽然大家都把它归为STL,但是严格意义上来说string诞生比STL早,它并不属于STL,但是string和其他STL中的数据结构很相似,就一起介绍了。
我们借助cplusplus网站进行学习,string - C++ Reference
首先大家看一下string的定义
可以看到string是一个类型,它是typedef了一个名为basic_string<char>的类模板,既然是类那就有对应的成员变量成员函数,这也正是我们接下来学习的方向。
首先看一看构造函数。
C++98版本下string的构造函数有7种,我们主要研究这三种,分别是默认构造、拷贝构造、传字符串构造。
#define _CRT_SECURE_NO_WARNINGS 1 #include <iostream> #include <string> using namespace std; int main() { string s1; string s2("hello world"); string s3(s2); cout << s1 << endl; cout << s2 << endl; cout << s3 << endl; return 0; }有一些点需要注意,首先使用string类需要包含<string>头文件,此外,从代码可以看到,string是重载了<<流插入运算符的,可以直接使用。
这三种最常用的构造函数也是最简单的,其实没啥可说的,我接着介绍其他部分。
接下来我讲一讲string的遍历,一般我们是希望能像数组一样遍历字符串以拿到每个字符,这是一个常见的需求,对于string来说常见的遍历方式有三种。
第一种:
for (int i = 0; i < s2.size(); i++) { cout << s2[i] << " "; } cout << endl;第一种遍历方法需要一些前置知识,我调用了string类型的一个成员方法size(),我先来介绍一下这个函数。
size就是返回字符串的有效长度,这里size和length是一样的,至少在string这个类里边没有区别,用哪个都可以,和C语言类似,一般我们认为'\0'是字符串的结束标志,也就是说一般string让内存开空间会多开一个存放\0,遇到后视为终止。
那么这是最基本最常见的遍历方式了,我们之前学习C语言的时候也经常用这种方式遍历,它的优势是可以拿到对应的下标。
来看第二种:
string::iterator it = s2.begin(); while (it != s2.end()) { cout << *it << " "; it++; } cout << endl;这里需要我们了解一下iterator也就是迭代器相关的知识。
迭代器总共有四种,第一种iterator就是我上边使用的那种,首先迭代器是可以+-整数的,还可以解引用?难道迭代器就是指针吗?你可以类比指针来看,但说他就是指针这是不合理的。我们都知道C++支持重载运算符,这里的解引用一定就是进行了重载让程序在视觉上达到解引用的效果。解引用后可以拿到当前字符。
看下一种const_iterator,在原类型之前加一个const意味着权限缩小为只读,这是否意味着我们可以通过上种迭代器修改字符串的内容呢?是可以的。
那么这里加const意味着什么也就不用多说了。
第三种是 reverse_iterator适用于我们逆序遍历字符串。
string::reverse_iterator rit = s2.rbegin(); while (rit != s2.rend()) { cout << *rit << " "; rit++; } cout << endl;其实这段代码其他都很好理解,就有一点,按理来说,从后往前访问我们应该让rit--才对,为啥是++呢?从内存的角度确实是--,但是为了方便理解处理成这样。
那你可能又有疑问,为啥string要实现begin(),end(),rbegin(),rend(),这不是多此一举吗?那我就来带大家看看这几个成员函数。
首先begin和rbegin不同点在于前者返回首个字符后者返回最后一个有效字符,其次,返回值类型也不一样,iterator和reverse_iterator完全是两种不同的类型。也就是说二者不可混用,你如果要手动使用iterator完成逆序访问也是没问题的,从end一直--即可,但是混用由于类型不匹配,从语法的角度看都是错误的。
end和rend也是同理。
这也足以证明两种迭代器天差地别。
我要说的是,iterator相当常用,这不是string所独享的,后续STL其他数据结构会经常用iterator进行遍历。但是string大家用迭代器还是相对少一些。
第三种:
for (auto e : s2) { cout << e << " "; } cout << endl;这是最方便的一种遍历方式,利用了auto和范围for,C++规定auto声明的变量必须由编译器在编译时期推导而得。
auto可以声明指针类型,auto*也可以声明指针类型,二者没有任何区别,唯一区别是auto*后必须声明指针,否则报错。
若用auto在同一行声明多个变量,必须类型相同,否则报错。
auto不能作为函数参数但是可以做返回值,不过一般不建议这么用。因为你auto返回auto接收,会降低代码可读性,你要到函数中仔细查看才能发现自己返回的是什么类型执行的是什么逻辑。
auto不能直接用来声明数组。
那auto有啥用呢?常见的用法是用auto代替长度很长的类型名。
接下来介绍一下范围for,范围for的一大特点:自动赋值,自动迭代,自动判断结束。
范围for可以作用到数组和容器对象上进行遍历。
范围for的底层实际上是替换迭代器。
范围for和迭代器遍历其实还是有区别的。
我们这里修改并没有起效,怎么做才能达到想要效果呢?传引用!
至此,三种常见的遍历方法已经介绍完毕,实践中要按需选取。
接下来,我来讲一下string的扩容问题。
首先来看一下string初始类型大小
cout << sizeof(string) << endl;在vs2022中,32位下string这个类初始大小就是28,这是否说明一开始string就开辟了28个字节的空间用来存放数据呢?
当我们存放的字符串长度小于16时存放在预开辟的大小为16的数组里,除此之外还有一个size_t保存字符串长度,一个size_t保存从堆上开辟空间的总容量。此外还有一个指针。
接下来我带大家研究一下string在vs2022上是如何扩容的
void PushBackTest() { string s; size_t sz = s.capacity(); cout << "初始大小:" << sz << endl; cout << "开始尾插数据" << endl; for (int i = 0; i < 100; i++) { s.push_back('c'); if (sz != s.capacity()) { sz = s.capacity(); cout << "增长到:" << sz << endl; } } }可以看到,vs2022采用近似1.5倍扩容。
其实扩容扩的还是挺频繁的,假如我本来就要存一个长字符串,你1.5倍慢慢扩扩到猴年马月去了,那有没有什么办法直接预留一块空间一步到位呢?其实是有的。
我接下来为大家介绍reserve
这段话啥意思呢,当你要存放的字符串长度大于现有空间那肯定扩容了对吧,假如你要缩小,可不可以呢?这是取决于编译器的,但是即使是缩小也不会改变原有数据,也就是说不会比你字符串的长度还小。
那在vs2022中,有没有实现这情况呢?
void test_reserve() { string s("abcdefghijklmnopqrst"); cout << s.size() << endl; cout << s.capacity() << endl; s.reserve(25); cout << s.size() << endl; cout << s.capacity() << endl; s.reserve(50); cout << s.size() << endl; cout << s.capacity() << endl; s.reserve(18); cout << s.size() << endl; cout << s.capacity() << endl; }可见,在vs2022下只有当要存的字符串长度长于当前内存空间以及手动扩大时编译器才会扩大空间,而且执行了某种对齐操作,其他情况是不会的。
这一讲对主要的函数和变量已经介绍差不多了,接下来我简单提一下其他的几个
resize是干啥的呢?假如,你传的n比当前的字符串长度小,那么从后往前会缩减至现在长度,反之,会尾插传参字符直至长度达到n,若是第一种传参,则会补'\0'
void test_resize() { string s("aiuhfkdafrhtshrghy"); cout << s << endl; cout << s.size() << endl; cout << s.capacity() << endl; s.resize(5, 'c'); cout << s << endl; cout << s.size() << endl; cout << s.capacity() << endl; s.resize(10, 'c'); cout << s << endl; cout << s.size() << endl; cout << s.capacity() << endl; s.resize(15); cout << s << endl; cout << s.size() << endl; cout << s.capacity() << endl; }而且,当你实际是缩减的时候是不会减少内存空间的。
先前我们介绍了begin,end,rbegin,rend,图里这四个是用于对应的const类型对象的。
void test_clear_shrink() { string s(100, 'a'); cout << s.capacity() << endl; s.resize(25); cout << s.capacity() << endl; s.shrink_to_fit(); cout << s.capacity() << endl; s.clear(); cout << s.capacity() << endl; s.shrink_to_fit(); cout << s.capacity() << endl; }clear就是清理所有字符,但是不缩小空间。empty就是看字符串的有效长度是否为0.
而shrink_to_fit()函数则是在vs编译器下真正可以做到缩小空间的函数。
那么关于string的第一部分介绍先到此为止,请期待后续内容。