C++小實驗測試:下面程序中main函數里a.a和b.b的輸出值是多少?
#include <iostream> struct foo { foo() = default; int a; }; struct bar { bar(); int b; }; bar::bar() = default; int main() { foo a{}; bar b{}; std::cout << a.a << '\t' << b.b; }
答案是a.a是0,b.b是不確定值(不論你是gcc編譯器,還是clang編譯器,或者是微軟的msvc++編譯器)。為什么會這樣?這是因為C++中的初始化已經開始畸形發展了。
接下來,我要探索一下為什么會這樣。在我們知道原因之前,先給出一些初始化的概念:默認初始化,值初始化,零初始化。
T global; //T是我們的自定義類型,因為它是全局變量,所以首先零初始化,實際上進行的是默認初始化 void foo() { T i; //默認初始化 T j{}; //值初始化(C++11) T k = T(); //值初始化 T l = T{}; //值初始化(C++11) T m(); //函數聲明 new T; //默認初始化 new T(); //值初始化 new T{}; //值初始化(C++11) } struct A { T t; A() : t() //t將值初始化 { //構造函數 } }; struct B { T t; B() : t{} //t將值初始化(C++11) { //構造函數 } }; struct C { T t; C() //t將默認初始化 { //構造函數 } };
上面這些不同形式的初始化方式有點復雜,我會對這些C++11的初始化做一下簡化:
- 默認初始化:如果T是一個類,那么調用默認構造函數進行初始化;如果是一個數組,每個元素默認初始化;除了前面的情況之外,不進行初始化,其值未定義。至於合成的默認構造函數初始化數據成員的規則是:1.如果類數據成員存在類內初始值,則用該值初始化相應成員(c++11);2.否則,默認初始化數據成員。 參考鏈接:http://en.cppreference.com/w/cpp/language/default_initialization
- 值初始化:如果T是一個類,那么類的對象進行默認初始化(如果T類型的默認構造函數不是用戶自定義的,默認初始化之前先進行零初始化);如果是一個數組,每個元素值初始化,否則進行零初始化。 參考鏈接:http://en.cppreference.com/w/cpp/language/value_initialization
- 零初始化:對於static或者thread_local變量將會在其他類型的初始化之前先初始化。如果T是算數、指針、枚舉類型,將會初始化為0;如果是類類型,基類和數據成員會零初始化;如果是數組,數組元素也零初始化。 參考鏈接:http://en.cppreference.com/w/cpp/language/zero_initialization
看一下上面的例子,如果T是int類型,那么global和那些T類型的使用值初始化形式的變量都會初始化為0(因為int是內置類型,不是類類型,也不是數組,將會零初始化,又因為int是算術類型,如果進行零初始化,則初始值為0)而其他的默認初始化都是未定義值。
回到開頭的例子,現在我們已經有了搞明白這個例子所必要的基礎知識。造成結果不同的根本原因是:foo和bar被它們不同位置的默認構造函數所影響。
foo的構造函數在起初聲明時是要求默認合成,而不是我們自定義提供的,因此它屬於編譯器合成的默認構造函數。而bar的構造函數則不同,它是在定義時被要求合成,因此它屬於我們用戶自定義的默認構造函數。
前面提到的關於值初始化的規則時,有說明到:如果T類型的默認構造函數不是用戶自定義的,默認初始化之前先進行零初始化。因為foo的默認構造函數不是我們自定義的,是編譯器合成的,所以在對foo類型的對象進行值初始化時,會先進行一次零初始化,然后再調用默認構造函數,這導致a.a的值被初始化為0,而bar的默認構造函數是用戶自定義的,所以不會進行零初始化,而是直接調用默認構造函數,從而導致b.b的值是未初始化的,因此每次都是隨機值。
這個陷阱迫使我們注意:如果你不想要你的默認構造函數是用戶自定義的,那么必須在類的內部聲明處使用"=default",而不是在類外部定義處使用。
對於類類型來說,用戶提供自定義的默認構造函數有一些額外的“副作用”。比如,對於缺少用戶提供的自定義默認構造函數的類,是無法定義該類的const對象的。示例如下:
class exec { public: exec() = default; int i; }; class exec2 { public: exec2(); int i; }; exec2::exec2() = default; const exec e; //錯誤,合成的默認構造函數沒有初始化數據成員 const exec2 e2; //正確,自定義的默認構造函數初始化了數據成員
通過開頭的例子,我們已經對C++的一些初始化方式有了直觀的感受。 C++中的初始化分為6種:零初始化、默認初始化、值初始化、直接初始化、拷貝初始化、列表初始化。
零初始化和變量的類型和位置有關系,比如是否static,是否aggregate聚合類型。能進行0初始化的類型的對象的值都是0,比如int為0,double為0.0,指針為nullptr;
現在我們已經了解了幾種初始化的規則,下面則是幾種初始化方式的使用形式:
1. 默認初始化是定義對象時,沒有使用初始化器,也即沒有做任何初始化說明時的行為。典型的:
int i; vector<int> v;
2. 值初始化是定義對象時,要求初始化,但沒有給出初始值的行為。典型的:
int i{}; new int(); new int{}; //C++11
3. 直接初始化和拷貝初始化主要是相對於我們自定義的對象的初始化而言的,對於內置類型,這兩者沒有區別。對於自定義對象,直接初始化和拷貝初始化區別是直接調用構造函數還是用"="來進行初始化。典型的:
vector<int> v1(10); //直接初始化,匹配某一構造函數 vector<string> v2(10); //直接初始化,匹配某一構造函數 vector<int> v3=v1; //拷貝初始化,使用=進行初始化
對於書本中給出的示例:
string dots(10, '.'); //直接初始化 string s(dots); //直接初始化
這里s的初始化書本說是直接初始化,看起來似乎像是拷貝初始化,其實的確是直接初始化,因為直接初始化是用參數來直接匹配某一個構造函數,而拷貝構造函數和其他構造函數形成了重載,以至於剛好調用了拷貝構造函數。
事實上,C++語言標准規定復制初始化應該是先調用對應的構造函數創建一個臨時對象,然后拷貝構造函數再將構造的臨時對象拷貝給要創建的對象。例如:
string a = "hello";
上面代碼中,因為“hello"的類型是const char *,所以string類的string(const char *)構造函數會被首先調用,創建一個臨時對象,然后拷貝構造函數將這個臨時對象復制到a。但是標准還規定,為了提高效率,允許編譯器跳過創建臨時對象這一步,直接調用構造函數構造要創建的對象,從而忽略調用拷貝構造函數進行優化,這樣就完全等價於直接初始化了,當然可以使用-fno-elide-constructors選項來禁用優化。
如果我們將string類型的拷貝構造函數定義為private或者定義為delete,那么就無法通過編譯,雖然能夠進行優化省略拷貝構造函數的調用,但是拷貝構造函數在語法上還是要能正常訪問的,這也是為什么C++ primer第五版第13章拷貝控制13.1.1節末尾442頁最后一段話中說:
“即使編譯器略過了拷貝/移動構造函數,但在這個程序點上,拷貝/移動構造函數必須是存在且可訪問的(例如,不能是priviate的)。
更詳細的說明可以參考這篇文章:C++的一大誤區——深入解釋直接初始化與復制初始化的區別,文章的一些評論也是值得一看的,看一下某些典型的錯誤觀點是如何得到文章作者的回復。
拷貝初始化不僅在使用=定義變量時會發生,在以下幾種特殊情況中也會發生:
1.將一個對象作為實參傳遞給一個非引用的形參;
2.從一個返回類型為非引用的函數返回一個對象;
3.用花括號列表初始化一個數組中的元素或一個聚合類中的成員。
其實還有一個情況,比如:當以值拋出或捕獲一個異常時。
另外還有比較讓人迷惑的地方在於vector<string> v2(10),在《C++ Primer 5th》中說這是值初始化的方式,但是仔細看書本,這里的值初始化指的是容器中string元素,也就是說v2本身是直接初始化的,而v2中的10個string元素,由於沒有給出初始值,因此標准庫對容器中的元素采用了值初始化的方式進行初始化。
結合來說:
只要使用了括號(圓括號或花括號)但沒有給出具體初始值,就是值初始化。可以簡單理解為括號告訴編譯器你希望該對象初始化。
沒有使用括號,就是默認初始化。可以簡單理解成,你放任不管,允許編譯器使用默認行為。通常這是糟糕的行為,除非你真的懂自己在干什么。
4. 列表初始化是C++新標准給出的一種初始化方式,可用於內置類型,也可以用於自定義對象,前者比如數組,后者比如vector。典型的:
int array[5]={1,2,3,4,5}; vector<int> v={1,2,3,4,5};
文章寫到這里,讀者認真的看到這里,似乎已經懂了C++的各種初始化規則和方式,下面用幾個例子來檢測一下:
#include <iostream> using namespace std; class Init1 { public: int i; }; class Init2 { public: Init2() = default; int i; }; class Init3 { public: Init3(); int i; }; Init3::Init3() = default; class Init4 { public: Init4(); int i; }; Init4::Init4() { //constructor } class Init5 { public: Init5(): i{} { } int i; }; int main(int argc, char const *argv[]) { Init1 ia1; Init1 ia2{}; cout << "Init1: " << " " << "i1.i: " << ia1.i << "\t" << "i2.i: " << ia2.i << "\n"; Init2 ib1; Init2 ib2{}; cout << "Init2: " << " " << "i1.i: " << ib1.i << "\t" << "i2.i: " << ib2.i << "\n"; Init3 ic1; Init3 ic2{}; cout << "Init3: " << " " << "i1.i: " << ic1.i << "\t" << "i2.i: " << ic2.i << "\n"; Init4 id1; Init4 id2{}; cout << "Init4: " << " " << "i1.i: " << id1.i << "\t" << "i2.i: " << id2.i << "\n"; Init5 ie1; Init5 ie2{}; cout << "Init5: " << " " << "i1.i: " << ie1.i << "\t" << "i2.i: " << ie2.i << "\n"; return 0; }
試問上面代碼中,main程序中的各個輸出值是多少?先不忙使用編譯器編譯程序,根據之前介紹的知識先推斷一番:
首先,我們需要明白,對於類來說,構造函數是用來負責類對象的初始化的,一個類對象無論如何一定會被初始化。也就是說,當實例化類對象時,一定會調用構造函數,不論構造函數是否真的初始化了數據成員。故而對於沒有定義任何構造函數的自定義類來說,該類的默認構造函數不存在“被需要/不被需要”這回事,它必然會被合成。
- 對於Init1,由於我們對其沒有做任何構造函數的聲明和定義,因此會合成默認構造函數。
- 對於Init2,我們在類內部聲明處要求合成默認構造函數,因此也會有合成的默認構造函數。
由於Init1和Init2它們擁有類似的合成默認構造函數,因此它們的ia1.i和ib1.i值相同,應該都是隨機值,而ia2.i和ib2.i被要求值初始化,因此它們的值都是0。
- 對於Init3,我們在類外部定義處要求編譯器為我們生成默認構造函數,此默認構造函數為用戶自定義的默認構造函數。
- 對於Init4,我們顯式的定義了用戶自定義默認構造函數。
由於Init3和Init4它們擁有類似的用戶自定義默認構造函數,因此它們的ic1.i和id1.i值相同,應該都是隨機值,而ic2.i和id2.i雖然被要求值初始化,但也是隨機值。
- 對於Init5,我們顯式的定義了用戶自定義默認構造函數,並且使用了構造函數初始化列表來值初始化數據成員。
由於Init5我們為它顯式提供了默認構造函數,並且手動的初始化了數據成員,因此它的ie1.i和ie2.i都會被初始化為0。
以上是我們的預測,結果會是這樣嗎?遺憾的是,結果不一定是這樣。是我們哪里出錯了?我們並沒有錯誤,上面的程序結果取決於你使用的操作系統、編譯器版本(比如gcc-5.0和gcc-7.0)和發行版(比如gcc和clang)。可能有的人能獲得和推測完全相同的結果,而有的人不能,比如在經常被批不遵守C++標准的微軟VC++編譯器(VS 2017,DEBUG模式)下,結果卻完全吻合(可能是由於微軟開始接納開源和Linux,逐漸的嚴格遵守了語言標准),GCC的結果也是完全符合,而廣受好評的Clang卻部分結果符合。當然,相同的Clang編譯器在Mac和Ubuntu下結果甚至都不一致,GCC在某些時候甚至比Clang還人性化的Warning告知使用了未初始化的數據成員。
雖然,上面程序中有一些地方因為操作系統和編譯器的原因和我們預期的結果不相同,但也有必然相同的地方,比如最后一個使用了構造函數初始化列表的類的行為就符合預期。還有在合成的默認構造函數之前會先零初始化的地方,必然會初始化為0。
至此,我們已經對C++的初始化方式和規則已經有了一個了然於胸的認識,那就是:由於平台和編譯器的差異,以及對語言標准的遵守程度不同,我們決不能依賴於合成的默認構造函數。這也是為什么C++ Primer中多次強調我們不要依賴合成的默認構造函數,也說明了C++ Primer在關於手動分配動態內存那里告訴我們,對於我們自定義的類類型來說,為什么要求值初始化是沒有意義的。
C++語言設計的一個基本思想是“自由”,對於某些東西它既給出了具體要求,又留出了發揮空間,而那些未加以明確的地方是屬於語言的“灰暗地帶”,我們需要小心翼翼的避過。在對象的初始化這里,推薦的做法是將默認構造函數刪除,由我們用戶自己定義自己的構造函數,並且合理的初始化到每個成員,如果需要保留默認構造函數,一定要對它的行為做到心里有數。