《C++ Primer 5th》笔记(7 / 19):类
文章目錄
- 定義抽象數據類型
- 設計Sales_data類
- 關鍵概念:不同的編程角色
- 使用改進的Sales_data類
- 定義改進的Sales_data類
- 定義成員函數
- 引入this
- 引入const成員函數
- 類作用域和成員函數
- 在類的外部定義成員函數
- 定義一個返回this對象的函數
- 定義類相關的非成員函數
- 定義read和print函數
- 定義add函數
- 構造函數
- 合成的默認構造函數
- 某些類不能依賴于合成的默認構造函數
- 定義Sales_data的構造函數
- = default的含義
- 構造函數初始化列表
- 在類的外部定義構造函數
- 拷貝、賦值和析構
- 某些類不能依賴于合成的版本
- 訪問控制與封裝
- 使用class或struct關鍵字
- 友元
- 關鍵概念:封裝的益處
- 友元的聲明
- 類的其他特性
- 類成員再探
- 定義一個類型成員
- Screen類的成員函數
- 令成員作為內聯函數
- 重載成員函數
- 可變數據成員
- 類數據成員的初始值
- 返回*this的成員函數
- 從const成員函數返回*this,它的返回類型將是常量引用
- 基于const的重載
- 建議:對于公共代碼使用私有功能函數
- 類類型
- 類的聲明(可先聲明,暫時不定義)
- 友元再探
- 類之間的友元關系
- 令成員函數作為友元
- 函數重載和友元
- 友元聲明和作用域
- 類的作用域
- 作用域和定義在類外部的成員
- 名字查找與類的作用域
- 用于類成員聲明的名字查找
- 類型名要特殊處理
- 成員定義中的普通塊作用域的名字查找
- 類作用域之后,在外圍得作用域中查找
- 在文件中名字得出現處對其進行解析
- 構造函數再探
- 構造函數初始值列表
- 構造函數的初始值有時必不可少
- 建議:使用構造函數初始值
- 成員初始化的順序
- 默認實參和構造函數
- 委托構造函數
- 默認構造函數的作用
- 使用默認構造函數
- 隱式的類類型轉換
- 只允許一步類類型轉換
- 類類型轉換不是總有效
- explicit抑制構造函數定義的隱式轉換
- explicit構造函數只能用于直接初始化,不能用于拷貝初始化
- 為轉換顯示地使用構造函數(可忽視explicit)
- 標準庫中含有顯式構造函數的類
- 聚合類
- 字面值常量類
- constexpr構造函數
- 類的靜態成員
- 聲明靜態成員
- 使用類的靜態成員
- 定義靜態成員
- 靜態成員的類內初始化
- 靜態成員能用于某些場景,而普通成員不能
- 一些術語
類的基本思想是數據抽象(data abstraction)和封裝(encapsulation)。數據抽象是一種依賴于接口( interface)和實現( implementation)分離的編程(以及設計)技術。類的接口包括用戶所能執行的操作;類的實現則包括類的數據成員、負責接口實現的函數體以及定義類所需的各種私有函數。
封裝實現了類的接口和實現的分離。封裝后的類隱藏了它的實現細節,也就是說,類的用戶只能使用接口而無法訪問實現部分。
類要想實現數據抽象和封裝,需要首先定義一個抽象數據類型(abstract data type)。在抽象數據類型中,
- 由類的設計者負責考慮類的實現過程;
- 使用該類的程序員則只需要抽象地思考類型做了什么,而無須了解類型的工作細節。
定義抽象數據類型
在第1章中使用的Sales_item類是一個抽象數據類型,我們通過它的接口來使用一個Sales_item對象。我們不能訪問Sales_item對象的數據成員,事實上,我們甚至根本不知道這個類有哪些數據成員。
與之相反,第2章Sales_data類不是一個抽象數據類型。它允許類的用戶直接訪問它的數據成員,并且要求由用戶來編寫操作。
要想把Sales_data變成抽象數據類型,我們需要定義一些操作以供類的用戶使用。一旦 Sales_data定義了它自己的操作,我們就可以封裝(隱藏)它的數據成員了。
設計Sales_data類
我們的最終目的是令Sales_data支持與Sales_item類完全一樣的操作集合。Sales_item類有一個名為isbn的成員函數(member function),并且支持+、=、+=、<<和>>運算符。
我們將在第14章學習如何自定義運算符?,F在,我們先為這些運算定義普通(命名的)函數形式。
綜上所述,Sales_data的接口應該包含以下操作:
- 一個isbn 成員函數,用于返回對象的ISBN編號
- 一個combine 成員函數,用于將一個Sales_data對象加到另一個對象上
- 一個名為 add 的函數,執行兩個Sales_data對象的加法
- 一個read函數,將數據從istream讀入到Sales_data對象中
- 一個print函數,將Sales_data對象的值輸出到ostream
關鍵概念:不同的編程角色
程序員們常把運行其程序的人稱作用戶(user)。類似的,類的設計者也是為其用戶設計并實現一個類的人;顯然,類的用戶是程序員,而非應用程序的最終使用者。
當我們提及“用戶”一詞時,不同的語境決定了不同的含義。如果我們說用戶代碼或者Sales data類的用戶,指的是使用類的程序員;如果我們說書店應用程序的用戶,則意指運行該應用程序的書店經理。
Note:C++程序員們無須刻意區分應用程序的用戶以及類的用戶。
在一些簡單的應用程序中,類的用戶和類的設計者常常是同一個人。盡管如此,還是最好把角色區分開來。當我們設計類的接口時,應該考慮如何才能使得類易于使用;而當我們使用類時,不應該顧及類的實現機理。
要想開發一款成功的應用程序,其作者必須充分了解并實現用戶的需求。同樣,優秀的類設計者也應該密切關注那些有可能使用該類的程序員的需求。作為一個設計良好的類,既要有直觀且易于使用的接口,也必須具備高效的實現過程。
使用改進的Sales_data類
在考慮如何實現我們的類之前,首先來看看應該如何使用上面這些接口函數。
舉個例子,我們使用這些函數編寫第一章店程序的另外一個版本,其中不再使用Sales_item對象,而是使用Sales_data對象:
Sales_data total; // variable to hold the running sum if (read(cin, total)) { // read the first transactionSales_data trans; // variable to hold data for the next transactionwhile(read(cin, trans)) { // read the remaining transactionsif (total.isbn() == trans.isbn()) // check the isbnstotal.combine(trans); // update the running total else {print(cout, total) << endl; // print the resultstotal = trans; // process the next book}}print(cout, total) << endl; // print the last transaction } else { // there was no inputcerr << "No data?!" << endl; // notify the user }定義改進的Sales_data類
struct Sales_data {// new members: operations on Sales_data objectsstd::string isbn() const { return bookNo; }Sales_data& combine(const Sales_data&);double avg_price() const;//計算平均價格// data members are unchanged from § 2.6.1 (p. 72)std::string bookNo;//ISBN編號unsigned units_sold = 0;//本書銷量double revenue = 0.0;//總銷售收入 };// nonmember Sales_data interface functions Sales_data add(const Sales_data&, const Sales_data&); std::ostream &print(std::ostream&, const Sales_data&); std::istream &read(std::istream&, Sales_data&);定義和聲明成員函數的方式與普通函數差不多。
成員函數的聲明必須在類的內部,它的定義則既可以在類的內部也可以在類的外部。
作為接口組成部分的非成員函數,例如 add、read和 print等,它們的定義和聲明都在類的外部。
Note:定義在類內部的函數是隱式的inline函數(第6章內容)。
定義成員函數
盡管所有成員都必須在類的內部聲明,但是成員函數體可以定義在類內也可以定義在類外。
對于Sales_data類來說,isbn函數定義在了類內,而combine和 avg_price定義在了類外。
我們首先介紹isbn函數,它的參數列表為空,返回值是一個string對象:
和其他函數一樣,成員函數體也是一個塊。
關于 isbn函數一件有意思的事情是:它是如何獲得bookNo成員所依賴的對象的呢?
引入this
對isbn成員函數的調用:
total.isbn()在這里,我們使用了點運算符來訪問total對象的isbn成員,然后調用它。
本章將介紹一種例外的形式,當我們調用成員函數時,實際上是在替某個對象調用它。如果isbn指向Sales_data的成員(例如 bookNo),則它隱式地指向調用該函數的對象的成員。在上面所示的調用中,當isbn返回 bookNo時,實際上它隱式地返回total.bookNo。
成員函數通過一個名為this的額外的隱式參數來訪問調用它的那個對象。當我們調用一個成員函數時,用請求該函數的對象地址初始化this。例如,如果調用
total.isbn()則編譯器負責把total的地址傳遞給isbn的隱式形參this,可以等價地認為編譯器將該調用重寫成了如下的形式:
//偽代碼,用于說明調用成員函數的實際執行過程 Sales_data::isbn(&total)其中,調用Sales_data的isbn成員時傳入了total的地址。
在成員函數內部,我們可以直接使用調用該函數的對象的成員,而無須通過成員訪問運算符來做到這一點,因為this所指的正是這個對象。任何對類成員的直接訪問都被看作this的隱式引用,也就是說,當isbn使用bookNo時,它隱式地使用this 指向的成員,就像我們書寫了this->bookNo一樣。
對于我們來說,this形參是隱式定義的。實際上,任何自定義名為this 的參數或變量的行為都是非法的。我們可以在成員函數體內部使用this,因此盡管沒有必要,但我們還是能把isbn定義成如下的形式:
std::string isbn() const { return this->bookNo;}因為this 的目的總是指向“這個”對象,所以 this 是一個常量指針(第2章內容),我們不允許改變this中保存的地址。
引入const成員函數
isbn函數的另一個關鍵之處是緊隨參數列表之后的 const關鍵字,這里,const的作用是修改隱式this指針的類型。
默認情況下,this的類型是指向類類型非常量版本的常量指針。例如在Sales_data成員函數中, this 的類型是Sales_data *const。
盡管this是隱式的,但它仍然需要遵循初始化規則,意味著(在默認情況下)我們不能把this綁定到一個常量對象上。這一情況也就使得我們不能在一個常量對象上調用普通的成員函數。
(MyNote:const對象不能調用普通成員函數。)
如果isbn是一個普通函數而且this是一個普通的指針參數,則我們應該把this聲明成const Sales data *const。畢竟,在isbn 的函數體內不會改變this所指的對象,所以把this設置為指向常量的指針有助于提高函數的靈活性。(MyNote:這樣可以讓非常量對象 與 常量對象都可以調用。)
然而,this是隱式的并且不會出現在參數列表中,所以在哪兒將this聲明成指向常量的指針就成為我們必須面對的問題。C++語言的做法是允許把const關鍵字放在成員函數的參數列表之后,此時,緊跟在參數列表后面的const表示 this是一個指向常量的指針。像這樣使用const的成員函數被稱作常量成員函數(const member function)。
可以把isbn的函數體想象成如下的形式:
//偽代碼,說明隱式的this指針是如何使用的 //下面的代碼是非法的:因為我們不能顯式地定義自己的this指針 //謹記此處的this是一個指向常量的指針,因為isbn是一個常量成員 std::string Sales_data::isbn(const Sales_data *const this){ return this->isbn;}因為this是指向常量的指針,所以常量成員函數不能改變調用它的對象的內容。在上例中,isbn可以讀取調用它的對象的數據成員,但是不能寫入新值。
Note:常量對象,以及常量對象的引用或指針都只能調用常量成員函數。(本節一語蔽之)
類作用域和成員函數
回憶之前我們所學的知識,類本身就是一個作用域。類的成員函數的定義嵌套在類的作用域之內,因此,isbn中用到的名字bookNo其實就是定義在Sales_data內的數據成員。
值得注意的是,即使 bookNo定義在isbn之后,isbn也還是能夠使用bookNo。
編譯器分兩步處理類:
因此,成員函數體可以隨意使用類中的其他成員而無須在意這些成員出現的次序。
在類的外部定義成員函數
像其他函數一樣,當我們在類的外部定義成員函數時,成員函數的定義必須與它的聲明匹配。也就是說,返回類型、參數列表和函數名都得與類內部的聲明保持一致。如果成員被聲明成常量成員函數,那么它的定義也必須在參數列表后明確指定const 屬性。同時,類外部定義的成員的名字必須包含它所屬的類名:
double Sales_data::avg_price() const {if (units_sold)return revenue/units_sold;elsereturn 0; }函數名Sales_data::avg_price使用作用域運算符來說明如下的事實:我們定義了一個名為avg_price的函數,并且該函數被聲明在類Sales_data的作用域內。一旦編譯器看到這個函數名,就能理解剩余的代碼是位于類的作用域內的。因此,當avg_price使用revenue和 units_sold 時,實際上它隱式地使用了Sales_data的成員。
定義一個返回this對象的函數
函數combine的設計初衷類似于復合賦值運算符+=,調用該函數的對象代表的是賦值運算符左側的運算對象,右側運算對象則通過顯式的實參被傳入函數:
Sales_data& Sales_data::combine(const Sales_data &rhs){units_sold += rhs.units_sold;//把rhs的成員加到this對象的成員上revenue +=rhs.revenue;return *this;//返回調用該函數的對象 }當我們的交易處理程序調用如下的函數時,
total.combine (trans) ; //更新變量total當前的值total的地址被綁定到隱式的 this 參數上,而rhs 綁定到了trans 上。因此,當combine執行下面的語句時,
units_sold += rhs.units_sold;//把rhs的成員添加到this對象的成員中效果等同于求total.units_sold和trans.unit_sold的和,然后把結果保存到total.units_sold中。
該函數一個值得關注的部分是它的返回類型和返回語句。一般來說,當我們定義的函數類似于某個內置運算符時,應該令該函數的行為盡量模仿這個運算符。內置的賦值運算符把它的左側運算對象當成左值返回(,因此為了與它保持一致,combine函數必須返回引用類型。因為此時的左側運算對象是一個Sales_data的對象,所以返回類型應該是Sales_data&。
如前所述,我們無須使用隱式的this指針訪問函數調用者的某個具體成員,而是需要把調用函數的對象當成一個整體來訪問:
return *this;//返回調用該函數的對象其中,return語句解引用this指針以獲得執行該函數的對象,換句話說,上面的這個調用返回total的引用。
定義類相關的非成員函數
類的作者常常需要定義一些輔助函數,比如 add、read和 print等。盡管這些函數定義的操作從概念上來說屬于類的接口的組成部分,但它們實際上并不屬于類本身。
我們定義非成員函數的方式與定義其他函數一樣,通常把函數的聲明和定義分離開來。如果函數在概念上屬于類但是不定義在類中,則它一般應與類聲明(而非定義)在同一個頭文件內。在這種方式下,用戶使用接口的任何部分都只需要引入一個文件。
Note:一般來說,如果非成員函數是類接口的組成部分,則這些函數的聲明應該與類在同一個頭文件內。
定義read和print函數
// input transactions contain ISBN, number of copies sold, and sales price istream &read(istream &is, Sales_data &item) {double price = 0;is >> item.bookNo >> item.units_sold >> price;item.revenue = price * item.units_sold;return is; }ostream &print(ostream &os, const Sales_data &item) {os << item.isbn() << " " << item.units_sold << " "<< item.revenue << " " << item.avg_price();return os; }read函數從給定流中將數據讀到給定的對象里,print函數則負責將給定對象的內容打印到給定的流中。
除此之外,關于上面的函數還有兩點是非常重要的。第一點,read和print分別接受一個各自IO類型的引用作為其參數,這是因為IO類屬于不能被拷貝的類型,因此我們只能通過引用來傳遞它們。而且,因為讀取和寫入的操作會改變流的內容,所以兩個函數接受的都是普通引用,而非對常量的引用。(MyNote:is, os不用const修飾,因為讀取和寫入的操作會改變流的內容)
第二點,print函數不負責換行。一般來說,執行輸出任務的函數應該盡量減少對格式的控制,這樣可以確保由用戶代碼來決定是否換行。
定義add函數
add函數接受兩個Sales_data對象作為其參數,返回值是一個新的Sales_data,用于表示前兩個對象的和:
Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {Sales_data sum = lhs; // copy data members from lhs into sumsum.combine(rhs); // add data members from rhs into sumreturn sum; }在函數體中,我們定義了一個新的Sales_data對象并將其命名為sum。sum將用于存放兩筆交易的和,我們用lhs的副本來初始化 sum。默認情況下,拷貝類的對象其實拷貝的是對象的數據成員。在拷貝工作完成之后,sum的bookNo.units_sold和revenue將和lhs一致。接下來我們調用combine函數,將rhs 的units_sold和revenue添加給sum。最后,函數返回sum的副本。
構造函數
每個類都分別定義了它的對象被初始化的方式,類通過一個或幾個特殊的成員函數來控制其對象的初始化過程,這些函數叫做構造函數(constructor)。構造函數的任務是初始化類對象的數據成員,無論何時只要類的對象被創建,就會執行構造函數。
在這一節中,我們將介紹定義構造函數的基礎知識。構造函數是一個非常復雜的問題,我們還會在本章末和第13,15,18章介紹更多關于構造函數的知識。
構造函數的名字和類名相同。和其他函數不一樣的是,構造函數沒有返回類型;除此之外類似于其他的函數,構造函數也有一個(可能為空的)參數列表和一個(可能為空的)函數體。類可以包含多個構造函數,和其他重載函數差不多,不同的構造函數之間必須在參數數量或參數類型上有所區別。
不同于其他成員函數,構造函數不能被聲明成const的。當我們創建類的一個const對象時,直到構造函數完成初始化過程,對象才能真正取得其“常量”屬性。因此,構造函數在const對象的構造過程中可以向其寫值。
合成的默認構造函數
我們的Sales_data類并沒有定義任何構造函數,可是之前使用了Sales_data對象的程序仍然可以正確地編譯和運行。舉個例子,程序定義了兩個對象:
Sales_data total; //保存當前求和結果的變量 Sales_data trans; //保存下一條交易數據的變量這時我們不禁要問:total和trans是如何初始化的呢?
我們沒有為這些對象提供初始值,因此我們知道它們執行了默認初始化。類通過一個特殊的構造函數來控制默認初始化過程,這個函數叫做默認構造函數( default constructor)。默認構造函數無須任何實參。
如我們所見,默認構造函數在很多方面都有其特殊性。其中之一是,如果我們的類沒有顯式地定義構造函數,那么編譯器就會為我們隱式地定義一個默認構造函數。
編譯器創建的構造函數又被稱為合成的默認構造函數(synthesized default constructor)。對于大多數類來說,這個合成的默認構造函數將按照如下規則初始化類的數據成員:
- 如果存在類內的初始值,用它來初始化成員。
- 否則,默認初始化該成員。
因為Sales_data為units_sold和revenue提供了初始值,所以合成的默認構造函數將使用這些值來初始化對應的成員;同時,它把 bookNo默認初始化成一個空字符串。
某些類不能依賴于合成的默認構造函數
合成的默認構造函數只適合非常簡單的類,比如現在定義的這個Sales_data版本。對于一個普通的類來說,必須定義它自己的默認構造函數,原因有三:
第一個原因也是最容易理解的一個原因就是編譯器只有在發現類不包含任何構造函數的情況下才會替我們生成一個默認的構造函數。一旦我們定義了一些其他的構造函數,那么除非我們再定義一個默認的構造函數,否則類將沒有默認構造函數。這條規則的依據是,如果一個類在某種情況下需要控制對象初始化,那么該類很可能在所有情況下都需要控制。
Note:只有當類沒有聲明任何構造函數時,編譯器才會自動地生成默認構造函數。
第二個原因是對于某些類來說,合成的默認構造函數可能執行錯誤的操作。回憶我們之前介紹過的,如果定義在塊中的內置類型或復合類型(比如數組和指針)的對象被默認初始化,則它們的值將是未定義的。該準則同樣適用于默認初始化的內置類型成員。因此,含有內置類型或復合類型成員的類應該在類的內部初始化這些成員,或者定義一個自己的默認構造函數。否則,用戶在創建類的對象時就可能得到未定義的值。
WARNING:如果類包含有內置類型或者復合類型的成員,則只有當這些成員全都被賦予了類內的初始值時,這個類才適合于使用合成的默認構造函數。
第三個原因是有的時候編譯器不能為某些類合成默認的構造函數。例如,如果類中包含一個其他類類型的成員且這個成員的類型沒有默認構造函數,那么編譯器將無法初始化該成員。對于這樣的類來說,我們必須自定義默認構造函數,否則該類將沒有可用的默認構造函數。在第13章中我們將看到還有其他一些情況也會導致編譯器無法生成一個正確的默認構造函數。
定義Sales_data的構造函數
對于我們的Sales_data類來說,我們將使用下面的參數定義4個不同的構造函數
- 一個istream&,從中讀取一條交易信息。
- 一個const string&,表示ISBN編號;一個unsigned,表示售出的圖書數量;以及一個 double,表示圖書的售出價格。
- 一個const string&,表示ISBN編號;編譯器將賦予其他成員默認值。
- 一個空參數列表(即默認構造函數),正如剛剛介紹的,既然我們已經定義了其他構造函數,那么也必須定義一個默認構造函數。
= default的含義
我們從解釋默認構造函數的含義開始:
Sales_data() = default ;首先請明確一點:因為該構造函數不接受任何實參,所以它是一個默認構造函數。我們定義這個構造函數的目的僅僅是因為我們既需要其他形式的構造函數,也需要默認的構造函數。我們希望這個函數的作用完全等同于之前使用的合成默認構造函數。
在C++11新標準中,如果我們需要默認的行為,那么可以通過在參數列表后面寫上**= default**來要求編譯器生成構造函數。其中,= default 既可以和聲明一起出現在類的內部,也可以作為定義出現在類的外部。和其他函數一樣,如果= default在類的內部,則默認構造函數是內聯的;如果它在類的外部,則該成員默認情況下不是內聯的。
WARNING:上面的默認構造函數之所以對Sales_data有效,是因為我們為內置類型的數據成員提供了初始值。如果你的編譯器不支持類內初始值,那么你的默認構造函數就應該使用構造函數初始值列表(馬上就會介紹)來初始化類的每個成員。
構造函數初始化列表
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),它負責為新創建的對象的一個或幾個數據成員賦初值。構造函數初始值是成員名字的一個列表,每個名字后面緊跟括號括起來的(或者在花括號內的)成員初始值。不同成員的初始化通過逗號分隔開來。
含有三個參數的構造函數分別使用它的前兩個參數初始化成員 bookNo和units_sold,revenue 的初始值則通過將售出圖書總數和每本書單價相乘計算得到。
只有一個string類型參數的構造函數使用這個string對象初始化 bookNo,對于units_sold和 revenue則沒有顯式地初始化。當某個數據成員被構造函數初始值列表忽略時,它將以與合成默認構造函數相同的方式隱式初始化。在此例中,這樣的成員使用類內初始值初始化,因此只接受一個string參數的構造函數等價于
//與上面定義的那個構造函數效果相同 Sales_data (const std::string &s):bookNo(s) , units_sold(0) , revenue(0){ }通常情況下,構造函數使用類內初始值不失為一種好的選擇,因為只要這樣的初始值存在我們就能確保為成員賦予了一個正確的值。不過,如果你的編譯器不支持類內初始值,則所有構造函數都應該顯式地初始化每個內置類型的成員。
Best Practices:構造函數不應該輕易覆蓋掉類內的初始值,除非新賦的值與原值不同。如果你不能使用類內初始值,則所有構造函數都應該顯式地初始化每個內置類型的成員。
有一點需要注意,在上面的兩個構造函數中函數體都是空的。這是因為這些構造函數的唯一目的就是為數據成員賦初值,一旦沒有其他任務需要執行,函數體也就為空了。
在類的外部定義構造函數
與其他幾個構造函數不同,以istream為參數的構造函數需要執行一些實際的操作。在它的函數體內,調用了read函數以給數據成員賦以初值:
Sales_data::Sales_data(std::istream &is){read(is, *this); // read函數的作用是從is中讀取一條交易信息然后//存入this對象中 }構造函數沒有返回類型,所以上述定義從我們指定的函數名字開始。和其他成員函數一樣,當我們在類的外部定義構造函數時,必須指明該構造函數是哪個類的成員。因此,Sales_data::Sales_data的含義是我們定義Sales_data類的成員,它的名字是Sales_data。又因為該成員的名字和類名相同,所以它是一個構造函數。
這個構造函數沒有構造函數初始值列表,或者講得更準確一點,它的構造函數初始值列表是空的。盡管構造函數初始值列表是空的,但是由于執行了構造函數體,所以對象的成員仍然能被初始化。
沒有出現在構造函數初始值列表中的成員將通過相應的類內初始值(如果存在的話)初始化,或者執行默認初始化。對于Sales_data來說,這意味著一旦函數開始執行,則bookNo將被初始化成空string對象,而units_sold和revenue將是0。
為了更好地理解調用函數read 的意義,要特別注意read的第二個參數是一個Sales_data對象的引用。在中曾經提到過,使用this來把對象當成一個整體訪問,而非直接訪問對象的某個成員。因此在此例中,我們使用*this 將“this”對象作為實參傳遞給read函數。
// 上文的read函數 // input transactions contain ISBN, number of copies sold, and sales price istream &read(istream &is, Sales_data &item) {double price = 0;is >> item.bookNo >> item.units_sold >> price;item.revenue = price * item.units_sold;return is; }拷貝、賦值和析構
除了定義類的對象如何初始化之外,類還需要控制拷貝、賦值和銷毀對象時發生的行為。
對象在幾種情況下會被拷貝,如我們初始化變量以及以值的方式傳遞或返回一個對象等(參見第6章的傳參與返回章節)。
當我們使用了賦值運算符時會發生對象的賦值操作。
當對象不再存在時執行銷毀的操作,比如一個局部對象會在創建它的塊結束時被銷毀,當vector對象(或者數組)銷毀時存儲在其中的對象也會被銷毀。
如果我們不主動定義這些操作,則編譯器將替我們合成它們。一般來說,編譯器生成的版本將對對象的每個成員執行拷貝、賦值和銷毀操作。例如,當編譯器執行如下賦值語句時,
total = trans; //處理下一本書的信息它的行為與下面的代碼相同
//Sales_data的默認賦值操作等價于: total.bookNo = trans.bookNo; total.units_sold = trans.units_sold; total.revenue = trans.revenue;我們將在第13章中介紹如何自定義上述操作。
某些類不能依賴于合成的版本
盡管編譯器能替我們合成拷貝、賦值和銷毀的操作,但是必須要清楚的一點是,對于某些類來說合成的版本無法正常工作。特別是,當類需要分配類對象之外的資源時,合成的版本常常會失效。舉個例子,第12章將介紹C++程序是如何分配和管理動態內存的。而在而第13章我們將會看到,管理動態內存的類通常不能依賴于上述操作的合成版本。
不過值得注意的是,很多需要動態內存的類能(而且應該)使用vector對象或者string對象管理必要的存儲空間。使用vector或者string 的類能避免分配和釋放內存帶來的復雜性。However, it is worth noting that many classes that need dynamic memory can (and generally should) use a vector or a string to manage the necessary storage. Classes that use vectors and strings avoid the complexities involved in allocating and deallocating memory.
進一步講,如果類包含vector或者string 成員,則其拷貝、賦值和銷毀的合成版本能夠正常工作。當我們對含有vector成員的對象執行拷貝或者賦值操作時,vector類會設法拷貝或者賦值成員中的元素。當這樣的對象被銷毀時,將銷毀vector對象,也就是依次銷毀vector中的每一個元素。這一點與string是非常類似的。
WARNING:在學習第13章關于如何自定義操作的知識之前,類中所有分配的資源都應該直接以類的數據成員的形式存儲。
訪問控制與封裝
到目前為止,我們已經為類定義了接口,但并沒有任何機制強制用戶使用這些接口。我們的類還沒有封裝,也就是說,用戶可以直達Sales_data對象的內部并且控制它的具體實現細節。在C++語言中,我們使用訪問說明符(access specifiers)加強類的封裝性:
- 定義在public說明符之后的成員在整個程序內可被訪問,public成員定義類的接口。
- 定義在private說明符之后的成員可以被類的成員函數訪問,但是不能被使用該類的代碼訪問,private部分封裝了(即隱藏了)類的實現細節。
再一次定義Sales_data類,其新形式如下所示:
class Sales_data {public: // access specifier addedSales_data() = default;Sales_data(const std::string &s, unsigned n, double p):bookNo(s), units_sold(n), revenue(p*n) { }Sales_data(const std::string &s): bookNo(s) { }Sales_data(std::istream&);std::string isbn() const { return bookNo; }Sales_data &combine(const Sales_data&);private: // access specifier addeddouble avg_price() const {return units_sold ? revenue / units_sold : 0;}std::string bookNo;unsigned units_sold = 0;double revenue = 0.0; };使用class或struct關鍵字
在上面的定義中我們還做了一個微妙的變化,我們使用了class關鍵字而非struct開始類的定義。這種變化僅僅是形式上有所不同,實際上我們可以使用這兩個關鍵字中的任何一個定義類。唯一的一點區別是,struct和class的默認訪問權限不太一樣。
類可以在它的第一個訪問說明符之前定義成員,對這種成員的訪問權限依賴于類定義的方式。如果我們使用struct關鍵字,則定義在第一個訪問說明符之前的成員是public的;相反,如果我們使用class 關鍵字,則這些成員是private的。
出于統一編程風格的考慮,當我們希望定義的類的所有成員是 public的時,使用struct;反之,如果希望成員是private的,使用class。
WARNING:使用class和struct定義類唯一的區別就是默認的訪問權限。
友元
既然Sales_data 的數據成員是private的,我們的read、print和 add函數也就無法正常編譯了,這是因為盡管這幾個函數是類的接口的一部分,但它們不是類的成員。
類可以允許其他類或者函數訪問它的非公有成員,方法是令其他類或者函數成為它的友元(friend)。如果類想把一個函數作為它的友元,只需要增加一條以friend關鍵字開始的函數聲明語句即可:
class Sales_data {// friend declarations for nonmember Sales_data operations addedfriend Sales_data add(const Sales_data&, const Sales_data&);friend std::istream &read(std::istream&, Sales_data&);friend std::ostream &print(std::ostream&, const Sales_data&);// other members and access specifiers as beforepublic:Sales_data() = default;Sales_data(const std::string &s, unsigned n, double p):bookNo(s), units_sold(n), revenue(p*n) { }Sales_data(const std::string &s): bookNo(s) { }Sales_data(std::istream&);std::string isbn() const { return bookNo; }Sales_data &combine(const Sales_data&);private:std::string bookNo;unsigned units_sold = 0;double revenue = 0.0; };// declarations for nonmember parts of the Sales_data interface Sales_data add(const Sales_data&, const Sales_data&); std::istream &read(std::istream&, Sales_data&); std::ostream &print(std::ostream&, const Sales_data&);友元聲明只能出現在類定義的內部,但是在類內出現的具體位置不限。友元不是類的成員也不受它所在區域訪問控制級別的約束。我們將在下章節介紹更多關于友元的知識。
Tip:一般來說,最好在類定義開始或結束前的位置集中聲明友元。
(MyNote:友元,好朋友聲明,在類內部聲明,表示這些函數是我類的好朋友,它們可以訪問我們成員變量。)
關鍵概念:封裝的益處
封裝有兩個重要的優點:
-
確保用戶代碼不會無意間破壞封裝對象的狀態。
-
被封裝的類的具體實現細節可以隨時改變,而無須調整用戶級別的代碼。
一旦把數據成員定義成private的,類的作者就可以比較自由地修改數據了。當實現部分改變時,我們只需要檢查類的代碼本身以確認這次改變有什么影響;換句話說,只要類的接口不變,用戶代碼就無須改變。如果數據是public的,則所有使用了原來數據成員的代碼都可能失效,這時我們必須定位并重寫所有依賴于老版本實現的代碼,之后才能重新使用該程序。
把數據成員的訪問權限設成 private還有另外一個好處,這么做能防止由于用戶的原因造成數據被破壞。如果我們發現有程序缺陷破壞了對象的狀態,則可以在有限的范圍內定位缺陷:因為只有實現部分的代碼可能產生這樣的錯誤。因此,將查錯限制在有限范圍內將能極大地降低維護代碼及修正程序錯誤的難度。
Note:盡管當類的定義發生改變時無須更改用戶代碼,但是使用了該類的源文件必須重新編譯。
友元的聲明
友元的聲明僅僅指定了訪問的權限,而非一個通常意義上的函數聲明。如果我們希望類的用戶能夠調用某個友元函數,那么我們就必須在友元聲明之外再專門對函數進行一次聲明。
為了使友元對類的用戶可見,我們通常把友元的聲明與類本身放置在同一個頭文件中(類的外部)。因此,我們的Sales_data頭文件應該為read、print和 add提供獨立的聲明(除了類內部的友元聲明之外)。
Note:許多編譯器并未強制限定友元函數必須在使用之前在類的外部聲明。
一些編1譯器允許在尚無友元函數的初始聲明的情況下就調用它。不過即使你的編譯器支持這種行為,最好還是提供一個獨立的函數聲明。這樣即使你更換了一個有這種強制要求的編譯器,也不必改變代碼。
類的其他特性
類成員再探
為了展示這些新的特性,我們需要定義一對相互關聯的類,它們分別是Screen和Window_mgr。
定義一個類型成員
Screen表示顯示器中的一個窗口。每個Screen包含一個用于保存Screen內容的string成員和三個String::size_type類型的成員,它們分別表示光標的位置以及屏幕的高和寬。
除了定義數據和函數成員之外,類還可以自定義某種類型在類中的別名。由類定義的類型名字和其他成員一樣存在訪問限制,可以是public或者private中的一種:
class Screen {public:typedef std::string::size_type pos;private:pos cursor = 0;pos height = 0, width = 0;std::string contents; };我們在Screen的public部分定義了pos,這樣用戶就可以使用這個名字。Screen的用戶不應該知道Screen使用了一個string對象來存放它的數據,因此通過把pos定義成public成員可以隱藏Screen實現的細節。
關于 pos 的聲明有兩點需要注意。首先,我們使用了typedef,也可以等價地使用類型別名:
class Screen {public://使用類型別名等價地聲明一個類型名字using pos = std::string::size_type;//其他成員與之前的版本一致 };其次,用來定義類型的成員必須先定義后使用,這一點與普通成員有所區別,具體原因隨后解釋。因此,類型成員通常出現在類開始的地方。
Screen類的成員函數
要使我們的類更加實用,還需要添加一個構造函數令用戶能夠定義屏幕的尺寸和內容,以及其他兩個成員,分別負責移動光標和讀取給定位置的字符:
class Screen {public:typedef std::string::size_type pos;Screen() = default; // needed because Screen has another constructor// cursor initialized to 0 by its in-class initializer//令用戶能夠定義屏幕的尺寸和內容Screen(pos ht, pos wd, char c): height(ht), width(wd),contents(ht * wd, c) { }char get() const // get the character at the cursor {return contents[cursor]; // implicitly inline}// 讀取給定位置的字符inline char get(pos ht, pos wd) const; // explicitly inline//移動光標Screen &move(pos r, pos c); // can be made inline laterprivate:pos cursor = 0;pos height = 0, width = 0;std::string contents; };因為我們已經提供了一個構造函數,所以編譯器將不會自動生成默認的構造函數。如果我們的類需要默認構造函數,必須顯式地把它聲明出來。在此例中,我們使用=default告訴編譯器為我們合成默認的構造函數。
需要指出的是,第二個構造函數(接受三個參數)為cursor成員隱式地使用了類內初始值。如果類中不存在cursor的類內初始值,我們就需要像其他成員一樣顯式地初始化 cursor 了。
令成員作為內聯函數
在類中,常有一些規模較小的函數適合于被聲明成內聯函數。如我們之前所見的,定義在類內部的成員函數是自動inline的。因此,Screen的構造函數和返回光標所指字符的get函數默認是inline函數。
我們可以在類的內部把inline作為聲明的一部分顯式地聲明成員函數,同樣的,也能在類的外部用inline關鍵字修飾函數的定義:
inline // we can specify inline on the definition Screen &Screen::move(pos r, pos c) {pos row = r * width; // compute the row locationcursor = row + c ; // move cursor to the column within that rowreturn *this; // return this object as an lvalue }char Screen::get(pos r, pos c) const // declared as inline in the class {pos row = r * width; // compute row locationreturn contents[row + c]; // return character at the given column }雖然我們無須在聲明和定義的地方同時說明inline,但這么做其實是合法的。不過,最好只在類外部定義的地方說明inline,這樣可以使類更容易理解。
Note:和我們在頭文件中定義inline函數的原因一樣,inline成員函數也應該與相應的類定義在同一個頭文件中。
重載成員函數
和非成員函數一樣,成員函數也可以被重載,只要函數之間在參數的數量和/或類型上有所區別就行。成員函數的函數匹配過程同樣與非成員函數非常類似。
舉個例子,我們的Screen類定義了兩個版本的get函數。
一個版本返回光標當前位置的字符;
另一個版本返回由行號和列號確定的位置的字符。
編譯器根據實參的數量來決定運行哪個版本的函數:
Screen myScreen ; char ch = myScreen.get();//調用Screen::get() ch = myScreen.get (0, 0);//調用Screen::get(pos, pos)可變數據成員
有時(但并不頻繁)會發生這樣一種情況,我們希望能修改類的某個數據成員,即使是在一個const成員函數內??梢酝ㄟ^在變量的聲明中加入mutable關鍵字做到這一點。
一個可變數據成員(mutable data member)永遠不會是const,即使它是const對象的成員。因此,一個const 成員函數可以改變一個可變成員的值。舉個例子,我們將給Screen添加一個名為access_ctr的可變成員,通過它我們可以追蹤每個Screen的成員函數被調用了多少次:
class Screen { public:void some_member() const; private:mutable size_t access_ctr; // may change even in a const object// other members as before };void Screen::some_member() const {++access_ctr; // keep a count of the calls to any member function// whatever other work this member needs to do }盡管some_member是一個const成員函數,它仍然能夠改變access_ctr的值。該成員是個可變成員,因此任何成員函數,包括const函數在內都能改變它的值。
類數據成員的初始值
在定義好Screen類之后,我們將繼續定義一個窗口管理類并用它表示顯示器上的一組 Screen。這個類將包含一個Screen類型的 vector,每個元素表示一個特定的Screen。默認情況下,我們希望window_mgr類開始時總是擁有一個默認初始化的
Screen。在C++11新標準中,最好的方式就是把這個默認值聲明成一個類內初始值:
當我們初始化類類型的成員時,需要為構造函數傳遞一個符合成員類型的實參。在此例中,我們使用一個單獨的元素值對vector成員執行了列表初始化,這個 Screen 的值被傳遞給vector<Screen>的構造函數,從而創建了一個單元素的vector對象。
具體地說,Screen 的構造函數接受兩個尺寸參數和一個字符值,創建了一個給定大小的空白屏幕對象。
如我們之前所知的,類內初始值必須使用=的初始化形式(初始化Screen的數據成員時所用的)或者花括號括起來的直接初始化形式(初始化Screens所用的)。
Note:當我們提供一個類內初始值時,必須以符號=或者花括號表示。
返回*this的成員函數
接下來我們繼續添加一些函數,它們負責設置光標所在位置的字符或者其他任一給定位置的字符:
class Screen { public:Screen &set(char);Screen &set(pos, pos, char);// other members as before };inline Screen &Screen::set(char c) {contents[cursor] = c; // set the new value at the current cursor locationreturn *this; // return this object as an lvalue }inline Screen &Screen::set(pos r, pos col, char ch) {contents[r * width + col] = ch; // set specified location to givenvaluereturn *this; // return this object as an lvalue }和move操作一樣,我們的set成員的返回值是調用set的對象的引用。返回引用的函數是左值的,意味著這些函數返回的是對象本身而非對象的副本。如果我們把一系列這樣的操作連接在一條表達式中的話:
//把光標移動到一個指定的位置,然后設置該位置的字符值 myScreen.move(4, 0).set ('#’);這些操作將在同一個對象上執行。在上面的表達式中,我們首先移動myScreen內的光標,然后設置myScreen 的contents成員。也就是說,上述語句等價于
myScreen.move(4,0); myScreen.set('#');如果我們令move和set返回Screen而非Screen&,則上述語句的行為將大不相同。在此例中等價于:
//如果move返回Screen而非Screen& Screen temp = myScreen.move(4, 0);//對返回值進行拷貝 temp.set('#');//不會改變myScreen的contents假如當初我們定義的返回類型不是引用,則move的返回值將是*this的副本,因此調用set 只能改變臨時副本,而不能改變myScreen的值。
從const成員函數返回*this,它的返回類型將是常量引用
接下來,我們繼續添加一個名為display的操作,它負責打印Screen的內容。我們希望這個函數能和move 以及 set出現在同一序列中,因此類似于move和set,display函數也應該返回執行它的對象的引用。
從邏輯上來說,顯示一個Screen并不需要改變它的內容,因此我們令display為一個const成員,此時,this將是一個指向const的指針而*this是 const對象。由此推斷,display的返回類型應該是const Sales_data&。然而,如果真的令display返回一個const 的引用,則我們將不能把display嵌入到一組動作的序列中去:
Screen myScreen; //如果display返回常量引用,則調用set將引發錯誤 myScreen.display(cout).set('*');即使myScreen是個非常量對象,對set的調用也無法通過編譯。問題在于display的const版本返回的是常量引用,而我們顯然無權set一個常量對象。
Note:一個const成員函數如果以引用的形式返回*this,那么它的返回類型將是常量引用。
基于const的重載
通過區分成員函數是否是const 的,我們可以對其進行重載,其原因與我們之前根據指針參數是否指向const而重載函數的原因差不多。具體說來,因為非常量版本的函數對于常量對象是不可用的,所以我們只能在一個常量對象上調用const 成員函數。另一方面,雖然可以在非常量對象上調用常量版本或非常量版本,但顯然此時非常量版本是一個更好的匹配。
在下面的這個例子中,我們將定義一個名為 do_display的私有成員,由它負責打印Screen的實際工作。所有的display操作都將調用這個函數,然后返回執行操作的對象:
class Screen { public:// display overloaded on whether the object is const or notScreen &display(std::ostream &os){ do_display(os); return *this; }const Screen &display(std::ostream &os) const{ do_display(os); return *this; } private:// function to do the work of displaying a Screenvoid do_display(std::ostream &os) const {os << contents;}// other members as before };和我們之前所學的一樣,當一個成員調用另外一個成員時,this指針在其中隱式地傳遞。因此,
- 當display調用do_display時,它的this指針隱式地傳遞給do_display。
- 而當display的非常量版本調用do_display時,它的this指針將隱式地從指向非常量的指針轉換成指向常量的指針。
當do_display完成后,display函數各自返回解引用this所得的對象。
- 在非常量版本中,this指向一個非常量對象,因此display返回一個普通的(非常量)引用;
- 而const成員則返回一個常量引用。
當我們在某個對象上調用display 時,該對象是否是 const決定了應該調用display的哪個版本:
Screen myScreen(5,3); const Screen blank(5, 3); myScreen.set('#').display(cout); // calls non const version blank.display(cout); // calls const version建議:對于公共代碼使用私有功能函數
有些讀者可能會奇怪為什么我們要費力定義一個單獨的do_display函數。畢竟,對do_display的調用并不比 do_display函數內部所做的操作簡單多少。為什么還要這么做呢?實際上我們是出于以下原因的?
- 一個基本的愿望是避免在多處使用同樣的代碼。
- 我們預期隨著類的規模發展,display函數有可能變得更加復雜,此時,把相應的操作寫在一處而非兩處的作用就比較明顯了。
- 我們很可能在開發過程中給do_display函數添加某些調試信息,而這些信息將在代碼的最終產品版本中去掉。顯然,只在 do_display一處添加或刪除這些信息要更容易一些。
- 這個額外的函數調用不會增加任何開銷。因為我們在類內部定義了do_display,所以它隱式地被聲明成內聯函數。這樣的話,調用 do_display就不會帶來任何額外的運行時開銷。
在實踐中,設計良好的C++代碼常常包含大量類似于do_display的小函數,通過調用這些函數,可以完成一組其他函數的“實際”工作。
(MyNote:重復代碼->重構。)
類類型
每個類定義了唯一的類型。對于兩個類來說,即使它們的成員完全一樣,這兩個類也是兩個不同的類型。例如:
struct First{int memi;int getMem(); }; struct Second{int memi;int getMem(); }; First obj1; second obj2 = obj1; //錯誤:obj1和obj2的類型不同Note:即使兩個類的成員列表完全一致,它們也是不同的類型。對于一個類來說,它的成員和其他任何類(或者任何其他作用域)的成員都不是一回事兒。
我們可以把類名作為類型的名字使用,從而直接指向類類型?;蛘?#xff0c;我們也可以把類名跟在關鍵字class或struct后面:
Sales_data iteml; //默認初始化Sales_data類型的對象 class Sales_data iteml; //一條等價的聲明上面這兩種使用類類型的方式是等價的,其中第二種方式從C語言繼承而來,并且在C++語言中也是合法的。
類的聲明(可先聲明,暫時不定義)
就像可以把函數的聲明和定義分離開來一樣,我們也能僅僅聲明類而暫時不定義它:
class Screen;// Screen類的聲明這種聲明有時被稱作前向聲明(forward declaration),它向程序中引入了名字Screen并且指明Screen是一種類類型。對于類型Screen來說,在它聲明之后定義之前是一個不完全類型(incomplete type),也就是說,此時我們已知Screen是一個類類型,但是不清楚它到底包含哪些成員。
不完全類型只能在非常有限的情景下使用:可以定義指向這種類型的指針或引用,也可以聲明(但是不能定義)以不完全類型作為參數或者返回類型的函數。
對于一個類來說,在我們創建它的對象之前該類必須被定義過,而不能僅僅被聲明。否則,編譯器就無法了解這樣的對象需要多少存儲空間。類似的,類也必須首先被定義,然后才能用引用或者指針訪問其成員。畢竟,如果類尚未定義,編譯器也就不清楚該類到底有哪些成員。(MyNote:只聲明不定義的局限)
隨后我們將描述一種例外的情況:直到類被定義之后數據成員才能被聲明成這種類類型。換句話說,我們必須首先完成類的定義,然后編譯器才能知道存儲該數據成員需要多少空間。因為只有當類全部完成后類才算被定義,所以一個類的成員類型不能是該類自己。
然而,一旦一個類的名字出現后,它就被認為是聲明過了(但尚未定義),因此類允許包含指向它自身類型的引用或指針:
class Link_Screen {Screen window;Link_Screen *next;Link_Screen *prev; };友元再探
我們的Sales_data類把三個普通的非成員函數定義成了友元。類還可以把其他的類定義成友元,也可以把其他類(之前已定義過的)的成員函數定義成友元。此外,友元函數能定義在類的內部,這樣的函數是隱式內聯的。
類之間的友元關系
舉個友元類的例子,我們的Window_mgr類的某些成員可能需要訪問它管理的Screen類的內部數據。例如,假設我們需要為Window_mgr添加一個名為 clear的成員,它負責把一個指定的Screen的內容都設為空白。為了完成這一任務,clear需要訪問Screen的私有成員;而要想令這種訪問合法,Screen需要把Window_mgr指定成它的友元:
class Screen {// Window_mgr members can access the private parts of class Screenfriend class Window_mgr;// ... rest of the Screen class };如果一個類指定了友元類,則友元類的成員函數可以訪問此類包括非公有成員在內的所有成員。通過上面的聲明,Window_mgr被指定為Screen的友元,因此我們可以將Window_mgr的clear成員寫成如下的形式:
class Window_mgr { public:// location ID for each Screen on the windowusing ScreenIndex = std::vector<Screen>::size_type;// reset the Screen at the given position to all blanksvoid clear(ScreenIndex); private:std::vector<Screen> Screens{Screen(24, 80, ' ')}; };void Window_mgr::clear(ScreenIndex i) {// s is a reference to the Screen we want to clearScreen &s = Screens[i];// reset the contents of that Screen to all blankss.contents = string(s.height * s.width, ' '); }一開始,首先把s定義成Screens vector中第i個位置上的Screen的引用,隨后利用Screen的height和 width成員計算出一個新的string對象,并令其含有若干個空白字符,最后我們把這個含有很多空白的字符串賦給contents成員。
如果clear不是Screen的友元,上面的代碼將無法通過編譯,因為此時clear將不能訪問Screen的height、width和contents成員。而當Screen將window_mgr指定為其友元之后,Screen 的所有成員對于window_mgr就都變成可見的了。
必須要注意的一點是,友元關系不存在傳遞性。也就是說,如果Window_mgr有它自己的友元,則這些友元并不能理所當然地具有訪問Screen的特權。
Note:每個類負責控制自己的友元類或友元函數。
令成員函數作為友元
除了令整個Window_mgr作為友元之外,Screen還可以只為clear提供訪問權限。當把一個成員函數聲明成友元時,我們必須明確指出該成員函數屬于哪個類:(MyNote:進一步細粒度化。)
class Screen {// Window_mgr::clear must have been declared before class Screenfriend void Window_mgr::clear(ScreenIndex);// ... rest of the Screen class };要想令某個成員函數作為友元,我們必須仔細組織程序的結構以滿足聲明和定義的彼此依賴關系。在這個例子中,我們必須按照如下方式設計程序:
- 首先定義Window_mgr類,其中聲明clear函數,但是不能定義它。在 clear使用Screen的成員之前必須先聲明Screen。
- 接下來定義Screen,包括對于clear的友元聲明。
- 最后定義clear,此時它才可以使用Screen的成員。
函數重載和友元
盡管重載函數的名字相同,但它們仍然是不同的函數。因此,如果一個類想把一組重載函數聲明成它的友元,它需要對這組函數中的每一個分別聲明:
// overloaded storeOn functions extern std::ostream& storeOn(std::ostream &, Screen &); extern BitMap& storeOn(BitMap &, Screen &);class Screen { // ostream version of storeOn may access the private parts of Screen objectsfriend std::ostream& storeOn(std::ostream &, Screen &);// . . . };友元聲明和作用域
類和非成員函數的聲明不是必須在它們的友元聲明之前。當一個名字第一次出現在一個友元聲明中時,我們隱式地假定該名字在當前作用域中是可見的。然而,友元本身不一定真的聲明在當前作用域中。(MyNote:再本章前部分的“友元的聲明”有相關說明。)
甚至就算在類的內部定義該函數,我們也必須在類的外部提供相應的聲明從而使得函數可見。換句話說,即使我們僅僅是用聲明友元的類的成員調用該友元函數,它也必須是被聲明過的:
struct X {friend void f() { /* friend function can be defined in the class body */ }X() { f(); } // error: no declaration for fvoid g();void h(); };void X::g() { return f(); } // error: f hasn't been declared void f(); // declares the function defined inside X void X::h() { return f(); } // ok: declaration for f is now in scope關于這段代碼最重要的是理解友元聲明的作用是影響訪問權限,它本身并非普通意義上的聲明。
Note:請注意,有的編譯器并不強制執行上述關于友元的限定規則。
(MyNote:友元函數聲明不同于普通函數聲明,它們是兩碼事。)
類的作用域
每個類都會定義它自己的作用域。在類的作用域之外,普通的數據和函數成員只能由對象、引用或者指針使用成員訪問運算符(.)來訪問。對于類類型成員則使用作用域運算符(::)訪問。不論哪種情況,跟在運算符之后的名字都必須是對應類的成員:
Screen::pos ht = 24, wd = 80; // use the pos type defined by Screen Screen scr(ht, wd, ' '); Screen *p = &scr; char c = scr.get(); // fetches the get member from the object scr c = p->get(); // fetches the get member from the object to which p points作用域和定義在類外部的成員
一個類就是一個作用域的事實能夠很好地解釋為什么當我們在類的外部定義成員函數時必須同時提供類名和函數名。在類的外部,成員的名字被隱藏起來了。
一旦遇到了類名,定義的剩余部分就在類的作用域之內了,這里的剩余部分包括參數列表和函數體。結果就是,我們可以直接使用類的其他成員而無須再次授權了。
例如,我們回顧一下Window _mgr類的clear成員,該函數的參數用到了Window_mgr類定義的一種類型:
void Window_mgr::clear(ScreenIndex i) {Screen &s = Screens[i];s.contents = string(s.height * s.width, ' '); }因為編譯器在處理參數列表之前已經明確了我們當前正位于Window_mgr類的作用域中,所以不必再專門說明ScreenIndex是Window_mgr類定義的。出于同樣的原因,編譯器也能知道函數體中用到的Screens也是在Window_mgr類中定義的。
另一方面,函數的返回類型通常出現在函數名之前。因此當成員函數定義在類的外部時,返回類型中使用的名字都位于類的作用域之外。這時,返回類型必須指明它是哪個類的成員。
例如,我們可能向Window_mgr類添加一個新的名為addScreen的函數,它負責向顯示器添加一個新的屏幕。這個成員的返回類型將是ScreenIndex,用戶可以通過它定位到指定的Screen:
class Window_mgr { public:// add a Screen to the window and returns its indexScreenIndex addScreen(const Screen&);// other members as before };// return type is seen before we're in the scope of Window_mgr Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s) {Screens.push_back(s);return Screens.size() - 1; }因為返回類型出現在類名之前,所以事實上它是位于Window_mgr類的作用域之外的。在這種情況下,要想使用ScreenIndex作為返回類型,我們必須明確指定哪個類定義了它。
名字查找與類的作用域
在目前為止,我們編寫的程序中,名字查找(name lookup)(尋找與所用名字最匹配的聲明的過程)的過程比較直截了當:
- 首先,在名字所在的塊中尋找其聲明語句,只考慮在名字的使用之前出現的聲明。
- 如果沒找到,繼續查找外層作用域。
- 如果最終沒有找到匹配的聲明,則程序報錯。
對于定義在類內部的成員函數來說,解析其中名字的方式與上述的查找規則有所區別,不過在當前的這個例子中體現得不太明顯。類的定義分兩步處理:
- 首先,編譯成員的聲明。
- 直到類全部可見后才編譯函數體。
Note:編譯器處理完類中的全部聲明后才會處理成員函數的定義。(MyNote:類中,先全部聲明,后定義)
按照這種兩階段的方式處理類可以簡化類代碼的組織方式。因為成員函數體直到整個類可見后才會被處理,所以它能使用類中定義的任何名字。
相反,如果函數的定義和成員的聲明被同時處理,那么我們將不得不在成員函數中只使用那些已經出現的名字。
用于類成員聲明的名字查找
這種兩階段的處理方式只適用于成員函數中使用的名字。聲明中使用的名字,包括返回類型或者參數列表中使用的名字,都必須在使用前確??梢?/strong>。如果某個成員的聲明使用了類中尚未出現的名字,則編譯器將會在定義該類的作用域中繼續查找。
例如:
typedef double Money; string bal;class Account { public:Money balance() { return bal; } private:Money bal;// ... };當編譯器看到balance函數的聲明語句時,它將在Account類的范圍內尋找對Money的聲明。編譯器只考慮Account中在使用Money前出現的聲明,因為沒找到匹配的成員,所以編譯器會接著到Account的外層作用域中查找。
在這個例子中,編譯器會找到Money的typedef語句,該類型被用作balance函數的返回類型以及數據成員bal的類型。另一方面,balance函數體在整個類可見后才被處理,因此,該函數的return 語句返回名為bal 的成員,而非外層作用域的string對象。
類型名要特殊處理
一般來說,內層作用域可以重新定義外層作用域中的名字,即使該名字已經在內層作用域中使用過。然而在類中,如果成員使用了外層作用域中的某個名字,而該名字代表一種類型,則類不能在之后重新定義該名字:
typedef double Money; class Account { public:Money balance() { return bal; } // uses Money from the outer scope private:typedef double Money; // error: cannot redefine MoneyMoney bal;// ... };需要特別注意的是,即使Account中定義的Money類型與外層作用域一致,上述代碼仍然是錯誤的。
盡管重新定義類型名字是一種錯誤的行為,但是編譯器并不為此負責。一些編譯器仍將順利通過這樣的代碼,而忽略代碼有錯的事實。(MyNote:┑( ̄Д  ̄)┍)
Tip:類型名的定義通常出現在類的開始處,這樣就能確保所有使用該類型的成員都出現在類名的定義之后。
成員定義中的普通塊作用域的名字查找
成員函數中使用的名字按照如下方式解析:(MyNote:本節重點)
-
首先,在成員函數內查找該名字的聲明。和前面一樣,只有在函數使用之前出現的聲明才被考慮。
-
如果在成員函數內沒有找到,則在類內繼續查找,這時類的所有成員都可以被考慮。
-
如果類內也沒找到該名字的聲明,在成員函數定義之前的作用域內繼續查找。
(MyNote:初讀這些界定詞有點繞,結合下面程序理解。)
一般來說,不建議使用其他成員的名字作為某個成員函數的參數。不過為了更好地解釋名字的解析過程,我們不妨在dummy_fcn函數中暫時違反一下這個約定:
//注意:這段代碼僅為了說明而用,不是一段很好的代碼 //通常情況下不建議為參數和成員使用同樣的名字 int height; //定義了一個名字,稍后將在Screen中使用 class Screen { public:typedef std::string::size_type pos;void dummy_fcn(pos height) {cursor = width * height; // which height? the parameter} private:pos cursor = 0;pos height = 0, width = 0; };當編譯器處理dummy_fcn 中的乘法表達式時,它首先在函數作用域內查找表達式中用到的名字。函數的參數位于函數作用域內,因此 dummy_fcn函數體內用到的名字height指的是參數聲明。
在此例中,height參數隱藏了同名的成員。如果想繞開上面的查找規則,應該將代碼變為:
// bad practice: names local to member functions shouldn't hide member names void Screen::dummy_fcn(pos height) {cursor = width * this->height; // member height// alternative way to indicate the membercursor = width * Screen::height; // member height }Note:盡管類的成員被隱藏了,但我們仍然可以通過加上類的名字或顯式地使用this指針來強制訪問成員。
其實最好的確保我們使用height成員的方法是給參數起個其他名字:
//建議的寫法:不要把成員名字作為參數或其他局部變量使用 void Screen::dummy_fcn (pos ht) {cursor = width * height ; //成員height }在此例中,當編譯器查找名字height時,顯然在 dummy_fcn函數內部是找不到的。編譯器接著會在Screen內查找匹配的聲明,即使height的聲明出現在dummy_fcn使用它之后,編譯器也能正確地解析函數使用的是名為height的成員。
類作用域之后,在外圍得作用域中查找
如果編譯器在函數和類的作用域中都沒有找到名字,它將接著在外圍的作用域中查找。
在我們的例子中,名字height定義在外層作用域中,且位于Screen的定義之前。然而,外層作用域中的對象被名為height 的成員隱藏掉了。因此,如果我們需要的是外層作用域中的名字,可以顯式地通過作用域運算符來進行請求:
//不建議的寫法:不要隱藏外層作用域中可能被用到的名字void Screen::dummy_fcn(pos height) {cursor = width * ::height ;//哪個height?是那個全局的 }Note:盡管外層的對象被隱藏掉了,但我們仍然可以用作用域運算符訪問它。
在文件中名字得出現處對其進行解析
當成員定義在類的外部時,名字查找的第三步不僅要考慮類定義之前的全局作用域中的聲明,還需要考慮在成員函數定義之前的全局作用域中的聲明。例如:
int height; // defines a name subsequently used inside Screen class Screen { public:typedef std::string::size_type pos;void setHeight(pos);pos height = 0; // hides the declaration of height in the outer scope };Screen::pos verify(Screen::pos); void Screen::setHeight(pos var) {// var: refers to the parameter// height: refers to the class member// verify: refers to the global functionheight = verify(var); }請注意,全局函數verify的聲明在Screen類的定義之前是不可見的。然而,名字查找的第三步包括了成員函數出現之前的全局作用域。在此例中,verify的聲明位于setHeight的定義之前,因此可以被正常使用。
(MyNote:Java變量都定義在類內,C++的類內外都可聲明定義,麻煩不止一點點。)
(MyNote:C++有關類的作用域:1. 成員函數作用域 2. 類作用域 3.類外圍作用域。本節重點看“成員定義中的普通塊作用域的名字查找”。)
構造函數再探
構造函數初始值列表
當我們定義變量時習慣于立即對其進行初始化,而非先定義、再賦值:
string foo = "Hello world!" ; //定義并初始化 string bar; //默認初始化成空string對象 bar = "Hello world! "; //為bar賦一個新值就對象的數據成員而言,初始化和賦值也有類似的區別。**如果沒有在構造函數的初始值列表中顯式地初始化成員,則該成員將在構造函數體之前執行默認初始化。**例如:
// Sales_data構造函數的一種寫法,雖然合法但比較草率:沒有使用構造函數初始值 Sales_data::Sales_data(const string &s, unsigned cnt, double price){bookNo = s ;units_sold = cnt;revenue = cnt * price; }這段代碼和前文的原始定義效果是相同的:
struct Sales_data {// constructors addedSales_data() = 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) { }Sales_data(std::istream &);//... };當構造函數完成后,數據成員的值相同。區別是原來的版本初始化了它的數據成員,而這個版本是對數據成員執行了賦值操作。這一區別到底會有什么深層次的影響完全依賴于數據成員的類型。
構造函數的初始值有時必不可少
有時我們可以忽略數據成員初始化和賦值之間的差異,但并非總能這樣。如果成員是const或者是引用的話,必須將其初始化。類似的,當成員屬于某種類類型且該類沒有定義默認構造函數時,也必須將這個成員初始化。例如:
class ConstRef { public:ConstRef(int ii); private:int i;const int ci ;int &ri; };和其他常量對象或者引用一樣,成員ci和ri都必須被初始化。因此,如果我們沒有為它們提供構造函數初始值的話將引發錯誤:
//錯誤:ci和ri必須被初始化 ConstRef::ConstRef(int ii){ {//賦值:i = ii ;//正確ci = ii ;//錯誤:不能給const賦值ri = i;//錯誤:ri沒被初始化 }隨著構造函數體一開始執行,初始化就完成了。我們初始化 const或者引用類型的數據成員的唯一機會就是通過構造函數初始值,因此該構造函數的正確形式應該是:
// ok: explicitly initialize reference and const members ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) { }Note:如果成員是const、引用,或者屬于某種未提供默認構造函數的類類型,我們必須通過構造函數初始值列表為這些成員提供初值。
建議:使用構造函數初始值
在很多類中,初始化和賦值的區別事關底層效率問題:前者直接初始化數據成員,后者則先初始化再賦值。In many classes, the distinction between initialization and assignment is strictly a matter of low-level efficiency: A data member is initialized and then assigned when it could have been initialized directly.
除了效率問題外更重要的是,一些數據成員必須被初始化。建議讀者養成使用構造函數初始值的習慣,這樣能避免某些意想不到的編譯錯誤,特別是遇到有的類含有需要構造函數初始值的成員時。
成員初始化的順序
顯然,在構造函數初始值中每個成員只能出現一次。否則,給同一個成員賦兩個不同的初始值有什么意義呢?
不過讓人稍感意外的是,構造函數初始值列表只說明用于初始化成員的值,而不限定初始化的具體執行順序。
成員的初始化順序與它們在類定義中的出現順序一致:第一個成員先被初始化,然后第二個,以此類推。
構造函數初始值列表中初始值的前后位置關系不會影響實際的初始化順序。
一般來說,初始化的順序沒什么特別要求。不過如果一個成員是用另一個成員來初始化的,那么這兩個成員的初始化順序就很關鍵了。
舉個例子,考慮下面這個類:
class X {int i;int j; public:// undefined: i is initialized before jX(int val): j(val), i(j) { } };在此例中,從構造函數初始值的形式上來看仿佛是先用val初始化了j,然后再用j初始化i。實際上,i先被初始化,因此這個初始值的效果是試圖使用未定義的值j初始化i!
有的編譯器具備一項比較友好的功能,即當構造函數初始值列表中的數據成員順序與這些成員聲明的順序不符時會生成一條警告信息。
Best Practices:最好令構造函數初始值的順序與成員聲明的順序保持一致。而且如果可能的話,盡量避免使用某些成員初始化其他成員。
如果可能的話,最好用構造函數的參數作為成員的初始值,而盡量避免使用同一個對象的其他成員。這樣的好處是我們可以不必考慮成員的初始化順序。
例如,X的構造函數如果寫成如下的形式效果會更好:
X(int val):i(val), j(val) { }在這個版本中,i和j初始化的順序就沒什么影響了。
默認實參和構造函數
Sales_data 默認構造函數的行為與只接受一個string 實參的構造函數差不多。
唯一的區別是接受String實參的構造函數使用這個實參初始化bookNo,而默認構造函數(隱式地)使用string的默認構造函數初始化bookNo。我們可以把它們重寫成一個使用默認實參(第6章內容)的構造函數:
class Sales_data { public://定義默認構造函數,令其與只接受一個string實參的構造函數功能相同Sales_data(std::string s = ""):bookNo(s){ }//其他構造函數與之前一致Sales_data(std::string s, unsigned cnt, double rev):bookNo(s) , units_sold(cnt) , revenue (rev * cnt) { }Sales_data(std::istream &is) { read(is, *this); }//其他成員與之前的版本一致 };當沒有給定實參,或者給定了一個string實參時,兩個版本的類創建了相同的對象。因為我們不提供實參也能調用上述的構造函數,所以該構造函數實際上為我們的類提供了默認構造函數。
Note:如果一個構造函數為所有參數都提供了默認實參,則它實際上也定義了默認構造函數。
值得注意的是,我們不應該為Sales_data接受三個實參的構造函數提供默認值。因為如果用戶為售出書籍的數量提供了一個非零的值,則我們就會期望用戶同時提供這些書籍的售出價格。
委托構造函數
C++11新標準擴展了構造函數初始值的功能,使得我們可以定義所謂的委托構造函數(delegating constructor)。一個委托構造函數使用它所屬類的其他構造函數執行它自己的初始化過程,或者說它把它自己的一些(或者全部)職責委托給了其他構造函數。
和其他構造函數一樣,一個委托構造函數也有一個成員初始值的列表和一個函數體。在委托構造函數內,成員初始值列表只有一個唯一的入口,就是類名本身。和其他成員初始值一樣,類名后面緊跟圓括號括起來的參數列表,參數列表必須與類中另外一個構造函數匹配。
舉個例子,我們使用委托構造函數重寫Sales_data類,重寫后的形式如下所示:
class Sales_data { public:// nondelegating constructor initializes members from corresponding arguments//1.Sales_data(std::string s, unsigned cnt, double price):bookNo(s), units_sold(cnt), revenue(cnt*price) {}// remaining constructors all delegate to another constructor//2.Sales_data(): Sales_data("", 0, 0) {}//3.Sales_data(std::string s): Sales_data(s, 0,0) {}//4.Sales_data(std::istream &is): Sales_data() { read(is, *this); }// other members as before };在這個Sales_data類中,除了一個構造函數外其他的都委托了它們的工作。
第一個構造函數接受三個實參,使用這些實參初始化數據成員,然后結束工作。
我們定義默認構造函數令其使用三參數的構造函數完成初始化過程,它也無須執行其他任務,這一點從空的構造函數體能看得出來。
接受一個string 的構造函數同樣委托給了三參數的版本。
接受istream&的構造函數也是委托構造函數,它委托給了默認構造函數,默認構造函數又接著委托給三參數構造函數。當這些受委托的構造函數執行完后,接著執行istream&構造函數體的內容。它的構造函數體調用read函數讀取給定的istream。
當一個構造函數委托給另一個構造函數時,受委托的構造函數的初始值列表和函數體皆被執行。在Sales_data類中,受委托的構造函數體恰好是空的。假如函數體包含有代碼的話,將先執行這些代碼,然后控制權才會交還給委托者的函數體。(MyNote:先函數體代碼,再委托構造函數)
默認構造函數的作用
當對象被默認初始化(Default initialization) 或 值初始化(Value initialization)(第3章)時自動執行默認構造函數。
默認初始化在以下情況下發生:
-
當我們在塊作用域內不使用任何初始值定義一個非靜態變量或者數組時。(第2章)
-
當一個類本身含有類類型的成員且使用合成的默認構造函數時(本章)。
-
當類類型的成員沒有在構造函數初始值列表中顯式地初始化時(本章)。
值初始化在以下情況下發生:
-
在數組初始化的過程中如果我們提供的初始值數量少于數組的大小時(第3章)。
-
當我們不使用初始值定義一個局部靜態變量時(第6章)。
-
當我們通過書寫形如T()的表達式顯式地請求值初始化時,其中T是類型名(vector的一個構造函數只接受一個實參用于說明vector大小(第3章),它就是使用一個這種形式的實參來對它的元素初始化器進行值初始化)。
類必須包含一個默認構造函數以便在上述情況下使用,其中的大多數情況非常容易判斷。
不那么明顯的一種情況是類的某些數據成員缺少默認構造函數:
class NoDefault { public:NoDefault(const std::string&);// additional members follow, but no other constructors };struct A { // my_mem is public by default; NoDefault my_mem; };A a; // error: cannot synthesize a constructor for Astruct B {B() {} // error: no initializer for b_memberNoDefault b_member; //沒有默認構造函數 };Best Practices:在實際中,如果定義了其他構造函數,那么最好也提供一個默認構造函數。
默認初始化(default initialization)當對象未被顯式地賦予初始值時執行的初始化行為。由類本身負責執行的類對象的初始化行為。全局作用域的內置類型對象初始化為0;局部作用域的對象未被初始化即擁有未定義的值。(來自第2章術語表)
值初始化(value initialization)是種初始化過程。內置類型初始化為0,類類型由類的默認構造函數初始化。只有當類包含默認構造函數時,該類的對象才會被值初始化。對于容器的初始化來說,如果只說明了容器的大小而沒有指定初始值的話,就會執行值初始化。此時編譯器會生成個值,而容器的元素被初始化為該值。(第3章術語表)
(MyNote:個人沒有感覺默認初始化和值初始化這兩個術語有明顯區別。(ーー゛)個人理解:值初始化主要用在數組,容器,其余的都是默認初始化。)
使用默認構造函數
下面的obj的聲明可以正常編譯通過:
Sales_data obj();//正確:定義了一個函數而非對象但當我們試圖使用obj 時,編譯器將報錯,提示我們不能對函數使用成員訪問運算符。
if(obj.isbn () == Primer_5th_ed.isbn())//錯誤:obj是一個函數問題在于,盡管我們想聲明一個默認初始化的對象,obj實際的含義卻是一個不接受任何參數的函數并且其返回值是Sales_data類型的對象。
如果想定義一個使用默認構造函數進行初始化的對象,正確的方法是去掉對象名之后的空的括號對:
//正確:obj是個默認初始化的對象 Sales_data obj;WARNING:
對于C++的新手程序員來說有一種常犯的錯誤,它們試圖以如下的形式聲明一個用默認構造函數初始化的對象
Sales_data obj();//錯誤:聲明了一個函數而非對象 Sales_data obj2;//正確:obj2是一個對象而非函數隱式的類類型轉換
第4章曾經介紹過C++語言在內置類型之間定義了幾種自動轉換規則。
同樣的,我們也能為類定義隱式轉換規則。如果構造函數只接受一個實參,則它實際上定義了轉換為此類類型的隱式轉換機制,有時我們把這種構造函數稱作轉換構造函數converting constructor)。
將在第14章介紹如何定義將一種類類型轉換為另一種類類型的轉換規則。
Note:能通過一個實參調用的構造函數定義了一條從構造函數的參數類型向類類型隱式轉換的規則。
在Sales_data類中,接受string 的構造函數和接受 istream的構造函數分別定義了從這兩種類型向 Sales_data 隱式轉換的規則。也就是說,在需要使用Sales_data的地方,我們可以使用string或者istream 作為替代:
string null_book = "9-999-99999-9" ; //構造一個臨時的Sales_data對象 //該對象的units_sold和revenue等于0,bookNo等于null_book item.combine(null_book);在這里我們用一個string實參調用了Sales_data的combine 成員。該調用是合法的,編譯器用給定的string自動創建了一個Sales_data對象。新生成的這個(臨時)Sales_data對象被傳遞給combine。因為combine的參數是一個常量引用,所以我們可以給該參數傳遞一個臨時量。
只允許一步類類型轉換
第4章指出,編譯器只會自動地執行一步類型轉換。
例如,因為下面的代碼隱式地使用了兩種轉換規則,所以它是錯誤的:
//錯誤:需要用戶定義的兩種轉換: // (1)把“9-999-99999-9”C式字符串字面量轉換成string // (2)再把這個(臨時的) string轉換成Sales_data item.combine("9-999-99999-9");如果我們想完成上述調用,可以顯式地把字符串轉換成string或者Sales_data對象:
//正確:顯式地轉換成string,隱式地轉換成Sales_data item.combine(string("9-999-99999-9"));//正確:隱式地轉換成string,顯式地轉換成Sales_data item.combine(Sales_data("9-999-99999-9"));類類型轉換不是總有效
是否需要從string到 Sales_data的轉換依賴于我們對用戶使用該轉換的看法。在此例中,這種轉換可能是對的。null_book中的string可能表示了一個不存在的ISBN編號。
另一個是從istream到Sales_data的轉換:
//使用istream構造函數創建一個函數傳遞給combine item.combine(cin);這段代碼隱式地把cin轉換成Sales_data,這個轉換執行了接受一個 istream 的Sales_data構造函數。該構造函數通過讀取標準輸入創建了一個(臨時的)Sales_data對象,隨后將得到的對象傳遞給combine。
Sales_data對象是個臨時量(第2章內容),一旦 combine完成我們就不能再訪問它了。實際上,我們構建了一個對象,先將它的值加到item中,隨后將其丟棄。
explicit抑制構造函數定義的隱式轉換
在要求隱式轉換的程序上下文中,我們可以通過將構造函數聲明為explicit 加以阻止:
class Sales_data { public:Sales_data() = default;Sales_data(const std::string &s, unsigned n, double p):bookNo(s), units_sold(n), revenue(p*n) { }explicit Sales_data(const std::string &s): bookNo(s) { }explicit Sales_data(std::istream&);// remaining members as before };此時,沒有任何構造函數能用于隱式地創建Sales_data對象,之前的兩種用法都無法通過編譯:
item.combine(null_book); // error: string constructor is explicit item.combine(cin); // error: istream constructor is explicit關鍵字explicit只對一個實參的構造函數有效。需要多個實參的構造函數不能用于執行隱式轉換,所以無須將這些構造函數指定為explicit的。
只能在類內聲明構造函數時使用explicit關鍵字,在類外部定義時不應重復:
// error: explicit allowed only on a constructor declaration in a class header explicit Sales_data::Sales_data(istream& is) {read(is, *this); }explicit
英 [?k?spl?s?t] 美 [?k?spl?s?t]
adj. 清楚明白的;易于理解的;(說話)清晰的,明確的;直言的;坦率的;直截了當的;不隱晦的;不含糊的
explicit構造函數只能用于直接初始化,不能用于拷貝初始化
發生隱式轉換的一種情況是當我們執行拷貝形式的初始化時(使用=)。此時,我們只能使用直接初始化而不能使用explicit構造函數:
Sales_data item1(nul1_book);//正確:直接初始化 //錯誤:不能將explicit構造函數用于拷貝形式的初始化過程 Sales_data item2 = null_book;當我們用 explicit關鍵字聲明構造函數時,它將只能以直接初始化的形式使用。而且,編譯器將不會在自動轉換過程中使用該構造函數。
如果使用等號(=)初始化一個變量,實際上執行的是拷貝初始化(copy initialization),編譯器把等號右側的初始值拷貝到新創建的對象中去。
與之相反,如果不使用等號,則執行的是直接初始化(direct initialization)。
(來自第3章)
為轉換顯示地使用構造函數(可忽視explicit)
盡管編譯器不會將 explicit 的構造函數用于隱式轉換過程,但是我們可以使用這樣的構造函數顯式地強制進行轉換:
//正確:實參是一個顯式構造的Sales_data對象 item.combine(Sales_data(null_book)); //正確: static_cast可以使用explicit的構造函數 item.combine(static_cast<Sales_data>(cin)) ;在第一個調用中,我們直接使用Sales_data的構造函數,該調用通過接受string的構造函數創建了一個臨時的Sales_data對象。
在第二個調用中,我們使用static_cast (第4章內容)執行了顯式的而非隱式的轉換。其中,static_cast 使用istream構造函數創建了一個臨時的Sales_data對象。
標準庫中含有顯式構造函數的類
我們用過的一些標準庫中的類含有單參數的構造函數:
- 接受一個單參數的const char*的string構造函數不是explicit的。
- 接受一個容量參數的vector構造函數是explicit的。
(兩個都是第3章內容)
聚合類
聚合類(aggregate class)使得用戶可以直接訪問其成員,并且具有特殊的初始化語法形式。當一個類滿足如下條件時,我們說它是聚合的:
- 所有成員都是public的。
- 沒有定義任何構造函數。
- 沒有類內初始值。
- 沒有基類,也沒有virtual函數,關于這部分知將在第15章詳細介紹。
例如,下面的類是一個聚合類:
struct Data {int ival;string s; };我們可以提供一個花括號括起來的成員初始值列表,并用它初始化聚合類的數據成員:
//val1.ival = 0; val1.s = string ( "Anna") Data val1 = {0, "Anna"};初始值的順序必須與聲明的順序一致,也就是說,第一個成員的初始值要放在第一個,然后是第二個,以此類推。
下面的例子是錯誤的:
//錯誤:不能使用"Anna"初始化ival,也不能使用1024初始化s Data val2 = { "Anna", 1024};與初始化數組元素的規則(第3章內容)一樣,如果初始值列表中的元素個數少于類的成員數量,則靠后的成員被值初始化(第3章內容)。初始值列表的元素個數絕對不能超過類的成員數量。
值得注意的是,顯式地初始化類的對象的成員存在三個明顯的缺點:
- 要求類的所有成員都是public的。
- 將正確初始化每個對象的每個成員的重任交給了類的用戶(而非類的作者)。因為用戶很容易忘掉某個初始值,或者提供一個不恰當的初始值,所以這樣的初始化過程冗長乏味且容易出錯。
- 添加或刪除一個成員之后,所有的初始化語句都需要更新。
字面值常量類
在第6章中提到過constexpr函數的參數和返回值必須是字面值類型。
除了算術類型、引用和指針外,某些類也是字面值類型。和其他類不同,字面值類型的類可能含有constexpr函數成員。這樣的成員必須符合constexpr函數的所有要求,它們是隱式const的。
數據成員都是字面值類型的聚合類是字面值常量類。如果一個類不是聚合類,但它符合下述要求,則它也是一個字面值常量類:
- 數據成員都必須是字面值類型。
- 類必須至少含有一個constexpr構造函數。
- 如果一個數據成員含有類內初始值,則內置類型成員的初始值必須是一條常量表達式(第2章內容);或者如果成員屬于某種類類型,則初始值必須使用成員自己的constexpr構造函數。
- 類必須使用析構函數的默認定義,該成員負責銷毀類的對象。
constexpr構造函數
盡管構造函數不能是const 的,但是字面值常量類的構造函數可以是constexpr函數。事實上,一個字面值常量類必須至少提供一個constexpr構造函數。
不同于其他成員函數,構造函數不能被聲明成const的。當我們創建類的一個const對象時,直到構造函數完成初始化過程,對象才能真正取得其“常量”屬性。因此,構造函數在const對象的構造過程中可以向其寫值。(本章構造函數內容)
constexpr函數(constexpr function)是指能用于常量表達式的函數。(第6章內容)
constexpr構造函數可以聲明成=default的形式(或者是刪除函數的形式,第13章內容)。另外,constexpr構造函數必須既符合構造函數的要求(意味著不能包含返回語句),又符合constexpr函數的要求(意味著它能擁有的唯一可執行語句就是返回語句,第6章內容)。Otherwise, a constexpr constructor must meet the requirements of a constructor—meaning it can have no return statement—and of a constexpr function—meaning the only executable statement it can have is a return statement.
綜合這兩點可知,constexpr構造函數體一般來說應該是空的。
我們通過前置關鍵字constexpr就可以聲明一個constexpr構造函數了:
class Debug { public:constexpr Debug(bool b = true): hw(b), io(b), other(b) {}constexpr Debug(bool h, bool i, bool o):hw(h), io(i), other(o) {}constexpr bool any() { return hw || io || other; }void set_io(bool b) { io = b; }void set_hw(bool b) { hw = b; }void set_other(bool b) { hw = b; } private:bool hw; // hardware errors other than IO errorsbool io; // IO errorsbool other; // other errors };constexpr構造函數必須初始化所有數據成員,初始值或者使用constexpr構造函數,或者是一條常量表達式。
constexpr構造函數用于生成constexpr對象以及constexpr函數的參數或返回類型:
constexpr Debug io_sub(false, true, false); // debugging IO if (io_sub.any()) // equivalent to if(true)cerr << "print appropriate error messages" << endl;constexpr Debug prod(false); // no debugging during production if (prod.any()) // equivalent to if(false)cerr << "print an error message" << endl;constexpr函數被隱式地指定為內聯函數,內聯函數可避免函數調用的開銷。(第6章內容)
類的靜態成員
有時,類需要它的一些成員與類本身直接相關,而不是與類的各個對象保持關聯。例如,一個銀行賬戶類可能需要一個數據成員來表示當前的基準利率。在此例中,我們希望利率與類關聯,而非與類的每個對象關聯。從實現效率的角度來看,沒必要每個對象都存儲利率信息。而且更加重要的是,一旦利率浮動,我們希望所有的對象都能使用新值。
(MyNote:個人來理解:類的靜態成員指同一類的所有對象共享的成員。)
聲明靜態成員
我們通過在成員的聲明之前加上關鍵字static使得其與類關聯在一起。和其他成員一樣,靜態成員可以是public的或private的。靜態數據成員的類型可以是常量、引用、指針、類類型等。
舉個例子,我們定義一個類,用它表示銀行的賬戶記錄:
class Account { public:void calculate() { amount += amount * interestRate; }static double rate(){ return interestRate; }static void rate(double); private:std::string owner;double amount;static double interestRate;static double initRate(); };類的靜態成員存在于任何對象之外,對象中不包含任何與靜態數據成員有關的數據。因此,
-
每個Account對象將包含兩個數據成員:owner和 amount。
-
只存在一個interestRate對象而且它被所有Account對象共享。
類似的,靜態成員函數也不與任何對象綁定在一起,它們不包含this指針。作為結果,靜態成員函數不能聲明成const的,而且我們也不能在static函數體內使用this指針。這一限制既適用于this的顯式使用,也對調用非靜態成員的隱式使用有效。
使用類的靜態成員
我們使用作用域運算符直接訪問靜態成員:
double r; r = Account::rate(); //使用作用域運算符訪問靜態成員雖然靜態成員不屬于類的某個對象,但是我們仍然可以使用類的對象、引用或者指針來訪問靜態成員:
Account ac1 ; Account *ac2 = &acl ; //調用靜態成員函數rate的等價形式r= acl.rate() ;//通過Account的對象或引用 r=ac2->rate();//通過指向Account對象的指針成員函數不用通過作用域運算符就能直接使用靜態成員:
class Account { public:void calculate() { amount += amount * interestRate; } private:static double interestRate;//其他成員與之前的版本一致 };定義靜態成員
和其他的成員函數一樣,我們既可以在類的內部也可以在類的外部定義靜態成員函數。當在類的外部定義靜態成員時,不能重復static關鍵字,該關鍵字只出現在類內部的聲明語句:
void Account::rate(double newRate){interestRate = newRate; }Note:和類的所有成員一樣,當我們指向類外部的靜態成員時,必須指明成員所屬的類名。static關鍵字則只出現在類內部的聲明語句中。
因為靜態數據成員不屬于類的任何一個對象,所以它們并不是在創建類的對象時被定義的。這意味著它們不是由類的構造函數初始化的。而且一般來說,我們不能在類的內部初始化靜態成員。相反的,必須在類的外部定義和初始化每個靜態成員。和其他對象一樣,一個靜態數據成員只能定義一次。
類似于全局變量(第6章內容),靜態數據成員定義在任何函數之外。因此一旦它被定義,就將一直存在于程序的整個生命周期中。
我們定義靜態數據成員的方式和在類的外部定義成員函數差不多。我們需要指定對象的類型名,然后是類名、作用域運算符以及成員自己的名字:
//定義并初始化一個靜態成員 double Account::interestRate = initRate();這條語句定義了名為interestRate的對象,該對象是類Account的靜態成員,其類型是double。從類名開始,這條定義語句的剩余部分就都位于類的作用域之內了。因此,我們可以直接使用initRate函數。
注意,雖然initRate是私有的,我們也能用它初始化interestRate。和其他成員的定義一樣,interestRate的定義也可以訪問類的私有成員。
Tip:要想確保對象只定義一次,最好的辦法是把靜態數據成員的定義與其他非內聯函數的定義放在同一個文件中。
靜態成員的類內初始化
通常情況下,類的靜態成員不應該在類的內部初始化。然而,我們可以為靜態成員提供const整數類型的類內初始值,不過要求靜態成員必須是字面值常量類型的constexpr(MyNote:前提)。
初始值必須是常量表達式,因為這些成員本身就是常量表達式,所以它們能用在所有適合于常量表達式的地方。例如,我們可以用一個初始化了的靜態數據成員指定數組成員的維度:
class Account { public:static double rate() { return interestRate; }static void rate(double); private:static constexpr int period = 30;// period is a constant expressiondouble daily_tbl[period]; };如果某個靜態成員的應用場景僅限于編譯器可以替換它的值的情況,則一個初始化的const或constexpr static不需要分別定義。相反,如果我們將它用于值不能替換的場景中,則該成員必須有一條定義語句。
例如,如果period的唯一用途就是定義daily_tbl的維度,則不需要在Account外面專門定義period。此時,如果我們忽略了這條定義,那么對程序非常微小的改動也可能造成編譯錯誤,因為程序找不到該成員的定義語句。舉個例子,當需要把Account::period傳遞給一個接受const int&的函數時,必須定義period。
如果在類的內部提供了一個初始值,則成員的定義不能再指定一個初始值了:
//一個不帶初始值的靜態成員的定義 constexpr int Account::period; //初始值在類的定義內提供Best Practices:即使一個常量靜態數據成員在類內部被初始化了,通常情況下也應該在類的外部定義一下該成員。
靜態成員能用于某些場景,而普通成員不能
如我們所見,靜態成員獨立于任何對象。因此,在某些非靜態數據成員可能非法的場合,靜態成員卻可以正常地使用。舉個例子,靜態數據成員可以是不完全類型(指先聲明,暫時不定義,本章“類類型內容”)。特別的,靜態數據成員的類型可以就是它所屬的類類型。而非靜態數據成員則受到限制,只能聲明成它所屬類的指針或引用:
class Bar { public:// ... private:static Bar meml;//正確:靜態成員可以是不完全類型Bar *mem2 ;//正確:指針成員可以是不完全類型Bar mem3 ;//錯誤:數據成員必須是完全類型 };靜態成員和普通成員的另外一個區別是我們可以使用靜態成員作為默認實參:
class Screen { public://bkground表示一個在類中稍后定義的靜態成員Screen& clear(char = bkground) ; private:static const char bkground; };非靜態數據成員不能作為默認實參,因為它的值本身屬于對象的一部分,這么做的結果是無法真正提供一個對象以便從中獲取成員的值,最終將引發錯誤。
一些術語
類(class)C++提供的自定義數據類型的機制。類可以包含數據、函數和類型成員。一個類定義一種新的類型和一個新的作用域。
總結
以上是生活随笔為你收集整理的《C++ Primer 5th》笔记(7 / 19):类的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python模块(1)-Argparse
- 下一篇: 数据结构和算法(05)---链表(c++