影石C++二面:簡述C++智能指針用法,unique_ptr的sizeof與刪除器有什么影響?
在C++ 編程中,內存管理一直是個關鍵且棘手的問題。傳統的手動內存管理方式,即使用new分配內存后,需手動調用delete釋放內存,稍有不慎就會引發內存泄漏、懸空指針等問題,嚴重影響程序的穩定性和安全性。為了解決這些問題,C++ 引入了智能指針這一強大工具。
智能指針本質上是對普通指針的封裝,利用 C++ 的 RAII(Resource Acquisition Is Initialization,資源獲取即初始化)機制,在智能指針對象的作用域結束時,自動釋放其所管理的內存資源,從而避免了手動管理內存的繁瑣與風險。C++ 標準庫提供了多種智能指針類型,其中較為常用的有unique_ptr、shared_ptr和weak_ptr 。
一、智能指針登場:編程世界的 “及時雨”
在傳統指針管理內存的混亂局面中,智能指針宛如一道曙光,照亮了 C++ 程序員前行的道路。智能指針,從本質上來說,它是一種特殊的類,專門用于管理動態分配的內存。它就像是一個貼心的管家,默默地幫你處理內存管理的瑣碎事務,讓你可以專注于程序的核心邏輯。
智能指針的核心秘密在于它利用了 RAII(Resource Acquisition Is Initialization)原則,這個聽起來高大上的原則其實并不復雜。簡單來說,就是在對象創建時獲取資源,在對象生命周期結束時自動釋放資源。就好比你租了一間房子,當你入住(對象創建)的時候,你獲得了房子的使用權(獲取資源),當你租期結束(對象生命周期結束)的時候,你自然而然地就會離開房子,房子的使用權也就被釋放了。
以std::unique_ptr為例,它是一種獨占式的智能指針。當你創建一個std::unique_ptr對象時,它會獲取對某個動態分配對象的所有權,并且只有它自己能擁有這個對象。當std::unique_ptr對象離開其作用域時,它所指向的對象會被自動銷毀,內存也會被釋放。這就像是你買了一輛車,這輛車只有你一個人能開(獨占所有權),當你不再使用這輛車(std::unique_ptr離開作用域)的時候,車就會被妥善處理(對象被銷毀,內存被釋放)。
#include <iostream>
#include <memory>
int main() {
// 創建一個std::unique_ptr,指向一個動態分配的int對象
std::unique_ptr<int> ptr = std::make_unique<int>(42);
// 使用ptr訪問對象
std::cout << "Value: " << *ptr << std::endl;
// ptr離開作用域,所指向的int對象會被自動銷毀,內存被釋放
return 0;
}除了std::unique_ptr,C++ 標準庫還提供了std::shared_ptr和std::weak_ptr等智能指針。std::shared_ptr允許多個指針共享對同一個對象的所有權,它通過引用計數來管理對象的生命周期。當最后一個指向對象的std::shared_ptr被銷毀時,對象才會被釋放。這就像是你和你的朋友們一起合租了一套房子,只要還有人在租(引用計數不為 0),房子就不會被收回(對象不會被釋放),當所有人都搬走了(引用計數為 0),房子就會被房東收回(對象被銷毀)。
而std::weak_ptr則是一種弱引用指針,它通常與std::shared_ptr一起使用,用于解決std::shared_ptr可能出現的循環引用問題。它不會增加對象的引用計數,只是默默地觀察著std::shared_ptr所指向的對象。就好比你是房子的租客(std::shared_ptr),而你的朋友只是偶爾來看看你(std::weak_ptr),他并不會影響你對房子的租用時間(不會增加引用計數) 。
智能指針的出現,讓 C++ 程序員們從繁瑣的內存管理中解脫出來,大大提高了編程的效率和代碼的安全性。它就像是給程序加上了一層堅固的保護罩,有效地避免了內存泄漏、懸空指針和野指針等問題。在接下來的內容中,我們將深入探討每種智能指針的具體用法和應用場景,讓你能夠更加熟練地運用它們,編寫出更加健壯、高效的 C++ 程序。
二、智能指針的 “三兄弟”
2.1unique_ptr:獨占鰲頭的內存衛士
在智能指針的大家庭里,std::unique_ptr就像是一位孤獨的勇士,獨自守護著內存的安全。它是獨占式智能指針,擁有著獨一無二的地位,同一時間只有它能指向一個對象,就像你擁有一把獨特的鑰匙,只能打開一扇特定的門 。
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass() {
std::cout << "MyClass 構造函數被調用" << std::endl;
}
~MyClass() {
std::cout << "MyClass 析構函數被調用" << std::endl;
}
};
int main() {
// 使用std::make_unique創建std::unique_ptr,指向MyClass對象
std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
// 也可以直接使用new來初始化,但不推薦這種方式
// std::unique_ptr<MyClass> ptr1(new MyClass());
// 訪問對象成員,ptr1擁有對象的獨占所有權
ptr1->print();
// 轉移所有權,ptr2現在擁有對象,ptr1變為空指針
std::unique_ptr<MyClass> ptr2 = std::move(ptr1);
// 檢查ptr1是否為空,此時ptr1為空
if (!ptr1) {
std::cout << "ptr1 為空" << std::endl;
}
// ptr2離開作用域,所指向的MyClass對象會被自動銷毀
return 0;
}在這段代碼中,ptr1創建時獲得了MyClass對象的獨占所有權。當ptr2 = std::move(ptr1)時,所有權從ptr1轉移到了ptr2,ptr1變為空指針,就像你把自己的唯一一把鑰匙交給了別人,自己就不再擁有開門的能力。當ptr2離開作用域時,它所指向的MyClass對象會被自動銷毀,內存也會被釋放,無需我們手動操作,大大降低了內存泄漏的風險。
std::unique_ptr特別適合用于管理那些不需要共享,只需要獨占所有權的資源,比如一個獨立的文件句柄,每個文件句柄只對應一個文件,不需要與其他地方共享,使用std::unique_ptr就能很好地管理它的生命周期 。
2.2shared_ptr:資源共享的 “協調者”
std::shared_ptr則像是一個熱情的協調者,允許多個指針共享對同一個對象的所有權。它通過引用計數來管理對象的生命周期,每多一個std::shared_ptr指向同一個對象,引用計數就會增加,當引用計數降為 0 時,對象才會被銷毀。這就好比一群人共同租用一套房子,只要還有人在租,房子就不會被收回,當所有人都搬走了,房子才會被房東收回 。
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass() {
std::cout << "MyClass 構造函數被調用" << std::endl;
}
~MyClass() {
std::cout << "MyClass 析構函數被調用" << std::endl;
}
};
int main() {
// 創建std::shared_ptr,指向MyClass對象,此時引用計數為1
std::shared_ptr<MyClass> ptr1 = std::make_shared<MyClass>();
// 輸出當前引用計數
std::cout << "ptr1的引用計數: " << ptr1.use_count() << std::endl;
// 創建另一個std::shared_ptr,指向同一個MyClass對象,引用計數變為2
std::shared_ptr<MyClass> ptr2 = ptr1;
// 輸出當前引用計數
std::cout << "ptr1的引用計數: " << ptr1.use_count() << std::endl;
std::cout << "ptr2的引用計數: " << ptr2.use_count() << std::endl;
// ptr1和ptr2離開作用域,引用計數減為0,MyClass對象被銷毀
return 0;
}在這個例子中,ptr1創建時引用計數為 1,當 ptr2 = ptr1 時,ptr2也指向了同一個 MyClass 對象,引用計數增加到 2。當 ptr1 和 ptr2 離開作用域時,引用計數逐漸減為 0,MyClass 對象就會被自動銷毀。std::shared_ptr 為我們提供了一種方便的資源共享方式,讓多個部分的代碼可以共同訪問和管理同一個對象,而不用擔心內存的釋放問題 。
2.3weak_ptr:解決循環引用的 “秘密武器”
雖然std::shared_ptr在資源共享方面表現出色,但它也存在一個潛在的問題 —— 循環引用。當兩個或多個對象通過std::shared_ptr相互引用時,就會導致引用計數永遠不會降為 0,從而造成內存泄漏。這就像是兩個人互相拉著對方的手,誰也不愿意先松開,結果誰也走不了 。
而 std::weak_ptr 就是解決這個問題的 “秘密武器”。它是一種弱引用智能指針,不會增加對象的引用計數,只是默默地觀察著 std::shared_ptr 所指向的對象。就像你只是在遠處看著別人租用房子,你并不會影響房子的租用時間 。
#include <iostream>
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> b_ptr;
~A() {
std::cout << "A 析構函數被調用" << std::endl;
}
};
class B {
public:
// 使用std::weak_ptr避免循環引用
std::weak_ptr<A> a_weak;
~B() {
std::cout << "B 析構函數被調用" << std::endl;
}
};
int main() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b;
b->a_weak = a;
// a和b離開作用域,A和B對象會被正確銷毀
return 0;
}在這段代碼中,如果B類中也使用std::shared_ptr來引用A,就會形成循環引用,導致A和B對象無法被銷毀。而使用std::weak_ptr后,b->a_weak = a這一行不會增加a的引用計數,從而打破了循環引用,使得A和B對象在離開作用域時能夠被正確銷毀 。
當我們需要訪問std::weak_ptr所指向的對象時,可以使用lock()方法,它會嘗試返回一個有效的std::shared_ptr。如果對象已經被釋放,lock()方法會返回一個空的std::shared_ptr,這樣我們就可以安全地檢查對象是否還存在 。
void B::some_method() {
// 使用lock()方法獲取std::shared_ptr
auto a_ptr = a_weak.lock();
if (a_ptr) {
// 對象存在,可以安全訪問
a_ptr->do_something();
} else {
// 對象已被銷毀
std::cout << "A 已經被銷毀" << std::endl;
}
}std::weak_ptr 的出現,有效地解決了 std::shared_ptr 的循環引用問題,讓我們在使用共享資源時更加安全和放心 。
三、智能指針的應用實戰
3.1案例一:工廠模式中的資源管理
在軟件開發中,工廠模式就像是一個神奇的工廠,能根據不同的需求生產出各種產品。而在 C++ 中,std::unique_ptr在工廠模式里扮演著重要的資源管理角色。
假設我們正在開發一個游戲,游戲中有各種不同類型的角色,比如戰士、法師、刺客等。我們可以使用工廠模式來創建這些角色,并且使用std::unique_ptr來管理它們的生命周期 。
#include <iostream>
#include <memory>
// 定義角色基類
class Character {
public:
virtual void display() const = 0;
virtual ~Character() = default;
};
// 戰士類,繼承自Character
class Warrior : public Character {
public:
void display() const override {
std::cout << "我是一名戰士" << std::endl;
}
};
// 法師類,繼承自Character
class Mage : public Character {
public:
void display() const override {
std::cout << "我是一名法師" << std::endl;
}
};
// 刺客類,繼承自Character
class Assassin : public Character {
public:
void display() const override {
std::cout << "我是一名刺客" << std::endl;
}
};
// 角色工廠類
class CharacterFactory {
public:
static std::unique_ptr<Character> createCharacter(const std::string& type) {
if (type == "warrior") {
return std::make_unique<Warrior>();
} else if (type == "mage") {
return std::make_unique<Mage>();
} else if (type == "assassin") {
return std::make_unique<Assassin>();
}
return nullptr;
}
};
int main() {
// 使用工廠創建戰士角色
std::unique_ptr<Character> warrior = CharacterFactory::createCharacter("warrior");
if (warrior) {
warrior->display();
}
// 使用工廠創建法師角色
std::unique_ptr<Character> mage = CharacterFactory::createCharacter("mage");
if (mage) {
mage->display();
}
// warrior和mage離開作用域,所指向的角色對象會被自動銷毀
return 0;
}在這個例子中,CharacterFactory類的createCharacter方法根據傳入的類型創建不同的角色對象,并返回一個std::unique_ptr<Character>。這樣,調用者就獲得了角色對象的獨占所有權,當std::unique_ptr離開作用域時,角色對象會被自動銷毀,有效地避免了內存泄漏。就像你在游戲中創建了一個角色,當你不再使用這個角色(離開作用域)時,游戲系統會自動幫你清理這個角色占用的資源 。
3.2案例二:圖形界面編程中的數據共享
在圖形界面編程的世界里,經常會遇到多個窗口需要共享數據的情況。比如,在一個繪圖軟件中,有一個主窗口顯示繪制的圖形,還有一個屬性窗口顯示圖形的相關屬性,這兩個窗口都需要訪問和修改圖形的數據。這時,std::shared_ptr就派上用場了,它能很好地實現數據的共享,同時保證內存安全 。
#include <iostream>
#include <memory>
#include <string>
// 定義圖形數據類
class GraphicData {
public:
std::string name;
int width;
int height;
GraphicData(const std::string& n, int w, int h) : name(n), width(w), height(h) {}
};
// 主窗口類
class MainWindow {
public:
std::shared_ptr<GraphicData> data;
MainWindow(const std::shared_ptr<GraphicData>& d) : data(d) {}
void display() {
std::cout << "主窗口顯示圖形: " << data->name << ", 寬: " << data->width << ", 高: " << data->height << std::endl;
}
};
// 屬性窗口類
class PropertyWindow {
public:
std::shared_ptr<GraphicData> data;
PropertyWindow(const std::shared_ptr<GraphicData>& d) : data(d) {}
void update(int w, int h) {
data->width = w;
data->height = h;
std::cout << "屬性窗口更新圖形屬性, 寬: " << data->width << ", 高: " << data->height << std::endl;
}
};
int main() {
// 創建圖形數據
std::shared_ptr<GraphicData> graphicData = std::make_shared<GraphicData>("矩形", 100, 200);
// 創建主窗口和屬性窗口,并共享圖形數據
MainWindow mainWindow(graphicData);
PropertyWindow propertyWindow(graphicData);
// 主窗口顯示圖形
mainWindow.display();
// 屬性窗口更新圖形屬性
propertyWindow.update(150, 250);
// 主窗口再次顯示圖形,查看更新后的結果
mainWindow.display();
// graphicData的引用計數降為0,圖形數據對象被自動銷毀
return 0;
}在這個例子中,GraphicData類表示圖形的數據,MainWindow和PropertyWindow都通過std::shared_ptr<GraphicData>來共享這個數據。當其中一個窗口修改了數據,另一個窗口也能看到更新后的結果。而且,由于std::shared_ptr的引用計數機制,只有當所有窗口都不再使用這個數據時,數據才會被銷毀,保證了內存的安全和高效使用 。
四、智能指針使用的 “避坑指南”
4.1性能考量:時間與空間的平衡
智能指針雖然為我們帶來了便捷的內存管理方式,但在使用時也需要關注其性能開銷。以std::shared_ptr為例,它的引用計數機制和鎖機制在一定程度上會影響程序的性能。每次std::shared_ptr的拷貝或賦值操作,都需要對引用計數進行原子操作,這涉及到線程安全的問題,所以會有一定的性能損耗。而且,每個std::shared_ptr都需要維護一個控制塊,用于存儲引用計數等信息,這也會占用額外的內存空間 。
在性能敏感的場景中,比如對實時性要求極高的游戲開發、高頻交易系統等,就需要謹慎權衡使用std::shared_ptr帶來的便利性和性能開銷。如果只是簡單的局部變量,且對性能要求較高,使用std::unique_ptr可能是更好的選擇,因為它沒有引用計數和控制塊的開銷,執行效率更高 。
4.2避免循環引用:打破資源釋放的 “死循環”
循環引用是使用std::shared_ptr時需要特別注意的問題。當兩個或多個對象通過std::shared_ptr相互引用時,就會形成循環引用,導致引用計數永遠不會降為 0,對象無法被正確銷毀,從而造成內存泄漏。在實際編程中,一定要仔細檢查對象之間的引用關系,盡量避免循環引用的出現。如果不可避免地需要相互引用,就要使用std::weak_ptr來打破循環 。
4.3正確初始化與賦值:筑牢指針的 “根基”
正確初始化和賦值智能指針是確保程序正確性的基礎。在初始化std::unique_ptr和std::shared_ptr時,推薦使用std::make_shared和std::make_unique函數,而不是直接使用new。這是因為std::make_shared和std::make_unique函數可以一次性分配內存,減少內存碎片,并且在異常處理方面更加安全 。
// 推薦使用std::make_unique初始化std::unique_ptr
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// 不推薦直接使用new初始化std::unique_ptr
std::unique_ptr<int> ptr2(new int(42));
// 推薦使用std::make_shared初始化std::shared_ptr
std::shared_ptr<int> ptr3 = std::make_shared<int>(42);
// 不推薦直接使用new初始化std::shared_ptr
std::shared_ptr<int> ptr4(new int(42));直接使用原始指針初始化智能指針時,很容易出現所有權管理混亂的問題。比如,不小心對同一個原始指針創建了多個智能指針,就會導致重復釋放內存的錯誤 。
int* rawPtr = new int(42);
std::shared_ptr<int> ptr5(rawPtr);
std::shared_ptr<int> ptr6(rawPtr); // 錯誤,rawPtr被多個shared_ptr管理,會導致重復釋放在賦值操作中,也要注意智能指針的所有權轉移和引用計數的變化,避免出現懸空指針或內存泄漏的問題 。
五、常規unique_ptr的 sizeof 值
在 64 位系統下,一個普通的指針大小為 8 字節,因為它需要存儲一個內存地址 。那常規的 unique_ptr 的 sizeof 值是多少呢?答案是和裸指針一樣,也是 8 字節。這是因為在不考慮自定義刪除器的情況下,unique_ptr 內部其實主要就是一個裸指針,用來指向它所管理的對象。
為了更直觀地感受這一點,我們來看看下面這段代碼:
#include <iostream>
#include <memory>
int main() {
std::unique_ptr<int> ptr(new int(10));
std::cout << "sizeof(std::unique_ptr<int>) = " << sizeof(ptr) << std::endl;
int* rawPtr = new int(10);
std::cout << "sizeof(int*) = " << sizeof(rawPtr) << std::endl;
delete rawPtr;
return 0;
}運行這段代碼,你會發現輸出結果中,sizeof(std::unique_ptr<int>)和sizeof(int*)的值都是 8。這就清楚地表明,在這種常規情況下,unique_ptr 的大小和裸指針是一致的。
從底層實現的角度來看,unique_ptr 就是圍繞著這個裸指針來構建它的功能的。當我們用new創建一個對象并交給 unique_ptr 管理時,unique_ptr 就把這個對象的指針保存起來,然后在 unique_ptr 生命周期結束時,它會自動調用delete來釋放這個對象,從而實現了自動內存管理 。這個過程中,unique_ptr 本身除了這個指針,并沒有額外存儲大量的數據,所以它的大小和裸指針相同,這也使得 unique_ptr 在性能上和裸指針非常接近,幾乎沒有額外的開銷。
5.1當 unique_ptr 遇上刪除器
(1)刪除器的基本概念與作用
在 C++ 中,刪除器是一個可調用對象,它的作用就是在 unique_ptr 生命周期結束時,負責釋放其所指向的對象。默認情況下,unique_ptr 使用的是delete操作符作為刪除器,這對于大多數普通的動態分配對象來說已經足夠了 。比如我們前面例子中的std::unique_ptr<int> ptr(new int(10));,當ptr離開作用域時,默認刪除器就會調用delete來釋放int對象。
但是,在某些特殊情況下,默認刪除器就不太夠用了。比如當我們管理的不是普通的動態分配對象,而是一些需要特殊釋放操作的資源,像文件句柄、網絡連接、數據庫連接等。假設你打開了一個文件,用unique_ptr來管理這個文件句柄,當unique_ptr銷毀時,你需要調用fclose函數來關閉文件,而不是簡單地使用delete,這時就需要自定義刪除器了 。
(2)添加刪除器后的 sizeof 變化
當我們給 unique_ptr 添加自定義刪除器時,它的sizeof值就可能會發生變化。這是因為 unique_ptr 內部除了要存儲指向對象的指針,還要存儲刪除器對象或者刪除器的相關信息 。
先來看一種簡單的情況,當我們使用函數指針作為刪除器時:
#include <iostream>
#include <memory>
void customDeleter(int* p) {
std::cout << "Custom deleter is called" << std::endl;
delete p;
}
int main() {
std::unique_ptr<int, void(*)(int*)> ptr(new int(10), customDeleter);
std::cout << "sizeof(std::unique_ptr<int, void(*)(int*)>) = " << sizeof(ptr) << std::endl;
return 0;
}在這段代碼中,我們定義了一個函數customDeleter作為刪除器,并將其傳遞給unique_ptr。在 64 位系統下,運行這段代碼,你會發現sizeof(std::unique_ptr<int, void(*)(int*)>)的值變成了 16 字節 。這是因為除了 8 字節的指針,還需要額外 8 字節來存儲函數指針(在 64 位系統下,函數指針大小也是 8 字節)。
再看另一種情況,使用 lambda 表達式作為刪除器:
#include <iostream>
#include <memory>
int main() {
auto customDeleter = [](int* p) {
std::cout << "Lambda custom deleter is called" << std::endl;
delete p;
};
std::unique_ptr<int, decltype(customDeleter)> ptr(new int(10), customDeleter);
std::cout << "sizeof(std::unique_ptr<int, decltype(customDeleter)>) = " << sizeof(ptr) << std::endl;
return 0;
}這里我們用 lambda 表達式定義了一個刪除器,運行代碼后會發現,sizeof(std::unique_ptr<int, decltype(customDeleter)>)的值仍然是 8 字節 。這是因為這個 lambda 表達式是無狀態的(沒有捕獲外部變量),編譯器在優化時會將其占用的空間優化掉,所以unique_ptr的大小并沒有增加。
5.2影響 sizeof 值變化的因素
從上面的例子可以看出,添加刪除器后,unique_ptr 的sizeof值變化受到多種因素的影響 。
首先,刪除器類型起著關鍵作用。如果刪除器是函數指針,就像前面用void(*)(int*)作為刪除器的例子,由于函數指針本身需要占用一定的內存空間(在 64 位系統下通常為 8 字節),所以會使 unique_ptr 的大小增加 。而當刪除器是無狀態的 lambda 表達式時,編譯器有可能對其進行優化,使得它不占用額外的空間,這樣 unique_ptr 的大小就不會改變。但如果 lambda 表達式捕獲了外部變量,它就變成了有狀態的,這時編譯器可能就無法完全優化掉它占用的空間,unique_ptr的大小就可能會增加 。
編譯器優化也是一個重要因素。不同的編譯器對于 unique_ptr 和刪除器的處理方式可能會有所不同 。一些先進的編譯器能夠識別出某些情況下刪除器的特殊性,從而進行更高效的優化。比如對于一些簡單的、無副作用的刪除器操作,編譯器可能會將其相關的檢查和調用過程優化掉,減少運行時的開銷,也可能在內存布局上進行優化,使得 unique_ptr 占用的空間更小 。
此外,刪除器對象本身的大小也會影響 unique_ptr 的sizeof值。如果自定義的刪除器是一個復雜的類對象,包含了多個成員變量和函數,那么它本身占用的空間就會比較大,當它作為刪除器被 unique_ptr 存儲時,就會顯著增加 unique_ptr 的大小 。



























