C++从重复代码到标准库:模板、STL与string入门
学完类和对象、运算符重载与动态内存后我们已经可以自己设计一个类也知道对象的构造、拷贝、赋值和销毁不是凭空发生的。但继续写代码很快会碰到两个问题两个函数的逻辑完全一样只是参数类型不同难道每换一种类型都要重写一次字符串明明只是“一串字符”为什么使用字符数组时还要反复处理空间、拷贝和\0模板和 STL 正是在解决这类重复劳动。模板把“类型不同、逻辑相同”的代码抽出来STL 则把常用的数据结构和算法整理成了一套能够协同工作的标准工具。string是我们接触这套工具时很合适的第一站因为它既像前面学过的字符数组又是一个真正的类。本文不追求一次讲完模板和 STL而是沿着学习时间线解决三个问题模板为什么能减少重复代码STL 中的容器、迭代器和算法如何配合string比字符数组多替我们管理了什么一、同一个比较逻辑为什么要写很多遍假设我们需要求两个数中的较小值。对于int可以这样写intMin(intleft,intright){returnleftright?left:right;}后来又要比较两个double最直接的办法是再写一个函数doubleMin(doubleleft,doubleright){returnleftright?left:right;}两个函数真正不同的只有类型比较过程没有改变。继续为long、float和其他类型重写只是在复制代码。函数模板允许我们暂时把具体类型写成一个占位符templateclassTTMyMin(constTleft,constTright){returnleftright?left:right;}这里的T不是某一种固定类型而是模板参数。调用时编译器会根据实参尝试推导Tstd::coutMyMin(3,5)\n;std::coutMyMin(2.5,1.8)\n;第一处调用中T被推导为int第二处调用中T被推导为double。可以把这一过程理解为编译器根据实际使用情况生成对应类型的函数实例。1. 模板不是“什么类型都一定能用”模板只是把类型暂时参数化并没有取消代码对类型能力的要求。MyMin的函数体使用了所以传入的类型必须支持符合要求的比较操作。这与前面学过的运算符重载正好连起来。自定义类型如果定义了合适的operator也可以使用这个模板classDate{public:Date(intyear,intmonth,intday):_year(year),_month(month),_day(day){}booloperator(constDateother)const{if(_year!other._year)return_yearother._year;if(_month!other._month)return_monthother._month;return_dayother._day;}private:int_year;int_month;int_day;};MyMin(Date(...), Date(...))能否工作不取决于模板“认识不认识日期”而取决于模板使用的操作能否作用于Date。这给模板入门阶段留下了一个很重要的判断阅读一个模板时不只看模板参数叫什么还要看函数体对这个类型做了哪些操作。2. 为什么参数写成const T如果写成按值传参templateclassTTMyMin(T left,T right);传入类类型时通常需要构造两个形参副本。改成const T后可以在不修改实参的前提下引用原对象减少不必要的拷贝。这正好承接前面学过的拷贝构造和const引用避免额外副本const又限制函数不能通过形参修改原对象。不过当前版本仍然按值返回因为返回的是两个实参中的一个值。更通用的模板返回设计还会涉及对象生命周期和引用悬空问题这一阶段先不提前展开。二、STL不是一堆互不相干的接口模板解决了“同一逻辑适配不同类型”的问题。标准模板库 STL 则进一步利用这种能力提供了一批通用容器和算法。初学 STL 时最容易把它学成函数名清单今天背push_back明天背find后天背sort。这样虽然暂时会用几个接口却不容易理解它们为什么能够组合。先抓住三个角色容器负责保存数据例如vector、list。迭代器用来定位容器中的元素并描述一个操作区间。算法对迭代器给出的区间进行处理例如sort、find、reverse。例如std::vectorintvalues{4,1,3,2};std::sort(values.begin(),values.end());这里values是保存整数的容器values.begin()指向第一个元素values.end()指向最后一个元素的下一个位置[begin, end)表示左闭右开的有效区间sort处理这个区间中的元素。为什么是左闭右开区间假设容器有 4 个元素有效下标是0、1、2、3。begin()对应第 0 个元素end()位于第 3 个元素之后因此[begin, end)恰好覆盖全部 4 个元素却不会访问end()所在位置。这和前面学习数组、排序以及循环边界时的思想是一致的边界不是凭记忆写出来的而是根据“哪些位置有效、循环体会访问哪里”推导出来的。为什么算法不直接只为vector设计如果算法只接收一种固定容器它就会与容器紧密绑定。STL 让算法主要面向迭代器区间只要迭代器提供算法需要的能力同一个算法就可能服务于不同容器。因此STL 的重点不只是“标准库帮我们写好了代码”更是把数据的保存方式和处理逻辑尽量分开。三、从字符数组过渡到stringC 风格字符串通常存放在字符数组中并以\0标记结束chartext[20]hello;如果要在后面拼接内容我们需要考虑数组剩余空间是否足够原字符串在哪里结束新内容应从哪个位置开始复制最后是否正确保留\0使用动态空间时由谁释放。这些问题并不是字符本身的业务逻辑而是字符串存储管理带来的负担。std::string把一串字符封装成了类#includestringstd::string texthello;text C;我们不再直接管理底层字符缓冲区而是通过对象的成员函数和运算符完成操作。这并不是说内存管理消失了而是string对象在内部负责维护资源并通过构造、拷贝、赋值和析构等机制管理自己的生命周期。这与前面学过的类和对象不是两套知识string正是一个已经设计好的标准库类。四、string如何描述和修改一串字符下面不按接口表机械罗列而是用一段字符串逐步完成“创建、读取、查找和修改”。1. 构造与拼接std::string firsthello;std::stringsecond( C);firstsecond;first.push_back(!);std::coutfirst\n;预期输出hello C!能够用于string可以联系前面学过的运算符重载来理解对于类类型运算符最终对应这个类型提供的操作而不是编译器天然知道如何拼接所有对象。push_back每次在末尾添加一个字符。如果添加的是一段字符串应使用或append等适合字符串的操作不能把字符和字符串混为一谈。2.size()与capacity()不是一回事std::coutfirst.size()\n;std::coutfirst.capacity()\n;size()表示当前有效字符的数量capacity()表示在下一次需要重新分配存储空间前当前实现能够容纳的字符数量。对于hello C!size()可以根据字符数量确定为 10但capacity()的具体值与标准库实现和对象当前状态有关不能把某次机器上的结果写成所有环境都固定相同。因此不能这样遍历for(std::size_t i0;ifirst.capacity();i){std::coutfirst[i];// 错误capacity不是有效字符个数}正确边界应来自size()for(std::size_t i0;ifirst.size();i){std::coutfirst[i];}这仍然是数组下标边界问题只是数组换成了类对象。3.operator[]与at()的差别std::coutfirst[0]\n;std::coutfirst.at(0)\n;二者都可以访问指定位置的字符但越界处理不同operator[]不提供与at()相同的越界异常检查使用者必须保证下标合法at()越界时会抛出std::out_of_range。初学时不要把这简化成“at()永远更好”。需要明确检查时它更直接能够通过程序逻辑保证下标合法时operator[]也很常见。无论使用哪一个最根本的工作仍是判断下标是否处于有效范围。4. 查找时为什么要检查nposstd::size_t posfirst.find(C);if(pos!std::string::npos){std::cout找到的位置pos\n;}find找到目标时返回起始位置未找到时返回std::string::npos。不要直接把返回值存进int后再与-1形成习惯性判断接口返回的是size_type使用std::size_t或auto并与std::string::npos比较更清晰。autoposfirst.find(Java);if(posstd::string::npos){std::cout没有找到\n;}5. 截取、删除和插入其实都依赖区间std::string texthello C!;std::string parttext.substr(6,3);std::coutpart\n;substr(6, 3)表示从下标 6 开始取 3 个字符所以得到C再看删除和插入text.erase(5,4);// 从下标5开始删除4个字符空格、C、、text.insert(5, STL);std::couttext\n;预期输出hello STL!学习这些接口时与其分别死记参数不如每次都画出下标字符 h e l l o C ! 下标 0 1 2 3 4 5 6 7 8 9erase(5, 4)从位置 5 开始连续删除 4 个字符保留下标 0 到 4 的hello和原下标 9 的!。五、string也能进入STL的算法区间string保存的是连续字符序列也提供begin()和end()。因此标准算法可以通过迭代器处理它的字符区间#includealgorithm#includestringstd::string number12345;std::reverse(number.begin(),number.end());std::coutnumber\n;预期输出54321这段代码把前面的三个部分连起来了string负责保存字符begin()和end()给出[begin, end)区间reverse对区间执行反转。算法并不需要我们手动传入字符个数也不必知道string内部如何扩容。它只需要通过迭代器访问区间中的元素。如果只反转中间三个字符也可以缩小区间std::string number12345;std::reverse(number.begin()1,number.begin()4);std::coutnumber\n;参与反转的是下标[1, 4)也就是2、3、4所以预期结果为14325这里最重要的仍然不是背代码而是能根据左闭右开区间判断哪些元素参与操作。六、string替我们管理内存但仍有边界1.size()变化时底层存储可能发生什么当不断向字符串末尾添加字符时现有容量可能不够。string的实现通常需要取得更大的存储空间把原字符转移或复制过去再释放旧存储。std::string text;for(inti0;i100;i){text.push_back(a);}我们不需要像手写动态字符数组那样亲自完成扩容但这不代表扩容没有成本。因此size()表示当前有多少有效字符capacity()与当前可用存储能力有关容量如何增长属于实现策略不应假定每次固定翻倍扩容可能使原来指向内部字符的指针、引用或迭代器失效具体应根据相应操作的规则判断。最后一点先建立风险意识即可完整的迭代器失效规则可以放到后续容器学习中系统展开。2.reserve()只是预留容量不是增加字符std::string text;text.reserve(100);std::couttext.size()\n;reserve(100)用于请求至少能够容纳一定数量字符的容量但不会凭空创建 100 个有效字符。因此text.size()仍然是 0。如果提前知道会追加大量字符合理使用reserve可以减少反复扩容的可能但具体容量值和分配策略仍由实现决定。3. 与C接口交互时使用c_str()有些旧接口接收const char*而我们手中是std::stringstd::string filenamedata.txt;constchar*ptrfilename.c_str();c_str()提供以空字符结尾的字符序列视图便于与需要 C 风格字符串的接口交互。但要注意返回的指针指向string对象管理的内部数据不应通过这个指针修改内部字符当字符串执行某些修改操作后之前取得的指针可能不再有效指针的有效性依赖原string对象仍然存在。这与前面动态内存部分的思想相同看到一个指针时不只看它保存了什么地址还要追问那块数据由谁拥有、能用到什么时候。七、几个容易混淆的写法1. 字符与字符串字面量混淆std::string textabc;text.push_back(d);// 一个字符textef;// 一段字符串单引号表示字符双引号表示字符串字面量。push_back接收一个字符不能把d直接当成d。2. 把capacity()当作遍历边界for(std::size_t i0;itext.capacity();i)// 错误思路容量可能大于有效字符数。遍历有效字符应使用size()或者直接使用范围forfor(charch:text){std::coutch ;}3. 不检查find结果就继续使用autopostext.find(xyz);text.erase(pos,3);// 如果没找到pos就是npos正确做法是先判断autopostext.find(xyz);if(pos!std::string::npos){text.erase(pos,3);}4. 保存内部指针后继续修改stringstd::string texthello;constchar*ptrtext.c_str();text world;// 此时不要继续假定ptr仍然指向有效的原内部存储字符串修改可能改变内部存储位置。需要指针时应在修改完成后重新获取或者重新设计代码以避免长期保存内部地址。5. 混合类型导致模板推导失败MyMin(3,4.5);// 一个是int一个是double原模板的两个参数都要求推导为同一个T但这里分别得到int和double推导会发生冲突。可以明确统一类型MyMin(3.0,4.5);// 或MyMindouble(3,4.5);这也说明模板不是“把所有类型问题自动抹平”编译器仍然需要得到明确、合法的实例。八、完整示例把模板、string和STL连起来#includealgorithm#includeiostream#includestring#includevector// T必须支持operatortemplateclassTTMyMin(constTleft,constTright){returnleftright?left:right;}voidPrint(conststd::stringtext){std::couttext text\n;std::coutsize text.size()\n;std::coutempty std::boolalphatext.empty()\n;}intmain(){std::string firsthello;std::stringsecond( C);firstsecond;std::coutfirst\n;first.push_back(!);std::coutfirst\n;std::coutfind C: first.find(C)\n;std::coutsubstr: first.substr(6,3)\n;first.erase(5,4);first.insert(5, STL);std::coutfirst\n;std::string number12345;std::reverse(number.begin(),number.end());std::coutnumber\n;std::coutMyMin(3,5)\n;std::coutMyMin(std::string(apple),std::string(banana))\n;std::vectorintvalues{4,1,3,2};std::sort(values.begin(),values.end());for(intvalue:values){std::coutvalue ;}std::cout\n;Print();return0;}按代码规则推导预期输出如下hello C hello C! find C: 6 substr: C hello STL! 54321 3 apple 1 2 3 4 text size 0 empty true说明本文撰写时当前本机环境未找到g所以上述内容是依据 C17 代码行为给出的预期输出不冒充本机实测结果。发布前应在 Visual Studio、Clang 或安装好 GCC 的环境中再次编译并将真实控制台结果作为验证截图。九、沿着学习时间线重新看这三个知识点今天学到的内容并不是突然出现的一套新语法而是对前面知识的继续抽象。从重载到模板函数重载可以让同名函数处理不同参数类型但每一种类型通常仍要有对应实现。模板则在“算法逻辑相同”时把类型差异抽成参数。模板内部能使用哪些操作又取决于类型是否支持相应运算符或成员函数。从类和动态内存到string手写一个资源管理类需要考虑构造、拷贝、赋值、析构和扩容。string已经把这些工作封装起来让使用者主要表达“我要拼接、查找还是删除字符”。但资源依然存在所以内部指针有效期、扩容成本和对象生命周期仍然值得注意。从数组区间到STL算法以前写循环和排序时我们一直在处理下标与区间。STL 没有抛弃这种思想而是用迭代器把区间表达得更通用。[begin, end)仍然是左闭右开只是边界从整数下标变成了迭代器。十、这一阶段真正需要形成的判断学完模板、STL 简介和string不必一次背下所有成员函数。更重要的是形成下面几条判断代码只是类型不同、逻辑相同时考虑能否使用模板减少重复。模板能否实例化要看模板内部所需操作是否被实参类型支持。遇到 STL 代码时先分清谁保存数据、谁表示区间、谁执行算法。使用string时size()表示有效字符数capacity()不能当作遍历边界。看到find就考虑npos看到下标就检查有效范围。从string取得内部指针后要继续追问对象生命周期和后续修改是否会让指针失效。学习接口不能脱离前面的数组、类、运算符重载和动态内存标准库只是把这些机制组织成了更稳定、更通用的工具。下一阶段继续学习string时可以进一步实现一个简化版string亲自处理容量、扩容、深拷贝和迭代器。到那时今天看到的接口就不再只是“会调用”而会与前面学过的类和对象真正连成一条线。