商城首页欢迎来到中国正版软件门户

您的位置: 首页 > 文章列表 > 编程开发 > C++string类原理、踩坑与对象语义详解

C++string类原理、踩坑与对象语义详解

  发布于2026-07-08 阅读(0)

扫一扫,手机访问

一、为什么 string 是一个“资源管理类”

动笔之前,得先搞明白 `string` 到底是个什么角色。 普通的 `int`、`double`,值直接躺在栈上,函数一结束自动烟消云散,不用你操心。但 `string` 不一样——字符数据是住在**堆**上的,对象手里只攥着一个指针。这块堆内存的生死大权,得靠人来手动操控:啥时候分配、啥时候释放、被复制时该怎么处理。 这种“手里攥着资源、还要负责管理资源一辈子”的类,在 C++ 里有个专门的名号——**RAII 类**(Resource Acquisition Is Initialization)。`string` 就是其中最典型的样板之一: - 构造函数:去拿资源(`new` 内存)。 - 析构函数:把资源还回去(`delete` 内存)。 - 拷贝构造 / 赋值:处理资源是复制一份,还是直接过户。 一旦想通了这个本质,后面所有函数的设计逻辑,就都顺理成章了。

二、整体结构设计

我选了三个成员变量来完整描述一个字符串的实时状态:
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` 去释放,这个元数据就没人读了,析构函数自然也不会一个一个被调用。

四、深拷贝 vs 浅拷贝,以及 double free

这是整个 string 实现里最核心、最绕不开的一个概念。 **浅拷贝**:就是把成员变量的值直接复制过去。对于指针 `_str` 来说,就是复制指针的值,导致两个对象指向同一块堆内存。
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;
}
这个版本逻辑上一点毛病没有,就是最朴素的深拷贝:给自己申请内存,把对方的数据搬过来。写法简单直接,功能也正确。

最终方案:copy-and-swap

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)  // 去掉 const
swap 必须能修改双方,参数加 `const` 在逻辑上根本说不通。

六、swap 的实现与意义

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)

最终版本:copy-and-swap

string& operator=(const string& s)
{
    if (this != &s)
    {
        string tmp(s._str);   // 深拷贝到临时对象
        swap(tmp);            // 交换资源,tmp 拿走旧资源
    }
    return *this;
}
这个版本的精妙之处在于:资源的释放完全交给了 `tmp` 的析构函数来干,不需要手动 `delete`。整个过程异常安全——如果 `new` 失败了抛出异常,`tmp` 根本就没构造出来,`*this` 的状态也就没有被破坏掉。

八、reserve:扩容的核心

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` 已经足够清晰了。

九、PushBack 和 append

PushBack:追加单个字符

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`,代码不重复。

append:追加字符串

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;
}

十、insert:插入时的无符号类型陷阱

插入单个字符

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 之后再减,就会绕回最大值,陷入死循环。

十一、erase:删除子串

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` 不会下溢,这里是安全的。

十二、find:字符串查找

查找单个字符

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`(数组的起始地址),就得到了下标。这是指针运算的一个经典用法:两个指向同一数组的指针相减,得到的是它们之间相隔的元素个数(距离)。

十三、substr:截取子串

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` 对象,这会限制使用的灵活性。

十五、c_str() 的意义

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*`,这是有意为之:禁止外部通过这个指针去修改字符串的内容,保护好对象的内部状态。

十六、operator<< 和 operator>>

为什么要实现这两个

标准库的 `cout` 和 `cin` 不认识我们自己实现的 `string` 类。如果不重载 `operator<<` 和 `operator>>`,`cout << s` 就没法编译。实现了这两个函数之后,我们的 string 就能无缝接入 C++ 的流体系。

operator<<

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; }
指针本身就是最简单的迭代器,对于字符数组来说完全适用。

operator>>(隐蔽的 bug)

**错误版本**(注释里):
// 错误写法
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` 的效率高得多。

十七、引用计数:为什么现代 string 不用它

除了深拷贝,还有第三种资源管理策略:**引用计数**。思路是让多个对象共享同一块内存,同时用一个计数器来记录当前有多少个对象指向它。
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 堆内存,彻底绕开了动态分配的开销。只有超过阈值的较长字符串,才会退化到堆分配。这是目前性能最好且实现最干净的方案。

十八、代码里可以优化的地方

回顾整个实现,有几个地方其实还能做得更好: - **`substr` 的逐字符追加**:当前的实现用 `operator+=` 逐个字符追加,每次都有可能触发扩容判断。改用 `memcpy` 一次性复制会更高效(注释里已经有这个优化版本了)。 - **`operator<<` 的参数**:`operator<<` 的第二个参数写的是 `string&`,应该改成 `const string&`,因为输出操作不应该修改对象。 - **`substr` 没有检查 `pos`**:如果 `pos > _size`,`_size - pos` 会下溢(无符号数)。应该加一个 `assert(pos <= _size)` 或者直接返回一个空字符串。 - **`find` 的 `pos` 边界**:`find` 函数里的 `assert(pos < _size)` 会拒绝在空字符串或末尾位置进行查找。实际上,`pos == _size` 时应该允许(直接返回 `npos` 就行),断言条件过于严格了。 - **`erase` 没有更新 `_str[_size]`**:在截断分支(直接写 `'\0'` 的那条),`_str[pos] = '\0'` 是正确的;在移动分支,循环已经把 `_str[_size]` 处的 `'\0'` 一并移过来了。这里逻辑是对的,但值得在代码注释里说清楚,不然容易让人心里不踏实。

总结

写完这个 `string` 类,有几个核心概念算是真正理解到位了。 深拷贝并不只是“多 new 一份内存”,它的本质,是让每个对象对自己的资源全权负责,彼此相互独立。copy-and-swap 这个惯用法,把资源管理的复杂性封装进了构造函数和析构函数里,让赋值运算符变得既安全又简洁。swap 能做到 O(1),并不是什么魔法,是因为它交换的只是指针本身,而不是指针背后的数据。至于 `size_t` 的无符号特性,在循环边界处确实是个连环坑,必须时刻保持警惕。 说到底,C++ 的核心难点从来不是语法,而是对象的**生命周期**和**资源归属**。这一趟走下来,我觉得对这两个问题的理解,比之前要清晰得多了。
本文转载于:https://www.jb51.net/program/3632899y0.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。

热门关注