問題取自知乎:C++可以通過new創建對象,也可以通過Type o(...)創建對象,前者在傳遞對象給函數時只需傳遞指針,不存在很大開銷,后者可通過move操作傳遞對象,工程中應當更多使用哪個呢?
先復習基礎知識和明確問題:
- 這里討論的是 native C/C++ 程序。
- 棧指的是函數過程的調用棧 (call stack)。在 x86 機器上,棧由操作系統分配,並向低地址方向增長。棧的大小由編譯器/鏈接器指定,信息保存在可執行文件中 (executable file)。
- 堆 (heap) 是一個由操作系統或 CRT (C Runtime Library) 運行時庫管理的內存分配空間,有特定的內部數據結構,及其分配策略算法——解決內存的(重新)分配、回收與碎片問題。堆提供一組 API 給用戶,用戶調用這些 API 按需分配、釋放內存。常見的堆內存向高地址方向增長。C/C++ 中也把堆分配叫做動態分配。
實際工程中使用棧還是堆,是多角度、多判定標准綜合考慮的。只有聚焦具體的應用場景,結論才有意義。以下是常見的判定標准:
- 對於局部變量,它的存儲在棧中分配,如果它是一個 array, struct, union, class 統稱復合類型,那么它的每個成員都在棧中分配。它們的 size 是編譯時確定的,所以在棧中構造,編譯時即可預留空間(通過減小 esp/rsp 寄存器的值),內存分配的效率要比在堆中高,因為堆要在運行時執行分配算法。但前提是,由於 C/C++ 有指針這樣的間接類型,要保證這些復合類型內部的各成員,以及成員的成員,它們構造時都沒有使用堆。比如,一個內含
std::string
成員,或者char* p
成員,並在構造函數中p = new char[BUF_SIZE]
,這些即使在棧中構造對象,其實還是會對其成員執行堆分配算法,編譯器確定的只是靜態部分的 size。 - 對於靜態變量(包括全局變量、編譯單元級的 static、名字空間和類內的 static 和局部 static)。靜態變量在編譯時就預留了空間(信息放在目標和可執行文件中),分配的效率比在堆中高。但和局部變量類似,要看它們內部成員有沒有動態分配的。如果非局部的靜態對象內部有動態分配的成員,CRT 會在進入 main() 函數前執行堆分配算法。因為靜態對象的生存期是到程序結束,所以它們內部的動態分配成員需要手工釋放(如用 copy-and-swap 慣用法釋放
std::vector
或std::basic_string
[1]),以避免內存泄露檢測例程給出誤判[2]。 - 由編譯器/鏈接器決定的棧的大小是固定的,對於 MSVC 默認是 1MB,稱為 reserved size of stack allocation in virtual memory,可用
cl /F
[3] 或link /STACK
[4] 選項調節。多線程情況下,會給每個線程分配各自的棧空間[5]。相比於堆,1MB 的棧是一個拮據的空間。所以使用棧中的局部對象時,要評估它是否是大 size 對象,以及這個函數是否反復重入 (reentrant function)。如果你利用棧實現一些算法——典型地是遞歸算法,例如遞歸下降分析器 (recursive descent parser) 或深度優先搜索 (depth-first search)[6],當問題規模很大時就會棧溢出 (stack overflow),系統會保護性地檢測到這種錯誤(1MB 保留棧的最后一個 4KB 頁是用於檢測棧溢出的 guard page[5],所以實際可用的棧只有 1MB - 4KB)。將遞歸算法變換為非遞歸算法,考驗程序員功力。簡單問題容易用循環替代,復雜問題呢?有一個傻瓜的思路,用自定義的棧結構代替調用棧,而在堆中分配自定義的棧結構(比如用std::deque
)。 - 堆雖然比棧空間充裕,而且能動態增長,但也是有限的。即使 64bit 機器的地址空間足夠滿足大多數需要,但是堆還有執行分配算法和緩沖區拷貝等效率因素。在一些特殊需求下,還要利用系統提供的其它內存管理設施,如文件映射對象 (file mapping object)[7]。想一下十六進制編輯器 WinHex 如何做到秒速打開 GB 級別的文件的,肯定不是一下把文件都讀入內存緩沖區中。還有,有時需要有某些特殊性質的內存區域,比如能在進程間共享。以及有時那些區域根本不是內存,只是一段內存空間,映射到其它設備存儲上。
- C++ 中的
operator new/new[]
、std::vector
等,其實都是調用接口(一種約定的形式),它們實際上是在堆、棧,還是特殊的內存空間中分配存儲,都是可以自定義的[1][8]。std::shared_ptr
是比operator new
更良好的形式,而std::vector
或std::basic_string
(當元素是 char/wchar_t 時)是比operator new[]
更良好的形式[1]。 - 即使要分配的是普通的堆內存,為了特殊的效率需求,有時也要用自定義分配算法,代替默認的系統或 CRT 堆算法。你充分了解自己應用中要分配內存的使用細節和特點(比如,分配/釋放的頻率和時機;是很多碎小的對象,還是少數大個對象),因此即使采用簡單的算法,也可能比考慮各種情況但選平均最優的默認堆算法效率高。最簡單的情況是,無需寫 allocator,在某個過程初始化時,預分配足夠的堆內存(如調用
std::vector::reserve()
[1]),然后應用邏輯運行時,只是賦值、操作這些已分配內存。這種思想叫做分配池 (pooling)。
參考
- ^abcd《Effective STL》(2013),Scott Meyers 著。第10條 了解分配子 allocator 的約定和限制;第11條 理解自定義分配子的合理用法;第13條 vector 和 string 優先於動態分配的數組;第14條 使用 reserve 來避免不必要的重新分配;第17條 使用 swap 技巧除去多余的容量 https://book.douban.com/subject/24534868/
- ^Find memory leaks with the CRT library (_CrtDumpMemoryLeaks()) https://docs.microsoft.com/en-us/visualstudio/debugger/finding-memory-leaks-using-the-crt-library
- ^MSVC compiler option: /F (set stack size) https://docs.microsoft.com/en-us/cpp/build/reference/f-set-stack-size
- ^MSVC linker option: /STACK (stack allocations) (also editbin option) https://docs.microsoft.com/en-us/cpp/build/reference/stack-stack-allocations
- ^abThread Stack Size https://docs.microsoft.com/en-us/windows/win32/procthread/thread-stack-size
- ^C/C++ maximum stack size of program https://stackoverflow.com/questions/1825964/
- ^File Mapping https://docs.microsoft.com/en-us/windows/win32/memory/file-mapping
- ^《Effective C++》第3版 (2011),Scott Meyers 著。第8章 定制 new 和 delete https://book.douban.com/subject/5387403/
這個問題是一個非常好的問題!
它反應了堆與棧各有利弊。
棧,是不需要涉及內存分配的,你可以把它看成一個很長的連續內存,用來執行函數。自動以先進后出的方式使用。具體的進出在C++里你可以假設是不能操縱這個棧的,實際上它存在。
main函數是第一個進棧的函數(有人指出了這里不嚴謹,的確發生了其他的事情,為了理解方便可以假設這句話是“對的”,不會妨礙我們理解),中間執行了其他程序,就會有其他的函數入棧,執行完一個函數,這個函數就會從棧里彈出來。main函數是最后一個退出棧的函數(同理,這里也是不嚴謹的,比如靜態對象的析構函數在main退出以后執行)。
但是棧的弊端也在這里,函數在不斷的發生調用,棧是一個臨時的執行產所,我們不能把數據持久的保存在棧上,因為函數執行完,那個數據就等於是不再可以使用了,生命周期只有函數執行開始和結束之間的那一會兒。
有些數據,比如我們保存的一個班級的學生數據在內存里,我們希望可以對這個數據集合進行不斷的增刪改查,會用不同的函數來執行這些操作。那就要求這些數據集合保存的數據要能根據我們的需要產生和釋放,所以集合動態變化的那部分數據就只能放到另一個叫堆的內存空間里。
堆,堆就是用來彌補棧的數據不能持久保存的能力的,在堆上分配的內存,只有我們主動釋放,才會被回收,否則就一直為我們使用。如果忘記釋放,就等於縮小了可以使用內存的大小(又叫內存泄露,一個日夜執行的服務端程序,某個函數執行一次泄露一個字節,隨着客戶端大量頻繁的調用這個服務,可能都會導致服務器內存爆掉)。堆的好處就是可以持久的保存數據。
由於堆可以手動的釋放和申請,所以像vector,string這種動態可變大小的容器,他們存儲的數據都是放在堆上面的。但是vector<int> a;這里的a是一個棧對象,這一點也不矛盾。意思就是我知道a只是臨時的函數內對象,但是我也希望在執行函數的過程中,a具有擴容的能力。這是很自然的事情。
假如這里的a是在main函數內定義的,由於main函數的執行伴隨着整個程序,所以a的使用就更能夠達到被各個函數用於不斷增刪改查的目的。
現在回答的你問題,指針由於只需要傳遞地址,所以效率高(指針變量就像一個整形變量一樣,存儲了一個整數,只不過這個整數是一個地址而已,而不需要拷貝整個指針指向的對象)。這是C語言的邏輯。C語言都是這么做的。通過傳遞指針來做絕大多數的事情。但是弊端就是指針很難被管理,很容易忘記釋放,或者重復釋放,甚至不太知道哪里才是釋放的時機。C++ 在一開始就是為了解決這個問題才產生的。所以,如果你用C++語言還傳遞指針,這是不建議的,違背了C++的初衷。在C++里建議的方式是傳遞引用。
如果是傳遞不需要被修改的對象,C++里更好的方式是傳遞const T&,也就是常量引用。
const引用參數有四個好處:1 避免了堆內存分配;2 避免了棧對象拷貝;3 函數內不小心修改了該對象編譯器報錯;4 代碼直觀易於理解。
所以,《Effective C++ 》有一條指出應該最大限度的使用const引用參數。
既然是傳遞引用,就應該保證引用參數還在,不能這邊函數要使用一個引用對象,在外面其他地方因為超出作用域已經被釋放了。這就要求你要知道你的參數的生命周期(也就是業務需求),這一點實際上很容易做到,就像上文說的在main函數中定義的用來存儲班級學生信息的容器對象,就可以不斷的傳遞自身的引用給各個操作它的函數。
你說的move同樣是一個很好的問題。
考慮到從函數直接返回一個vector,比如 vector<int> fun(),代碼看起來像這樣: a = f();
而不是這樣:
vector a;
f(a);//這里得到a,看起來就不直觀。
既然要能夠從函數返回vector,那么我們也知道vector里面保存了動態內存數據,這時候的vector是一個非平凡的類,也就是值拷貝返回這種對象會導致其管理的動態內存有多個對象同時在指向的問題(加上拷貝得到的對象也在指向)。由誰釋放就是一個大問題。所以C++誕生了復制控制。
賦值控制也好,復制控制也罷,都是一個意思,就是解決vector這種類型的對象的拷貝問題。
還是剛才的那個從函數返回vector的問題,賦值控制解決了內存不會混亂的問題,C++沿用了C語言的值語義(賦值總是拷貝,如果是vector這樣的容器就連動態內存也一起拷貝),但是總是把一個對象的動態內存拷貝一份過來,另一個對象可能立刻又要釋放,這個對象也涉及分配一次內存,實在是代價太高。本來動態內存的分配就已經需要成本了,現在一個賦值語句,居然要釋放一次,再開辟一次內存,還要拷貝一次,這個代價太高了。
所以我們希望像函數返回vector這種場景的效率能更高一些,這就產生了移動語義。
移動語義就是偷梁換柱,將兩個對象的動態內存互換:
a = f();
f函數里面的那個vector使用移動語義把自己的動態內存交給a之后就析構了。這樣就避免了一次內存釋放和內存開辟。豈不是很完美。
所以你說的move這種情形,在C++里並不常見,只有在函數返回一個vector這種對象的時候才會發生。其余場景都被引用參數完成了。
C++中使用指針的場景:借用對象而不負責對象的生命周期管理的時候是比較適合用指針的(這要求團隊要有代碼規范)。具體參考(請留意文章中的注釋部分,是亮點):
C++ 原始指針、shared_ptr、unique_ptr分別在什么場景下使用但是也有一些庫,比如Rapidjson,為了快,統統使用移動語義。這種庫要求你賦值之后,對象的動態內存部分就被轉移走了。這種場景下很少發生不必要的動態內存的開辟和釋放。而且就算發生了動態內存的開辟和釋放,這個庫自己也在一開始就開辟了很長一段內存給自己使用,類似於內存池。用完了再來一段。最大限度的避免了內存碎片。
這種庫的作者可以駕馭這種內存分配 方式,對於普通開發者實在沒有必要這么干,我們會用就足矣。
但是,很多新人不好好打基礎,大學沒畢業就要實現一個內存池。這就是不務正業(對,瞅啥呢,說的就是你)。
就像vector的移動函數,我們知道有這么回事,知道會用,就足以。絕大多數情況下,至少我工作7年以來,從來沒有在工作中需要寫移動函數的。因為各種庫非常完善,STL里面的容器早就寫好了,你用就行了。工作中極少自己寫一個容器,都是基於STL的模板容器開發(直接使用,而不創造新的)。
所以,結論就是,指針幾乎不會在C++里作為參數傳遞(除非有編碼規范)。參數傳遞時move也不會發生,因為被引用參數取代。函數返回vector這種對象需要move,但是標准庫早就替你寫好了,所以你也不必擔心。