C++ string 類原理、踩坑與對(duì)象語(yǔ)義詳解
學(xué) C++ 到一定階段,
std::string用起來(lái)順手,但總感覺(jué)底下有一片黑盒子。我拷貝一個(gè)字符串,內(nèi)存是怎么分配的??jī)蓚€(gè)對(duì)象賦值,舊資源去哪了?函數(shù)結(jié)束時(shí)發(fā)生了什么?帶著這些問(wèn)題,我決定自己實(shí)現(xiàn)一遍 string 類。這篇文章記錄整個(gè)過(guò)程:包括每個(gè)函數(shù)的設(shè)計(jì)思路、我踩過(guò)的坑、以及由此引出的 C++ 對(duì)象語(yǔ)義。如果你也在這個(gè)階段,這篇文章大概對(duì)你有用。
一、為什么 string 是一個(gè)"資源管理類"
在開(kāi)始寫代碼之前,先理解 string 的本質(zhì)。
普通的 int、double 變量,值存在棧上,函數(shù)結(jié)束自動(dòng)消失,不用操心。但 string 不一樣——字符數(shù)據(jù)存在堆上,對(duì)象只持有一個(gè)指針。這塊堆內(nèi)存的生命周期需要手動(dòng)管理:什么時(shí)候分配、什么時(shí)候釋放、被復(fù)制的時(shí)候怎么處理。
這種"持有資源、負(fù)責(zé)管理資源生命周期"的類,在 C++ 里叫做 RAII 類(Resource Acquisition Is Initialization)。string 就是最典型的例子之一:
- 構(gòu)造函數(shù):獲取資源(new 內(nèi)存)
- 析構(gòu)函數(shù):釋放資源(delete 內(nèi)存)
- 拷貝構(gòu)造 / 賦值:處理資源的復(fù)制或轉(zhuǎn)移
理解了這一點(diǎn),后面所有的設(shè)計(jì)決策都能說(shuō)清楚了。
二、整體結(jié)構(gòu)設(shè)計(jì)
我選用三個(gè)成員變量來(lái)描述一個(gè)字符串的完整狀態(tài):
private:
char* _str; // 指向堆上的字符數(shù)組
size_t _size; // 當(dāng)前字符串長(zhǎng)度(不含 '\0')
size_t _capacity; // 當(dāng)前已分配容量(不含 '\0')
static const size_t npos = -1;
_str 是真正存數(shù)據(jù)的地方,_size 記錄當(dāng)前有多少個(gè)字符,_capacity 記錄申請(qǐng)了多大的空間。三者之間的關(guān)系始終滿足:
_size <= _capacity 實(shí)際分配字節(jié)數(shù) = _capacity + 1(多一個(gè)給 '\0')
npos 定義為靜態(tài)常量,值是 (size_t)-1,也就是 size_t 類型的最大值(在 64 位系統(tǒng)上是 0xFFFFFFFFFFFFFFFF)。它的語(yǔ)義是"不存在"或"到末尾",和標(biāo)準(zhǔn)庫(kù)一致。
關(guān)于頭文件和源文件的分離:我把函數(shù)聲明放在 String.h,定義放在 String.cpp,原因是避免重復(fù)定義。如果把函數(shù)體寫在 .h 里,每個(gè) include 這個(gè)頭文件的翻譯單元都會(huì)有一份定義,鏈接時(shí)報(bào)重定義錯(cuò)誤。聲明放 .h、定義放 .cpp 是 C++ 的標(biāo)準(zhǔn)做法。
整個(gè)類放在 namespace Jianyi 里。namespace 的作用是防止命名沖突——我們自己實(shí)現(xiàn)的 string 和標(biāo)準(zhǔn)庫(kù)的 std::string 同名,用 namespace 隔開(kāi)就不會(huì)互相干擾。
三、構(gòu)造函數(shù)與析構(gòu)函數(shù)
構(gòu)造函數(shù)
string(const char* str = "")
{
assert(str);
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
幾個(gè)細(xì)節(jié)值得說(shuō):
首先,參數(shù)默認(rèn)值是 ""(空字符串),而不是 nullptr。這樣 string s; 和 string s(""); 都能工作,而且 assert(str) 能攔住真正傳 null 的情況。
其次,new char[_capacity + 1] 多申請(qǐng)一個(gè)字節(jié)。_capacity 不計(jì)入 '\0',但數(shù)組必須留位置給它,strcpy 依賴這個(gè)終止符。這個(gè)"差一"的約定在整個(gè)實(shí)現(xiàn)中要保持一致,是容易出錯(cuò)的地方。
strcpy 會(huì)把 str 包括終止符在內(nèi)整個(gè)復(fù)制過(guò)去,所以不需要單獨(dú)寫 _str[_size] = '\0'。
析構(gòu)函數(shù)
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
delete[] 對(duì)應(yīng) new[],這是硬性規(guī)則。如果用 delete(不帶方括號(hào)),行為是未定義的——對(duì)于內(nèi)置類型數(shù)組通常僥幸沒(méi)問(wèn)題,但不能依賴。
析構(gòu)后把 _str 置為 nullptr,是防御性編程:如果析構(gòu)后有代碼試圖再次訪問(wèn)這個(gè)指針,至少不會(huì)訪問(wèn)已釋放的內(nèi)存(會(huì)立即崩潰,比靜默地讀到垃圾數(shù)據(jù)容易發(fā)現(xiàn)問(wèn)題)。
delete[] 的本質(zhì):它先調(diào)用數(shù)組中每個(gè)元素的析構(gòu)函數(shù)(對(duì) char 無(wú)意義,但對(duì)對(duì)象數(shù)組很重要),再釋放整塊內(nèi)存。這是 new[] / delete[] 必須配對(duì)的原因——分配時(shí) new[] 會(huì)在內(nèi)存塊頭部記錄元素?cái)?shù)量,delete[] 靠這個(gè)數(shù)量來(lái)逐一調(diào)用析構(gòu)函數(shù)。如果用 delete 釋放,這個(gè)元數(shù)據(jù)就不會(huì)被讀,析構(gòu)函數(shù)就不會(huì)被逐一調(diào)用。
四、深拷貝 vs 淺拷貝,以及 double free
這是整個(gè) string 實(shí)現(xiàn)最核心的概念。
淺拷貝:直接復(fù)制成員變量的值。對(duì)于 _str 來(lái)說(shuō),就是復(fù)制指針的值,兩個(gè)對(duì)象指向同一塊堆內(nèi)存。
s1._str ──┐
▼
[ h e l l o \0 ]
▲
s2._str ──┘
這個(gè)結(jié)構(gòu)有致命問(wèn)題:當(dāng) s1 和 s2 分別析構(gòu)時(shí),同一塊內(nèi)存會(huì)被 delete[] 兩次,這就是 double free。double free 是未定義行為,輕則程序崩潰,重則內(nèi)存結(jié)構(gòu)被破壞、引發(fā)安全漏洞。
如果不手動(dòng)實(shí)現(xiàn)拷貝構(gòu)造函數(shù)和賦值運(yùn)算符,編譯器默認(rèn)生成的版本就是淺拷貝。所以,凡是持有堆內(nèi)存的類,必須自己實(shí)現(xiàn)"Big Three":析構(gòu)函數(shù)、拷貝構(gòu)造函數(shù)、賦值運(yùn)算符。
深拷貝:為新對(duì)象單獨(dú)申請(qǐng)內(nèi)存,把數(shù)據(jù)完整復(fù)制一份。
s1._str ──? [ h e l l o \0 ] s2._str ──? [ h e l l o \0 ] (獨(dú)立的一份)
兩者獨(dú)立,析構(gòu)互不影響。代價(jià)是每次拷貝都需要一次 new,但安全性有保證。
五、拷貝構(gòu)造函數(shù)(我踩的第一個(gè)大坑)
我注釋掉的第一版:直接深拷貝
// 被注釋掉的版本
string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
這個(gè)版本邏輯上是完全正確的,就是樸素的深拷貝:給自己申請(qǐng)內(nèi)存,把對(duì)方的數(shù)據(jù)復(fù)制過(guò)來(lái)。寫法清晰,沒(méi)有問(wèn)題。
我最終用的版本:copy-and-swap
string(const string& s)
{
string tmp(s._str); // 用 char* 構(gòu)造函數(shù)創(chuàng)建臨時(shí)對(duì)象
swap(tmp); // 把 *this 和 tmp 互換
} // tmp 析構(gòu),釋放 *this 原來(lái)(未初始化)的資源
這里用的是 copy-and-swap 慣用法。理解這個(gè)版本,關(guān)鍵是搞清楚 swap 交換的是誰(shuí)。
swap(tmp) 等價(jià)于 this->swap(tmp),交換的是 *this 和 tmp。執(zhí)行完之后,*this 持有 tmp 創(chuàng)建的那塊內(nèi)存(也就是從 s._str 深拷貝來(lái)的數(shù)據(jù)),tmp 則拿走了 *this 原來(lái)的內(nèi)容。由于 tmp 是局部變量,函數(shù)結(jié)束時(shí)它會(huì)自動(dòng)析構(gòu),順帶釋放掉 *this 原來(lái)那塊資源。
我曾經(jīng)寫錯(cuò)的版本
// 錯(cuò)誤寫法?。?
string(const string& s)
{
string tmp(s._str);
swap(s); // swap 的對(duì)象是 s,不是 tmp!
}
問(wèn)題有兩個(gè):第一,swap(s) 交換的是 *this 和參數(shù) s,tmp 根本沒(méi)被用到;第二,s 是 const string&,不能被修改,而 swap 需要修改雙方,這從設(shè)計(jì)上就矛盾了。
當(dāng)時(shí)我把 swap 的簽名也寫錯(cuò)了:
void swap(const string& s) // 錯(cuò)誤:const 參數(shù)無(wú)法被修改
正確的簽名應(yīng)該是:
void swap(string& s) // 去掉 const
swap 必須修改雙方,參數(shù)加 const 在邏輯上就說(shuō)不通。
六、swap 的實(shí)現(xiàn)與意義
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
這里內(nèi)部調(diào)用的是 std::swap,交換的是指針和兩個(gè)整數(shù)。不管字符串有多長(zhǎng),只有三次賦值操作,時(shí)間復(fù)雜度永遠(yuǎn)是 O(1)。
STL 為什么追求 O(1) 的 swap?因?yàn)?swap 在標(biāo)準(zhǔn)庫(kù)算法中被大量使用(排序、partition、各種容器操作),如果 swap 是 O(n) 的,這些算法的復(fù)雜度就會(huì)惡化。
這里有一個(gè)關(guān)于模板的細(xì)節(jié):std::swap 的通用實(shí)現(xiàn)是三次賦值(tmp = a, a = b, b = tmp),對(duì) string 來(lái)說(shuō)意味著兩次深拷貝,是 O(n) 的。但標(biāo)準(zhǔn)庫(kù)對(duì) std::string 有特化版本,會(huì)調(diào)用成員函數(shù) swap,也就是我們實(shí)現(xiàn)的這個(gè)版本,降回 O(1)。
普通函數(shù)和函數(shù)模板的優(yōu)先級(jí):當(dāng)存在完全匹配的普通函數(shù)時(shí),編譯器優(yōu)先選擇普通函數(shù),而不是實(shí)例化模板。std::string 的 swap 特化本質(zhì)上就是這個(gè)機(jī)制的體現(xiàn)。我們自己的 Jianyi::string 沒(méi)有做這個(gè)特化,但如果有人寫 std::swap(a, b) 來(lái)交換我們的對(duì)象,就會(huì)走通用版本(O(n));寫 a.swap(b) 才能走我們的成員函數(shù)(O(1))。
七、賦值運(yùn)算符
我注釋掉的第一版
// 被注釋掉的版本
string& operator=(const string& s)
{
delete[] _str;
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
return *this;
}
邏輯清晰:先釋放自己的舊資源,再按對(duì)方重新分配。但有一個(gè)致命缺陷——自賦值問(wèn)題。
如果寫 s = s,進(jìn)入函數(shù)后先執(zhí)行 delete[] _str,此時(shí) s._str 指向的內(nèi)存已經(jīng)被釋放。接下來(lái) strcpy(_str, s._str) 訪問(wèn)的是懸空指針,行為未定義。
我的錯(cuò)誤判斷
// 錯(cuò)誤寫法 if (_str != s._str)
這個(gè)判斷想攔住自賦值,但條件判斷的是指針值,不是對(duì)象地址。兩個(gè)不同的對(duì)象,在淺拷貝場(chǎng)景下完全可能 _str 相同(指向同一塊內(nèi)存)。正確的自賦值判斷是比較對(duì)象本身的地址:
if (this != &s)
最終版本:copy-and-swap
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s._str); // 深拷貝到臨時(shí)對(duì)象
swap(tmp); // 交換資源,tmp 拿走舊資源
}
return *this;
}
這個(gè)版本的妙處在于:資源的釋放由 tmp 的析構(gòu)函數(shù)完成,不需要手動(dòng) delete。整個(gè)過(guò)程異常安全——如果 new 失敗拋異常,tmp 根本沒(méi)構(gòu)造成功,*this 的狀態(tài)沒(méi)有被改動(dòng)。
八、reserve:擴(kuò)容的核心
void string::reserve(size_t n)
{
if (n >= _capacity)
{
char* temp = new char[n + 1];
strcpy(temp, _str);
delete[] _str;
_str = temp;
_capacity = n;
}
}
reserve 只擴(kuò)容,不縮容(if (n >= _capacity) 保證了這一點(diǎn))。這和 std::string::reserve 的語(yǔ)義一致——你可以申請(qǐng)更大的空間,但不能用 reserve 強(qiáng)行縮小。
操作順序很關(guān)鍵:先 new,再 strcpy,再 delete[] 舊指針,最后更新 _str。順序不能倒。如果先 delete[] 再 new,一旦 new 失敗,_str 就成了懸空指針,對(duì)象處于損壞狀態(tài)。
潛在優(yōu)化點(diǎn):strcpy 只適合復(fù)制以 '\0' 結(jié)尾的字符串,換成 memcpy(_str, _capacity + 1) 在某些實(shí)現(xiàn)里更快(省去逐字符檢查終止符的開(kāi)銷),但這里用 strcpy 足夠清晰。
九、PushBack 和 append
PushBack:追加單個(gè)字符
void string::PushBack(char ch)
{
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : 2 * _capacity);
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
擴(kuò)容策略是翻倍(2 * _capacity),初始容量為 0 時(shí)直接給 4。翻倍策略保證均攤 O(1) 的追加代價(jià)——即使偶爾觸發(fā)擴(kuò)容(O(n)),均攤到每次追加上依然是常數(shù)時(shí)間。
string& string::operator+=(char ch)
{
PushBack(ch);
return *this;
}
operator+= 直接復(fù)用 PushBack,邏輯不重復(fù)。
append:追加字符串
void string::append(const char* str)
{
assert(str);
size_t len = strlen(str);
if (_size + len > _capacity)
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
size_t n = 0;
while (n < len)
{
_str[n + _size] = str[n];
++n;
}
_size += len;
_str[_size] = '\0';
}擴(kuò)容判斷:優(yōu)先翻倍,如果翻倍后還不夠就直接擴(kuò)到需要的大?。?code>_size + len)。這個(gè)邏輯在 insert 里也有類似寫法,是一個(gè)常見(jiàn)的"至少滿足需求,盡量翻倍"策略。
追加數(shù)據(jù)后必須手動(dòng)設(shè)置 _str[_size] = '\0',因?yàn)檫@里用的是逐字符賦值,不像 strcpy 會(huì)自動(dòng)附帶終止符。
operator+= 有一個(gè) const char* 版本聲明在頭文件里,但實(shí)現(xiàn)體我沒(méi)有寫(被注釋掉了)。實(shí)際會(huì)調(diào)用 append:
string& string::operator+=(const char* str)
{
append(str);
return *this;
}
十、insert:插入時(shí)的無(wú)符號(hào)類型陷阱
插入單個(gè)字符
void string::insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
reserve(_capacity == 0 ? 4 : 2 * _capacity);
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
_size += 1;
}后移數(shù)據(jù)從末尾開(kāi)始,end 從 _size + 1 往 pos 走,每次把 end - 1 位置的字符移到 end。
核心踩坑:我一開(kāi)始寫的是 while (end >= pos)。end 和 pos 都是 size_t(無(wú)符號(hào)整數(shù))。當(dāng) pos = 0、end 減到 0 之后再執(zhí)行 --end,結(jié)果不是 -1,而是 size_t 的最大值(約 1.8 × 10^19),條件永遠(yuǎn)為真,死循環(huán)。
改成 while (end > pos),當(dāng) end == pos 時(shí)循環(huán)停止,從根本上避免了無(wú)符號(hào)數(shù)下溢。這是 C++ 里用 size_t 做循環(huán)變量的經(jīng)典陷阱,C 語(yǔ)言的隱式類型轉(zhuǎn)換讓這類 bug 非常難發(fā)現(xiàn)。
代碼注釋里也寫了:while(end <= (int)pos) 是另一種解法——強(qiáng)轉(zhuǎn)成有符號(hào)類型,但不如直接改邏輯條件來(lái)得干凈。
插入字符串
void string::insert(size_t pos, const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
for (size_t i = 0; i < len; ++i)
_str[pos + i] = str[i];
_size += len;
}和單字符版本類似,但每次移動(dòng) len 個(gè)位置。先把 pos 之后的數(shù)據(jù)整體后移 len 格,再把 str 的內(nèi)容填入 [pos, pos + len) 的位置。
注釋里還有一個(gè)被廢棄的版本:
// 廢棄版本
for (size_t i = _size; i >= pos; --i)
{
_str[i + len] = _str[i];
}
同樣是 size_t 的無(wú)符號(hào)下溢問(wèn)題——i 減到 0 之后再減會(huì)繞回最大值,死循環(huán)。
十一、erase:刪除子串
void string::erase(size_t pos, size_t len)
{
assert(pos <= _size);
if (len == npos || len > _size - pos)
{
_str[pos] = '\0';
_size = pos;
}
else
{
for (size_t i = pos + len; i <= _size; ++i)
_str[i - len] = _str[i];
_size -= len;
}
}兩種情況:如果 len 是 npos(表示"到末尾"),或者刪除長(zhǎng)度超出剩余部分,就直接截?cái)?mdash;—在 pos 處寫 '\0',更新 _size 即可,不需要移動(dòng)任何數(shù)據(jù)。
否則,把 pos + len 之后的數(shù)據(jù)前移 len 格。循環(huán)條件 i <= _size 包含了 _size 本身,這樣連終止符 '\0' 也會(huì)一起被移過(guò)來(lái),不需要單獨(dú)設(shè)置。
邊界注意:len > _size - pos 這個(gè)判斷,如果 _size < pos 會(huì)發(fā)生無(wú)符號(hào)數(shù)下溢(值繞回變成超大數(shù)),但 assert(pos <= _size) 保證了進(jìn)函數(shù)時(shí) pos <= _size,所以 _size - pos 不會(huì)下溢,是安全的。
十二、find:字符串查找
查找單個(gè)字符
size_t string::find(char ch, size_t pos)
{
assert(pos < _size);
for (size_t i = pos; i < _size; ++i)
if (_str[i] == ch)
return i;
return npos;
}
從 pos 開(kāi)始線性掃描,找到返回下標(biāo),找不到返回 npos。
查找子串
size_t string::find(char* str, size_t pos)
{
assert(pos < _size);
const char* temp = strstr(_str + pos, str);
if (temp == nullptr)
return npos;
else
return temp - _str;
}
這里借助了標(biāo)準(zhǔn)庫(kù)函數(shù) strstr。strstr 的原理是滑動(dòng)匹配:從主串的每個(gè)位置開(kāi)始,嘗試和子串逐字符比較;如果當(dāng)前位置不匹配,移動(dòng)到下一個(gè)位置重新開(kāi)始。樸素實(shí)現(xiàn)是 O(n × m),標(biāo)準(zhǔn)庫(kù)的實(shí)現(xiàn)通常有優(yōu)化(類似 KMP 或 Boyer-Moore),但接口語(yǔ)義是一樣的。
strstr 返回的是指針,指向主串中子串開(kāi)始的位置。用這個(gè)指針減去 _str(數(shù)組起始地址),就得到了下標(biāo)。這是指針運(yùn)算的一個(gè)典型用法:兩個(gè)指針相減得到元素個(gè)數(shù)(距離),前提是兩者指向同一塊數(shù)組。
十三、substr:截取子串
string string::substr(size_t pos, size_t len)
{
if (len > _size - pos)
len = _size - pos;
string sub;
sub.reserve(len);
sub._str[0] = '\0';
for (size_t i = 0; i < len; ++i)
sub += _str[pos + i];
return sub;
}先修正 len(不讓它超出剩余長(zhǎng)度),然后構(gòu)造一個(gè)空字符串 sub,reserve 好空間,再逐字符追加。
注釋里有一個(gè)優(yōu)化版本:
// 優(yōu)化版本(被注釋掉) string sub; sub._str = new char[len + 1]; sub._capacity = len; sub._size = len; memcpy(sub._str, _str + pos, len); sub._str[len] = '\0'; return sub;
這個(gè)版本一次性分配內(nèi)存、用 memcpy 批量復(fù)制,性能更好,不需要逐字符調(diào)用 operator+= 可能觸發(fā)多次擴(kuò)容。代價(jià)是代碼稍微復(fù)雜一點(diǎn),需要直接操作成員變量(所以只能在類內(nèi)部寫,或者聲明為友元)。
十四、比較運(yùn)算符
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator>(const string& s1, const string& s2) { return strcmp(...) > 0; }
bool operator<=(const string& s1, const string& s2) { return (s1 < s2) || s1 == s2; }
bool operator>=(const string& s1, const string& s2) { return (s1 > s2) || s1 == s2; }
bool operator!=(const string& s1, const string& s2) { return !(s1 == s2); }核心是 < 和 ==,其他運(yùn)算符全部復(fù)用這兩個(gè)。strcmp 返回負(fù)數(shù)表示"小于",0 表示"等于",正數(shù)表示"大于"——和返回 bool 的比較運(yùn)算符語(yǔ)義完全對(duì)應(yīng)。
這些運(yùn)算符都是非成員函數(shù),寫在類外面。原因是 ==、< 等是對(duì)稱的二元運(yùn)算,左右操作數(shù)應(yīng)該平等對(duì)待,如果寫成成員函數(shù),左操作數(shù)必須是 string 對(duì)象,會(huì)限制使用靈活性。
十五、c_str() 的意義
const char* c_str() const
{
return _str;
}
這個(gè)函數(shù)看起來(lái)簡(jiǎn)單,但它是 string 和 C 風(fēng)格字符串交互的橋梁。很多 C 標(biāo)準(zhǔn)庫(kù)函數(shù)(printf、fopen、strlen 等)只接受 const char*,不認(rèn)識(shí) C++ 的 string 對(duì)象。c_str() 提供了一個(gè)合法的、以 '\0' 結(jié)尾的字符指針,讓 C++ string 能融入 C 的生態(tài)。
返回 const char* 而不是 char*,是有意為之:禁止外部通過(guò)這個(gè)指針修改字符串內(nèi)容,保護(hù)對(duì)象的內(nèi)部狀態(tài)。
十六、operator<< 和 operator>>
為什么要實(shí)現(xiàn)這兩個(gè)
標(biāo)準(zhǔn)庫(kù)的 cout 和 cin 不認(rèn)識(shí)我們自己實(shí)現(xiàn)的 string 類。如果不重載 operator<< 和 operator>>,cout << s 就沒(méi)法編譯。實(shí)現(xiàn)了之后,我們的 string 就能無(wú)縫接入 C++ 的流體系。
operator<<
ostream& operator<<(ostream& out, string& s)
{
for (auto ch : s)
out << ch;
return out;
}
逐字符輸出。這里用了范圍 for 循環(huán),依賴 string 提供了 begin() 和 end() 迭代器:
typedef char* iterator;
typedef const char* const_iterator;
iterator begin() { return _str; }
iterator end() { return _str + _size; }指針就是最簡(jiǎn)單的迭代器,對(duì)字符數(shù)組完全適用。
operator>>(我踩的最隱蔽的 bug)
錯(cuò)誤版本(注釋里):
// 錯(cuò)誤寫法 buff[i++] = ch; s += buff; // buff 里有 ch s += ch; // ch 又被單獨(dú)加了一次 —— 重復(fù)!
每個(gè)字符被寫入了兩次,一次在 buff 里追加到 s,一次單獨(dú) += ch。讀進(jìn)來(lái)的字符串會(huì)變成雙倍。
正確版本:
istream& operator>>(istream& in, string& s)
{
s.clear();
const int N = 256;
char buff[N];
int i = 0;
char ch = in.get();
while (ch != ' ' && ch != '\n' && ch != EOF)
{
buff[i++] = ch;
if (i == N - 1)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}每個(gè)字符只走一條路徑:先進(jìn) buff,buff 滿了就 flush 到 s,清空 buff 繼續(xù)。讀完循環(huán)后,把剩余不滿一 buff 的數(shù)據(jù)再 flush 一次。
用緩沖區(qū)的好處:每 255 個(gè)字符才觸發(fā)一次 operator+=,大幅減少了可能的擴(kuò)容次數(shù),比逐字符 s += ch 高效很多。
十七、引用計(jì)數(shù):為什么現(xiàn)代 string 不用它
除了深拷貝,還有第三種資源管理策略:引用計(jì)數(shù)。思路是讓多個(gè)對(duì)象共享同一塊內(nèi)存,同時(shí)用一個(gè)計(jì)數(shù)器記錄有多少個(gè)對(duì)象指向它。
s1._str ──┐
▼
[ h e l l o \0 ] (count = 2)
▲
s2._str ──┘
拷貝時(shí)不申請(qǐng)新內(nèi)存,只把 count 加 1。析構(gòu)時(shí)把 count 減 1,只有 count 減到 0 時(shí)才真正 delete。這避免了頻繁 new/delete 的開(kāi)銷,在大量拷貝場(chǎng)景下性能更好。
早期的 std::string 實(shí)現(xiàn)(GCC 的 libstdc++ 在 C++11 之前)確實(shí)用過(guò)引用計(jì)數(shù)。但 C++11 之后基本被廢棄,原因有兩個(gè):
第一,多線程問(wèn)題。引用計(jì)數(shù)本身需要原子操作來(lái)保證線程安全,而原子操作有不小的開(kāi)銷。在多核環(huán)境下,多個(gè)線程頻繁地增減同一個(gè)引用計(jì)數(shù),會(huì)導(dǎo)致緩存行頻繁失效(false sharing),性能反而不如深拷貝。
第二,寫時(shí)拷貝(COW)的隱患。引用計(jì)數(shù)通常配合寫時(shí)拷貝使用:只有在修改字符串時(shí)才真正復(fù)制一份("lazy copy")。但這個(gè)機(jī)制在多線程下存在微妙的 race condition,實(shí)現(xiàn)正確性極難保證。
現(xiàn)代 string 普遍采用SSO(Small String Optimization):短字符串(通常 15 個(gè)字符以內(nèi))直接存在對(duì)象內(nèi)部的固定緩沖區(qū)里,不 new 堆內(nèi)存,徹底避免了動(dòng)態(tài)分配的開(kāi)銷,只有超過(guò)閾值的長(zhǎng)字符串才退化到堆分配。這是目前性能最好且實(shí)現(xiàn)最干凈的方案。
十八、代碼里可以優(yōu)化的地方
結(jié)合整個(gè)實(shí)現(xiàn),有幾個(gè)地方可以做得更好:
substr 的逐字符追加:當(dāng)前實(shí)現(xiàn)用 operator+= 逐字符追加,每次都有可能觸發(fā)擴(kuò)容判斷。改用 memcpy 一次性復(fù)制更高效(注釋里已經(jīng)有這個(gè)優(yōu)化版本)。
operator>> 的參數(shù):operator<< 的第二個(gè)參數(shù)寫的是 string&,應(yīng)該改成 const string&,因?yàn)檩敵霾僮鞑恍薷膶?duì)象。
substr 沒(méi)有檢查 pos:如果 pos > _size,_size - pos 會(huì)下溢(無(wú)符號(hào)數(shù))。應(yīng)該加一個(gè) assert(pos <= _size) 或直接返回空字符串。
find 的 pos 邊界:find 里 assert(pos < _size) 會(huì)拒絕在空字符串或末尾位置查找,實(shí)際上 pos == _size 時(shí)應(yīng)該允許(直接返回 npos),斷言條件過(guò)嚴(yán)。
erase 沒(méi)有更新 _str[_size]:在截?cái)喾种Вㄖ苯訉?'\0' 的那條),_str[pos] = '\0' 是正確的;在移動(dòng)分支,循環(huán)已經(jīng)把 _str[_size] 的 '\0' 移過(guò)來(lái)了。這里是對(duì)的,但值得在代碼注釋里說(shuō)清楚,否則容易讓人擔(dān)心。
總結(jié)
寫完這個(gè) string 類,我對(duì)幾個(gè)核心概念有了真實(shí)的理解:
深拷貝不只是"多 new 一份內(nèi)存",它的本質(zhì)是讓每個(gè)對(duì)象對(duì)自己的資源負(fù)全責(zé),互不干擾。copy-and-swap 慣用法把資源管理的復(fù)雜性封裝進(jìn)構(gòu)造函數(shù)和析構(gòu)函數(shù)里,讓賦值運(yùn)算符變得異常安全且簡(jiǎn)潔。swap 的 O(1) 不是魔法,而是因?yàn)榻粨Q的只是指針,不是數(shù)據(jù)。size_t 的無(wú)符號(hào)特性在循環(huán)邊界處是一個(gè)持續(xù)的坑,必須特別小心。
C++ 的核心難點(diǎn)不是語(yǔ)法,是對(duì)象的生命周期和資源歸屬。這一點(diǎn)寫完 string 之后,我覺(jué)得理解得清楚多了。
到此這篇關(guān)于C++ string 類原理、踩坑與對(duì)象語(yǔ)義詳解的文章就介紹到這了,更多相關(guān)C++ string 類原理內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C++新特性詳細(xì)分析基于范圍的for循環(huán)
C++11這次的更新帶來(lái)了令很多C++程序員期待已久的for?range循環(huán),每次看到j(luò)avascript,?lua里的for?range,心想要是C++能有多好,心里別提多酸了。這次C++11不負(fù)眾望,再也不用羨慕別家人的for?range了。下面看下C++11的for循環(huán)的新用法2022-04-04
c語(yǔ)言struct結(jié)構(gòu)體強(qiáng)制類型轉(zhuǎn)換的實(shí)現(xiàn)
本文深入探討了C語(yǔ)言中結(jié)構(gòu)體的定義、初始化及成員訪問(wèn),包括無(wú)標(biāo)簽和顯示標(biāo)簽聲明,以及如何通過(guò)typedef簡(jiǎn)化結(jié)構(gòu)體使用,具有一定的參考價(jià)值,感興趣的可以了解一下2026-01-01
C++實(shí)現(xiàn)經(jīng)典24點(diǎn)紙牌益智游戲
這篇文章主要介紹了C++實(shí)現(xiàn)經(jīng)典24點(diǎn)紙牌益智游戲,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-03-03
詳解C語(yǔ)言中的rename()函數(shù)和remove()函數(shù)的使用方法
這篇文章主要介紹了詳解C語(yǔ)言中的rename()函數(shù)和remove()函數(shù)的使用方法,是C語(yǔ)言入門學(xué)習(xí)中的基礎(chǔ)知識(shí),需要的朋友可以參考下2015-09-09
C++實(shí)現(xiàn)對(duì)象化的矩陣相乘小程序
這篇文章主要為大家詳細(xì)介紹了C++實(shí)現(xiàn)對(duì)象化的矩陣相乘小程序,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-09-09
仿現(xiàn)代C++智能指針實(shí)現(xiàn)引用計(jì)數(shù)
這篇文章主要為大家詳細(xì)介紹了如何仿現(xiàn)代C++智能指針實(shí)現(xiàn)引用計(jì)數(shù),文中的示例代碼講解詳細(xì),具有一定的借鑒價(jià)值,有需要的小伙伴可以了解下2024-03-03

