C++手撕string类:从内存管理到STL兼容实现
1. 从零开始为什么要“手撕”一个string类如果你是一名C开发者无论你是刚入门的新手还是已经工作几年的老手STLStandard Template Library中的std::string绝对是你最熟悉、使用频率最高的组件之一。它封装了字符数组的复杂性提供了size()、substr()、find()、operator等一系列便捷的接口让我们能像操作基本类型一样轻松处理文本。但不知道你有没有想过这个看似简单的“字符串”类内部是如何运作的当你在面试中被问到“实现一个简易的string类”时你是否能清晰地勾勒出它的骨架“手撕STL”这个说法在程序员圈子里很流行它指的不仅仅是背诵API而是深入底层理解并亲手实现一遍标准库中的核心数据结构。对于string类而言这个过程的价值远超想象。首先它能彻底打破你对“黑盒”的依赖。当你自己管理过内存、处理过拷贝、实现过迭代器后你再使用std::string时会对它的性能开销、异常安全有直觉般的理解。比如你会明白为什么在循环中频繁使用s “a”可能效率低下以及std::move是如何优化字符串传递的。其次这是对C核心能力的综合训练。一个完整的string类实现几乎涵盖了面向对象封装、构造/析构、资源管理RAII、拷贝控制、模板编程迭代器、char_traits、运算符重载等所有关键知识点。通过实现它你能把书本上零散的概念串联成一个有机的整体。最后这几乎是中高级C岗位面试的“保留节目”。面试官通过这个题目能快速考察你的代码基本功、对内存管理的理解以及对C语义的掌握深度。所以今天我们不只停留在使用层面而要拿起“手术刀”层层解剖从零开始构建一个我们自己的MyString类。我们会遵循“先搭骨架再填血肉最后优化细节”的思路让你不仅写出能跑的代码更理解每一行代码背后的设计哲学和潜在陷阱。准备好了吗我们开始。2. 核心设计一个字符串类的骨架应该长什么样在动手写代码之前我们必须先想清楚一个字符串类最核心的职责是什么我认为有三点安全地持有字符数据、高效地支持常见操作、正确地管理资源生命周期。围绕这三点我们可以勾勒出MyString类的初步蓝图。2.1 数据成员与基础约定首先我们需要决定如何在对象内部存储字符串数据。C风格字符串以空字符\0结尾这个约定简单且被广泛接受与大量C库函数兼容。我们的MyString也将采用这种方式。因此核心数据成员就是一个指向堆内存的字符指针char* m_data。为了高效获取长度我们还需要一个成员来记录当前字符串的长度size_t m_size。是否还需要记录容量m_capacity这是一个重要的设计选择。std::string通常采用“短字符串优化SSO”或记录容量的策略来减少内存分配次数。为了简化我们第一版先实现记录容量的版本这更直观。所以我们的类私有成员初步定为class MyString { private: char* m_data; // 指向堆上分配的字符数组以\0结尾 size_t m_size; // 当前字符串的长度不包含结尾的\0 size_t m_capacity; // 当前分配的内存能容纳的字符数至少为m_size1 // ... 其他辅助函数 };这里有一个关键细节m_capacity指的是为m_data分配的总字节数它至少要比m_size大1以便存放结尾的\0。m_size则是用户感知的字符串长度即strlen的结果。2.2 六大核心成员函数构造、析构、拷贝与移动这是C类的基石也被称为“Big Three”或“Big Five”加上移动语义后。它们的正确实现直接决定了类的行为是否像内置类型一样直观和安全。构造函数我们需要处理多种初始化方式。最基本的是从C风格字符串构造这需要分配内存并拷贝内容。另外我们可能还需要默认构造函数创建空字符串、拷贝构造函数深拷贝以及从一个字符重复多次构造的功能。析构函数这是最简单的但至关重要。它的唯一职责就是释放m_data指向的堆内存。如果忘记实现或实现错误会导致内存泄漏。拷贝赋值运算符operator这是bug的高发区。必须正确处理自赋值str str的情况。经典的实现方式是“拷贝-交换”惯用法它异常安全且代码简洁。我们稍后会详细实现。移动构造函数与移动赋值运算符C11这是现代C提升性能的关键。它们“窃取”右值临时对象的资源主要是那个m_data指针将其置空从而避免不必要的深拷贝。实现它们能让你的MyString在函数返回、临时对象传递等场景下效率大增。注意在实现拷贝控制成员时务必遵循“Rule of Three/Five”原则。如果你定义了拷贝构造函数、拷贝赋值运算符或析构函数中的任何一个那么很可能需要把另外两个或四个包括移动操作也定义出来以确保行为一致。编译器生成的默认版本可能不符合你的深拷贝需求。2.3 需要实现哪些常用接口除了核心的生命周期管理一个有用的字符串类还必须提供一系列操作接口。我们可以参考std::string先实现最常用的部分容量相关size(),length(),capacity(),empty(),clear(),reserve(size_t)。元素访问operator[](size_t)包括const和非const版本at(size_t)进行边界检查。修改操作append(const MyString),operator,c_str(),data()C17后data()返回可写的指针。字符串操作find(),substr()。这两个函数实现起来涉及算法是很好的练习。非成员函数输入输出流运算符operator和operator以及比较运算符operator,operator!,等。在接下来的章节我们将把这些设计一一转化为代码并深入探讨每个实现细节中的“坑”与技巧。3. 从构造到析构打好资源管理的地基让我们从最基础的开始实现类的构造和析构。这是确保后续所有操作安全的前提。3.1 构造函数族应对不同的初始化场景我们将实现四个最常用的构造函数。默认构造函数它应该创建一个有效的空字符串而不是一个空指针。MyString::MyString() : m_data(new char[1]), m_size(0), m_capacity(0) { m_data[0] \0; }这里我分配了1字节的内存来存放\0。m_capacity设为0是一种设计表示初始没有任何预留空间。你也可以选择初始分配一个小缓冲区比如16字节以优化后续的小规模追加操作这就是一种简单的优化策略。从C风格字符串构造这是最常用的构造函数。MyString::MyString(const char* str) { if (str nullptr) { // 处理空指针输入可以抛出异常或初始化为空字符串 m_size 0; m_capacity 0; m_data new char[1]; m_data[0] \0; } else { m_size strlen(str); m_capacity m_size; // 初始容量刚好够用没有多余空间 m_data new char[m_capacity 1]; // 1 for \0 strcpy(m_data, str); // 拷贝内容包括结尾的\0 } }这里有几个要点1) 必须检查输入指针是否为空这是健壮性的体现。2)strlen的时间复杂度是O(N)我们调用一次并保存结果避免以后重复计算。3) 分配内存时是m_capacity 1确保有位置放\0。4) 使用strcpy是最简单安全的拷贝方式。拷贝构造函数实现深拷贝。MyString::MyString(const MyString other) : m_size(other.m_size), m_capacity(other.m_capacity) { m_data new char[m_capacity 1]; strcpy(m_data, other.m_data); // 拷贝内容 }拷贝构造函数的参数是const MyString。它不修改原对象只是分配一块新内存并把other的内容完整地复制过来。这是“值语义”的核心。移动构造函数C11性能优化的关键。MyString::MyString(MyString other) noexcept : m_data(other.m_data), m_size(other.m_size), m_capacity(other.m_capacity) { // 将源对象置于有效但可析构的状态 other.m_data nullptr; other.m_size 0; other.m_capacity 0; }移动构造函数的参数是MyString表示一个右值引用通常是临时对象。它的核心思想是“资源转移”直接接管other.m_data指针的所有权然后将other的成员置为空/零状态。noexcept关键字很重要它告诉标准库这个操作不会抛出异常这能使std::vectorMyString::push_back等操作在重新分配内存时使用移动而非拷贝效率更高。3.2 析构函数善始善终析构函数的实现简单但绝不能省略。MyString::~MyString() { delete[] m_data; // 释放数组内存 // m_data, m_size, m_capacity 会被自动销毁 }这里必须使用delete[]来匹配new[]的分配。即使m_data在移动后被置为nullptrdelete[] nullptr也是安全的C标准规定这是空操作。3.3 拷贝赋值运算符难点与经典解法赋值运算符operator比拷贝构造函数复杂因为它需要处理一个已经存在的对象的状态。一个朴素且错误的实现可能是MyString MyString::operator(const MyString other) { if (this ! other) { // 1. 检查自赋值 delete[] m_data; // 2. 释放原有资源 m_size other.m_size; m_capacity other.m_capacity; m_data new char[m_capacity 1]; // 3. 分配新资源 strcpy(m_data, other.m_data); // 4. 拷贝数据 } return *this; // 5. 返回本对象的引用 }这个版本虽然处理了自赋值但存在一个严重问题异常不安全。如果在第3步new分配内存时失败抛出std::bad_alloc此时m_data已经被delete对象处于一个被破坏的状态指针悬空。后续任何操作或甚至析构函数调用都会导致未定义行为。解决这个问题的黄金法则是“拷贝-交换惯用法”MyString MyString::operator(MyString other) { // 注意参数是值传递会调用拷贝构造函数 swap(*this, other); // 与传入的副本交换资源 return *this; // 函数结束参数other现在持有*this的旧资源被析构自动释放内存。 }这个实现非常巧妙。它利用拷贝构造函数来创建other的副本。如果拷贝构造失败new抛出异常异常会发生在修改*this之前*this的状态保持不变这是强异常保证。然后我们用一个自定义的swap函数交换*this和other的内容。最后当函数返回时局部变量other现在持有*this原来的资源被析构资源被自动清理。它同时正确处理了自赋值且代码极其简洁。我们需要实现一个高效的swap友元函数void swap(MyString lhs, MyString rhs) noexcept { using std::swap; // 启用ADL swap(lhs.m_data, rhs.m_data); swap(lhs.m_size, rhs.m_size); swap(lhs.m_capacity, rhs.m_capacity); }移动赋值运算符的实现可以类似地利用swap或者直接实现为MyString MyString::operator(MyString other) noexcept { if (this ! other) { delete[] m_data; // 释放自身原有资源 // 转移资源 m_data other.m_data; m_size other.m_size; m_capacity other.m_capacity; // 置空源对象 other.m_data nullptr; other.m_size 0; other.m_capacity 0; } return *this; }至此我们完成了MyString类的“大五”核心函数。它们共同构成了类资源管理的坚固基石。接下来我们将在此基础上添砖加瓦实现丰富的功能接口。4. 功能实现打造一个实用的字符串工具有了稳固的基础我们现在可以为MyString添加实用的功能了。我们将按照由简到难的顺序实现容量查询、元素访问、修改操作和字符串算法。4.1 容量与访问简单但需谨慎这部分函数大多是一行代码的getter但实现时需要注意const正确性。// 基础查询 size_t MyString::size() const { return m_size; } size_t MyString::length() const { return m_size; } // 与size()通常一致 size_t MyString::capacity() const { return m_capacity; } bool MyString::empty() const { return m_size 0; } // 元素访问 - 运算符[]不检查边界 char MyString::operator[](size_t pos) { // 通常不进行边界检查以追求最高性能与std::string行为一致 return m_data[pos]; } const char MyString::operator[](size_t pos) const { return m_data[pos]; } // 元素访问 - at()检查边界 char MyString::at(size_t pos) { if (pos m_size) { throw std::out_of_range(MyString::at: pos out of range); } return m_data[pos]; } const char MyString::at(size_t pos) const { if (pos m_size) { throw std::out_of_range(MyString::at: pos out of range); } return m_data[pos]; } // 获取C风格字符串指针 const char* MyString::c_str() const { return m_data; } const char* MyString::data() const { return m_data; } // C11前data()返回const char* // 如果需要C17的可写data()可以重载一个非const版本但需谨慎因为不能保证以\0结尾。关键点const重载对于operator[]和at()我们提供了const和非const两个版本。const版本用于const对象返回const引用防止修改。[]vsat()这是标准库的常见设计。operator[]追求速度不进行边界检查访问越界是未定义行为。at()进行边界检查越界时抛出std::out_of_range异常更安全但稍慢。data()与c_str()在C17之前data()返回const char*且不保证以\0结尾尽管对于类似我们这样以\0结尾的实现它和c_str()结果相同。C17要求data()返回char*非const版本并保证结尾有\0。为了简化我们目前只实现const版本。4.2 修改操作追加、清空与预留空间修改操作往往涉及内存管理是性能优化的关键点。clear()和reserve()void MyString::clear() { // 并不释放内存只是将长度置零并将第一个字符设为\0 m_size 0; if (m_data) { m_data[0] \0; } } void MyString::reserve(size_t new_capacity) { if (new_capacity m_capacity) { return; // 如果请求的容量不大于当前容量什么都不做 } // 分配新内存 char* new_data new char[new_capacity 1]; // 拷贝原有数据包括\0 if (m_data) { strcpy(new_data, m_data); } else { new_data[0] \0; } // 释放旧内存更新指针和容量 delete[] m_data; m_data new_data; m_capacity new_capacity; }reserve()是减少内存重新分配次数的利器。例如如果你知道最终要构建一个很长的字符串可以提前reserve足够大的空间避免在append过程中多次new/delete。append()和operatorMyString MyString::append(const MyString str) { return append(str.m_data, str.m_size); } MyString MyString::append(const char* s, size_t count) { if (s nullptr || count 0) { return *this; } size_t new_size m_size count; if (new_size m_capacity) { // 需要扩容。一个常见的策略是倍增或按固定系数增长以避免频繁分配。 size_t new_capacity std::max(new_size, m_capacity * 2); reserve(new_capacity); } // 将新内容拷贝到原有内容的末尾 memcpy(m_data m_size, s, count); m_size new_size; m_data[m_size] \0; // 添加新的结尾符 return *this; } MyString MyString::operator(const MyString str) { return append(str); } MyString MyString::operator(const char* s) { return append(s, strlen(s)); }append的实现有几个优化点扩容策略当现有容量不足时我们不是仅仅分配刚好够用的内存new_size而是分配max(new_size, m_capacity * 2)。这种“倍增”策略是std::vector等容器的典型做法它能将追加操作的平均时间复杂度从O(N²)降低到O(N)。这是“手撕”过程中必须理解的经典算法思想。使用memcpy对于已知长度的内存块拷贝memcpy通常比strcpy更高效因为它不需要逐个字符检查\0。手动添加\0memcpy后我们必须手动在字符串新结尾处设置\0。4.3 字符串算法实现find和substr这两个函数稍微复杂一些涉及字符串搜索和子串构造算法。find()函数size_t MyString::find(const MyString str, size_t pos) const { if (pos m_size || str.m_size m_size - pos) { return npos; // 起始位置越界或子串比剩余部分还长 } if (str.empty()) { return pos; // 空串总是被找到标准规定返回起始位置 } // 朴素字符串匹配算法Brute-Force for (size_t i pos; i m_size - str.m_size; i) { bool found true; for (size_t j 0; j str.m_size; j) { if (m_data[i j] ! str.m_data[j]) { found false; break; } } if (found) { return i; } } return npos; }我们实现了最朴素的匹配算法时间复杂度为O(M*N)。std::string::find在实际实现中可能会使用更高效的算法如KMP或Boyer-Moore但为了教学清晰朴素算法足够了。注意对边界条件的处理比如空子串和越界检查。npos是一个静态常量通常定义为static const size_t npos -1;。substr()函数MyString MyString::substr(size_t pos, size_t count) const { // 参数检查 if (pos m_size) { throw std::out_of_range(MyString::substr: pos out of range); } // 计算实际要拷贝的长度 size_t len std::min(count, m_size - pos); // 构造结果字符串 MyString result; result.reserve(len); // 预分配空间避免内部多次分配 for (size_t i 0; i len; i) { result.m_data[i] m_data[pos i]; } result.m_data[len] \0; result.m_size len; return result; // 注意这里会触发返回值优化RVO或移动语义效率很高 }substr需要构造一个新的MyString对象并返回。这里我们使用了reserve来优化一次性分配好所需内存。函数返回时由于result是局部变量编译器会应用返回值优化RVO直接在其最终位置构造或者至少会调用移动构造函数如果我们实现了的话避免了额外的拷贝开销。这是现代C编写返回对象函数的推荐方式。5. 迭代器与运算符重载让类用起来更“自然”为了让我们的MyString用起来更像标准库的容器并支持基于范围的for循环我们需要实现迭代器。同时重载一些常用的运算符能让代码更直观。5.1 实现简易迭代器迭代器本质上是一个指针的抽象。对于MyString这样的连续内存容器最简单的迭代器就是原生指针。class MyString { public: // 迭代器类型定义为了兼容STL算法 using iterator char*; using const_iterator const char*; using reverse_iterator std::reverse_iteratoriterator; using const_reverse_iterator std::reverse_iteratorconst_iterator; // 迭代器获取函数 iterator begin() { return m_data; } iterator end() { return m_data m_size; } // 指向\0符合STL“尾后迭代器”约定 const_iterator begin() const { return m_data; } const_iterator end() const { return m_data m_size; } const_iterator cbegin() const { return m_data; } const_iterator cend() const { return m_data m_size; } // 反向迭代器可以利用标准库的std::reverse_iterator适配器这里省略实现 };通过定义iterator和const_iterator别名并提供begin()和end()方法我们的MyString就能无缝接入STL的世界了。现在你可以这样写MyString str Hello; for (char ch : str) { // 基于范围的for循环 ch std::toupper(ch); } std::sort(str.begin(), str.end()); // 使用STL算法5.2 重载输入输出与比较运算符流操作符// 输出运算符 std::ostream operator(std::ostream os, const MyString str) { os str.c_str(); // 直接输出C风格字符串 return os; } // 输入运算符简易版读取一个单词 std::istream operator(std::istream is, MyString str) { str.clear(); // 先清空目标字符串 char ch; // 跳过开头的空白字符 while (is.get(ch) std::isspace(ch)) {} if (!is) { return is; // 读取失败或到达文件尾 } // 将第一个非空白字符放回流中以便后续读取 is.unget(); // 读取直到遇到空白字符 while (is.get(ch) !std::isspace(ch)) { str.append(1, ch); // 需要一个append(char)的重载这里简化处理 } return is; }operator的完整实现需要考虑缓冲区、错误状态等这里是一个简化版本。一个更健壮的实现可能会使用std::istream::getline或直接操作流缓冲区。比较运算符 我们可以利用C标准库的strcmp函数轻松实现字典序比较。bool operator(const MyString lhs, const MyString rhs) { return lhs.size() rhs.size() strcmp(lhs.c_str(), rhs.c_str()) 0; } bool operator!(const MyString lhs, const MyString rhs) { return !(lhs rhs); } bool operator(const MyString lhs, const MyString rhs) { return strcmp(lhs.c_str(), rhs.c_str()) 0; } bool operator(const MyString lhs, const MyString rhs) { return rhs lhs; } bool operator(const MyString lhs, const MyString rhs) { return !(lhs rhs); } bool operator(const MyString lhs, const MyString rhs) { return !(lhs rhs); }注意strcmp在比较前会一直读取直到\0所以理论上只需要strcmp的结果就能判断相等。但先比较长度可以快速排除掉大部分不相等的情况是一个小优化。对于操作符strcmp返回负值、零、正值分别表示小于、等于、大于正好符合要求。6. 进阶优化与深度思考实现基础版本后我们可以思考如何让它更高效、更健壮。这里探讨几个进阶话题。6.1 短字符串优化SSO这是现代std::string实现中一个至关重要的优化。其核心思想是对于很短的字符串比如15或22个字符以内不分配堆内存而是直接将其存储在对象自身的缓冲区中通常是在m_data指针、m_size、m_capacity所占用的空间里进行复用。这带来了两个巨大好处提升速度避免了堆内存分配/释放的开销这对小字符串操作性能提升显著。提高局部性数据在栈上CPU缓存命中率更高。实现SSO需要精巧的内存布局设计。通常会使用一个联合体union来区分“长字符串”和“短字符串”两种状态。在短字符串状态下对象内部的字节被解释为一个字符数组和一个用于存储长度或剩余空间的字段。这增加了实现的复杂性但却是工业级字符串库的标配。6.2 写时复制Copy-On-Write, COW另一种曾经流行的优化是写时复制。其原理是多个string对象可以共享同一块内存数据。只有当某个对象需要修改数据时“写”操作它才真正执行拷贝从而将共享的内存分离。这在以读为主、拷贝频繁的场景下能节省大量内存和拷贝时间。然而COW在多线程环境下需要复杂的引用计数和同步机制带来了额外的开销。并且由于C11引入了移动语义在很多场景下移动操作已经足够高效避免了不必要的深拷贝。因此现代C标准库的实现如GCC的libstdc和Clang的libc大多已弃用COW转而依赖移动语义和SSO。了解COW有助于理解技术演进的历史但在新项目中通常不推荐自己实现它。6.3 异常安全保证在我们的实现中我们已经有意无意地应用了异常安全编程的原则拷贝构造和append中的基本保证如果内存分配失败new抛出bad_alloc对象会保持为一个有效状态要么是构造前的状态要么是append调用前的状态不会发生资源泄漏。拷贝赋值运算符的强保证通过拷贝-交换操作要么完全成功要么完全失败调用者看到的对象状态不会改变。这是最高级别的异常安全保证。在写资源管理类时时刻思考每个操作是否提供了至少基本的异常安全保证是写出健壮代码的关键。6.4 与std::string的差异与兼容性我们的MyString是一个教学演示版本与成熟的std::string相比缺少很多内容丰富的构造函数和赋值运算符重载如string(size_t, char),string(initializer_listchar)。大量的成员函数insert,erase,replace,compare,rfind,find_first_of等。分配器支持std::string是一个模板类basic_stringchar, char_traitschar, allocatorchar的别名支持自定义分配器。更复杂的迭代器类型完整的迭代器类别、反向迭代器等。C11/14/17/20的新方法如shrink_to_fit(),starts_with(),ends_with(),contains()(C23)等。尽管如此实现这个简化版的过程已经让你透彻理解了字符串类设计的核心挑战和解决方案。当你再使用std::string时你会对它的每一次拷贝、每一次扩容、每一次查找背后的成本有更深刻的认识。7. 常见问题与调试技巧在实现和使用自定义字符串类的过程中你肯定会遇到各种问题。下面是一些典型问题的排查思路和解决技巧。7.1 内存问题泄漏、越界与重复释放这是手动管理内存时最常见的问题。症状程序运行一段时间后内存占用不断增长泄漏程序随机崩溃错误信息涉及内存访问违规越界程序在析构或赋值时崩溃重复释放。排查工具Valgrind (Linux/Mac)这是神器。用valgrind --leak-checkfull ./your_program运行你的程序它能精准定位内存泄漏、非法读写、使用未初始化内存等问题。AddressSanitizer (ASan)编译时添加-fsanitizeaddress标志GCC/Clang它能在运行时检测出内存错误并提供清晰的调用栈。调试器 (GDB/LLDB)在崩溃时查看调用栈检查指针的值是否为nullptr或野指针。预防技巧严格遵守RAII在构造函数中获取资源在析构函数中释放。确保所有执行路径包括异常抛出都能正确释放资源。“拷贝-交换”惯用法是保证赋值操作异常安全的有效手段。清晰所有权一个内存块只由一个“所有者”负责释放。移动语义将所有权转移后源对象必须放弃所有权指针置nullptr。边界检查在at()、substr()等函数中进行严格的边界检查。在memcpy、循环中使用m_size作为边界条件。7.2 性能瓶颈频繁的重新分配症状在循环中拼接字符串如for(...) { s “a”; }速度极慢。分析如果每次append都恰好需要扩容且采用每次只增加所需容量的策略那么N次追加会导致大约N²/2次字符的拷贝123...N这是平方级复杂度。解决方案使用reserve()预分配如果事先知道最终大小提前调用reserve。实现倍增扩容策略正如我们在append中所做按倍数如2倍扩容可以将平均追加操作的复杂度摊还到O(1)。使用std::ostringstream对于复杂的字符串拼接标准库的字符串流是更好的选择它内部管理缓冲区效率很高。7.3 关于c_str()和data()的陷阱问题c_str()返回的指针在MyString对象被修改或销毁后即失效。const char* p myStr.c_str(); myStr.append( world); // 可能导致内部内存重新分配 std::cout p; // p可能已经是悬空指针行为未定义最佳实践如果需要持有一个C风格字符串应该立即拷贝它如使用strdup或存到std::string中而不是保存这个指针。7.4 自赋值与异常安全这是我们反复强调的重点。务必在你的拷贝赋值运算符中检查自赋值(this ! other)并使用“拷贝-交换”等能提供强异常安全保证的写法。一个健壮的赋值操作应该像原子操作一样要么完全成功要么完全失败且不影响原对象。实现一个自己的string类是一次绝佳的C学习之旅。它强迫你去面对资源管理、异常安全、接口设计、性能优化等核心问题。当你完成了这个练习再回头看std::string的文档你会发现每一个API设计背后的深意。希望这篇长文能为你提供清晰的路径和实用的细节。最好的学习方式就是动手打开你的编辑器从class MyString {开始吧。