拓冰建站拓冰建站
首页 / 资讯中心 / 正文

【C++】string的使用与模拟实现

前言std::string大概是 C 里被用得最多、也被误解得最多的类型。常见的误解有三个第一以为它就是char数组的语法糖本质上和char buf[100]差不多第二以为std::string一定在堆上分配内存所以性能肯定比栈上的字符数组差第三以为模拟实现就是把std::string的源码抄一遍——实际上标准只规定了接口的行为内部怎么存、有没有短字符串优化Small String Optimization, SSO、扩容因子是多少全是实现定义的。本文分两半前半讲std::string的接口和真正会用到的用法后半写一个能编译、能跑、行为正确的简化版MyString通过它把拷贝控制这件事讲清楚。最后给出几个真会踩的坑尤其是c_str()的悬垂指针和迭代器失效。目标读者是刚学完 C 字符串、准备用std::string替换strcpy/strcat的人。本文代码以 C17 为基准GCC 13 / Clang 17 / MSVC 19.3x 均可编译。一、std::string 到底是什么std::string不是标准库里的一个类而是一个类型别名// 概念示意不是可直接编译的声明 namespace std { templateclass CharT, class Traits char_traitsCharT, class Allocator allocatorCharT class basic_string; using string basic_stringchar; }也就是说std::string是std::basic_stringchar。真正被标准规定的东西是basic_string的接口和复杂度要求具体怎么实现由标准库厂商决定。你可以自己验证大小// C17 #include iostream #include string #include vector int main() { std::cout sizeof(std::string) sizeof(std::string) \n; std::cout sizeof(std::vectorint) sizeof(std::vectorint) \n; std::string s hi; std::cout size s.size() , capacity s.capacity() \n; return 0; }这段代码在 GCC 13libstdc默认的 C11 ABI上通常打印sizeof(std::string) 32。为什么是 32 而不是一个指针加两个整数的 24因为多数实现在对象内部留了一小块本地缓冲区做 SSO短字符串直接存在对象里不碰堆。SSO 的阈值是彻头彻尾的实现细节标准一个字都没规定。下面是三家主流实现的常见情况仅供理解具体数值请以你本地的sizeof和capacity()实测为准实现所属编译器SSO 内部缓冲区常见容量sizeof(std::string)常见值libstdcC11 ABIstd::__cxx11::basic_stringGCC 515 个字符 结尾空字符32libstdc旧 ABI_GLIBCXX_USE_CXX11_ABI0GCC 5 之前无 SSO写时复制8libcClang22 个字符 结尾空字符24MSVC STLMSVC15 个字符 结尾空字符32所以当有人问std::string存 16 个字符会不会分配内存时正确答案是取决于实现GCC 的 libstdc 通常在 16 个字符时就已经转到堆上了而 Clang 的 libc 要到 23 个字符才转。别把它当标准断言。二、真正会用到的接口std::string的成员函数很多但日常高频的其实就是下面这些。全部以标准的规定为准需要精确签名时查 cppreference 或标准 [string] 一节。分类成员函数说明容量size()/length()两者等价返回字符个数不含结尾空字符容量capacity()当前已分配空间能容纳多少字符容量empty()是否为空容量reserve(n)预留至少 n 个字符的空间避免多次扩容容量resize(n)改变元素个数多出的位置用char()填充容量clear()清空内容容量一般不变访问operator[](i)不检查越界越界是 UB访问at(i)越界抛std::out_of_range访问front()/back()C11 起空串上调用是 UB访问data()/c_str()返回指向内部缓冲的指针c_str()一定以\0结尾修改push_back(c)/pop_back()追加/删除末尾字符修改append(s)/operator追加比append更常用也更短修改insert(pos, s)在 pos 处插入修改erase(pos, n)删除从 pos 起 n 个字符查找find(s, pos 0)返回首次出现位置找不到返回npos查找rfind,find_first_of,find_first_not_of返回值同样用npos表示失败截取substr(pos 0, count npos)返回新串有拷贝pos 越界抛std::out_of_range比较compare(s)返回负值/0/正值关于data()有一个必须记住的版本差异标准版本const std::string上的data()返回非 const 对象上的data()返回C11 / C14const char*const char*C17 起const char*char*可写也就是说C17 起非 const 的data()是可写的但不能写超过size()的位置也不能改写size()之后那个结尾空字符——那是 UB。三、三个最常用的惯用法用法一拼接时先reserve。循环里反复会触发重新分配reallocation分配更大的块、把旧数据搬过去、释放旧块。搬一次就是 O(n)。如果提前知道大概长度reserve能把这个开销压成一次。// C17 #include string #include vector #include iostream std::string join(const std::vectorstd::string parts, char sep) { std::size_t total 0; for (const auto p : parts) total p.size(); if (!parts.empty()) total parts.size() - 1; std::string out; out.reserve(total); // 只预留一次 for (std::size_t i 0; i parts.size(); i) { if (i) out sep; out parts[i]; } return out; } int main() { std::vectorstd::string v{alpha, beta, gamma}; std::cout join(v, ,) \n; // alpha,beta,gamma return 0; }用法二用findsubstr切分注意npos的比较方式。std::string::npos是static const size_type npos -1即size_type的最大值。比较时要小心类型宽度把find的结果存进int会在 64 位平台上截断。// C17 #include string #include vector std::vectorstd::string split(const std::string s, char sep) { std::vectorstd::string out; std::string::size_type start 0; while (true) { std::string::size_type pos s.find(sep, start); if (pos std::string::npos) { out.push_back(s.substr(start)); break; } out.push_back(s.substr(start, pos - start)); start pos 1; } return out; }用法三需要 C 接口时用c_str()但只在调用期间用。// C17 #include string #include cstdio int main() { std::string name cpp; // 直接把指针交给 C 函数printf 在本次调用内使用它安全 std::printf(%s\n, name.c_str()); return 0; }c_str()返回的指针在任何会修改这个 string 的操作之后都可能失效包括push_back、、reserve导致的扩容。这不是可能失效的模糊说法——标准规定这类操作会使指向元素的指针/引用失效的规则适用于data()c_str()同理。实战一个可编译的简化版 MyString下面这个类不追求接口完备只实现最核心的部分构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值、size、c_str、operator[]、operator并且不实现 SSO所有非空数据都在堆上。通过它可以看清拷贝控制的五件事。// C17单文件可直接编译g -stdc17 -Wall -Wextra mystring.cpp #include cstddef #include cstring #include iostream #include utility class MyString { public: // 默认构造空串也要有一个合法的 buf_保证 c_str() 可用 MyString() : size_(0), cap_(0), buf_(new char[1]) { buf_[0] \0; } MyString(const char* s) { size_ std::strlen(s); cap_ size_; buf_ new char[size_ 1]; std::memcpy(buf_, s, size_ 1); // 连结尾 \0 一起拷 } // 拷贝构造深拷贝 MyString(const MyString other) { size_ other.size_; cap_ other.size_; buf_ new char[size_ 1]; std::memcpy(buf_, other.buf_, size_ 1); } // 移动构造接管别人的缓冲区并把对方置为有效但空的状态 MyString(MyString other) noexcept : size_(other.size_), cap_(other.cap_), buf_(other.buf_) { other.size_ 0; other.cap_ 0; other.buf_ new char[1]; // 对方仍然要能析构、能 c_str() other.buf_[0] \0; } MyString operator(const MyString other) { if (this ! other) { // 必须自赋值检查否则自己释放自己 MyString tmp(other); // 先拷贝异常安全 swap(tmp); } return *this; } MyString operator(MyString other) noexcept { if (this ! other) { MyString tmp(std::move(other)); swap(tmp); } return *this; } ~MyString() { delete[] buf_; } void swap(MyString other) noexcept { std::swap(size_, other.size_); std::swap(cap_, other.cap_); std::swap(buf_, other.buf_); } std::size_t size() const { return size_; } bool empty() const { return size_ 0; } const char* c_str() const { return buf_; } char operator[](std::size_t i) { return buf_[i]; } // 不检查越界 const char operator[](std::size_t i) const { return buf_[i]; } void reserve(std::size_t n) { if (n cap_) return; char* nb new char[n 1]; std::memcpy(nb, buf_, size_ 1); delete[] buf_; buf_ nb; cap_ n; } MyString operator(const MyString rhs) { if (rhs.size_ 0) return *this; if (size_ rhs.size_ cap_) { reserve(size_ rhs.size_); // 简化策略需要多少要多少 } std::memcpy(buf_ size_, rhs.buf_, rhs.size_ 1); size_ rhs.size_; return *this; } MyString operator(const char* rhs) { return *this MyString(rhs); } private: std::size_t size_; std::size_t cap_; char* buf_; }; MyString operator(MyString lhs, const MyString rhs) { lhs rhs; // 传值 返回天然享受移动语义 return lhs; } std::ostream operator(std::ostream os, const MyString s) { return os s.c_str(); } int main() { MyString a(Hello); MyString b a; // 拷贝构造 b MyString(, world); std::cout b (size b.size() )\n; MyString c a b; // 移动构造 std::cout c (size c.size() )\n; MyString d; d std::move(c); // 移动赋值 std::cout d \n; a a; // 自赋值必须是安全的 std::cout a \n; return 0; }这段代码在 GCC 13 / Clang 17 上用-stdc17 -Wall -Wextra编译应无警告。几个设计点值得说明默认构造也分配 1 字节这样c_str()永远返回一个合法的、以\0结尾的指针。移动构造里noexcept不是装饰。标准容器比如std::vector在扩容时判断元素的移动构造函数是否noexcept是则用移动否则为了强异常保证会退回到拷贝。标上noexcept能让容器选择更省的路径。移动后源对象仍处于有效但未指定状态。我在移动构造里给源对象重新分配了空缓冲区这比留一个空指针更安全后者会让源对象的c_str()直接崩溃。赋值用拷贝并交换copy-and-swap天然处理自赋值且异常安全。operator按值接收左操作数于是lhs本身就是一份拷贝可以直接在上面追加再返回——返回时享受移动语义。需要坦白一点这个简化版的移动构造里做了一次new char[1]却把移动构造标成了noexcept。一旦这次分配失败抛出std::bad_alloc程序会直接调用std::terminate。真实的标准库实现靠 SSO 或共享的空串静态对象避免这次分配它们的移动构造是真的不会失败。写生产代码时不要照抄这一点要么让移动构造真的不做可能抛异常的事要么就别标noexcept——而不标noexcept又会失去容器扩容时优先移动的机会这正是 SSO 重要性的来源之一。常见坑点坑 1把c_str()的返回值存下来跨语句使用。❌const char* p s.c_str(); s x; // 可能触发扩容p 变成悬垂指针 std::printf(%s\n, p); // UB标准不保证任何行为✅ 只在同一个表达式/同一个调用内用c_str()确实要留住就复制一份到std::string或std::vectorchar。坑 2substr越界不会报错但at会。❌std::string s abc; char c s[10];——operator[]不检查越界是 UB。✅ 用s.at(10)它在越界时抛std::out_of_range。注意substr(pos)在pos size()时也抛std::out_of_range。坑 3把npos塞进int。❌int pos s.find(x); // npos 在 64 位平台被截断判断结果错乱 if (pos std::string::npos) // 类型不匹配比较结果不可靠✅ 用std::string::size_type或auto接收find的返回值再和std::string::npos比较。坑 4以为reserve之后指针就永远稳定了。❌ 在循环里reserve一次然后一路追加并缓存data()返回的指针。✅reserve只是减少扩容次数任何可能改动size或capacity的操作都可能让之前的指针失效。要长期持有就存下标不存指针。坑 5中文字符串用size()当字数。❌std::string s 中文;然后认为s.size() 2。UTF-8 下一个汉字通常是 3 字节因此s.size()是 6。✅ 明确区分字节数和字符数。字符级处理需要宽字符、std::u8stringC20或第三方 Unicode 库只想知道 UTF-8 码点个数可以手写一个按首字节判断续字节个数的计数器。坑 6循环里s s x;而不是s x;。❌for (int i 0; i 1000; i) s s x; // 每次都构造临时串再拷贝赋值✅s x;——operator直接在原串上追加不需要构造完整副本。总结主题结论std::string的本质std::basic_stringchar的别名接口由标准规定布局由实现决定SSO实现细节GCC 的 libstdc 与 MSVC STL 常见 15 字符Clang 的 libc 常见 22 字符取字符指针用c_str()且只在调用期间使用C17 起非 const 的data()可写查找失败返回std::string::npos必须用std::string::size_type接收效率循环拼接前先reserve用而不是s s ...实现自己的字符串类必须成套实现拷贝构造/拷贝赋值/移动构造/移动赋值/析构移动构造标noexceptstd::string的接口看着平易近人真正的难点在两个地方一是生命周期——所有返回指针的函数c_str、data都把什么时候会失效的责任交给了调用者二是实现差异——SSO、容量增长策略这些看起来应该是标准的东西其实全是厂商自由发挥。把这两点记牢用std::string就很少会出问题自己写一个简化版则是把拷贝控制这套规则真正内化的最快办法。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门