
1. 为什么每个C初学者都绕不开String类从C风格字符串的痛点说起如果你是从C语言转过来的或者刚开始接触C第一感觉往往是“C也不过如此”——变量、循环、函数跟C差不多嘛。但等到真正用字符串做点实际的事情比如拼接用户输入、比较两个名字是否相同、从一行文本里截取某个字段你就会发现事情没那么简单。C语言里字符串本质是一个以\0结尾的字符数组。这意味着你每次处理字符串都要自己操心三件事数组得多大、够不够装、边界在哪。strcpy、strcat、strcmp这些函数用起来爽但一旦数组长度设计错了轻则输出乱码重则缓冲区溢出直接崩溃。更别提你想把一个字符串作为函数返回值时必须用char*配合动态内存分配最后还得记得free——少一个free就是内存泄漏多一个free就是重复释放崩溃简直步步惊心。C的std::stringString类就是为了解决这一系列问题出现的。它是标准库提供的一个类内部帮你管理字符数组的内存你只管往里塞字符、往外取子串、拼接、查找、比较剩下的容量扩容、自动释放、边界安全全都由类自己负责。举个直观的例子两个C风格字符串拼接你要写三步——先strlen算长度再malloc开空间最后strcat复制内容换成std::string就是一行str3 str1 str2;完事。这篇内容我会从一个实际项目角度切入把String类的常用方法、底层原理、常见坑和面试高频考点一次性讲透。它适合正在学C基础语法、准备用C做课程设计或小项目、以及打算校招面试的同学——因为String类的题目在笔试和面试里出现频率极高而且往往不止考“怎么用”更考“底层怎么实现”。2. String类的常用方法全景从初始化到遍历的完整实操2.1 初始化方式别再只写string s abc了String类的构造函数有很多重载实际项目里最常见的初始化方式有下面几种#include iostream #include string using namespace std; int main() { // 空字符串 string s1; // 用C风格字符串初始化 string s2 hello; string s3(world); // 用另一个string初始化拷贝构造 string s4(s2); string s5 s3; // 重复填充5个字符a string s6(5, a); // 用迭代器范围初始化 string s7(s2.begin(), s2.end()); cout s1.empty() endl; // 1表示空 cout s2 s3 endl; cout s4 s5 endl; cout s6 endl; // aaaaa cout s7 endl; // hello return 0; }这里特别提醒一下string s6(5, a)这种构造很多人第一次见会懵它的含义是构造一个包含5个字符a的字符串不是“把5和a拼接起来”。另外string s hello;走的是隐式转换本质也是调用构造函数不是直接赋值指针——这一点后面讲底层的时候会再展开。2.2 容量相关方法empty、size与length的那些细节先看一组最常见的容量查询方法string str C String; cout str.size() endl; // 11不含\0 cout str.length() endl; // 11效果一样 cout str.capacity() endl; // 当前分配的内存容量通常大于size cout str.empty() endl; // 0非空size()和length()返回值完全一样都是字符个数。为什么标准库要提供两个名字一样的方法历史原因——length()沿袭自早期C风格size()则与其他STL容器vector、map等保持一致。实际项目里你用哪个都行不用纠结。capacity()就有点意思了。它返回的是string内部实际分配的内存大小往往比size()大一些这是为了后续追加字符时减少扩容次数。你往字符串里不断push_back或时capacity会按一定策略增长——通常是翻倍或1.5倍增长而不是每次加一个字符就重新分配一次内存。这个设计直接影响性能后面专门讲。2.3 元素访问at()带边界检查而operator[]不带访问string中的某个字符常见两种方式string str Hello; char c1 str[1]; // e char c2 str.at(1); // e str[0] h; // 修改 str.at(0) h; // 修改区别很关键operator[]不做边界检查下标越界时行为未定义可能返回垃圾值甚至在某些编译器下什么都不报错就过去了at()会做边界检查越界时抛出std::out_of_range异常。性能上operator[]略快少一次判断但安全性差很多。我的习惯是读循环遍历时用operator[]没毛病但凡是下标来自用户输入或外部数据一律用at()包一层try-catch或者干脆先判断index str.length()再访问。做OJ题或竞赛时可以图快用[]写工程代码时命比那点性能值钱。2.4 修改操作push_back、append、insert、erase一次讲清字符串的修改操作是最常用的也是面试手写题的重灾区。string s hello; // 尾部追加 s.push_back(!); // hello! s world; // hello! world s.append( C); // hello! world C // 插入在指定位置插入字符/字符串 s.insert(5, ,); // 在下标5处插入逗号 // 删除 s.erase(5, 1); // 从下标5开始删1个字符 s.erase(s.begin()); // 删除第一个字符 s.pop_back(); // 删除最后一个字符 // 清空 s.clear(); // 长度为0这里要说一个很多人会犯的错误insert的第一个参数是下标第二个参数是插入内容。如果第二个参数是字符需要写成insert(pos, 1, ch)或insert(pos, string(1, ch))不能直接传char因为char不会自动转成string。编译器会报一个很隐晦的错误我第一次遇到时排查了半天。erase的类型也很多可以传下标长度也可以传迭代器。实际项目里我用下标长度的频率高一些因为好读刷题时用迭代器版本的更多因为配合find很方便s.erase(s.find(abc), 3)。2.5 查找与子串find、rfind、substr搭配使用查找这一块是笔试常客尤其是find和substr的组合string s hello world, hello C; size_t pos s.find(hello); // 0第一次出现位置 size_t pos2 s.find(hello, 1); // 从下标1开始找结果是13 size_t pos3 s.rfind(hello); // 13从后往前找 size_t pos4 s.find(xyz); // string::npos没找到 if (pos string::npos) { // 没找到 } // 提取子串 string sub s.substr(0, 5); // hello从0开始取5个 string sub2 s.substr(7); // world, hello C, 从7取到结尾string::npos是一个特殊常量代表“不存在的位置”。判断查找是否成功必须用npos比较不能写if (pos 0)——因为查找结果可能恰好是0那意味着从第一个字符就命中了。substr的第二个参数如果省略默认取到字符串末尾这个特性在截取文件后缀、URL路径等场景里非常实用。举个例子解析一个文件名字符串string filename report_2025.pdf; size_t dot filename.rfind(.); string ext filename.substr(dot 1); // pdf用rfind而不是find是因为文件名里可能出现多个点比如my.file.final.pdf从后往前找最接近末尾的那个点才准确。2.6 遍历、比较与转换与C风格交互的最后一块拼图遍历方式有三种下标、迭代器、范围for。实际工作中我推荐范围for代码最简洁string s abc; for (char c : s) { cout c ; }如果要修改每个字符写for (char c : s)加引用。比较操作直接用关系运算符string a apple; string b banana; if (a b) { // 按字典序比较 cout a在b前面 endl; }最后是String类和C风格字符串的互转string s hello; const char* cstr s.c_str(); // 转成const char*只读 char* buffer s[0]; // 通过地址拿到可写指针需保证非空 string s2 cstr; // 反过来C风格字符串转stringc_str()返回的指针指向const数据你不能通过它修改string内容而且这个指针在String对象被修改后可能失效——因为底层可能重新分配了内存。所以写代码时不要保存c_str()的返回值长期使用用完了马上转成string或立即拷贝走。3. 底层原理拆解String对象在内存里到底长什么样3.1 内部结构三个指针与动态内存分配很多人以为std::string内部就是一个char数组那么简单实际上它通常持有三个指针或类似的迭代器成员起始指针、结束位置、容量结束位置。这三个指针共同管理一块堆上的动态内存。当你写string s;时对象本身可能只有几十个字节3个指针一些元数据真正存的字符数据在堆上。这意味着什么意味着string在函数间传递时默认拷贝的是“对象指针的值”但字符数据是共享的——这点非常关键直接关系到后面要讲的深拷贝与浅拷贝问题。C11之后标准库还引入了短字符串优化SSOShort String Optimization。短字符串一般是15个字符以内具体取决于实现直接存在String对象内部的固定缓冲区里不涉及堆分配。所以一个很短的字符串创建时不需要new操作性能比想象中要好很多。这也解释了为什么capacity有时等于size有时大于size——短的走内部缓冲长了才上堆。3.2 浅拷贝与深拷贝String类的到底做了什么初学C的人经常会听到“浅拷贝”和“深拷贝”这对概念。放在String类里理解非常直观浅拷贝就是只复制指针两个String对象指向同一块堆内存深拷贝则是新分配一块内存把字符逐个复制过去两个对象各管各的。你可能在想是不是深拷贝更安全当然是。但C03时代的标准库String确实用的是深拷贝谁赋值谁先释放原来的内存再重新分配。到了C11引入移动语义之后情况变得复杂而高效如果赋值给一个临时对象右值源码会直接“偷走”对方的指针不需要拷贝字符数据——这就是移动构造和移动赋值。string a hello; string b a; // 深拷贝a是左值b有自己的内存 string c std::move(a); // 移动c直接接管a的内存a变为空面试里常问“为什么std::move能让字符串拷贝变快”本质就是移动语义避免了深拷贝时的堆分配与字符复制。值得注意的是std::move本身只是把左值转成右值引用真正起作用的是String类重载的移动构造函数。3.3 扩容策略为什么反复不会拖垮性能String对象创建后capacity并不总是恰好等于长度。当你不断追加字符导致长度超过当前capacity时String会申请一块更大的内存、把旧数据搬过去、释放旧内存然后更新指针——这个过程叫扩容。最常见的扩容策略是倍数增长新容量大约是旧容量的1.5~2倍。为什么不是每次加1因为扩一次容要分配内存复制全部数据如果每追加一个字符就扩容一次往100万字符的字符串里逐字节追加复杂度会退化成O(n²)。用倍数增长总扩容次数是O(log n)均摊下来每个字符的追加成本是O(1)。这个特性在实际工程里的启示是如果你大致知道最终字符串长度可以提前用reserve()预留容量string s; s.reserve(10000); for (int i 0; i 10000; i) { s a; }不加reserve时可能触发十几次扩容每次扩容都要反复拷数据加了之后全程一次内存分配都没有。我在处理日志拼接、网络协议组包时都会先估个长度再reserve跑出来的性能差异非常明显。3.4 c_str()返回的指针为什么不能乱存c_str()返回的是当前内部缓冲的起始地址。如果你只读不改准确但一旦String发生写操作并触发扩容、或者调用非常量成员函数修改了内容这个指针就会失效。举个经典反例string s hello; const char* p s.c_str(); s world; // 可能触发扩容 cout p endl; // 未定义行为可能打印乱码或崩溃正确的做法是用到哪取到哪取完马上用完不要跨语句保存太久。4. 常用方法与代码实战我把高频场景都写了一遍4.1 场景一分离文件路径中的目录、文件名与扩展名实际开发中解析文件路径的需求非常多用String类可以写得很干净#include iostream #include string void ParsePath(const string path) { size_t lastSlash path.rfind(/); string dir (lastSlash string::npos) ? . : path.substr(0, lastSlash); string filename (lastSlash string::npos) ? path : path.substr(lastSlash 1); size_t dot filename.rfind(.); string basename (dot string::npos) ? filename : filename.substr(0, dot); string ext (dot string::npos) ? : filename.substr(dot 1); cout 目录: dir endl; cout 文件名: basename endl; cout 扩展名: ext endl; } int main() { ParsePath(/home/user/project/main.cpp); return 0; }这里有几个细节值得注意rfind(/)从右往左找斜杠即使路径中有很多层目录也能准确切出最后一级rfind(.)找扩展名能兼容多各种带点的文件名找不到分隔符时返回npos要单独处理并给默认值。4.2 场景二统计字符串中每个字符的出现次数这个需求在文本分析、日志统计里很常见配合unordered_map不超过十行#include iostream #include string #include unordered_map int main() { string text hello c string class; unordered_mapchar, int freq; for (char c : text) { if (c ! ) freq[c]; } for (auto [ch, cnt] : freq) { cout ch : cnt endl; } return 0; }范围for循环遍历每个字符空格直接跳过用哈希表记录频次。如果你想知道出现次数最多的字符再遍历一遍map取最大值即可。这个题目也是面试手写题的一个变种考的是对范围for和容器的熟练度。4.3 场景三字符串与数字互转避开陈旧C函数的雷早些年很多人习惯用atoi、itoa、sprintf做字符串和数字之间的转换但这些函数要么不安全要么行为不够清晰。C11之后官方推荐使用to_string和stoi系列#include iostream #include string int main() { int num 42; string str to_string(num); // 42 double pi 3.14159; string piStr to_string(pi); // 3.141590 string s1 123; int val1 stoi(s1); // 123 string s2 3.14; double val2 stod(s2); // 3.14 string s3 100abc; int val3 stoi(s3); // 100解析到非数字字符为止 // 但注意如果第一个字符就不是数字会抛异常 try { int bad stoi(abc); } catch (const std::invalid_argument e) { cout 无法解析为数字 endl; } return 0; }stoi的好处是能捕获异常。解析用户输入时先判断首字符是不是数字或者直接包在try-catch里避免程序崩溃。4.4 场景四去除字符串首尾的空格这个需求特别常见String类标准库没有直接提供trim方法需要自己动手#include iostream #include string string Trim(const string s) { size_t start s.find_first_not_of( \t\n\r); if (start string::npos) return ; size_t end s.find_last_not_of( \t\n\r); return s.substr(start, end - start 1); } int main() { string input hello c ; cout [ Trim(input) ] endl; return 0; }find_first_not_of找到第一个不是空格/tab/换行的字符位置find_last_not_of找到最后一个。两者一夹就得到了去除首尾空格的子串。如果整个字符串全是空白第一个调用就返回npos直接返回空字符串。这个技巧在解析配置文件、读取用户命令时非常实用。4.5 全面梳理String类常用方法速查表用到最后把最常用的方法整理成一张表方便平时查阅类别方法说明构造string(), string(n, c), string(s), string(begin, end)空构造、填充构造、拷贝构造、迭代器范围构造容量size()/length(), empty(), capacity(), reserve(n)字符个数、是否为空、容量大小、预留容量访问operator[], at(), front(), back()下标访问、边界检查访问、首尾访问修改push_back(), pop_back(), , append(), insert(), erase()追加、删除、拼接、插入、删除查找find(), rfind(), find_first_of(), find_last_of()查找子串、反向查找、查找字符集子串substr(pos, n)提取从pos开始的n个字符比较, !, , , compare()字典序比较转换c_str(), data(), to_string(), stoi/stol/stod与C风格字符串和数字互转其他clear(), resize(), replace()清空、调整长度可以填充、替换子串replace(pos, len, newStr)可以替换指定位置、指定长度的内容在处理模板替换、格式化输出时很好用值得单独练一练。5. 踩坑实录这些隐藏细节关键时刻能救命5.1npos比较的位置问题前面我在查找部分提到过这里单独拿出来再讲一遍因为它真的是初学者的头号陷阱string s hello; size_t pos s.find(x); if (pos 0) { // 错pos 0永远成立因为size_t是无符号类型 cout 找到了; }size_t是无符号整数负数判断永远不会成立。正确写法是if (pos string::npos) { // 没找到 } else { // 找到了位置是pos }还有一个细节npos的实际值是(size_t)-1也就是无符号下的最大值。所以任何find失败的结果跟pos 0比较都会误判成功遍历时一定要用npos判断不能图省事。5.2 读入带空格的整行文本默认cin s遇到空格会停止读取所以想读入hello world整行必须用getline#include iostream #include string int main() { string line; getline(cin, line); // 读一整行包括空格 cout 输入为: line endl; return 0; }有个常见的组合坑如果之前用过cin n读取数字紧接着再getline会读到换行符残留而得到一个空字符串。解决办法是读取数字后加一句cin.ignore()或者干脆都用getline读完再手动转数字string numStr; getline(cin, numStr); int n stoi(numStr);5.3resize和reserve别搞混这两个方法名字像行为差得远reserve(n)只改变容量不改变大小。调用后size()不变但capacity()至少为n。resize(n)改变大小。如果n大于当前size会追加字符默认为\0如果n小于当前size会截断。string s hello; s.reserve(100); // size还是5capacity变成至少100 s.resize(3); // 变成hel多余字符被丢弃 s.resize(6); // 变成hel\0\0\0默认填充空字符如果只想预留空间并逐步填充用reserve如果想创建指定长度的字符串并修改内容用resize或直接构造时指定。5.4 引用与值传递选择在C里传string进函数尽量用const string而不是string svoid Print1(string s); // 会拷贝一份浪费 void Print2(const string s); // 只读引用零拷贝如果函数内部需要修改传入的字符串用非const引用参数如果需要保留原值另存一份再在函数里做拷贝。这个习惯对性能的影响在字符串较长时立竿见影。我自己写代码的默认原则是能传const引用就绝不按值传参。5.5 编译器与标准库版本的差异不同平台的STL对String的底层实现有细微差别。比如MSVC的std::string不支持SSO而是使用引用计数COWCopy-on-Write后来在VS2015改掉了GCC的libstdc使用SSO所以短字符串特别快。这意味着25个字符的字符串在不同的编译器下可能一个走堆分配、一个走内部缓冲。初学阶段不用太在意这些差异但如果你做的是性能敏感的项目、或者面试被问“std::string在内存里如何存储”知道SSO和这些历史原因会让你的回答更深刻。推荐跨平台看下cppreference的string文档和本机STL的头文件代码对照理解比死记硬背有效得多。6. 经典面试题与笔试高频考点手撕String类6.1 手写String类考察什么面试手撕一个简化版String类几乎是C岗位的保留项目它综合考查类设计、内存管理、拷贝控制、运算符重载。我在面试实习生时也爱用这道题因为它能区分“会写代码”和“懂这门语言”。常见要求是只实现默认构造函数、带const char*的构造函数、拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数、operator[]、c_str()等几个核心接口。写一遍你就知道深浅#include cstring #include iostream class MyString { public: MyString() : data_(nullptr), len_(0) {} MyString(const char* str) { len_ strlen(str); data_ new char[len_ 1]; strcpy(data_, str); } // 拷贝构造深拷贝 MyString(const MyString other) { len_ other.len_; if (other.data_) { data_ new char[len_ 1]; strcpy(data_, other.data_); } else { data_ nullptr; } } // 拷贝赋值先释放旧内存再深拷贝 MyString operator(const MyString other) { if (this other) { return *this; // 自赋值保护 } delete[] data_; len_ other.len_; if (other.data_) { data_ new char[len_ 1]; strcpy(data_, other.data_); } else { data_ nullptr; } return *this; } // 移动构造直接接管指针 MyString(MyString other) noexcept : data_(other.data_), len_(other.len_) { other.data_ nullptr; other.len_ 0; } // 移动赋值 MyString operator(MyString other) noexcept { if (this other) { return *this; } delete[] data_; data_ other.data_; len_ other.len_; other.data_ nullptr; other.len_ 0; return *this; } ~MyString() { delete[] data_; } char operator[](size_t index) { return data_[index]; } const char* c_str() const { return data_ ? data_ : ; } size_t size() const { return len_; } private: char* data_; size_t len_; };写的时候注意几个点拷贝赋值要先判断自赋值this other否则先delete再拷贝会把源数据也删了移动构造要把源指针置nullptr防止源析构释放同一块内存——也就是“悬垂问题”析构函数必须delete[]否则内存泄漏。这些细节就是面试官一层层追问你时想听到的。6.2 面试官喜欢追问的底层问题vectorchar和string有什么区别什么时候用哪个string提供了字符串相关的语义比如find、substr、c_str()转换而且有SSO优化、和其它STL容器的互操作更顺畅。vectorchar更接近通用容器没有字符串语义。处理二进制缓冲区时用vectorchar更合适处理文本时用string。string是否线程安全 标准库保证不同的string对象之间操作是线程安全的但同一个string对象的并发读/写需要外部加锁。实际上因为String内部会缓存迭代器等信息自C11起该做法不可靠且被禁止不要在多个线程里同时修改同一个string。std::string的find超时吗时间复杂度多少 标准库的find通常是朴素匹配最坏O(n*m)。如果你的字符串规模巨大且查找频繁建议用std::string_view或手写KMP、Boyer-Moore算法做优化。很多做题人栽在超大字符串的超时上其实就是因为用了朴素find。为什么C17建议用string_view替代const string做参数string_view只持有指针和长度不拷贝、不拥有数据传参开销比const string更小。在只读场景特别是字符串来自外部比如文件内容用string_view更贴合设计意图。6.3 面试中的find超时优化方案假设面试官给你一个很长的文本几MB和一堆模式串让你统计每个模式串出现次数。最直接做法是每个模式串调一次find简单但最坏复杂度O(nmk)。如果模式串很多、文本很大就会超时。优化思路分几级如果单个模式串用KMP把匹配复杂度降到O(nm)。如果多个模式串用Trie树Aho-Corasick自动机一次扫描文本同时匹配所有模式串。如果文本固定且反复查询可以先建后缀数组或后缀自动机。我实际在日志分析工具里做过类似优化文本是几千万字符的日志模式串大概几百个从三重循环改成AC自动机后运行时间从半小时降到几秒差距是数量级的。平时刷题不妨自己实现一个KMP——它同时也是常考算法一石二鸟。6.4 对std::string::npos的理解npos是static const size_t的一个常量成员值是-1转换成size_t的结果也就是无符号整数的最大值。它同时用来表示“查找失败”和“空子串的结尾位置”。因为size_t无符号所以任何find返回失败时都会等于npos而你的比较条件必须显式写出来不能依赖0。有一个常见的陷阱s.npos是从对象访问静态成员语法合法因为编译器把对象当成它的类型去查找静态成员但代码规范上不推荐。直接写string::npos才是正道。7. 实际开发中的经验不同需求场景如何选型与优化工程实践里string用得多了慢慢就会形成一些“手感”。这里分享几条我在实际写代码时的经验希望能帮你少走弯路。7.1 拼字符串选择还是append两者基本等价但append有更丰富的重载可以追加一部分、可以追加多个相同字符、可以通过迭代器范围追加。比如string s hello; string suffix world!; s.append(suffix, 0, 5); // 追加suffix[0..5)结果是hello worl s.append(3, !); // 追加3个感叹号当追加的内容来自另一个字符串的一部分时用append(pos, count)比先substr再省去一次临时字符串的创建效率略高。常规追加用更直观特殊场景用append。7.2 大量小字符串的拼接谨慎使用a b c d这种写法看似优雅实则每一步都生成临时字符串尤其是中间产物会被反复构造和析构。举个例子string result a b c;这会先构造ab的临时对象再构造(ab)c的临时对象再拷贝给result。三个对象三次堆分配。如果字符串很长浪费很明显。工程上一般改成string result; result.reserve(a.size() b.size() c.size()); result a; result b; result c;只分配一次内存三次追加。对多行日志、SQL语句组装这种频繁拼接的场景性能提升肉眼可见。7.3 字符串作为函数参数与返回值的最佳实践按值返回一个局部string在C11之后很安全——编译器会省略拷贝直接移动或RVO优化。所以return s;没问题不用担心拷贝开销。函数参数就复杂一些。不管实参是const char*还是string只要函数内部不需要修改它一律const string最稳妥。如果函数需要在多个线程里各自处理大量字符串考虑直接传string_view以避开隐式转换和拷贝。7.4 什么时候该用string_viewC17加入的string_view是只读的字符串视图不拥有数据。它特别适合这些场景解析网络协议、JSON、配置文件数据来源是缓冲区或文件内容不用拷贝。函数只读字符串、只做查找和子串操作时传string_view比传const string更轻量。大量字符串切分时substr返回的是新字符串要分配内存而string_view::substr返回的仍是视图零拷贝。但要注意string_view的生命周期不能超过底层数据。底层数据被释放或修改后string_view就成了野指针。我这里有一条铁律字符串来自谁生命周期就归谁管string_view绝不跨函数长期保存。7.5 输出与性能调试的建议调试字符串问题时优先用cout或fprintf打印size()、capacity()、实际内容。如果怀疑扩容频繁导致性能下降可以做一个简单的计数器记录扩容次数。我自己习惯写一个带计数的小工具类内嵌到项目里临时统计定位问题后再删掉。在生产代码里避免直接输出原始用户输入到日志——字符串可能包含换行或控制字符刷屏事小日志注入事大。处理用户输入字符串时养成习惯先Trim再做长度限制再输出。8. 写在最后String类值得花时间吃透学C的过程里String类像一个“小切口”背后连着内存管理、拷贝控制、运算符重载、标准库设计、性能优化这些大主题。光会调用API只能算是会打字真正理解了它内部的深拷贝、移动语义、扩容策略你才算真正跨过了C的一道门坎。我个人体会最深的是C的很多面试题并不会直接问“String类的find怎么用”而是问“vector和string的内存分配有什么区别”“手写String类要怎么设计移动赋值”或者把String类和算法题混在一起考。所以这篇内容我特意把底层原理和面试高频环节放进了正文建议你学完API之后亲手把那个简化版MyString写三遍——第一遍照着敲第二遍不看代码写第三遍加注释讲清楚每行在干什么。三遍下来字符串相关的内存问题基本就通透了。如果你正打算用C做课程设计、后台开发或刷算法题String类就是每天都要碰的工具。把常用API记熟把几个容易踩的坑提前避开把手写代码的底子打好后面写任何项目都会顺畅很多。后面我还会基于这个主题继续聊string_view的应用、KMP算法实战、字符串哈希这些进阶内容如果你在练习中遇到有意思的问题欢迎一起讨论。