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