C++ Primer 第七章筆記
Chapter 7 Classes
7.1 定義抽象資料型別
7.1.2 定義改進的 Sales_data 類
定義和宣告成員函式的方式與普通函式差不多。成員函式的宣告在類的內部,它的定義既可以在類的內部也可以在類的外部。作為介面部分的非成員函式,它們的定義和宣告都在類的外部。
定義在類內部的函式是隱式的 inline 函式。
引入 this
成員函式通過一個名為 this 的額外的隱式引數來訪問呼叫它地那個物件。當我們呼叫一個成員函式時,用請求該函式地物件地址初始化 this。 this 是一個常量指標,不允許改變 this 儲存的物件。
引入 const 成員函式
在預設情況下,我們不能把 this 繫結到一個常量物件上。這一情況也就使得我們不能在一個常量物件上呼叫普通的成員函式。C++ 允許把 const 關鍵字放在成員函式地引數列表之後,此時,緊跟在引數列表後面的 const 表示 this 是一個指向常量地指標。像這樣使用 const 地成員函式被稱作常量成員函式(const member function)。
因為 this 是指向常量地指標,所以常量成員函式不能改變呼叫它的物件的內容。在例子中,isbn 可以讀取呼叫它的物件的資料成員,但是不能寫入新值。常量物件,以及常量物件的引用或指標都只能呼叫常量成員函式。
類作用域和成員函式
類本身就是一個作用域,類的成員函式的定義巢狀在類的作用域之內。其次,類的成員函式體可以隨意使用類中的其他成員而無須在意這些成員出現的次序,因為編譯器首先編譯成員的宣告,然後才輪到成員函式體。
在類的外部定義成員函式
像其他函式一樣,當我們在類的外部定義成員函式時,成員函式的定義必須與它的宣告匹配。也就是說,返回型別、引數列表和函式名都得與內部的宣告保持一致。如果成員被宣告成常量成員函式,那麼它的定義也必須在引數列表後明確指定 const 屬性。同時,類外部定義的成員的名字必須包含它所屬的類名:
double Sales_data::avg_price() const {
if (units_sold)
return revenue/units_sold;
else
return 0;
}
函式名 Sales_data::avg_price() 使用作用域運算子來說明如下的事實:我們定義一個名為 avg_price() 的函式,並且該函式被宣告在類 Sales_data 的作用域內。一旦編譯器看到這個函式名,就能理解剩餘的程式碼時位於類的作用域內的。
定義一個返回 this 物件的函式
函式 combine 的設計初衷類似於複合賦值運算子 +=,呼叫該函式的物件代表的是賦值運算子左側的運算物件,右側運算物件則通過顯式的實參被傳入引數:
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold += rhs.units_sold; // add the members of rhs into
revenue += rhs.revenue; // the members of ''this'' object
return *this; // return the object on which the function was called
}
total.combine(trans)
上面這個 return 返回 total 的呼叫。
7.1.3 定義類相關的非成員函式
類的作者常常需要當以一些輔助函式,比如 add、read 和 print 等。儘管這些函式定義的操作從概念上來說屬於類的介面的組成部分,但它們實際上並不屬於類本身。
我們定義非成員函式的方式與定義其他函式一樣,通常把函式的宣告和定義分離開來。如果在函式在概念上屬於類但是不定義在類中,則它一般應與類宣告(而非定義)在同一個標頭檔案內。在這種方式下,使用者使用介面的任何部分都只需要引入一個檔案。
一般來說,如果非成員函式時類介面的組成部分,則這些函式的宣告應該與類在同一個標頭檔案內。
7.1.4 建構函式
每個類都分別定義了它的物件被初始化的方式,類通過一個或幾個特殊的成員函式來控制其物件的初始化過程,這些函式叫做建構函式(constructor)。建構函式的任務是初始化類物件的資料成員,無論何時只要類的物件被建立,就會執行建構函式。
建構函式的名字和類名相同。和其他函式不一樣的是,建構函式沒有返回型別;初次之外類似於其他的函式,建構函式也有一個(可能為空的)引數列表和一個(可能為空的)函式體。類可以包含多個建構函式,和其他過載函式差不多,不同的建構函式之間必須在引數數量或引數型別上有所區別。
不同於其他成員函式,建構函式不能被宣告成 const 的。當我們建立類的一個 const 物件時,直到建構函式完成初始化過程,物件才能真正取得其 “常量” 屬性。因此,建構函式在 const 物件的構造過程中可以向其寫值。
合成的預設建構函式
我們的 Sales_data 類並沒有定義任何建構函式,可是之前使用了 Sales_data 物件的程式仍然可以正確地編譯和執行。
類通過一個特殊的建構函式來控制預設初始化過程,這個函式叫做預設建構函式(default constructor)。預設建構函式無須任何實參。
如我們所見,預設建構函式在很多方面都具有其特殊性。其中之一時,如果我們的類沒有顯示地定義建構函式,那麼編譯器就會為我們隱式地定義一個預設建構函式。
編譯器建立的建構函式被稱為合成的預設建構函式(synthesized default constructor)。對於大多數類來說,這個合成的預設建構函式將按照如下規則初始化類的資料成員:
- 如果存在類內的初始值,用它來初始化成員。
- 否則,預設初始化該成員。
某些類不能依賴於合成的預設建構函式
合成的預設建構函式只適合非常簡單的類。對於一個普通的類來說,必須定義它自己的預設建構函式,原因由三:第一個原因也是最容易理解的一個原因就是編譯器只有在發現類不包含任何建構函式的情況下才會替我們生成了一個預設的建構函式。一旦我們定義了一些其他的建構函式,那麼除非我們再定義一個預設的建構函式,否則類將沒有預設建構函式。這條規則的依據是,如果一個類再某種情況下需要控制物件初始化,那麼該類很可能再所有情況下都需要控制。
第二個原因是對於某些類來說,合成的預設建構函式可能執行錯誤的操作。如果定義再塊中的內建型別或複合型別(比如陣列和指標)的物件被預設初始化,則它們的值將是未定義的。該準則同樣適用於預設初始化的內建型別成員。因此,含有內建型別或複合型別成員的類應該阿紫類的內部初始化這些成員,或者定義一個自己的預設建構函式。否則,使用者在建立類的物件時就可能得到未定義的值。
第三個原因時有的時候編譯器不能因為某些類合成預設的建構函式。例如,如果類中包含一個其他類型別的成員且這個成員的型別沒有預設建構函式,那麼編譯器將無法初始化該成員。對於這樣的類來說,我們必須自定義預設建構函式,否則該類將沒有可用的預設建構函式。
= fault 的含義
從解釋預設建構函式的含義開始:
Sales_data() = default;
首先明確一點,因為該建構函式不接受任何實參,所以它時一個預設建構函式。我們定義這個建構函式的目的僅僅是因為我們既需要其他形式的建構函式,也需要預設的建構函式。我們希望這個函式的作用完全等同於之前使用的合成預設建構函式。
在 C++11 中,如果我們需要預設的行為,那麼可以通過在引數列表後面寫上 = default 來要求編譯器生成建構函式。其中, = default 既可以和宣告一起出現在類的內部,也可以定義出現在類的外部。和其他函式一樣,如果 = default 在類的內部,則預設建構函式是內聯的;如果它在類的外部,則將該成員預設情況下不是內聯的。
建構函式初始值列表
接下來我們介紹類中定義的另外兩個建構函式:
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { }
這兩個定義中出現了新的部分,即冒號以及冒號和花括號之間的程式碼,其中花括號定義了(空的)函式體。我們把新出現的部分稱為建構函式初始值列表(constructor initialize list),它負責為新建立的物件的一個或幾個資料成員賦初值。建構函式初始值是成員名字的一個列表,每個名字後面緊跟括號括起來的(包括在花括號內的)成員初始值。不同成員的初始化通過逗號分隔開來。
通常情況下,建構函式使用類內初始值不失為一種好的選擇,因為只要這樣的初始值存在我們就能確保為成員賦予了一個正確的值。不過,如果你的編譯器不支援類內初始值,則所有建構函式都應該顯式地初始化每個內建型別的成員。
在類的外部定義建構函式
與其他幾個建構函式不同,以 istream 為引數的建構函式需要執行一些實際的操作。在它的函式體內,呼叫了 read 函式以給資料成員賦以初值:
Sales_data::Sales_data(std::istream &is)
{
read(is, *this); // read will read a transaction from is into this // object
}
建構函式沒有返回型別,所以上述定義從我們指定的函式名字開始。當我們在類的外部定義建構函式時,必須指明該建構函式時哪個類的成員。因此,Sales_data::Sales_data 的含義是我們定義 Sales_data 類的成員,它的名字是 Sales_data。又因為該成員的名字和類名相同,所以它是一個建構函式。這個建構函式的列表是空的,儘管建構函式初始值是空的,但是由於執行了建構函式體,所以物件的成員仍然能被初始化。沒有出現在建構函式初始值列表中的成員將通過相應類內初始值(如果存在的話·)初始化,或者執行預設初始化。
7.1.5 拷貝、賦值和析構
除了定義類的物件如何初始化之外。類還需要控制拷貝、賦值和銷燬物件時發生的行為。物件在幾種情況下會被拷貝,如我們初始化變數以及以值的方式傳遞或返回一個物件等。當我們使用了賦值運算子時會發生產生物件的賦值操作。當物件不再存在時執行銷燬操作,比如一個區域性物件回在建立它的塊結束時會被銷燬,當 vector 物件(或者陣列)銷燬時儲存在其中的物件也會被銷燬。
如果我們不主動定義這些操作,則編譯器將會替我們合成它們。一般來說,編譯器生成的版本將對物件的每個成員執行拷貝、賦值和銷燬操作。
某些類不能依賴於合成的版本
管理動態記憶體的類通常不能依賴於上述操作的合成版本。
7.2 訪問控制與封裝
到目前為止,我們已經為類定義了介面,但並沒有任何機制強制使用者使用這些介面。我們的類還沒有封裝。也就是說,使用者可以直達 Sales_data 物件的內部並且控制它的具體實現細節。在 C++ 中,我們使用訪問說明符(access specifiers)加強類的封裝性:
-
定義在 public 說明符之後的成員在整個程式內可被訪問,public 成員定義類的介面。
-
定義在 private 說明符之後的成員可以被類的成員函式訪問,但是不能被使用該類的程式碼訪問,private 部分封裝了(即隱藏了)類的實現細節。
一個類可以包含 0 個或多個訪問說明符,而且對於某個訪問說明符能出現多次次也沒有嚴格限定。每個訪問說明符制定了接下來的成員的訪問級別,其有效範圍直到下一次訪問說明符或者到達結尾處為止。
使用 class 或 struct 關鍵字
class 和 struct 的預設訪問許可權不太一樣。類可以在它的第一個訪問說明符之前定義成員,對這種成員的訪問許可權依賴於類定義的方式如果我們使用 struct 關鍵字,則定義在第一個訪問說明符之前的成員是 public 的;相反,如果我們使用 class 關鍵字,則這些成員是 private 的。
出於統一程式設計風格的考慮,當我們希望定義的類的所有成員是 public 的時,使用 struct;反之,如果希望成員是 private 的,使用 class。
使用 class 和 struct 定義類的唯一區別就是預設的訪問許可權。
7.2.1 友元
類可以允許其他類或者函式訪問它的非公有成員,方法是令其他類或者函式成為它的友元(friend)。如果類想把一個函式作為它的友元,只需要增加一條以 friend 關鍵字開始的函式宣告語句即可。
友元宣告只能出現在類定義的內部,但是在類內出現的具體位置不限。友元不是類的成員也不受它所在區域訪問控制級別的約束。一般來說,最好在類定義開始或結束前的位置集中宣告友元。
友元的宣告
友元的宣告僅僅指定了訪問的許可權,而非一個通常意義上的函式宣告。如果我們希望類的使用者能夠呼叫某個友元函式,那麼我們就必須在友元宣告之外再專門對函式進行一次宣告。
為了使友元對類的使用者可見,我們通常把友元的宣告與類本身防止再同一個標頭檔案中(類的外部)。因此,我們的 Sales_data 標頭檔案應該為 read、print 和 add 提供獨立的宣告(除了類內部的友元宣告之外)。
許多編譯器並未強制限定友元函式必須再使用之前在類的外部宣告。
7.3 類的其他特性
7.3.1 類成員再談
定義一個型別成員
除了定義資料和函式成員之外,類還可以自定義某種型別在類中的別名。由類定義的類型別名和其他成員一樣存在訪問限制,可以是 public 或者 private 中的一種。用來定義型別的成員必須先定義後使用,這一點與普通成員有所區別,具體原因將在解釋。因此,型別成員通常在類開始的地方出現。
可變資料成員
如果我們希望能夠修改類的某個資料成員,即使是在一個 const 成員函式內。可以通過在變數的宣告中加入 mutable 關鍵字做到這一點。
一個可變資料成員(mutable data member)永遠不會是 const,即使它是 const 物件的成員。因此,一個 const 成員函式可以改變一個可變成員的值。
類資料成員的初始值
提供一個類內初始值時,必須以符號 = 或者花括號表示。
7.3.3 類型別
每個類定義了唯一的型別。對於兩個類來收,即使它們的成員完全一樣,這兩個類也是兩個不同的型別。
類的宣告
class Screeen;
我們也能僅宣告類而暫時不定義它。這種宣告被稱作前向宣告(forward declaration),它向程式中引入了名字 Screen 並且指明 Screen 是一種類型別。對於型別 Screen 來說,在它宣告之後定義之前是一個不完全型別(incomplete type),也就是說,此時我們已知 Screen 是一個類型別,但是不清楚它到底包含哪些成員。
不完全型別只能在非常有限的情境下使用:可以定義指向這種型別的指標或引用,也可以宣告(但是不能定義)以不完全型別作為引數或者返回型別的函式。
7.4 類的作用域
每個類都會定義它自己的作用域。在類的作用域之外,普通的資料和函式成員只能由物件、引用或者指標使用成員訪問運算子來訪問。對於類型別成員則使用作用域運算子訪問。不問哪種情況,跟在運算子之後的名字都必須是對應類的成員。
7.4.1 名字查詢與類的作用域
到目前為止,我們編寫的程式中,名字查詢(name lookup)(尋找與所用名字最匹配的宣告的過程)的過程比較直截了當:
-
首先,在名字所在的塊中尋找其宣告語句,只考慮在名字的使用之前出現的宣告。
-
如果沒找到,繼續查詢外層作用域。
-
如果最終沒有找到匹配的宣告,則程式報錯。
對於定義在類內部的成員函式來說,解析其中名字的方式與上述的查詢規則有所區別,不過在當前的這個例子中體現得不太明顯。類的定義分兩部處理:
-
首先,編譯成員的宣告。
-
直到類全部可見後才編譯函式體。
編譯器處理完類中的全部聲明後才會處理成員函式的定義。
用於類成員宣告的名字查詢
這種兩階段的處理方式只適用於成員函式中使用的名字。宣告中使用的名字,包括返回型別或者引數列表中使用的名字,都必須在使用前確保可見。如果某個成員的宣告使用了類中尚未出現的名字,則編譯器將會在定義該類的作用域中繼續查詢。
7.5 建構函式再探
7.5.1 建構函式初始值列表
建構函式的初始值有時必不可少
有時我們可以忽略資料成員的初始化和賦值之間的差異,但並非總能這樣。如果成員是 const 或者是引用的話,必須將其初始化。類似的,當成員屬於某種類型別且該類沒有定義預設建構函式時,也必須將這個成員初始化。
成員初始化的順序
成員的初始化順序與它們在類定義中的出現順序一致:第一個成員先被初始化,然後第二個,以此類推。建構函式初始值列表中初始值的前後位置關係不會影響實際的初始化順序。
最好令建構函式初始值的順序與成員宣告的順序保持一致。而且如果可能的話,儘量避免使用某些成員初始化其他成員。
預設實參和建構函式
如果一個建構函式為所有引數都提供了預設實參,則它實際上也定義了預設建構函式。
7.5.2 委託建構函式
一個委託建構函式使用它所屬類的其他建構函式執行它自己的初始化過程,或者說它把自己的一些(或者全部)職責委託給了其他建構函式。
和其他建構函式一樣,一個委託建構函式也有一個成員初始值的列表和一個函式體。在委託建構函式內,成員初始值列表只有一個唯一的入口,就是類名本身。和其他成員初始值一樣,類名後面緊跟著圓括號括起來的引數列表,引數列表必須與類中另外一個建構函式匹配。
7.5.3 預設建構函式的作用
當物件被預設初始化或值初始化時,自動執行預設建構函式。預設初始化在以下情況下發生:
-
當我們在塊作用域內不使用任何初始值頂一個非靜態變數或者陣列時。
-
當一個類本身含有類型別的成員且使用合成的預設建構函式時。
-
當類型別的成員沒有在建構函式初始值列表中顯式地初始值中顯示地初始化時。
值初始化在以下情況發生:
-
在陣列初始化的過程中如果我們提供的初始值數量少於陣列的大小時。
-
當我們不使用初始值定義一個區域性靜態變數時。
-
當我們通過書寫形如 T()的表示式只接受一個實參用於說明 vector 大小,它就是使用一個這種形式的實參來對它的元素初始化器進行值初始化。
7.5.4 隱式的類型別轉換
如果建構函式只接受一個實參,則它實際上定義了轉換為此類型別的隱式轉換機制,有時我們把這種那個建構函式稱作轉換建構函式(converting constructor)。
只允許一步類型別轉換
// error: requires two user-defined conversions:
// (1) convert "9-999-99999-9" to string
// (2) convert that (temporary) string to Sales_data
item.combine("9-999-99999-9");
// ok: explicit conversion to string, implicit conversion to Sales_data
item.combine(string("9-999-99999-9"));
// ok: implicit conversion to string, explicit conversion to Sales_data
item.combine(Sales_data("9-999-99999-9"));
抑制建構函式定義的隱式轉換
在要求隱式轉換的程式中,我們可以通過將建構函式宣告為 explicit 加以阻止,explicit 函式只能用於直接初始化。
為轉換顯式地使用建構函式
儘管編譯器不會將 explicit 的建構函式用於隱式轉換過程,但是我們可以使用這樣的建構函式顯式地強制進行轉換:
// ok: the argument is an explicitly constructed Sales_data object
item.combine(Sales_data(null_book));
// ok: static_cast can use an explicit constructor
item.combine(static_cast<Sales_data>(cin));
7.5.5 聚合類
聚合類(aggregate class)使得使用者可以直接訪問其成員,並且具有特殊的初始化語法形式。當一個類滿足如下條件時,我們說它時聚合的:
- 所有成員都是 public 的。
- 沒有定義任何建構函式。
- 沒有類內初始值。
- 沒有基類,也沒有 virtual 函式。
我們可以提供一個花括號括起來的成員初始值列表,並用它初始化聚合類的資料成員。初始值的順序必須與宣告的順序一致。與初始化陣列元素的規則一樣,如果初始值列表中的元素個數少於類的成員數量,則靠後的成員被值初始化。初始值列表的元素個數絕對不能超過類的成員數量。
顯式地初始化類的物件的成員存在三個明顯的缺點:
- 要求類的所有成員都是 public 的。
- 將正確初始化每個物件的每個成員的重任嫁給了類的使用者(而非類的作者)。因為使用者很容易忘記掉某個初始值,後者提供一個不恰當的初始值,所以這樣的初始化過程冗長乏味且容易出錯。
- 新增或刪除一個成員之後,所有的初始化語句都需要更新。
7.5.6 字面值常量類
資料成員都是字面值型別的聚合類是字面值常量類。如果一個類不是聚合類,但它符合下述要求,則它也是一個字面值常量類:
- 資料成員都必須是字面值型別。
- 類必須至少含有一個 constexpr 建構函式。
- 如果一個數據成員含有類內初始值,則內建型別的初始值必須是一條常量表達式;或者如果成員屬於某種類型別,則初始值必須使用成員自己的 constexpr 建構函式。
- 類必須使用解構函式的預設定義,該成員負責銷燬類的物件。
constexpr 建構函式
儘管建構函式不能是 const 的,但是字面值常量類的建構函式可以是 constexpr 函式。事實上,一個字面值常量類比如至少提供一個 constexpr 建構函式。
constexpr 函式可以宣告成 =default 的形式(胡這是刪除函式的形式),否則,constexpr 建構函式就必須既符合建構函式的要求(意味著不能包含返回語句),又符合 constexpr 函式的要求(意味著它能擁有的唯一可執行語句就是返回語句)。綜合這兩點可知,constexpr 建構函式體一般來說應該是空的。我們通過前置關鍵詞 constexpr 就可以宣告一個 constexpr 構造函數了。
7.6 類的靜態成員
有時候類需要它的一些成員與類的本身直接相關,而不是與類的各個物件保持關聯。
宣告靜態成員
我們通過在成員的宣告之前加上關鍵字 static 使得其與類關聯在一起。和其他成員一樣,靜態成員可以是 public 的或 private 的。靜態資料成員可以是常量、引用、指標、類型別等。
類的靜態成員存在於任何物件之外,物件中不包含任何與靜態資料成員有關的資料。類似的,靜態成員函式也不與任何物件繫結在一起,它們不包含 this 指標。作為結果,靜態成員函式不能宣告成 const 的,而且我們也不能在 static 函式體內使用 this 指標。這一限制既適用於 this 的顯式使用,也對呼叫非靜態成員的隱式使用有效。
使用類的靜態成員
使用作用域運算子直接訪問靜態成員。雖然靜態成員不屬於類的某個物件,但是我們仍然可以使用類的物件、引用或者指標來訪問靜態成員。成員函式不用過作用域運算子就能直接使用靜態成員。
定義靜態成員
和其他的成員函式一樣,我們既可以在類的內部也可以在類的外部定義靜態成員函式。當在類的外部定義靜態成員時,不能重複 static 關鍵字,該關鍵字只出現在類內部的宣告語句。
和類的所有成員一樣,當我們指向類外部的靜態成員時,必須指明成員所屬的類名。static 關鍵字則只出現在類內部的宣告語句中。
因為靜態資料成員不屬於類的任何一個物件,所以它們並不是在建立類的物件時被定義的。這意味著它們不是由類的建構函式初始化的。而且一般來說,我們不能再類的內部初始化靜態成員。相反的,必須在類的外部定義和初始化每個靜態成員。和其他物件一樣,一個靜態資料成員只能定義一次。
類似於全域性變數,靜態資料成員定義在任何函式之外。因此一旦它被定義,就將一直存在與程式的整個宣告週期之中。
要想確保物件只定義一次,最好的方法是把靜態資料成員的定義與其他非行內函數的定義放在同一個檔案中。
靜態成員的類內初始化
通常情況下,類的靜態成員不應該再類的內部初始化。然而,我們可以為靜態成員提供 const 整數型別的類內初始值。不過要求靜態成員必須是字面值常量型別的 constexpr。初始值必須是常量表達式,因為這些成員本身就是常量表達式,所以它們能用在適合於常量表達式的地方。