)
前言std::string大概是 C 里被用得最多、也被誤解得最多的類型。常見的誤解有三個第一以為它就是char數(shù)組的語法糖本質(zhì)上和char buf[100]差不多第二以為std::string一定在堆上分配內(nèi)存所以性能肯定比棧上的字符數(shù)組差第三以為模擬實現(xiàn)就是把std::string的源碼抄一遍——實際上標(biāo)準(zhǔn)只規(guī)定了接口的行為內(nèi)部怎么存、有沒有短字符串優(yōu)化Small String Optimization, SSO、擴容因子是多少全是實現(xiàn)定義的。本文分兩半前半講std::string的接口和真正會用到的用法后半寫一個能編譯、能跑、行為正確的簡化版MyString通過它把拷貝控制這件事講清楚。最后給出幾個真會踩的坑尤其是c_str()的懸垂指針和迭代器失效。目標(biāo)讀者是剛學(xué)完 C 字符串、準(zhǔn)備用std::string替換strcpy/strcat的人。本文代碼以 C17 為基準(zhǔn)GCC 13 / Clang 17 / MSVC 19.3x 均可編譯。一、std::string 到底是什么std::string不是標(biāo)準(zhǔn)庫里的一個類而是一個類型別名// 概念示意不是可直接編譯的聲明 namespace std { templateclass CharT, class Traits char_traitsCharT, class Allocator allocatorCharT class basic_string; using string basic_stringchar; }也就是說std::string是std::basic_stringchar。真正被標(biāo)準(zhǔn)規(guī)定的東西是basic_string的接口和復(fù)雜度要求具體怎么實現(xiàn)由標(biāo)準(zhǔn)庫廠商決定。你可以自己驗證大小// 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默認(rèn)的 C11 ABI上通常打印sizeof(std::string) 32。為什么是 32 而不是一個指針加兩個整數(shù)的 24因為多數(shù)實現(xiàn)在對象內(nèi)部留了一小塊本地緩沖區(qū)做 SSO短字符串直接存在對象里不碰堆。SSO 的閾值是徹頭徹尾的實現(xiàn)細(xì)節(jié)標(biāo)準(zhǔn)一個字都沒規(guī)定。下面是三家主流實現(xiàn)的常見情況僅供理解具體數(shù)值請以你本地的sizeof和capacity()實測為準(zhǔn)實現(xiàn)所屬編譯器SSO 內(nèi)部緩沖區(qū)常見容量sizeof(std::string)常見值libstdcC11 ABIstd::__cxx11::basic_stringGCC 515 個字符 結(jié)尾空字符32libstdc舊 ABI_GLIBCXX_USE_CXX11_ABI0GCC 5 之前無 SSO寫時復(fù)制8libcClang22 個字符 結(jié)尾空字符24MSVC STLMSVC15 個字符 結(jié)尾空字符32所以當(dāng)有人問std::string存 16 個字符會不會分配內(nèi)存時正確答案是取決于實現(xiàn)GCC 的 libstdc 通常在 16 個字符時就已經(jīng)轉(zhuǎn)到堆上了而 Clang 的 libc 要到 23 個字符才轉(zhuǎn)。別把它當(dāng)標(biāo)準(zhǔn)斷言。二、真正會用到的接口std::string的成員函數(shù)很多但日常高頻的其實就是下面這些。全部以標(biāo)準(zhǔn)的規(guī)定為準(zhǔn)需要精確簽名時查 cppreference 或標(biāo)準(zhǔn) [string] 一節(jié)。分類成員函數(shù)說明容量size()/length()兩者等價返回字符個數(shù)不含結(jié)尾空字符容量capacity()當(dāng)前已分配空間能容納多少字符容量empty()是否為空容量reserve(n)預(yù)留至少 n 個字符的空間避免多次擴容容量resize(n)改變元素個數(shù)多出的位置用char()填充容量clear()清空內(nèi)容容量一般不變訪問operator[](i)不檢查越界越界是 UB訪問at(i)越界拋std::out_of_range訪問front()/back()C11 起空串上調(diào)用是 UB訪問data()/c_str()返回指向內(nèi)部緩沖的指針c_str()一定以\0結(jié)尾修改push_back(c)/pop_back()追加/刪除末尾字符修改append(s)/operator追加比append更常用也更短修改insert(pos, s)在 pos 處插入修改erase(pos, n)刪除從 pos 起 n 個字符查找find(s, pos 0)返回首次出現(xiàn)位置找不到返回npos查找rfind,find_first_of,find_first_not_of返回值同樣用npos表示失敗截取substr(pos 0, count npos)返回新串有拷貝pos 越界拋std::out_of_range比較compare(s)返回負(fù)值/0/正值關(guān)于data()有一個必須記住的版本差異標(biāo)準(zhǔn)版本const std::string上的data()返回非 const 對象上的data()返回C11 / C14const char*const char*C17 起const char*char*可寫也就是說C17 起非 const 的data()是可寫的但不能寫超過size()的位置也不能改寫size()之后那個結(jié)尾空字符——那是 UB。三、三個最常用的慣用法用法一拼接時先reserve。循環(huán)里反復(fù)會觸發(fā)重新分配reallocation分配更大的塊、把舊數(shù)據(jù)搬過去、釋放舊塊。搬一次就是 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); // 只預(yù)留一次 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的結(jié)果存進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()但只在調(diào)用期間用。// C17 #include string #include cstdio int main() { std::string name cpp; // 直接把指針交給 C 函數(shù)printf 在本次調(diào)用內(nèi)使用它安全 std::printf(%s\n, name.c_str()); return 0; }c_str()返回的指針在任何會修改這個 string 的操作之后都可能失效包括push_back、、reserve導(dǎo)致的擴容。這不是可能失效的模糊說法——標(biāo)準(zhǔn)規(guī)定這類操作會使指向元素的指針/引用失效的規(guī)則適用于data()c_str()同理。實戰(zhàn)一個可編譯的簡化版 MyString下面這個類不追求接口完備只實現(xiàn)最核心的部分構(gòu)造、析構(gòu)、拷貝構(gòu)造、拷貝賦值、移動構(gòu)造、移動賦值、size、c_str、operator[]、operator并且不實現(xiàn) SSO所有非空數(shù)據(jù)都在堆上。通過它可以看清拷貝控制的五件事。// C17單文件可直接編譯g -stdc17 -Wall -Wextra mystring.cpp #include cstddef #include cstring #include iostream #include utility class MyString { public: // 默認(rèn)構(gòu)造空串也要有一個合法的 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); // 連結(jié)尾 \0 一起拷 } // 拷貝構(gòu)造深拷貝 MyString(const MyString other) { size_ other.size_; cap_ other.size_; buf_ new char[size_ 1]; std::memcpy(buf_, other.buf_, size_ 1); } // 移動構(gòu)造接管別人的緩沖區(qū)并把對方置為有效但空的狀態(tài) MyString(MyString other) noexcept : size_(other.size_), cap_(other.cap_), buf_(other.buf_) { other.size_ 0; other.cap_ 0; other.buf_ new char[1]; // 對方仍然要能析構(gòu)、能 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; // 拷貝構(gòu)造 b MyString(, world); std::cout b (size b.size() )\n; MyString c a b; // 移動構(gòu)造 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編譯應(yīng)無警告。幾個設(shè)計點值得說明默認(rèn)構(gòu)造也分配 1 字節(jié)這樣c_str()永遠(yuǎn)返回一個合法的、以\0結(jié)尾的指針。移動構(gòu)造里noexcept不是裝飾。標(biāo)準(zhǔn)容器比如std::vector在擴容時判斷元素的移動構(gòu)造函數(shù)是否noexcept是則用移動否則為了強異常保證會退回到拷貝。標(biāo)上noexcept能讓容器選擇更省的路徑。移動后源對象仍處于有效但未指定狀態(tài)。我在移動構(gòu)造里給源對象重新分配了空緩沖區(qū)這比留一個空指針更安全后者會讓源對象的c_str()直接崩潰。賦值用拷貝并交換copy-and-swap天然處理自賦值且異常安全。operator按值接收左操作數(shù)于是lhs本身就是一份拷貝可以直接在上面追加再返回——返回時享受移動語義。需要坦白一點這個簡化版的移動構(gòu)造里做了一次new char[1]卻把移動構(gòu)造標(biāo)成了noexcept。一旦這次分配失敗拋出std::bad_alloc程序會直接調(diào)用std::terminate。真實的標(biāo)準(zhǔn)庫實現(xiàn)靠 SSO 或共享的空串靜態(tài)對象避免這次分配它們的移動構(gòu)造是真的不會失敗。寫生產(chǎn)代碼時不要照抄這一點要么讓移動構(gòu)造真的不做可能拋異常的事要么就別標(biāo)noexcept——而不標(biāo)noexcept又會失去容器擴容時優(yōu)先移動的機會這正是 SSO 重要性的來源之一。常見坑點坑 1把c_str()的返回值存下來跨語句使用。?const char* p s.c_str(); s x; // 可能觸發(fā)擴容p 變成懸垂指針 std::printf(%s\n, p); // UB標(biāo)準(zhǔn)不保證任何行為? 只在同一個表達(dá)式/同一個調(diào)用內(nèi)用c_str()確實要留住就復(fù)制一份到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 位平臺被截斷判斷結(jié)果錯亂 if (pos std::string::npos) // 類型不匹配比較結(jié)果不可靠? 用std::string::size_type或auto接收find的返回值再和std::string::npos比較???4以為reserve之后指針就永遠(yuǎn)穩(wěn)定了。? 在循環(huán)里reserve一次然后一路追加并緩存data()返回的指針。?reserve只是減少擴容次數(shù)任何可能改動size或capacity的操作都可能讓之前的指針失效。要長期持有就存下標(biāo)不存指針。坑 5中文字符串用size()當(dāng)字?jǐn)?shù)。?std::string s 中文;然后認(rèn)為s.size() 2。UTF-8 下一個漢字通常是 3 字節(jié)因此s.size()是 6。? 明確區(qū)分字節(jié)數(shù)和字符數(shù)。字符級處理需要寬字符、std::u8stringC20或第三方 Unicode 庫只想知道 UTF-8 碼點個數(shù)可以手寫一個按首字節(jié)判斷續(xù)字節(jié)個數(shù)的計數(shù)器???6循環(huán)里s s x;而不是s x;。?for (int i 0; i 1000; i) s s x; // 每次都構(gòu)造臨時串再拷貝賦值?s x;——operator直接在原串上追加不需要構(gòu)造完整副本??偨Y(jié)主題結(jié)論std::string的本質(zhì)std::basic_stringchar的別名接口由標(biāo)準(zhǔn)規(guī)定布局由實現(xiàn)決定SSO實現(xiàn)細(xì)節(jié)GCC 的 libstdc 與 MSVC STL 常見 15 字符Clang 的 libc 常見 22 字符取字符指針用c_str()且只在調(diào)用期間使用C17 起非 const 的data()可寫查找失敗返回std::string::npos必須用std::string::size_type接收效率循環(huán)拼接前先reserve用而不是s s ...實現(xiàn)自己的字符串類必須成套實現(xiàn)拷貝構(gòu)造/拷貝賦值/移動構(gòu)造/移動賦值/析構(gòu)移動構(gòu)造標(biāo)noexceptstd::string的接口看著平易近人真正的難點在兩個地方一是生命周期——所有返回指針的函數(shù)c_str、data都把什么時候會失效的責(zé)任交給了調(diào)用者二是實現(xiàn)差異——SSO、容量增長策略這些看起來應(yīng)該是標(biāo)準(zhǔn)的東西其實全是廠商自由發(fā)揮。把這兩點記牢用std::string就很少會出問題自己寫一個簡化版則是把拷貝控制這套規(guī)則真正內(nèi)化的最快辦法。