【C++】string类的认识与常用接口详解(一)
前言在 C 语言阶段我们一般使用字符数组来存放字符串并通过strlen、strcpy、strcat等库函数完成字符串的各种操作。但是字符数组的空间需要我们自己管理字符串和操作字符串的函数也是分离的。如果数组空间开小了就可能出现越界访问如果需要扩容又要重新申请空间并转移数据使用起来还是比较麻烦的。C 标准库为我们提供了string类它将字符串的数据和常用操作封装到了一起还能根据需要自动管理空间。因此无论是在日常开发还是算法题中string都比 C 语言的字符数组更加方便。本文是string系列的第一篇主要介绍string类的基本认识auto与范围forstring对象的常见构造方式string的元素访问与四种迭代器size、capacity、clear、empty和reserve等容量相关接口。本文暂时不会介绍字符串的插入、查找等操作这些内容会在后面的文章中继续补充。一、为什么要学习 string 类1. C 语言中的字符串C 语言并没有单独的字符串类型通常使用以\0结尾的字符数组表示字符串charstr[]hello world;它在内存中可以简单理解为h e l l o w o r l d \0其中\0是字符串的结束标志并不属于字符串的有效内容。使用字符数组时我们需要注意下面这些问题数组空间需要自己确定和管理进行拷贝、拼接时要特别注意目标空间是否足够很多操作需要配合不同的库函数完成如果忘记保留\0的位置很容易产生越界访问。2. C 中的 stringstring本质上是标准库提供的一个类。我们可以先把一个string对象简单理解成由下面三部分组成classstring{private:char*_str;// 指向字符串数据size_t _size;// 当前有效字符个数size_t _capacity;// 当前容量};需要注意的是上面的代码只是为了帮助理解并不是标准规定的真实源码。不同编译器对string的具体实现可能并不相同但它们都会为我们管理字符串的内容、长度和空间。使用string时要包含头文件#includeiostream#includestringintmain(){std::strings(hello world);std::coutsstd::endl;return0;}虽然有些编译器可能通过其他头文件间接包含了string但这种行为并不能保证。只要使用了std::string就应该显式包含string。二、auto 关键字在早期的 C/C 中auto表示自动存储期不过这个含义平时几乎不会使用。C11 重新赋予了auto新的用途让编译器根据初始化表达式自动推导变量类型。inta10;autoba;// b 被推导为 intautochA;// ch 被推导为 charautod3.14;// d 被推导为 double由于编译器需要根据右边的内容推导类型所以使用auto声明变量时通常必须进行初始化autovalue10;// 正确// auto value; // 错误无法推导类型1. auto、指针与引用使用auto推导指针时写不写*都可以intx10;autop1x;// int*auto*p2x;// int*如果想得到引用则必须显式写出autorefx;// intref20;// 修改的就是 x2. 同一条语句中的类型必须一致autoa1,b2;// 正确都是 int// auto c 1, d 2.0; // 错误推导结果不一致3. 关于函数中的 auto在当前学习的 C11 语法范围内不能直接把auto写成普通函数形参的类型// C11 中不能这样写// void Func(auto value);较新的 C20 已经允许这种写法它本质上属于简写的函数模板。不过现阶段先按照 C11 的规则理解即可。从 C14 开始普通函数可以使用auto推导返回值autoAdd(intx,inty){returnxy;}这种写法虽然方便但在返回类型不明显时可能降低代码的可读性所以不能为了使用auto而滥用auto。三、范围 for传统的下标循环需要我们自己写出起点、终止条件和下标变化intarray[]{1,2,3,4,5};for(size_t i0;isizeof(array)/sizeof(array[0]);i){std::coutarray[i] ;}C11 引入了更加简洁的范围forfor(autoe:array){std::coute ;}范围for会自动取出范围内的元素、自动向后迭代并自动判断是否结束。1. 值拷贝与引用下面的e只是数组元素的一份拷贝修改它不会影响原数组for(autoe:array){e*2;}如果希望修改原来的元素需要使用引用for(autoe:array){e*2;}如果只想读取元素又不希望发生拷贝可以使用const引用for(constautoe:array){std::coute ;}对于char这样的简单类型按值拷贝的开销很小但以后遍历体积较大的对象时const auto会更加合适。2. 使用范围 for 遍历 stringstd::strings(hello world);for(autoch:s){std::coutch ;}范围for遍历标准库容器时底层可以理解为借助begin()和end()完成遍历。它不是一种全新的遍历原理而是编译器提供的一种简化写法。四、string 对象的常见构造1. 默认构造std::string s1;这会构造一个空字符串此时s1.size()0;s1.empty()true;2. 使用 C 字符串构造std::strings2(hello world);string会根据传入的 C 字符串构造对象字符串有效内容为hello world。3. 拷贝构造std::strings3(s2);使用已经存在的s2构造s3这时两个对象的字符串内容相同s2hello world s3hello world它们是两个独立的对象修改其中一个对象的内容不应该把另一个对象一起修改。4. 从另一个 string 的指定位置开始构造std::strings2(hello world);std::strings4(s2,6,5);std::strings5(s2,6);std::couts4std::endl;std::couts5std::endl;运行结果world worlds4(s2, 6, 5)表示从下标6开始取出最多5个字符s5(s2, 6)表示从下标6开始一直取到字符串末尾。如果指定的字符个数超过了剩余长度构造函数只会取到末尾但是起始位置pos不能超过原字符串的size()否则会抛出std::out_of_range异常。5. 使用 C 字符串的前 n 个字符构造std::strings6(hello world,5);std::couts6std::endl;运行结果hello这里的5表示只取前五个字符。6. 使用 n 个相同字符构造std::strings7(10,X);std::couts7std::endl;运行结果XXXXXXXXXX这种写法会构造一个包含十个字符X的字符串。五、string 的元素访问string重载了operator[]所以可以像访问数组一样访问字符串中的字符std::strings(hello);std::couts[0]std::endl;s[0]H;std::coutsstd::endl;运行结果h Hello合法字符的下标范围为[0, s.size())也就是说最后一个有效字符的下标是s.size() - 1。例如一个长度为5的字符串不能使用s[10]访问元素。operator[]不负责普通的越界检查越界访问会导致未定义行为。使用下标遍历std::strings(hello world);for(size_t i0;is.size();i){std::couts[i] ;}std::coutstd::endl;这里使用size_t作为下标类型是为了和size()的返回类型保持一致避免有符号数和无符号数之间进行比较。六、迭代器迭代器可以简单理解为一种行为类似指针的对象它为不同容器提供了相对统一的访问方式。我们以前学习顺序表、链表时不同数据结构的遍历方式并不相同数组可以通过下标访问链表则需要沿着结点指针向后寻找。迭代器相当于在这些不同结构之上提供了一套统一的遍历接口。1. 正向迭代器std::strings(hello world);std::string::iterator its.begin();while(it!s.end()){std::cout*it ;it;}std::coutstd::endl;其中begin()返回指向第一个字符的迭代器end()返回指向最后一个字符后面位置的迭代器*it取得当前位置的字符it让迭代器移动到下一个字符。end()指向的是尾后位置这个位置只用于判断遍历是否结束不能对它解引用。2. 反向迭代器std::strings(hello world);std::string::reverse_iterator rits.rbegin();while(rit!s.rend()){std::cout*rit ;rit;}std::coutstd::endl;运行结果d l r o w o l l e h其中rbegin()指向反向遍历时的第一个字符也就是原字符串的最后一个字符rend()表示反向遍历的结束位置对反向迭代器执行rit实际会向字符串前面的字符移动。3. string 中常见的四种迭代器string中常见的迭代器有四种迭代器类型遍历方向能否通过迭代器修改字符iterator正向可以const_iterator正向不可以reverse_iterator反向可以const_reverse_iterator反向不可以普通对象可以使用可修改的迭代器std::strings(hello world);std::string::iterator its.begin();*itH;如果对象被const修饰就只能使用只读迭代器conststd::strings(hello world);std::string::const_iterator cits.begin();std::cout*citstd::endl;// *cit H; // 错误不能通过 const_iterator 修改字符不想手写很长的迭代器类型时可以让auto自动推导conststd::strings(hello world);autocits.begin();while(cit!s.end()){std::cout*cit ;cit;}这正是auto非常适合发挥作用的场景。七、string 的容量相关接口1. size 与 lengthstd::strings(I love you);std::couts.size()std::endl;std::couts.length()std::endl;size()和length()都返回字符串的有效字符个数两者作用相同。之所以保留length()是因为这个名字对字符串来说很直观而size()可以和其他容器的接口保持统一。在平时编写代码时一般更多使用size()。需要注意字符串末尾用于标记结束的\0不计入size()。2. capacitycapacity()返回当前已经分配的容量。只要新的字符串长度没有超过容量通常就不需要重新申请更大的空间。std::strings(hello);std::couts.size()std::endl;std::couts.capacity()std::endl;可以将size和capacity简单理解为size当前真正存放了多少个有效字符capacity当前最多可以容纳多少个字符而不用重新分配空间。capacity()的具体数值和扩容策略与标准库实现有关同一段代码在 Visual Studio 和 g 下可能得到不同结果。因此不能在程序中依赖“每次固定扩容两倍”或者“固定扩容 1.5 倍”这样的规律。3. 观察 string 的扩容voidTestPushBack(){std::string s;size_t oldCapacitys.capacity();std::coutcapacity changed: oldCapacity\n;std::coutmaking s grow:\n;for(inti0;i100;i){s.push_back(c);if(oldCapacity!s.capacity()){oldCapacitys.capacity();std::coutcapacity changed: oldCapacity\n;}}}当size即将超过当前capacity时string需要申请一块更大的空间把原来的数据转移过去再释放原空间。扩容能够让字符串继续增长但申请空间和转移数据都存在额外开销。标准只规定string应该表现出的功能并没有要求所有实现使用相同的初始容量和增长倍数。所以不同平台打印出的容量变化不同是正常现象。4. reserve如果我们提前知道字符串大概需要存放多少个字符可以使用reserve()预留空间std::string s;s.reserve(100);这表示希望string提前准备至少能够容纳100个字符的容量。需要注意reserve()调整的是容量不会直接增加有效字符个数调用s.reserve(100)后s.size()仍然是0如果参数大于当前容量容量会增加到至少能够容纳该参数的大小如果参数不大于当前容量不能把它当成可靠的缩容手段常见实现通常不会因此缩小空间。std::strings(hello world);std::couts.size()std::endl;std::couts.capacity()std::endl;s.reserve(100);std::couts.size()std::endl;std::couts.capacity()std::endl;调用reserve()前后size()都表示hello world的字符个数变化的是对象预留的容量。reserve()的主要意义是减少可以预见的多次扩容。如果字符串很短或者根本无法估计最终长度就没有必要为了使用它而使用它。5. clear 与 emptyclear()用于清空字符串的有效内容std::strings(I love you);s.clear();调用后s.size()0;s.empty()true;clear()的重点是把有效字符个数变为0一般不会把已经申请的容量一起归还。这样对象后续重新存放字符串时还可以继续利用原来的空间。empty()用于判断字符串是否为空if(s.empty()){std::cout空字符串std::endl;}else{std::cout非空字符串std::endl;}相比于写成s.size() 0s.empty()的含义更加直观。八、代码中需要注意的细节1. 使用 string 时显式包含头文件#includestring不要依赖iostream间接包含string否则换一个编译器或标准库实现后代码可能无法通过编译。2. operator[] 不要越界如果字符串只有五个有效字符就不能访问下标10。只有满足下面条件的下标才能用来访问有效字符poss.size()3. 修改元素时使用引用for(autoch:s){chX;// 只修改副本}如果想修改原字符串应写成for(autoch:s){chX;}4. 不要依赖固定扩容倍数Visual Studio、g 以及不同版本的标准库都可能采用不同的空间策略。我们可以观察扩容现象但不能把某一台电脑上的容量序列当成 C 标准规定。九、总结本文认识了 C 标准库中的string类并学习了string的第一部分常用接口。首先string将字符串内容和相关操作封装成了一个类相比 C 语言的字符数组更加方便也能自动管理底层空间。随后补充了auto和范围for它们可以简化迭代器类型的书写和容器遍历。在构造对象时我们可以构造空字符串、使用 C 字符串构造、进行拷贝构造也可以截取指定范围或生成多个相同字符。访问元素时可以使用operator[]但必须注意下标不能越界。迭代器为容器提供了统一的遍历方式。begin()、end()用于正向遍历rbegin()、rend()用于反向遍历加上是否允许修改元素可以得到四种常见迭代器。最后size表示有效字符个数capacity表示当前容量clear清空有效内容empty判断是否为空reserve则可以提前预留空间并减少扩容次数。需要特别注意具体容量和扩容策略属于标准库的实现细节不同环境下得到不同结果是正常的。有关string的修改、查找等常用接口将在后续文章中继续学习。完