天天看點

《C++程式設計慣用法——進階程式員常用方法和技巧》——2.1 構造函數

本節書摘來自異步社群出版社《c++程式設計慣用法——進階程式員常用方法和技巧》一書中的第2章,第2.1節,作者: 【美】robert b. murray ,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。

構造函數中有着比我們所看見的還要多的細節。除了程式員所編寫的代碼之外,構造函數還可以調用其他的構造函數來初始化對象中的基類對象和資料成員。即使程式中并沒有明确的調用,編譯器也可以向代碼中插入構造函數的調用代碼(例如:當程式中有着隐式的資料轉換時)。在本節中,我們将關注一些和構造函數相關的常見問題,這些問題中的部分會導緻程式的執行速度變慢,另外一些則可能會導緻程式中bug的産生。

在閱讀本章時,你最好能夠時刻記住“初始化”和“指派”這兩者之間的差別(詳情參見下面的“回顧”)。你同時最好也不要被“預設構造函數”(不需要任何參數就可以被調用的構造函數,它要麼是在聲明時就沒有參數,要麼就是在聲明時它的所有參數都被賦予了預設的值)和“預設的複制構造函數”(由編譯器自動合成的複制構造函數)這些術語給迷惑住。

回顧:初始化和指派

在c++中,當一個新對象被建立時,會有初始化操作出現;而指派是用來修改一個已經存在的對象的值的(此時沒有任何新對象被建立)。

初始化出現在構造函數中,而指派出現在operator=操作符函數中。

c++中還有着一種特殊的初始化:用同一個類産生的另外一個對象的值來為将要被建立的對象進行初始化。執行這樣的初始化操作的構造函數也被稱為複制構造函數,它們通常都有着如下的格式:x::x(const x&)。

如果我們沒有在類中聲明一個複制構造函數,那麼編譯器就會為我們合成一個。這個預設的複制構造函數會将新對象中的每個資料成員初始化為源對象中相應成員的值。

當我們使用傳值的方式來調用函數時,編譯器會自動産生調用複制構造函數的代碼。被生成的臨時對象在函數傳回時将會(通過調用析構函數)被摧毀:

在上面的代碼中,我們通過調用complex::complex(const complex&)傳遞了c的一個拷貝給abs。當abs傳回時,這個拷貝将會被complex的析構函數(如果有的話)所摧毀。

2.1.1 預設的複制構造函數的行為是否符合我們的要求?

當我們編寫了一個新類時,請注意這兩點:預設的複制構造函數和指派操作的行為是否符合我們的預期要求。如果不是的話,我們就将不得不聲明和定義我們所需要的這兩個函數。

通常(但不是所有的)情況下,當對象的所有狀态都存儲在對象中時,預設構造函數的所作所為都和我們所預期的一樣。例如,我們來看看複數類complex的實作,它的對象中用兩個double存儲了複數的狀态:

由于我們沒有明确聲明複制構造函數,是以編譯器将會為我們合成一個預設的複制構造函數。它的行為就是複制這兩個資料成員,而這正是我們這個類所期望的操作。

在另外一方面,假設我們有一個類string,在其中有一個char*的資料成員,我們用它來指向string類對象所代表的字元串:

在上面的例子中,string類的預設複制構造函數将會僅僅對那個指針進行複制,最終導緻兩個string對象指向同一塊記憶體。這種結果并不是我們期望的,一旦第一個string對象被摧毀,那麼被指向的記憶體也将同時被釋放,這時剩下來的那個對象将發現,它所擁有的指針成員指向的是已經被釋放了的記憶體!

如果預設的行為和我們預期的不一樣,我們就必須明确地聲明和定義一個複制構造函數:

這樣我們就可以確定每個string都将擁有一份資料的私有拷貝。

在某些情況下,即使有新對象被建立時,由于某些特殊的操作的緣故,我們也不能使用預設的複制構造函數。假設我們有一個類file,我們用它來表示一個檔案描述符(也就是一個用于檔案i/o操作的句柄)。有些應用程式可能需要知道在某個給定的時間内到底存在着多少個file的對象(這樣做可能是為了避免打開的檔案數超過允許的檔案描述符上限)。我們可以很容易做到這一點:隻要讓file維護一個目前存在的file對象個數的計數器就可以了。在file的構造函數中我們會對這個計數器進行遞增,在析構函數中對計數器則是遞減:

靜态成員函數file::existing()将傳回現有的file對象的計數。

為使這樣一個方案生效,每個file構造函數都必須更新計數器。預設複制構造函數不做這個工作,是以我們必須自己來寫:

上面代碼中的靜态函數file::existing()将會傳回目前存在的file對象的個數。

對于預設複制構造函數是否能夠工作這個問題,我們并沒有一個通用的規則。一種從經驗中得到的方法就是:對那些包含指針的類要“另眼相待”。如果被指向的對象是“屬于”該産生的對象,那麼預設的複制構造函數就有可能是錯誤的,因為它隻是簡單地複制了指針而不是指針所指向的對象。

即使我們的代碼從來不會去調用複制構造函數,我們也不能忽略掉它。請記住,那些使用我們所編寫的類的使用者可能會去調用類的複制構造函數(他們有可能是通過建立新對象來顯式地調用它,也可能是通過用傳值方式向函數傳遞參數來隐式地調用它)。如果确實因為某些原因,使得為類實作複制構造函數變得非常困難,那麼請把它聲明為私用的,并且不要為它提供任何的定義:

一旦我們這樣做了,我們至少可以确信那些無意間調用到複制構造函數的代碼将會被編譯器(使用者代碼)或者連接配接器(類成員或者友元代碼)給找出來。雖然這樣做并不是很好,但比起那些默默地執行編譯器合成的預設複制構造函數的錯誤代碼來說,它要好得多了。

當類中的某個資料成員本身也是一個類對象時,我們應該避免用指派操作來為該成員進行初始化:

雖然這樣做構造函數也能得到正确的結果,但它的效率卻不能達到它本來應該達到的标準。當一個新的employee對象被建立時,成員name先将會被string的預設構造函數所初始化,然後在employee的構造函數中,它的值又會因為指派操作而再一次改變。這是兩個不同的步驟,不過我們可以把它合并到一個步驟中去:我們可以通過使用初始化文法(initialization syntax)來顯式地為name進行初始化(詳情參見下面的回顧):

回顧:成員初始化

預設情況下,在構造函數的函數體被執行前,對象中的所有成員都已經被它們的預設構造函數所初始化了。那些沒有構造函數的成員則将擁有一個未定義的初始值。

編寫構造函數的人可以對這種行為進行更改,方法是:在構造函數定義中的參數清單結束的括号後面增添一個冒号以及一個初始體(initializer)清單。每個初始體都包括一個名字以及一個參數清單,這其中的名字就是要建立的類中成員或其基類的名字:

下面的代碼會告訴編譯器隻使用一次函數調用來初始化name這個成員:

那些内建類型的成員也可以用這種文法來進行初始化,此時的參數清單必須是一個用來指定初始值的簡單表達式。

現在這個構造函數隻需要進行一次關于string的操作(也就是一次初始化),而原來的那個則需要兩次(一次初始化以及一次指派)。在我的計算機上面,對employee的構造函數進行這樣的改變可以獲得大概30%的效率提升。

當我們在編寫構造函數的定義時,請在寫完正式的參數清單後停下來一會,想一想有多少成員可以使用構造函數的初始化文法。通常我們都可以發現所有的成員都可以用這種方法來進行初始化,而當我們寫到構造函數的函數體時,将會發現其實我們什麼也不需要做!這是一個好的信号:我們的構造函數已經為我們類的成員們選擇了一個正确的初始值。

不是類對象的成員

初始化文法同樣也可以用來對不是類對象的成員進行初始化:

由于内建類型沒有構造函數,使用初始化文法來對整型成員salary進行初始化并不能獲得比指派更高的效率,但這樣做(使用同樣的方法來為所有的資料成員進行初始化)會使得代碼的可讀性更高。

成員初始化的順序

c++中規定,一個類中成員的初始化順序和它們在類中被聲明的順序(而不是構造函數定義中的順序)必須是一緻的。通常情況下,這種順序問題都不會有什麼影響,但在某些場合下,它将導緻産生問題——例如:某個成員的初始化過程中使用了另外成員的值。

讓我們來考慮一個改進後的employee類,它包括employee的名字和身份認證代碼:

我們編寫了兩個構造函數,一個以職工的名字作為參數,一個以職工的身份認證代碼作為參數。每個構造函數都會從一個職工花名冊中查找參數中沒有給出的那份資訊。

下面是我們對構造函數定義的一種(不正确的)嘗試:

上面的第二個構造函數可以順利地通過編譯,但卻不能正常工作。因為在類聲明中,成員name出現在成員id的前面,這確定它每次都會被第一個初始化——即便是我們在構造函數的定義中把id的初始化代碼寫在它的前面!這意味着在第二個構造函數中,lookup_employee實際上在id被初始化前就被調用——這時它的參數也就将是一個毫無意義的随機數值。在此例中,這個問題很容易被解決:我們可以使用外界傳遞過來的參數,而不是類中的成員:

但不是每次我們都可以這麼容易地解決問題;在某些情況下,我們可能不得不對類的定義進行重新整理。如果成員的順序意義重大的話,我們最好在頭檔案中把它記錄下來,這樣在随後的對類的聲明進行重新排列過程中,我們并不會導緻某些構造函數的工作出現問題[1]。

編譯器忽略掉構造函數中的初始體順序這個現象看起來好像有違我們的直覺,但這卻是c++要求“對象的析構過程必須和其建立過程相反”得到的結果。如果構造函數中指定了一個特殊的構造順序,那麼析構函數将不得不去查詢構造函數的定義,以獲得如果對成員進行析構的順序。由于構造函數和析構函數可以在不同的檔案中定義,這就将給編譯器的實作者造成一個難題。更糟糕的是,一個類可以有兩個或者更多的構造函數,對于這些構造函數的定義,我們并不能保證它們中的成員出現的實作都是一緻的。然而,我們可以確定類的聲明在所有的檔案中都将有效,并且不同的檔案中出現的聲明都是一緻的(否則就将造成整個程式的行為無法預期);是以我們使用類的聲明來解決成員的構造和析構順序。

類成員的引用

如果在類中的某個非靜态資料成員是一個引用的話,因為所有的引用都必須被明确地初始化,是以我們必須在該類的每個構造函數中都使用初始化文法:

此時我們必須問自己,為什麼我們要将emp聲明為employee&,而不是employee*呢?除了文法上的不同之外,将emp聲明為引用而不是指針将會在兩方面對它的使用造成限制:

我們必須在建立emp時就為它綁定一個職工(并不存在着一個為“0”或者為“null”的引用);

一旦我們将它和一個職工綁定後,emp就不可能再被用來綁定到另外一個職工上面。

通過将emp聲明為引用,如果我們的代碼中試圖違反上面的兩條規則,編譯器就将用編譯期的錯誤來幫我們找到它們。使用引用而不是指針從編譯器那能夠獲得的隻是編譯期的檢測,它們實際産生的代碼其實并無差別

繼續閱讀