【C++编程】STL容器(一)--- string简单模拟实现(常用接口实现 | 深浅拷贝讲解)

📅 2026/7/27 8:58:33
【C++编程】STL容器(一)--- string简单模拟实现(常用接口实现 | 深浅拷贝讲解)
目录前言一、string 类的成员变量二、string 类的默认成员函数2.1 默认构造函数2.2 拷贝构造函数2.3 析构函数三、赋值运算符重载3.1 浅赋值的问题3.2 传统写法3.3 现代写法传值 swap3.4 operator 与拷贝构造的区别四、容量与元素访问4.1 size()、c_str() 和 operator[]4.2 reserve 和 resize4.3 迭代器 begin/end五、修改操作5.1 push_back — 在尾部追加一个字符5.2 append — 在尾部追加一个字符串5.3 operator — 复用已有接口实现5.4 insert — 在任意位置插入5.5 erase — 删除操作六、查找与子串6.1 find — 查找字符6.2 find — 查找子串6.3 substr — 截取子串6.4 clear — 清空字符串七、比较运算符重载八、流插入与流提取运算符重载8.1 operator8.2 operator8.3 getline — 读取一整行结语前言上一篇博客【C编程】STL容器一 --- string接口全解析默认成员函数 | 迭代器 | 容量 | 元素访问 | 成员常量 | 修改器 | 字符串操作 | 非成员函数重载getline等-CSDN博客我们基本讲解完了string的所有接口的用法接下来我们来简单实现其中常用的接口当然我们有些逻辑拷贝函数、寻找子串等我们会直接用C语言中可以用的字符串函数。学会其中的逻辑思维其他用法也是照猫画虎。首先为了不与std库里面的string类冲突我们要自己搞个命名空间在里面实现我们的string类// --- string.h --- namespace Marks { class string { }; } // --- test.cpp --- int main() { // 调用自己模拟实现的string类 Marks::string s1; // 调用库里面的string类 std::string s2; }一、string 类的成员变量有了前言里的class string{}空壳子那接下来我们就要想一想实现一个 string 类到底要存哪些数据根据上一篇的内容我们直接看一个使用场景Marks::string s1(hello); std::cout s1.size() std::endl; std::cout s1[0] std::endl; s1.push_back(!); std::cout s1 std::endl;可以知道有三个需求size()要知道有多长、operator[]要能访问任意位置、push_back要能追加字符——追加就意味着空间可能不够需要扩容。那我们对应就梳理出三个成员namespace Marks { class string { private: size_t _size; // 当前有效字符个数 size_t _capacity; // 底层分配的总空间大小 char* _str; // 指向堆区字符数组的指针 }; }这里 _size 是当前有效字符数不含‘\0’_capacity 是实际开辟的空间大小它们之间遵循_size _capacity且_str[_size]始终放‘\0’来保持与 C 字符串兼容方便直接调用strcpy、strstr、strlen等C库函数。然后还有一个比较关键的静态常量 npos —— 表示任何有效下标都不可能达到这个值。因为其定义的类型是无符号整型size_t所以将其赋值为 -1 转成 size_t 就是全 1 的最大值。// 类内声明 { public: const static size_t npos; } // 类外定义 const size_t string::npos -1;到这里我们先简单小结一下string 类的核心是对堆上的动态字符数组的管理 _str 是数据载体 _size 和 _capacity 共同控制边界 npos 弥补下标无法表示“不存在”的缺位。下一步就是要给这些成员写构造函数来初始化了。二、string 类的默认成员函数这里我们先重点讲解默认构造函数、拷贝构造函数、析构函数这三个和内存管理直接相关的。剩下比较重要的运算符重载、赋值运算符这些我们后面小结会详细讲解。2.1 默认构造函数成员变量有了那第一个问题就是——当一个 string 对象刚创建出来时它的_str、_size、_capacity应该是什么状态最直接的想法是给个空字符串但要小心如果什么都不处理_str就是个野指针一调用delete[]就崩了。我们可以来逐步推演一下构造函数的写法。先看这个最简单的思路——给构造函数加个默认参数string(const char* str ) { _size std::strlen(str); _capacity _size; _str new char[_capacity 1]; std::memcpy(_str, str, _size 1); }这个版本里我们隐含着完成了什么事呢当我们这样调用时Marks::string s1; // 等价于 string() Marks::string s2(hello); // 正常传参你会发现string()的调用没有遇到任何问题——因为是一个合法的空字符串strlen()返回0new char[1]只开了一个\0的空间memcpy只拷贝了结尾的 \0一切都合理。但是如果像下面两种写法写的话会出现什么问题呢string(const char* str \0); // 为什么这样写就崩了 string(const char* str nullptr); // 这样更是给自己找麻烦这两种都是经典的反面教材。\0是一个char类型的空字符不是const char*。虽然有些老编译器不做严格检查放任隐式类型转换但它实际传进去的是地址0——跟nullptr效果一样随后strlen(nullptr)就是未定义行为轻则返回随机值重则直接崩溃。所以可以看到我们就用了一个const char* str 的默认构造函数即能初始化空对象又能接收任意 C 风格字符串一举两得。那它的内部逻辑是什么呢依次走三步先算出传进来的字符串有多长然后把长度的计算结果同时赋值给 _size 和 _capacity初始状态容量等于大小最后在堆上开辟 _capacity 1 字节的空间多出来的 1 是为了放 \0用 memcpy 把源字符串拷贝进去包括结尾的 \0。为什么用memcpy而不是strcpy这里其实都可以。memcpy更直观地体现了按字节精确拷贝的思路并且在后面我们实现operator等拷贝行为时memcpy的一致性更好。2.2 拷贝构造函数我们上面写的构造函数能处理空构造和传参构造了那你有没有想过这种情况Marks::string s1(hello); Marks::string s2(s1); // 用一个 string 对象初始化另一个这时候调用的是什么如果我们的类里没有写拷贝构造函数编译器会自动生成一个——而编译器生成的拷贝构造走的是浅拷贝就是把s1._str的指针值原样复制给s2._str。问题就来了我们看下面的执行过程Marks::string s1(hello); { Marks::string s2(s1); // s2._str s1._str指向同一块堆空间 } // s2 离开作用域析构释放 _str 指向的堆空间 std::cout s1.c_str(); // BOOM! s1._str 已经是野指针了这个就是典型需要深拷贝解决的场景自己实现拷贝构造函数每次拷贝时单独在堆上开辟新空间。string(const string s) { _str new char[s._capacity 1]; std::memcpy(_str, s._str, s._size 1); _size s._size; _capacity s._capacity; }这里注意顺序——先开辟新空间再把原对象的 _size、_capacity 拷贝过来。1 和之前一样为了预留 \0 的位置。2.3 析构函数有 new[] 就得有 delete[] 这是C的铁律。我们的析构函数就是做清理工作~string() { delete[] _str; _str nullptr; _size _capacity 0; }注意这里 delete[] 完之后习惯上把指针置空、把 _size 和 _capacity 归零——防止后续误用。那现在我们就该深入看一下深浅拷贝了。简单总结来说就是编译器默认生成的拷贝构造函数走的是浅拷贝——直接把指针值拷贝过去不复制指针指向的数据。导致两个指针指向同一块区域所以当两个对象分别析构时同一块堆内存会被释放两次程序直接崩溃。而我们自己实现的就是深拷贝——每个对象都有自己独立的一份堆空间互不影响。其实我们在前面讲默认成员函数的时候说过如果一个类涉及动态内存管理那么它必须自己实现拷贝构造函数和赋值运算符。这里的 string 就是最好的例子。三、赋值运算符重载拷贝构造处理的是“用一个 string 对象初始化另一个”的场景但如果两个对象都已经存在了要用 把一个对象赋给另一个呢Marks::string s1(hello); Marks::string s2(world); s1 s2; // 赋完值 s1 应该变成 world3.1 浅赋值的问题和拷贝构造的问题一模一样——编译器默认生成的operator是浅拷贝直接把s2._str指针拷给s1._str。先不说俩对象析构时会重复 delete 关 s1 原来指向的那块堆空间就没法回收了——经典内存泄漏。3.2 传统写法传统写法就是自己从头实现深拷贝先开辟新空间从原对象拷贝数据再释放自己原来的旧空间。string operator(const string s) { if (this ! s) { char* tmp new char[s._capacity 1]; std::memcpy(tmp, s._str, s._size 1); delete[] _str; _str tmp; _size s._size; _capacity s._capacity; } return *this; }这里有三个细节值得注意if(this ! s)—— 防止自赋值。如果 s1 s1你不处理就会把自己 delete 掉再去拷贝已释放的内存。先new再delete—— 顺序很重要。如果先delete[] _str再new万一new失败抛异常对象就毁了也回不去了。先用new拿到新空间确认成功再释放旧的这叫“先拿后放”保证异常安全。return *this返回值传引用—— 返回引用是为了支持链式赋值比如 s1 s2 s3。3.3 现代写法传值 swap传统写法功能上没问题但代码啰嗦——要自赋值判断、要 new/delete 换指针。我们用一个更优雅的方式让编译器帮我们干活。思路是这样的既然拷贝构造我们已经写好了那operator就可以玩个“偷梁换柱”——按值传参时编译器自动调用拷贝构造创建了一个临时副本然后我们只需要把自己的和这个副本 swap 一下就行。临时副本离开作用域的时候自动调用析构帮我们清理掉原来的旧资源。听起来有点绕我们直接看代码就清楚了// 需要先有一个 swap 成员函数 void swap(string s) { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); } // 现代写法传值时自动走拷贝构造 string operator(string tmp) { swap(tmp); // tmp 拿到旧资源析构时自动释放 return *this; }这个写法的几个好处不需要自赋值判断传值就自动生成了一份副本即使是自赋值也不会有问题。不需要手动 delete旧资源跟着 tmp 离开作用域自动析构彻底杜绝了 new/delete 写错的风险。异常安全拷贝在传参时完成如果拷崩了函数根本不会进到 swap原对象完好无损。这个技巧我们其实在类和对象篇讲赋值运算符时就提到过这里回到模拟实现 string 来看看它实际的威力。3.4 operator 与拷贝构造的区别这里也是之前在类和对象篇中提到过的区别这里再次说明一下拷贝构造operator何时调用用一个对象初始化另一个对象两个已存在对象之间的赋值旧资源不存在不需要处理存在需要释放本质创造并初始化修改已有对象的状态简单来说拷贝构造是“造一个新东西”operator 是“把旧东西改掉”。四、容量与元素访问默认成员函数搞定之后string 对象已经能安全地创建、拷贝、赋值、销毁了。接下来要让它能“用”起来——最基本的就是能知道字符串多长、能访问某个位置的字符、能在容量不够时自动扩容。4.1 size()、c_str() 和 operator[]这三个是 string 最基础的外部接口实现起来也最简单size_t size() const { return _size; } const char* c_str() const { return _str; } char operator[](size_t pos) { assert(pos _size); return _str[pos]; } const char operator[](size_t pos) const { assert(pos _size); return _str[pos]; }size()直接返回_sizec_str()直接返回_str指针——注意这两个都加了const因为只是读取不修改。operator[]需要区分两个版本普通版本返回char引用支持修改比如s1[0] Hconst 版本返回const char只读。两个版本都用assert做越界检查——因为标准库 string 的operator[]是不检查的追求性能但我们在模拟实现中加个 assert 方便调试出问题不至于莫名其妙地崩。4.2 reserve 和 resize接下来两个容易搞混的接口reserve和resize。它们有什么区别呢举个生活化的例子你租房子reserve是你提前和房东说“我后面可能有朋友来住给我换个更大的房子”但搬家后你原来有多少家具还是多少家具——只是空间变大了。而resize是你跟房东说“我现在就要这么多房间用”不够的话房东帮你扩容同时空的房间自动摆好默认家具填上默认字符。于是 reserve 和 resize 对应到成员变量上的操作就清楚了_size_capacity实际字符reserve(n)不变变大到 n不变resize(n)变成 n不够则变大多余位置填默认字符来看 reserve 的实现void reserve(size_t n) { if (n _capacity) { char* tmp new char[n 1]; std::memcpy(tmp, _str, _size 1); delete[] _str; _str tmp; _capacity n; } }逻辑很简单只有当请求空间 n 比当前_capacity大是才真正操作。开辟新空间 - 把旧数据拷过去 - 释放旧空间 - 更新_capacity。这里 1 还是老规矩——给\0留位置。再来看 resizevoid resize(size_t n, char ch \0) { if (n _size) { _size n; _str[_size] \0; } else { reserve(n); for (size_t i _size; i n; i) { _str[i] ch; } _size n; _str[_size] \0; } }这里分两种情况n _size要缩容——直接把_size砍到 n在新结尾放\0就好了。不需要去管_capacity缩容是“不用那么多空间”而不是“空间变小了”除非你想主动释放内存但这涉及 shrink_to_fit 的思想标准实现也不强制。n _size要扩容——先调用reserve(n)确保空间够然后把新多出来的位置从_size 到 n-1都填上ch默认填\0最后更新_size和字符串结束符。4.3 迭代器 begin/end前面我们讲了 size()、c_str()、operator[]通过这些接口确实能拿到数据。那如果我们想遍历整个字符串正常来写就是下标循环Marks::string s1(hello); for (size_t i 0; i s1.size(); i) { std::cout s1[i] ; } // 输出h e l l o这当然没问题但 C 给我们提供了更简洁的方式——范围 for 循环for (auto ch : s1) { std::cout ch ; }那范围 for 为什么能工作呢它的底层其实就是去找对象的 begin() 和 end() 函数——而且是精确匹配这两个名字写成 Begin() 或 End() 等其他样式的都不行。因为范围 for 展开后的代码大概长这样// 范围 for 底层展开示意 for (auto it s1.begin(); it ! s1.end(); it) { auto ch *it; // 循环体 }这里的 auto it 会自动推导为 Marks::string::iterator 类型——这也是 auto 的经典应用场景类型太长了不想手写交给编译器推断。那现在要实现 begin() 和 end() 让范围 for 能跑起来对于 string 来说非常直接——因为底层数据在内存中是连续存储的指针天然就是一个迭代器typedef char* iterator; typedef const char* const_iterator; iterator begin() { return _str; // 指向第一个字符 } iterator end() { return _str _size; // 指向最后一个字符的下一个位置 } const_iterator begin() const { return _str; } const_iterator end() const { return _str _size; }注意C标准规定的 end() 返回的是“最后一个元素的下一个位置”哨兵不是最后一个元素本身。所以这里_str _size 指向 \0 的位置刚好符合左闭右开[begin, end)的迭代器区间规范。到这里 string 的迭代器就实现了。你会发现它和标准库 string 完全一致——标准库中 string 类的迭代器本质上也是原生指针。正因如此我们后面实现operator和operator时才能直接用范围 for 来遍历。小总结一下关于 auto 的回顾auto 是 C11 引入的类型推导关键字编译时根据初始化表达式自动推断变量的实际类型。像 auto it s1.begin() 这里 it 会被推导为 char*。auto 最擅长处理的就是像迭代器这种类型名又长又不好拼的情况——而且后面还会频繁用到。五、修改操作前面我们搭好了构造、拷贝、赋值、容量管理string 对象已经能安全地“存在”了。但要真正模拟标准库 string还得让它支持增删改操作——也就是 push_back、append、、insert、erase 这几个高频接口。5.1 push_back — 在尾部追加一个字符先从最简单的开始在字符串末尾追加一个字符。void push_back(char ch) { if (_size _capacity) { // 扩容初始给 4后续每次翻倍 reserve(_capacity 0 ? 4 : _capacity * 2); } _str[_size] ch; _size; _str[_size] \0; }三步走先检查容量够不够不够就扩容- 把字符放在 _str[_size] 位置 - 更新 _size 并补 \0。扩容策略为什么是 2 倍如果每次只扩 1 个空间那连续 push_back 的时候就会反复调用 reserve频繁 new memcpy delete时间复杂度直接从均摊 O(1) 退化到 O(n²)。2 倍扩容是经典的时间换空间方案同理也可以 3 倍、4 倍等看自己习惯以及实际使用的需求来决定。5.2 append — 在尾部追加一个字符串追加单个字符有了那要追加一整个字符串呢void append(const char* str) { size_t len std::strlen(str); if (_size len _capacity) { reserve(_size len); // 至少扩容到能装下 } std::memcpy(_str _size, str, len 1); // 1 连 \0 一起拷 _size len; }逻辑和push_back几乎一样差别在于——追加的长度是strlen(str)扩容目标变成了_size len保证一定能装下memcpy从 _str _size 这个位置开始写入。5.3 operator — 复用已有接口实现push_back 和 append 写好了那 operator 就没什么新东西——直接复用就行string operator(char ch) { push_back(ch); return *this; } string operator(const char* str) { append(str); return *this; }返回string引用和前面operator一样的道理——支持链式调用s1 ! world。5.4 insert — 在任意位置插入如果说 push_back 和 append 是“尾部操作”的话insert 就是真正考验数据挪动能力的“任意位置操作”。我们看两个重载版本——插入 n 个字符和插入一个字符串。insert(pos, n, ch) — 插入 n 个相同字符void insert(size_t pos, size_t n, char ch) { assert(pos _size); if (_size n _capacity) { reserve(_size n); } // 把 [pos, _size) 的所有字符往后挪 n 个位置 size_t end _size; while (end pos end ! npos) { _str[end n] _str[end]; --end; } // 在空出来的位置上填入 ch for (size_t i 0; i n; i) { _str[pos i] ch; } _size n; }这里最核心的其实是数据挪动那个循环。我们要把从 pos 位置开始的字符全部往后搬 n 个位置为插入的内容腾出空间。注意一个细节——循环条件是 end pos end ! npos。这个 end ! npos 看起来可能有点多余但是关键就在于 pos 是 size_t 无符号类型如果 pos 0 当 end 从 0 减到 -1时-1 对 size_t 来说就是 npos即最大值 2^64 - 1那 end 0 就永远为 true导致死循环。所以必须加 end ! npos 来兜底——这是一个经典的 size_t 下溢陷阱。insert(pos, str) — 插入一个字符串void insert(size_t pos, const char* str) { assert(pos _size); size_t len std::strlen(str); if (_size len _capacity) { reserve(_size len); } size_t end _size; while (end pos end ! npos) { _str[end len] _str[end]; --end; } for (size_t i 0; i len; i) { _str[pos i] str[i]; } _size len; }逻辑完全和上面一样只是 n 换成了 strlen(str)填入内容从重复字符变成字符串的每个字符。5.5 erase — 删除操作有增就有删erase 从 pos 位置开始删除 len 个字符void erase(size_t pos, size_t len npos) { assert(pos _size); if (len npos || pos len _size) { // 删除 [pos, _size) 的全部内容 _size pos; _str[_size] \0; } else { // 把 [poslen, _size) 的内容往前覆盖 size_t end pos len; while (end _size) { _str[pos] _str[end]; } _size - len; } }两种情况如果len npos即不给删除长度默认全删或者 pos len 超出了 _size就直接把尾巴砍到 pos 位置。否则正常把后面的内容往前覆盖然后更新 _size。六、查找与子串增删改都齐了接下来就是“查”——在字符串里找某个字符或子串的位置。标准库 string 提供了 find 和 substr 两个核心接口我们挑重点来实现。6.1 find — 查找字符从指定位置开始找到第一个匹配字符的下标。找不到就返回 npossize_t find(char ch, size_t pos 0) { assert(pos _size); for (size_t i pos; i _size; i) { if (_str[i] ch) { return i; } } return npos; }这个没什么好说的就是朴素的线性遍历。pos 默认给 0 代表从头开始找。6.2 find — 查找子串查找子串比查找单字符稍微复杂一点。但我们不需要自己写字符串匹配算法如果像要了解可以去看看比较实用的 BM 算法—— C标准库早就提供了 strstrsize_t find(const char* str, size_t pos 0) { assert(pos _size); const char* ptr strstr(_str pos, str); if (ptr) { return ptr - _str; // 指针相减得到下标 } else { return npos; } }strstr(haystack, needle)在 haystack 中搜索 needle 第一次出现的位置返回指向该位置的指针。所以我们从 _str pos 开始搜找到后指针相减就能换算成下标——因为 _str 和 ptr 指向同一块连续内存ptr - _str 就是它们之间相隔的字符数。6.3 substr — 截取子串找到位置后通常的下一步就是“把这个位置开始的几个字符取出来”即 substrstring substr(size_t pos 0, size_t len npos) { assert(pos _size); size_t n len; if (len npos || pos len _size) { n _size - pos; // 最多取到字符串末尾 } string tmp; tmp.reserve(n); for (size_t i pos; i pos n; i) { tmp _str[i]; } return tmp; }这里有个细节如果用户不传 len默认 npos或者 pos len 超出了实际长度就取到字符串末尾。reserve(n) 提前开好空间避免多次扩容。用 逐个追加字符效率虽然不是最优但对 substr 这种低频操作来说足够了——如果真的追求极致性能可以改成 memcpy但这里保持代码逻辑清晰优先。6.4 clear — 清空字符串清空比删除简单得多——把 _size 归零首字符变 \0 就行void clear() { _str[0] \0; _size 0; }注意我们不主动缩容 _capacity。为什么因为用户 clear 完后大概率还会继续用这个对象追加内容保留空间下次就不用重新 new 了。真正需要释放底层空间是析构函数的活。七、比较运算符重载接下来就是 string 和 string 之间比较大小的环节比较方式依旧字典序从第一个字符开始对应位置比较 ASCII 值如果全部相等短的字符串更小和之前讲过的日期类一样比较运算符重载只须实现最核心的 和 其他关系都可以直接复用实现。我们直接用标准库 memcmp 来做逐字节比较bool operator(const string s) const { int ret std::memcmp(_str, s._str, _size s._size ? _size : s._size); // ret 0 表示前缀全部相同 → 比长度 // ret 0 表示 this 的前缀更小 → this s return ret 0 ? _size s._size : ret 0; } bool operator(const string s) const { return _size s._size std::memcmp(_str, s._str, _size) 0; }memcmp(a, b, n) 比较前 n 个字节——返回 0 表示相等、负数表示 a 更小、正数表示 a 更大。我们用两段中较短的那个长度作为 n 超出的部分没有意义如果前 n 个字节全相等ret 0就看谁更长如果不等就直接用 ret 的符号来判断。关于 还有一个小细节返回判断时将不需要调用函数的 _size s._size 写在前面这样可以少很多函数调用。以及在后续我们遇到这种类似的判断都应该将以不调用函数优先、判断严格的放在前面这样可以在很多情况减少走后续判断导致的时间开销。接下来其他关系直接复用即可bool operator(const string s) const { return *this s || *this s; } bool operator(const string s) const { return !(*this s); } bool operator(const string s) const { return !(*this s); } bool operator!(const string s) const { return !(*this s); }八、流插入与流提取运算符重载8.1 operatorstd::ostream operator(std::ostream out, const Marks::string s) { for (auto ch : s) { out ch; } return out; }范围 for 自动调用我们在 4.3 种实现的 begin() 和 end()逐个字符输出。operator 必须是全局函数——因为 cout s 的第一个参数是 ostream 而不是 string 写成成员函数声明不了。8.2 operator输入比输出要讲究的多。我们想实现的最终效果是 cin s1——读取一个以空格或换行结束的字符串。那直接用 ch ch 逐个读取肯定是不行的。因为 cin 默认会自动跳过空白字符空格、换行、制表符导致你根本感知不到空格和换行在哪里也就不知道什么时候该停下来。所以我们改用 cin.get()——它是原封不动读入每个字符连空格和换行一起返回。std::istream operator(std::istream in, Marks::string s) { s.clear(); // 1. 用 cin.get() 跳过前导空白空格和换行 char ch in.get(); while (ch || ch \n) { ch in.get(); } // 2. 批量读入正文用缓冲区提高效率 char buff[128]; int i 0; while (ch ! ch ! \n) { buff[i] ch; if (i 127) // 缓冲区满了 { buff[i] \0; s buff; // 一次性追加到 string i 0; // 重置指针 } ch in.get(); } // 3. 缓冲区里剩余的部分也要追加 if (i ! 0) { buff[i] \0; s buff; } return in; }我们来拆解这三步第一步跳过前导空白。很多场景下输入缓冲区前面会残留换行符或者空格比如你前面刚敲了回车所以先 in.get() 把这些吃掉。这里不能用 cin ch 因为它会自动跳过空白——它连你要处理的那个“空格结束标志”都给跳过去了你就没法区分空格是前导还是分隔符。而 in.get() 原封不动该吃就吃该停就停。第二步批量读入正文。这里就是核心了——我们声明了一个 128 字节的 buff 栈缓冲区。那为什么不直接 s ch 呢我们想一下 s ch 背后会发生什么每次都要判断 _size _capacity、不够就调 reserve 扩容、然后 memcpy、更新 _size。如果用户输入了一万个字符那就要判断一万次容器、可能要触发十几次扩容。而有了 buff[128]我们攒够 127 个字符才调用一次 s buff——判断和扩容的次数直接降到原来的 1/128 。这就是典型的空间换时间策略。当然这样做不是为了省那零点几秒——对于 cin 这种交互式 I/O 来说真正的瓶颈在系统调用和用户敲键盘的速度上。但我们是模拟标准库 string 的实现思路如果你的类将来被用在批处理读取几 G 的文本文件呢提前了解这种缓冲区优化的设计思路写代码时心里就有底了。第三步收尾。循环结束意味着遇到了空格或换行——读取停止。但缓冲区里可能还剩最后几个字符还没 进去所以要判断 i ! 0 时再补一次追加。8.3 getline — 读取一整行那 只能读到第一个空格如果我想读一整行——比如“hello world”这种带空格的句子怎么办这就该 getline 出场了std::istream getline(std::istream in, Marks::string s) { s.clear(); char ch in.get(); // getline 不跳过前导空白——包括空格也要读进来 // 只跳过前导换行处理缓冲区残留的回车 char buff[128]; int i 0; while (ch ! \n) // 只以换行为结束不因空格而停 { buff[i] ch; if (i 127) { buff[i] \0; s buff; i 0; } ch in.get(); } if (i ! 0) { buff[i] \0; s buff; } return in; }你会发现 getline 和 operator 整体的框架几乎一模一样差别就两点结束条件不同 遇到空格 或换行 \n 都停getline 只认换行 \n前导处理不同 吃掉了所有前导空格和换行getline 代码只处理了前导换行如果用户紧接着上一次输入回车后直接调用 getline 可能会读到空行前导空格和正文中空格依然保留可以看到这么设计之后operator 和 getline 各司其职——前者用于该单词后者用于读一行完整的文本。这也是跟标准库 std::cin str 和 std::getline(cin, str) 的行为一致的设计。结语至此我们就完成了一个相对完整的 string 类的模拟实现——从三大成员变量出发实现了构造函数、深浅拷贝、赋值运算符、容量管理、增删查改、比较、输入输出等一系列核心接口。但其实实际上 std::string 不只接口数多在不同的编译器会在底层对这些接口实现了很多工程上的优化比如小字符串优化 SSO、COW 等我们这里追求的是理解核心逻辑而非面面俱到。下一篇我们将进入 STL 的重头戏vector 的模拟实现写文不易希望各位给个三连~言已至此感谢各位读者花费时间阅读本人浅学才疏如有文笔拙劣之处还望见谅~