当前位置:

首页 > 编程开发 > C++string类原理、踩坑与对象语义详解

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

C++string作为资源管理类,通过RAII管理堆内存,实现深拷贝、赋值与析构。采用copy-and-swap技术保证异常安全和自赋值安全,swap操作O(1)。注意无符号类型下溢陷阱,使用翻倍扩容策略。重载比较运算符与流操作实现完整功能。

一、为什么 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++ 的核心难点从来不是语法,而是对象的**生命周期**和**资源归属**。这一趟走下来,我觉得对这两个问题的理解,比之前要清晰得多了。
本站声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系bd@zhengruan.com
作者最新文章
编程开发 资源管理
相关文章 更多
codex安装windows教程桌面版完整安装步骤指南
codex安装windows教程桌面版完整安装步骤指南

详解如何在 Windows 电脑上通过 Microsoft Store 安装 Codex 桌面应用,包括启动登录、切换 Codex 模式、关联本地代码项目及终端环境配置的界面操作指引。

github配置ssh key windows
github配置ssh key windows

本文详解如何在Windows环境中使用PowerShell生成SSH密钥对,配置ssh-agent自动加载私钥,并将公钥安全添加至GitHub账户,最后通过git remote set-url切换为SSH地址,解决每次操作需输入账号密码的问题。

backdrop和background区别
backdrop和background区别

深入解析backdrop与background在网页开发及视觉设计中的区别。通过CSS代码示例,展示如何正确使用background设置元素背景,以及利用backdrop-filter创建弹窗遮罩模糊效果,避免概念混用。

windsurf ide download Windows版安装教程
windsurf ide download Windows版安装教程

详解 Windsurf IDE 在 Windows 系统的下载来源、安装步骤及首次启动界面。指导用户如何导入编辑器设置、打开项目文件夹及使用终端与 AI 功能,适合初次接触该工具的开发者阅读。

ServBay安装配置详细教程与操作指南
ServBay安装配置详细教程与操作指南

新手入门 ServBay 本地开发环境,详解安装包下载、Dashboard 状态监控、Packages 组件安装、Services 服务控制及 Websites 项目配置。掌握 .servbay.config 版本管理与日志排查技巧,快速搭建稳定的 PHP、Node.js 等多语言开发环境。

codekit环境配置指南从安装到环境搭建完整教程
codekit环境配置指南从安装到环境搭建完整教程

详解 CodeKit 在 macOS 下的安装步骤、项目导入方法、Sass与JavaScript编译设置及浏览器自动刷新功能,助您快速搭建高效的前端开发环境。

codex安装windows 命令行完整操作教程
codex安装windows 命令行完整操作教程

详解Windows环境下安装OpenAI Codex CLI的步骤,包括WSL环境检查、Node.js/npm配置、npm全局安装命令及首次启动验证,适合开发者快速上手。

NativeRest环境配置要求与完整操作教程
NativeRest环境配置要求与完整操作教程

学习如何配置 NativeRest REST API 客户端。涵盖 Windows/macOS/Linux 安装后的工作区创建、环境变量管理、请求编辑及响应查看步骤,帮助开发者快速完成基础环境搭建与连通性测试。

CSS设置透明度的注意事项有哪些?opacity属性详解
CSS设置透明度的注意事项有哪些?opacity属性详解

深入解析CSS中设置透明度的核心属性opacity,剖析子元素继承、事件穿透、层叠上下文等关键注意事项,并提供与rgba、hsla的实用选型对比。

flutter页面传值到后台的方法及示例代码
flutter页面传值到后台的方法及示例代码

flutter页面传值到后台的完整实现方法及示例代码,帮助读者快速掌握相关技术要点。

查看更多
精品专题 更多
装机必备
装机必备

正软商城装机必备专区,精选办公、浏览器、安全防护、影音播放、压缩解压、设计创作和系统工具等电脑常用正版软件,帮助用户快速完成新电脑软件配置。

Windows
Windows

正软商城Windows软件专区,汇集适用于Windows电脑的办公、设计、安全防护、影音播放、开发工具和系统优化软件,提供软件介绍、系统要求、正版授权及购买下载服务。

PDF教程
PDF教程

正软商城PDF教程频道提供PDF编辑、转换、合并、拆分、压缩及格式处理方法,同时介绍常用PDF软件和工具的使用技巧。

Mac软件 更多
WPS Office macOS版
WPS Office macOS版
macOS

WPS Office是一款面向macOS用户的综合办公套件,将文字处理、电子表格、演示文稿、PDF工具、模板与云协作整合在同一应用中。它兼容常见Microsoft Office格式,支持Intel及Apple芯片Mac,基础功能可免费使用,

Shapr3D macOS版
Shapr3D macOS版
Mac

Shapr3D是一款面向工业设计、机械工程、建筑概念和三维打印工作流的CAD软件。Mac版采用Parasolid建模内核,支持草图约束、实体建模、工程图、可视化渲染及常见CAD格式交换,并可通过账户在多台设备之间同步项目。

REAPER macOS版
REAPER macOS版
Mac

REAPER是Cockos开发的数字音频工作站,提供多轨音频与MIDI录制、剪辑、处理、混音和母带制作工具。Mac版兼容Intel与Apple芯片,支持AU、VST、VST3、CLAP等插件格式,并提供高度可定制的工作流程。

WINDOWS 更多
Adobe Photoshop Windows版
Adobe Photoshop Windows版
Windows

Adobe Photoshop 是面向摄影师、设计师和内容创作者的专业图像处理工具,可在 Windows 上完成照片修饰、选区抠图、图层合成、文字排版、绘画及生成式编辑。软件采用订阅授权,由 Creative Cloud 管理安装、更新与许

Adobe InDesign Windows版
Adobe InDesign Windows版
Windows

Adobe InDesign 是面向印刷与数字出版的专业页面排版工具,可制作书籍、杂志、宣传册、海报、交互式 PDF 和电子书。它提供精细的文字样式、多页面管理、印前检查及多格式输出能力,并能与 Photoshop、Illustrator

Adobe Illustrator Windows版
Adobe Illustrator Windows版
Windows

Adobe Illustrator 是用于绘制矢量图形、插画、图标、徽标和版式的专业设计工具。它以路径和锚点为核心,作品放大后仍能保持清晰,并提供画板、文字、渐变、图像描摹及精确变换等功能,适合品牌视觉、印刷物料、数字界面和商业插画创作。