发布于2026-07-08 阅读(0)
扫一扫,手机访问
private:
char* _str; // 指向堆上的字符数组
size_t _size; // 当前字符串长度(不含 '\0')
size_t _capacity; // 当前已分配容量(不含 '\0')
static const size_t npos = -1;
`_str` 是真正存数据的老巢,`_size` 记录当前有多少个兵,`_capacity` 则标明了已经申请了多大的地盘。这三者之间始终要维持一个铁律:
_size <= _capacity 实际分配字节数 = _capacity + 1(多一个给 '\0')`npos` 定义成一个静态常量,值就是 `(size_t)-1`,说白了就是 `size_t` 类型的最大值(在 64 位系统上是 `0xFFFFFFFFFFFFFFFF`)。它代表的意思就是“找不到”或“到底了”,跟标准库的用法一致。 再聊聊头文件和源文件的分离问题。我把函数声明摆在 `String.h`,定义放在 `String.cpp`,这么做是为了躲开重复定义。要是把函数体直接写在 `.h` 里,每个 `#include` 这个头文件的翻译单元都会生成一份定义,链接时就会报重定义错误。声明放 `.h`、定义放 `.cpp`,这是 C++ 工程的“基本法”。 整个类我放进了 `namespace Jianyi` 里头。命名空间的作用很明确——防冲突。我们自己写的 `string` 和标准库的 `std::string` 重名了,靠命名空间隔开,大家井水不犯河水。
string(const char* str = "")
{
assert(str);
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
有几个细节值得拎出来讲一讲。
第一,参数的默认值设为 `""`(空字符串),不是 `nullptr`。这么一来,`string s;` 和 `string s("");` 都能正常工作,同时 `assert(str)` 还能防住真有人传个 `null` 进来。
第二,`new char[_capacity + 1]` 多申请了一个字节。`_capacity` 本身不包含 `'\0'`,但数组必须给它留个位置,`strcpy` 依赖这个终止符来“知道完事了”。这个“差一”的约定要在整个实现中保持一致,这是个很容易出纰漏的地方。
`strcpy` 会把 `str` 连着终止符一口气全复制过去,所以不需要再单独写一句 `_str[_size] = '\0'`。
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
`delete[]` 必须配对 `new[]`,这是没得商量的死规矩。要是贪方便用 `delete`(不带方括号),行为就是未定义的——对于内置类型的数组,很多时候能侥幸跑过去,但绝不能依赖这种运气。
析构完后把 `_str` 设成 `nullptr`,这是一种防御性的习惯。如果析构后还有乱七八糟的代码试图再碰这个指针,至少它会立刻崩溃,而不是静悄悄地读到一堆垃圾数据,让 bug 更难定位。
`delete[]` 到底在干嘛?它先挨个调用数组里每个元素的析构函数(对 `char` 这种基础类型来说没什么影响,但对对象数组就至关重要了),然后再释放整块内存。这就是为什么 `new[]` 和 `delete[]` 必须成对出现——分配时 `new[]` 会在内存块头部记下元素个数,`delete[]` 就靠这个数量来逐个调用析构函数。如果用 `delete` 去释放,这个元数据就没人读了,析构函数自然也不会一个一个被调用。
s1._str ──┐
▼
[ h e l l o \0 ]
▲
s2._str ──┘
这种结构藏着一个大杀器:当 s1 和 s2 各自析构时,同一块内存会被 `delete[]` 两次。这就是 **double free**。double free 是未定义行为,轻则让程序崩掉,重则搅乱内存结构,搞出安全漏洞。
如果不手动实现拷贝构造函数和赋值运算符,编译器默认生成的版本就是浅拷贝。所以,凡是持有了堆内存的类,**必须**自己动手实现“Big Three”:析构函数、拷贝构造函数、赋值运算符。
**深拷贝**:给新对象单独申请一块内存,把数据完整地复制一份。
s1._str ──► [ h e l l o \0 ] s2._str ──► [ h e l l o \0 ] (独立的一份)两者彼此独立,各自析构,互不干扰。代价就是每次拷贝都得来一次 `new`,但是换来了安全,值了。
// 被注释掉的版本
string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
这个版本逻辑上一点毛病没有,就是最朴素的深拷贝:给自己申请内存,把对方的数据搬过来。写法简单直接,功能也正确。
string(const string& s)
{
string tmp(s._str); // 用 char* 构造函数创建临时对象
swap(tmp); // 把 *this 和 tmp 互换
} // tmp 析构,顺手释放 *this 原本(未初始化)的资源
这里用的是 **copy-and-swap 惯用法**。要想理解这个版本,关键是搞清楚 `swap` 到底交换了谁。
`swap(tmp)` 等价于 `this->swap(tmp)`,它交换的是 `*this` 和 `tmp`。执行完之后,`*this` 拿到了 `tmp` 创建的那块内存(也就是从 `s._str` 深拷贝来的数据),而 `tmp` 则拿走了 `*this` 原来的内容。因为 `tmp` 是个局部变量,函数一结束它就会自动析构,顺带就把 `*this` 原来的那块资源给释放了。
// 错误写法!!
string(const string& s)
{
string tmp(s._str);
swap(s); // swap 的对象是 s,不是 tmp!
}
这里出了两个问题:第一,`swap(s)` 试图交换 `*this` 和参数 `s`,`tmp` 完全成了旁观者;第二,`s` 是 `const string&`,不能被修改,而 swap 需要双方都能改,这个设计本身就自相矛盾了。
当时还把 swap 的签名也写错了:
void swap(const string& s) // 错误:const 参数无法被修改正确的签名应该是:
void swap(string& s) // 去掉 constswap 必须能修改双方,参数加 `const` 在逻辑上根本说不通。
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
这里内部调用的是 `std::swap`,交换的是指针和两个整数。不管字符串有多长,都只涉及三次赋值操作,时间复杂度永远是 **O(1)**。
STL 为什么如此执着于 O(1) 的 swap?因为 swap 在标准库算法里被大量调用(排序、partition、各种容器操作),如果 swap 是 O(n) 的,那些算法的复杂度就会直线恶化。
这里有一个关于模板的细节:`std::swap` 的通用实现是三次赋值(tmp = a, a = b, b = tmp),对于 `string` 来说就意味着两次深拷贝,是 O(n) 的。但是标准库对 `std::string` 有一个特化版本,会去调用成员函数 `swap`,也就是我们刚实现的那个版本,把复杂度降回 O(1)。
关于普通函数和函数模板的优先级:当存在完全匹配的普通函数时,编译器会优先选择它,而不是去实例化模板。`std::string` 的 swap 特化本质上就是利用了这一点。我们自己的 `Jianyi::string` 没有做这个特化,如果有人写 `std::swap(a, b)` 来交换我们自己的对象,它就会走通用版本(O(n));只有写 `a.swap(b)` 才能调用到我们的成员函数(O(1))。
// 被注释掉的版本
string& operator=(const string& s)
{
delete[] _str;
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
return *this;
}
逻辑清晰:先释放自己的旧资源,再根据对方的需求重新分配。但是,这里面藏着一个致命缺陷——**自赋值问题**。
如果你写了 `s = s`,进入函数后第一件事就是 `delete[] _str`,那 `s._str` 指向的内存就立刻被释放了。接下来再执行 `strcpy(_str, s._str)`,访问的就是一个悬空指针,行为未定义。
// 错误写法 if (_str != s._str)这段代码想拦住自赋值,但条件是判断指针值,而不是对象地址。两个不同的对象,在浅拷贝场景下完全可能 `_str` 相同(指向同一块内存)。正确的自赋值判断,应该比较对象本身的地址:
if (this != &s)
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s._str); // 深拷贝到临时对象
swap(tmp); // 交换资源,tmp 拿走旧资源
}
return *this;
}
这个版本的精妙之处在于:资源的释放完全交给了 `tmp` 的析构函数来干,不需要手动 `delete`。整个过程异常安全——如果 `new` 失败了抛出异常,`tmp` 根本就没构造出来,`*this` 的状态也就没有被破坏掉。
void string::reserve(size_t n)
{
if (n >= _capacity)
{
char* temp = new char[n + 1];
strcpy(temp, _str);
delete[] _str;
_str = temp;
_capacity = n;
}
}
`reserve` 只肯扩容,绝不缩容(`if (n >= _capacity)` 保证了这一点)。这跟 `std::string::reserve` 的语义一致——你可以申请更大的空间,但不能指望用 `reserve` 来强行瘦身。
操作的先后顺序非常重要:先 `new`,再 `strcpy`,再 `delete[]` 旧指针,最后更新 `_str`。这个顺序不能乱。如果先 `delete[]` 再 `new`,一旦 `new` 失败了,`_str` 就成了悬空指针,整个对象就处于一个损坏状态。
这里其实还有一个潜在优化点:`strcpy` 只适合复制以 `'\0'` 结尾的字符串,换成 `memcpy` 在某些实现里会更高效(省去了逐字符检查终止符的开销),但在这个实现里,用 `strcpy` 已经足够清晰了。
void string::PushBack(char ch)
{
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : 2 * _capacity);
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
扩容策略是翻倍(`2 * _capacity`),容量为 0 时就给个 4。翻倍策略能保证均摊 O(1) 的追加代价——虽然偶尔会触发一次 O(n) 的扩容,但把成本平摊到每次追加操作上,依然是常数时间。
string& string::operator+=(char ch)
{
PushBack(ch);
return *this;
}
`operator+=` 直接复用 `PushBack`,代码不重复。
void string::append(const char* str)
{
assert(str);
size_t len = strlen(str);
if (_size + len > _capacity)
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
size_t n = 0;
while (n < len)
{
_str[n + _size] = str[n];
++n;
}
_size += len;
_str[_size] = '\0';
}
扩容判断这里有个小策略:优先尝试翻倍,如果翻倍后还不够用,就直接扩到需求的尺寸(`_size + len`)。这个逻辑在 `insert` 里也会出现,是个经典的“至少满足需求,尽量翻倍”的套路。
追加数据之后,必须手动把 `_str[_size]` 设为 `'\0'`,因为这里用的是逐字符赋值,不像 `strcpy` 会帮你自动带上终止符。
`operator+=` 还有一个接受 `const char*` 的版本在头文件里声明了,但实现体我没有写(被注释掉了)。实际它会去调用 `append`:
string& string::operator+=(const char* str)
{
append(str);
return *this;
}
void string::insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : 2 * _capacity);
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
_size += 1;
}
后移数据从末尾开始,`end` 从 `_size + 1` 向 `pos` 移动,每次把 `end - 1` 位置的字符搬到 `end` 的位置。
**核心踩坑**:我一开始写的是 `while (end >= pos)`。`end` 和 `pos` 都是 `size_t`(无符号整数)。当 `pos = 0`、`end` 减到 0 之后再执行 `--end`,结果不是 -1,而是 `size_t` 的最大值(约 1.8 × 10^19),条件永远为真,直接就死循环了。
改成 `while (end > pos)`,当 `end == pos` 时循环自然停止,从根本上避免了无符号数下溢。这是 C++ 里用 `size_t` 做循环变量时最容易掉进去的陷阱,C 语言的隐式类型转换会让这类 bug 变得极其隐蔽。
代码注释里也提到了:`while(end <= (int)pos)` 是另一种解法——强转成有符号类型,但不如直接改循环条件来得干净利落。
void string::insert(size_t pos, const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
for (size_t i = 0; i < len; ++i)
_str[pos + i] = str[i];
_size += len;
}
思路和单字符版本类似,但每次移动 `len` 个位置。先把 `pos` 之后的数据整体后移 `len` 格,再把 `str` 的内容填入 `[pos, pos + len)` 这个区间。
注释里还留着一个被废弃的版本:
// 废弃版本
for (size_t i = _size; i >= pos; --i)
{
_str[i + len] = _str[i];
}
又是同样的 `size_t` 无符号下溢问题——`i` 减到 0 之后再减,就会绕回最大值,陷入死循环。
void string::erase(size_t pos, size_t len)
{
assert(pos <= _size);
if (len == npos || len > _size - pos)
{
_str[pos] = '\0';
_size = pos;
}
else
{
for (size_t i = pos + len; i <= _size; ++i)
_str[i - len] = _str[i];
_size -= len;
}
}
这里分两种情况:如果 `len` 是 `npos`(表示“删到末尾”),或者删除的长度已经超出了剩余部分,那就直接截断——在 `pos` 处写个 `'\0'`,然后更新 `_size`,完事,不需要挪动任何数据。
否则,就把 `pos + len` 之后的所有数据往前移动 `len` 格。循环条件 `i <= _size` 包含了 `_size` 自己,这样连终止符 `'\0'` 也会一起被移过来,不需要再单独补一个。
边界条件需要注意:`len > _size - pos` 这个判断,如果 `_size < pos` 会发生无符号数下溢(值绕回变成巨大的数),但 `assert(pos <= _size)` 保证了进函数时 `pos` 一定小于等于 `_size`,所以 `_size - pos` 不会下溢,这里是安全的。
size_t string::find(char ch, size_t pos)
{
assert(pos < _size);
for (size_t i = pos; i < _size; ++i)
if (_str[i] == ch)
return i;
return npos;
}
从 `pos` 位置开始,一个字符一个字符地找,找到就返回下标,找不到就返回 `npos`。
size_t string::find(char* str, size_t pos)
{
assert(pos < _size);
const char* temp = strstr(_str + pos, str);
if (temp == nullptr)
return npos;
else
return temp - _str;
}
这里直接借用了标准库函数 `strstr`。`strstr` 的原理是**滑动匹配**:从主串的每个位置开始,尝试和子串逐字符比较;如果当前位置不匹配,就移动到下一个位置,从头再来。朴素的实现是 O(n × m),标准库的实现一般会有优化(类似 KMP 或 Boyer-Moore 的思路),但接口的语义是一样的。
`strstr` 返回的是一个指针,指向主串中子串开始的位置。用这个指针减去 `_str`(数组的起始地址),就得到了下标。这是指针运算的一个经典用法:两个指向同一数组的指针相减,得到的是它们之间相隔的元素个数(距离)。
string string::substr(size_t pos, size_t len)
{
if (len > _size - pos)
len = _size - pos;
string sub;
sub.reserve(len);
sub._str[0] = '\0';
for (size_t i = 0; i < len; ++i)
sub += _str[pos + i];
return sub;
}
先修正 `len`(确保它不超过剩余长度),然后构造一个空字符串 `sub`,预先 `reserve` 好空间,再逐个字符追加进去。
注释里还有一个优化版本:
// 优化版本(被注释掉) string sub; sub._str = new char[len + 1]; sub._capacity = len; sub._size = len; memcpy(sub._str, _str + pos, len); sub._str[len] = '\0'; return sub;这个版本一次性把内存分配好,用 `memcpy` 批量复制,性能更好,不需要逐字符调用 `operator+=` 而可能触发多次扩容。代价就是代码稍微复杂一些,需要直接操作成员变量(所以只能在类内部写,或者声明为友元)。
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator>(const string& s1, const string& s2) { return strcmp(...) > 0; }
bool operator<=(const string& s1, const string& s2) { return (s1 < s2) || s1 == s2; }
bool operator>=(const string& s1, const string& s2) { return (s1 > s2) || s1 == s2; }
bool operator!=(const string& s1, const string& s2) { return !(s1 == s2); }
整个比较体系的核心就是 ` < ` 和 `==`,其他的运算符全部复用这两个。`strcmp` 返回负数表示“小于”,0 表示“等于”,正数表示“大于”——和返回 bool 的比较运算符语义是完全对应的。
这些运算符都设计为**非成员函数**,写在类的外面。原因是 `==`、`<` 这类运算是对称的二元运算,左右操作数应该被平等对待。如果写成成员函数,左操作数就必须是一个 `string` 对象,这会限制使用的灵活性。
const char* c_str() const
{
return _str;
}
这个函数看起来很简单,但它其实是 string 和 C 风格字符串之间交流的桥梁。很多 C 标准库函数(`printf`、`fopen`、`strlen` 等)只认 `const char*`,不认识 C++ 的 `string` 对象。`c_str()` 提供了一个合法的、以 `'\0'` 结尾的字符指针,让 C++ 的 string 能够融入 C 的大家庭。
返回的是 `const char*` 而不是 `char*`,这是有意为之:禁止外部通过这个指针去修改字符串的内容,保护好对象的内部状态。
ostream& operator<<(ostream& out, string& s)
{
for (auto ch : s)
out << ch;
return out;
}
逐字符输出。这里用了范围 for 循环,它依赖 `string` 提供了 `begin()` 和 `end()` 迭代器:
typedef char* iterator;
typedef const char* const_iterator;
iterator begin() { return _str; }
iterator end() { return _str + _size; }
指针本身就是最简单的迭代器,对于字符数组来说完全适用。
// 错误写法 buff[i++] = ch; s += buff; // buff 里有 ch s += ch; // ch 又被单独加了一次 —— 重复!每个字符被写入了两次,一次在 `buff` 里,然后 `buff` 被追加到 `s`,一次又单独 `+= ch`。读进来的字符串会变成双倍长度。 **正确版本**:
istream& operator>>(istream& in, string& s)
{
s.clear();
const int N = 256;
char buff[N];
int i = 0;
char ch = in.get();
while (ch != ' ' && ch != '\n' && ch != EOF)
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
每个字符只走一条路:先进 `buff`,`buff` 满了就倒进 `s`,清空 `buff` 继续。循环结束后,再把剩余不满一 `buff` 的数据也倒进去。
使用缓冲区的好处是:每 255 个字符才触发一次 `operator+=`,大幅减少了潜在的扩容次数,比逐字符 `s += ch` 的效率高得多。
s1._str ──┐
▼
[ h e l l o \0 ] (count = 2)
▲
s2._str ──┘
拷贝的时候不申请新内存,只是把 count 加 1。析构的时候把 count 减 1,只有 count 减到 0 的时候才真正去 `delete`。这能避免频繁的 new/delete 开销,在大量拷贝的场景下性能会很好看。
早期的 `std::string` 实现(像 GCC 的 libstdc++ 在 C++11 之前)确实用过引用计数。但在 C++11 之后,基本就被抛弃了,原因有两个。
第一,**多线程问题**。引用计数本身需要原子操作来保证线程安全,而原子操作本身有不小的开销。在多核环境下,多个线程频繁地增减同一个引用计数,会导致缓存行频繁失效(false sharing),性能反而不如深拷贝。
第二,**写时拷贝(COW)的隐患**。引用计数通常配合写时拷贝使用:只有在真要修改字符串的时候,才去真正复制一份(所谓的“lazy copy”)。但这个机制在多线程下存在微妙的 race condition,想要保证实现正确性,难上加难。
现代 string 普遍采用的是 **SSO(Small String Optimization)**:短字符串(通常是 15 个字符以内)直接存在对象内部的固定缓冲区里,不 new 堆内存,彻底绕开了动态分配的开销。只有超过阈值的较长字符串,才会退化到堆分配。这是目前性能最好且实现最干净的方案。
上一篇:LAMP中如何设置防火墙规则
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8