C++之memcpy導(dǎo)致的深拷貝問題分析
代碼與講解承接上文:C++之vector深度剖析及模擬實現(xiàn)
memcpy:更深一層次的深淺拷貝問題
/* 自定義類型 */
void test_vector5()
{
vector<string> v;
v.push_back("11111111111111111111111111111");
v.push_back("22222222222222222222222222222");
v.push_back("33333333333333333333333333333");
v.push_back("44444444444444444444444444444");
for (auto e : v)
{
cout << e << " ";
}
cout << endl;
}打印結(jié)果是 3 和 4 沒有問題,但是 1 和 2 都是亂碼。是擴(kuò)容時出現(xiàn)了問題。
void reserve(size_t n)
{
// 提前算好size,不然后續(xù)會改變 _start 位置,就算不了size了
size_t sz = size();
if (n > capacity())
{
T* tmp = new T[n];
if (_start) //防止第一次進(jìn)來_start為空,memcpy出錯
{
memcpy(tmp, _start, sizeof(T) * sz);
delete[] _start;/* 這里出現(xiàn)了問題 */
}
_start = tmp;
_finish = tmp + sz;
_endofstorage = tmp + n;
}
}問題分析
- memcpy是內(nèi)存的二進(jìn)制格式拷貝,將一段內(nèi)存空間中內(nèi)容原封不動的拷貝到另外一段內(nèi)存空間中
- 如果拷貝的是自定義類型的元素,memcpy既高效又不會出錯,但如果拷貝的是自定義類型元素,并且自定義類型元素中涉及到資源管理時,就會出錯,因為memcpy的拷貝實際是淺拷貝。
問題根源:memcpy 的淺拷貝特性
memcpy 函數(shù)執(zhí)行的是逐字節(jié)的淺拷貝,它只是簡單地將內(nèi)存中的字節(jié)從一個位置復(fù)制到另一個位置,而不會調(diào)用任何構(gòu)造函數(shù)或賦值運(yùn)算符。
對于 vector<string> 這種情況:
- 每個
string對象內(nèi)部包含指向?qū)嶋H字符串?dāng)?shù)據(jù)的指針 - 使用
memcpy時,只是復(fù)制了這些指針值,而不是指針指向的實際字符串?dāng)?shù)據(jù) - 當(dāng)原 vector 被銷毀時,原
string對象會調(diào)用析構(gòu)函數(shù)釋放它們指向的內(nèi)存 - 但新 vector 中的
string對象仍然指向已被釋放的內(nèi)存區(qū)域,導(dǎo)致懸空指針 - 訪問這些懸空指針指向的內(nèi)存就是未定義行為,表現(xiàn)為亂碼或程序崩潰
解決方案:使用循環(huán)賦值實現(xiàn)深拷貝
當(dāng)將擴(kuò)容代碼改為:
void reserve(size_t n)
{
// 提前算好size,不然后續(xù)會改變 _start 位置,就算不了size了
size_t sz = size();
if (n > capacity())
{
T* tmp = new T[n];
if (_start) //防止第一次進(jìn)來_start為空,memcpy出錯
{
// memcpy(tmp, _start, sizeof(T) * sz); 拷貝自定義類型時會導(dǎo)致淺拷貝問題
for (size_t i = 0; i < sz; ++i)
{
tmp[i] = _start[i];
}
delete[] _start;
}
_start = tmp;
_finish = tmp + sz;
_endofstorage = tmp + n;
}
}這里發(fā)生了以下關(guān)鍵變化:
- 調(diào)用了賦值運(yùn)算符:對于每個元素,都會調(diào)用
string::operator=,這是一個深拷貝操作 - 創(chuàng)建獨立副本:每個新
string對象都會分配自己的內(nèi)存并復(fù)制字符串內(nèi)容 - 避免懸空指針:新舊 vector 中的
string對象指向不同的內(nèi)存區(qū)域,互不影響
Vector 的內(nèi)存布局
當(dāng)你創(chuàng)建一個vector<string>時,內(nèi)存布局是這樣的:
_vector 對象本身: _start -> [string對象1][string對象2][string對象3][string對象4]... _finish -> 指向最后一個元素的下一個位置 _endofstorage -> 指向分配的內(nèi)存塊的末尾
關(guān)鍵點是:vector 存儲的是 string 對象本身,而不是指向 string 對象的指針。這些 string 對象在內(nèi)存中是連續(xù)存儲的。
“Vector 存儲的是 string 對象本身”的含義
(下圖中的string類成員是假設(shè)出來的,實際成員可能不一樣,但內(nèi)存布局是一樣的)
_start[0] 這個內(nèi)存位置存儲的是: [ char* _str | size_t _size | size_t _capacity | ...其他成員 ]
更詳細(xì)的內(nèi)存結(jié)構(gòu)圖說明:
Vector內(nèi)存布局 (棧上或堆上)
┌─────────────────────────────────────────────────────────────┐
│ _start指針 │ 指向vector內(nèi)部數(shù)組的起始位置 │
├─────────────────────────────────────────────────────────────┤
│ _finish指針 │ 指向最后一個元素的下一個位置 │
├─────────────────────────────────────────────────────────────┤
│_endofstorage指針│ 指向分配的內(nèi)存塊的末尾 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────┬─────────┬─────────┬─────────┐ ← vector內(nèi)部數(shù)組(在堆上)
│ string0 │ string1 │ string2 │ string3 │
└─────────┴─────────┴─────────┴─────────┘
│ │ │ │
│ │ │ │
▼ ▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ← 每個string對象的_str成員指向的
│"1111│ │"2222│ │"3333│ │"4444│ 字符串?dāng)?shù)據(jù)(也在堆上,但不同位置)
└─────┘ └─────┘ └─────┘ └─────┘關(guān)鍵點分解
vector的對象數(shù)組:當(dāng)你創(chuàng)建vector<string> v(4)時,vector會在堆上分配一塊足夠大的連續(xù)內(nèi)存,用來存放4個完整的string對象。
每個string對象:這塊內(nèi)存中的每個"格子"都包含一個完整的string對象,包括:
char* _str(指針,通常4或8字節(jié))size_t _size(通常4或8字節(jié))size_t _capacity(通常4或8字節(jié))
可能的其他成員變量
字符串?dāng)?shù)據(jù):每個string對象的_str成員指向另一塊堆內(nèi)存,那里存儲著實際的字符串內(nèi)容("1111", "2222"等)。
為什么循環(huán)賦值有效
現(xiàn)在讓我們看看循環(huán):
for (size_t i = 0; i < sz; ++i)
{
tmp[i] = _start[i];
}對于每次迭代:
_start[i]獲取第i個string對象tmp[i]獲取新數(shù)組中第i個位置(此時可能是一個未初始化的string對象)- 調(diào)用string的賦值運(yùn)算符
string::operator=,將右側(cè)string的內(nèi)容復(fù)制到左側(cè)string
重要的是:這不是簡單的內(nèi)存拷貝,而是調(diào)用了string類的賦值運(yùn)算符,它會進(jìn)行深拷貝 - 分配新的內(nèi)存并復(fù)制字符串內(nèi)容。
重新理解拷貝問題
現(xiàn)在我們就能明白為什么memcpy有問題而循環(huán)賦值正確了:
memcpy:只復(fù)制了vector數(shù)組內(nèi)存塊(包含string對象的成員變量),包括復(fù)制了_str指針值。結(jié)果是新舊vector中的string對象指向相同的字符串?dāng)?shù)據(jù)內(nèi)存。
循環(huán)賦值:tmp[i] = _start[i]調(diào)用了string的賦值運(yùn)算符,這個運(yùn)算符會:
- 釋放
tmp[i]原有資源(如果有) - 為新的字符串?dāng)?shù)據(jù)分配內(nèi)存
- 復(fù)制字符串內(nèi)容
- 更新size和capacity成員
一個很好的驗證方式
我們可以添加一些調(diào)試輸出來驗證這個理解:
void test_debug() {
vector<string> v;
v.push_back("dfb");
v.push_back("asdf ds akjfhksdhfkhasdfkhskdfhk");
v.push_back("12bbbbbb6161rtb616t1b6r1t6516161bbb");
v.push_back("646asdg56as6dg65s16551agsd");
cout << "Address of vector array: " << (void*)v.begin() << endl;
for (int i = 0; i < v.size(); i++) {
cout << "Address of string object " << i << ": " << (void*)&v[i] << endl;
cout << "Address of string data " << i << ": " << (void*)v[i].c_str() << endl;
cout << "Sizeof(string): " << sizeof(string) << endl;
}
}這個代碼會顯示string對象本身是連續(xù)存儲的,但每個string對象指向的字符串?dāng)?shù)據(jù)在不同的內(nèi)存地址。
為什么標(biāo)準(zhǔn)庫vector沒有這個問題
標(biāo)準(zhǔn)庫的 std::vector 使用了一種叫做"類型特質(zhì)(type traits)"的技術(shù),能夠識別類型是否是"平凡可拷貝(trivially copyable)"的。
對于平凡可拷貝的類型(如基本數(shù)據(jù)類型、簡單結(jié)構(gòu)體),它使用 memcpy 等高效方法;對于非平凡類型(如 string),它會調(diào)用拷貝構(gòu)造函數(shù)或賦值運(yùn)算符。
總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
C++ opencv ffmpeg圖片序列化實現(xiàn)代碼解析
這篇文章主要介紹了C++ opencv ffmpeg圖片序列化實現(xiàn)代碼解析,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2020-08-08

