C++ 入门笔记(十二):string 底层初探 —— basic_string 模板、构造与迭代器本质
第十二节课开始碰底层了:string 为什么是类模板、比较运算符为什么要写三组重载、手写 string 的构造怎么实现的、迭代器为什么本质就是指针。最让我醍醐灌顶的一点是 ——手写 string 的迭代器,就是char*指针本身,begin 返回首地址、end 返回 _str + _size,左闭右开。
开篇
学习方式不变:听课 + 疑惑的地方自己敲代码验证 + AI 辅助。这节代码全部在 VS(MSVC)实际编译运行:basic_string 四种特化、手写简化版 string(构造 + 迭代器)、比较运算符全局重载、迭代器接口统一性、常量区 vs 堆。
一、basic_string 为什么是类模板?
**
运行结果:
**
template <class charT, class traits = char_traits<charT>,
class Alloc = allocator<charT>>
class basic_string;
我们平时用的 string 其实是 basic_string<char> 的别名。它还有其他特化:
类型 | 对应 | 用途 |
string | basic_string<char> | 普通 UTF-8/ASCII |
wstring | basic_string<wchar_t> | 旧 Windows 宽字符 |
u16string | basic_string<char16_t> | C++11,UTF-16 |
u32string | basic_string<char32_t> | C++11,UTF-32 |
实测:s1.size() = 5、ws1.size() = 5、u16.size() = 5、u32.size() = 5—— 长度都是 5,和编码无关。
如果直接写一个 string 类,那 wstring、u16string、u32string 就要分别写一份一模一样的代码,重复设计。模板把 "字符类型" 参数化,一份代码生成所有版本。wstring 现在用得少了(都用 u16/u32),但保留它是为了兼容旧 Windows 代码。
二、比较运算符为什么要写三组重载?
**
运行结果:
**
标准库 == 有三组重载:
bool operator==(const string& lhs, const string& rhs);
bool operator==(const char* lhs, const string& rhs); // char* 在左边
bool operator==(const string& lhs, const char* rhs); // char* 在右边
为什么不能写成成员函数?
成员函数版只能写成:bool operator==(const string& rhs) const——左操作数定死就是 string(因为是成员函数,左边是 this)。
那 str == s1(str 是 const char*)就编译不过了,因为左边是内置类型 char*,没法调用成员函数。
所以比较运算符必须写成全局函数,才能同时覆盖三种情况:string vs string、char* vs string、string vs char*。
比较规则和 strcmp 一样:ASCII 码逐位比,不是长度比。"xxx" vs "xxx11",到第三位都是 x,一个到结尾了一个还有,所以 "xxx" < "xxx11",返回 0。
为什么 str = s1 不能写?
str 是 const char*,赋值运算符没有重载把 my_string 赋给 const char*—— 编译器找不到匹配的 operator=,直接报错。
三、手写简化版 string:构造 + 迭代器
**
运行结果:
**
3.1 构造函数
my_string(const char* str = "")
: _size(strlen(str)) {
_capacity = _size;
_str = new char[_size + 1]; // 多开 1 字节存 \0
strcpy(_str, str);
}
- _size = strlen(str):有效字符数,不含 \0
- new char[_size + 1]:多开 1 字节,因为 strcpy 会把 '\0' 也拷过去
- strcpy(_str, str):把常量区的字符串拷到堆上
3.2 迭代器就是 char*
typedef char* iterator;
iterator begin() { return _str; } // 首地址
iterator end() { return _str + _size; } // 左闭右开:有效长度末尾
实测:
- for (auto ch : s) 和 while (it != s.end()) 输出完全一样 ——范围 for 本质就是迭代器遍历,编译器自动替换成 begin/end
- 迭代器能直接当 char* 用:*p = 'H'、*(p+1) = 'E'、++it 全支持,输出 HEllo world
3.3 为什么 end 是 _str + _size 不是 _str + _capacity?
因为遍历的是有效字符,不是预留的容量。如果 end 指向 capacity 末尾,就会把后面空的内存也遍历出来,没有意义。
左闭右开 [begin, end) 和写 for 循环 for (i=0; i<len; i++) 完全呼应 —— 从 0 到 len-1 正好是有效元素。这种设计降低学习成本,知识能迁移。
四、迭代器包一层的意义:接口统一
**
运行结果:
**
实测:string、vector、list 三种完全不同的底层(数组、动态数组、链表),遍历代码写法完全一样:
for (auto ch : s) cout << ch << " ";
for (auto x : v) cout << x << " ";
for (auto x : lt) cout << x << " ";
如果直接拿 char* 遍历 string,那换个 list(链表节点不是连续内存)就完全用不了。包一层迭代器,底层换成什么指针 / 节点都不重要,对外接口 *it、++it、it != end() 永远一样。这就是解耦、降低耦合、统一接口。
顺便说:MSVC 下 std::string::iterator 不是裸 char*(是包装类),是为了加调试检查;但接口用法和 char* 完全一样。手写版直接用 char*,更直观。
五、常量区 vs 堆:构造之后发生了什么
**
运行结果:
**
实测两块地址完全不同:
str 地址(常量区): 00007FF79914F3C0
s[0] 地址(堆): 000000B059AFFA78
- str = "hello world" 存在常量区,只读,改 str[0] 会崩溃
- string s(str) 在堆上new char[strlen+1],可改
- 修改 s[0] = 'H' 只影响堆上那份,常量区那份 "hello world" 没动
为什么 new char[_size+1] 要 +1?strlen ("hello world")=11,但 strcpy 要把 '\0' 也拷过去,实际占 12 字节。少开 1 字节就越界了。
结尾
这节课从 "会用 string" 进到了 "看懂 string 大概怎么实现的":它是 basic_string、比较运算符要三组全局重载、构造是 new char [_size+1]+strcpy、迭代器就是指针。下节课应该继续手写 string 或者开始 vector 了,学完再写下一篇。
学习方式:听课 + 疑惑处自己敲代码验证 + AI 辅助查缺补漏。所有示例均在 VS(MSVC)实际编译运行。