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