程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> C++11學習

C++11學習

編輯:關於C++

本章目的:

當Android用ART虛擬機替代Dalvik的時候,為了表示和Dalvik徹底劃清界限的決心,Google連ART虛擬機的實現代碼都切換到了C++11。C+11的標准規范於2011年2月正式落稿,而此前10余年間,C++正式標准一直是C++98/03[①]。相比C++98/03,C++11有了非常多的變化,甚至一度讓筆者大呼不認識C++了[②]。不過,作為科技行業的從業者,我們要銘記在心的一個鐵規就是要擁抱變化。既然我們不認識C++11,那就把它當做一門全新的語言來學習吧。

寫在開頭的話

從2007年到2010年,在我參加工作的頭三年中,筆者一直使用C++作為唯一的開發語言,寫過十幾萬行的代碼。從2010年轉向Android開發後,我才正式接觸Java。此後很多年裡,我曾經多次比較過兩種語言,有了一些很直觀,很感性的看法。此處和大家分享,讀者不妨一看:

對於業務系統[③]的開發而言,Java相比C++而言,開發確實方便太多。比如:

 

Java天生就是跨平台的。開發者無需考慮操作系統,硬件平台的差異。而C++開發則高度依賴於操作系統以及硬件平台。比如Windows的C++程序到Linux平台上幾乎都無法直接使用。這其中的問題倒也不能全賴在C++語言本身上。只是選擇一門開發語言不僅僅是選擇語言本身,其背後的生態系統(OS,硬件平台,公共類庫,開發資源,文檔等)隨之也被選擇。開發者無需考慮內存管理。雖然Java也有內存洩露之說,但至少在開發過程中,開發者不用斤斤計較於C++編程中必須要時刻考慮的“內存是否會洩露”,“對象被delete後是否會導致其他使用者操作無效內存地址”等問題。最後也是最重要的一點,Java有非常豐富的類庫,諸如網絡操作類,容器類,並發類,XML解析類等等等等。正是有了這些豐富的類庫,才使得業務系統開發者能聚焦在如何利用這些現成的工具、類庫來開發自己的業務系統,而不是從頭到腳得重復制造車輪。比如,當年我在Windows搞一套C++封裝的多線程工具類,之後移植到Linux上又得搞一套,而且還要花很多精力維護它們。

 

個人感受:

我個人對C++是沒有任何偏好的。之所以用C++,很大程度上是因為直接領導的選擇。作為一個工作多年的老員工,在他印象裡,那個年代的Java性能很差,比不得C++的靈巧和高效。另外,由於我們做得是高性能視音頻數據網絡傳輸(在局域網/廣域網,幾個GB的視音頻文件類似FTP這樣的上傳下載),C++貌似是當時唯一能同時和“面向對象”,“性能不錯”掛上鉤的語言了。

在研究ART的時候,筆者發現其源碼是用一種和我以前熟悉得C++差別很大的C++語言編寫得,這種差別甚至一度讓我感歎“不太認識C++語言了”。後來,我才了解到這種“全新的”C++就是C++11。當時我就在想,包括我自己在內,以及本書的讀者們要不要學習它呢?思來覆去,我覺得還是有這個必要:

 

從Android 6.0源碼來看,native模塊改用C++11來編寫已成趨勢。所以我們需要盡快了解C++11,為將來的學習和工作做准備。既然C++之父都說“C++11看起來像一門新的語言[6]”,那麼我們完全可以把它當做一門新的語言來學習,而不用考慮是否有過C/C++基礎的問題。這給了我們一個很好的學習機會。

 

既然下定決心,那麼就馬上開始學習。正式介紹C++11前,筆者要特別強調以下幾點注意事項:

 

編程語言學習,以實用為主。所以本章所介紹的C++11內容,一切以看懂ART源碼為最高目標。源碼中沒有涉及的C++11知識,本章盡量不予介紹。一些細枝末節,或者高深精尖的用法,筆者也不擬詳述。如果讀者想深入研究,不妨閱讀本章參考文獻所列出的六本C++專著。學習是一個循序漸進的過程。對於初學者而言,應首先以看懂C++11代碼為主,然後才能嘗試模仿著寫,直到完全自己寫。用C++寫程序,會碰到很多所謂的“坑”,只有親歷並吃過虧之後,才能深刻掌握這門語言。所以,如果讀者想真正學好C++,那麼一定要多寫代碼,不能停留在看懂代碼的水平上。

 

注意:

最後,本章不是專門來討論C++語法的,它更大的作用在於幫助讀者更快得了解C++。故筆者會嘗試采用一些通俗的語言來介紹它。因此,本章在關於C++語法描述的精准性上必然會有所不足。在此,筆者一方面請讀者諒解,另一方面請讀者及時反饋所發現的問題。

下面,筆者將正式介紹C++11,本章擬講解如下內容:

 

數據類型C++源碼構成及編譯Class操作符重載函數模板與類模板lambda表達式STL介紹其他一些常用知識點

 

1.1 數據類型

學習一門語言,首先從它定義的數據類型開始。本節先介紹C++基本內置的數據類型。

1.1.1 基本內置數據類型介紹

圖1所示為C++中的基本內置數據類型(注意,圖中沒有包含所有的內置數據類型):

\

 

圖1 C++基本數據類型

圖1展示了C++語言中幾種常用的基本數據類型。有幾點請讀者注意:

 

由於C++和硬件平台關聯較大,規范沒辦法像Java那樣嚴格規定每種數據類型所需的字節數,所以它只定義了每種數據類型最少需要多少字節。比如,規范要求一個int型整數至少占據2個字節(不過,絕大部分情況下一個int整數將占據4個字節)。C++定義了sizeof操作符,通過這個操作符可以得到每種數據類型(或某個變量)占據的字節個數。對於浮點數,規范只要求最小的有效數字個數。對於單精度浮點數float而言,要求最少支持6個有效數字。對於雙精度浮點數double類型而言,要求最少支持10個有效數字。

 

注意:

本章中,筆者可能會經常拿Java語言做對比。因為了解語言之間的差異更有助於快速掌握一門新的語言。

和Java不同的是,C++中的數據類型分無符號和有符號兩種,比如:

 

\

圖2 無符號數據類型定義

注意,無符號類型的關鍵詞為unsigned

1.1.2 指針、引用和void類型

現在來看C++裡另外三種常用的數據類型:指針、引用和void,如圖3所示:

 

\

圖3 指針、引用和void

由圖3可知:

 

指針類型的書寫格式為T *,其中T為某種數據類型。引用類型的書寫格式為T &,其中T為某種數據類型。void代表空類型,也就是無類型。這種類型只能用於定義指針變量,比如void*。當我們確實不關注內存中存儲的數據到底是什麼類型的話,就可以定義一個void*類型的指針來指向這塊內存。C++11開始,空指針由新關鍵字nullptr[④]表示,類似於Java中的null

 

下面我們著重介紹一下指針和引用。先來看指針:

1. 指針

關於指針,讀者只需要掌握三個基本知識點就可以了:

 

指針的類型。指針的賦值。指針的解引用。

 

(1) 指針的類型

指針本質上代表了虛擬內存的地址。簡單點說,指針就是內存地址。比如,在32位系統上,一個進程的虛擬地址空間為4G,虛擬內存地址從0x00xFFFFFFFF,這個段中的任何一個值都是內存地址。

一個程序運行時,其虛擬內存中會有什麼呢?肯定有數據和代碼。假設某個指針指向一塊內存,該內存存儲的是數據,C++中數據都得有數據類型。所以,指向這塊內存的指針也應該有類型。比如:

2int* p,變量p是一個指針,它指向的內存存儲了一個(對於數組而言,就是一組)int型數據。

2short* p,變量p指向的內存存儲了一個(或一組)short型數據。

如果指針對應的內存中存儲的是代碼的話,那麼指向這塊代碼入口地址(代碼往往是封裝在函數裡的,代碼的入口就是函數的入口)的指針就叫函數指針。函數指針的定義看起來有些古怪,如圖4所示:

 

\

圖4 函數指針定義示例

提示:

函數指針的定義語法看起來比較奇特,筆者也是實踐了很多次才了解它。

(2) 指針的賦值

定義指針變量後,下一個要考慮的問題就是給它賦什麼值。來看圖5:

 

\

圖5 指針變量的賦值

結合圖5可知,指針變量的賦值有幾種形式:

 

直接將一個固定的值(比如0x123456)作為地址賦給指針變量。這種做法很危險。除非明確知道這塊內存的作用以及所存儲的內容,否則不能使用這種方法。通過new操作符在堆上分配一塊內存,該內存的地址存儲在對應的指針變量中。通過取地址符&對獲取某個變量或者函數的地址。

 

注意

函數指針變量的賦值也可以直接使用目標函數名,也可使用取地址符&。二者效果一致

(3) 指針的解引用

指針只是代表內存的某個地址,如何獲取該地址對應內存中的內容呢?C++提供了解指針引用符號*來幫助大家,如圖6所示:

 

\

圖6 指針解引用

圖6中:

 

對於數據類型的指針,解引用意味著獲取對應地址中內存的內容。對於函數指針,解引用意味著調用這個函數。

 

討論:

為什麼C/C++中會有指針呢?因為C和C++語言作為系統編程(System Programming)語言,出於運行效率的考慮,它提供了指針這樣的機制讓程序員能夠直接操作內存。當然,這種做法的利弊已經討論了幾十年,其主要壞處就在於大部分程序員管不好內存,導致經常出現內存洩露,訪問異常內存地址等各種問題。

2. 引用

相比C,引用是C++特有的一個概念。我們來看圖7,它展示了指針和引用的區別:

 

\

圖7 引用的用法示例(1)

 

\

圖7 引用的用法示例(2)

由圖7可知:

 

引用只是變量的別名。由於是別名,所以C++要求在定義引用型變量時就必須將它和實際變量綁定。引用型變量綁定實際變量之後,這兩個變量(原變量和它的引用變量)其實就代表同一個東西了。圖7中(1)以魯迅為例,“魯迅”和“周樹人”都是同一個人。

 

C語言中沒有引用,一樣工作得很好。那麼C++引入引用的目的是什麼呢[⑤]?

 

既然是別名,那麼給原變量換一個更動聽的名字可能是一個作用。比較圖7中(2)的changeRefchangeNoRef可知,當函數的形參為引用時,函數內部對該形參的修改就是對實參的修改。再次強調,對於引用類型的形參而言,函數調用時,形參就變成了實參的別名。比較圖7中(2)的changeRefchangePointers可知,指針型變量書寫起來需要使用解地址引用*符號,不太方便。引用和原變量是一對一的強關系,而指針則可以任意賦值,甚至還可以通過類型轉換變成別的類型的指針。在實際編碼過程中,一對一的強關系能減少一些錯誤的發生。

 

和Java比較

和Java語言比起來,如果Java中函數的形參是基礎類型(如int,long之類的),則這個形參是傳值的,與圖7中的changeNoRef類似。如果這個函數的形參是類類型,則該形參類似於圖7中的changeRef。在函數內部修改形參的數據,實參的數據相應會被修改。

1.1.3 字符和字符串

圖8所示為字符和字符串的示例:

 

\

圖8 字符和字符串示例

請讀者注意圖8中的Raw字符串定義的格式,它的標准格式為R"附加界定符(字符串)附加界定符"。附加界定符可以沒有。而筆者設置圖8中的附加界定符為"**123"。

Raw字符串是C++11引入的,它是為了解決正則表達式裡那些煩人的轉義字符\而提供的解決方法。來看看C++之父給出的一個例子,有這樣一個正則表達式('(?:[?\\']|\\.)?'|"(?:[?\\"]|\\.)?")|)

 

在C++中,如果使用轉義字符串來表達,則變成('(?:[?\\\\']|\\\\.)?'|\"(?:[?\\\\\"]|\\\\.)?\")|。使用轉義字符後,整個字符串變得很難看懂了。2如果使用Raw字符串,改成R"dfp(('(?:[?\\']|\\.)?'|"(?:[?\\"]|\\.)?")|)dfp"即可。此處使用的界定字符為"dfp"。

 

很顯然,使用Raw字符串使得代碼看起來更清爽,出錯的可能性也降低很多。

1.1.4 數組

直接來看關於數組的一個示例,如圖9所示:

 

\

圖9 數組示例

由圖9可知:

 

定義數組的語法格式為T name[數組大小]。數組大小可以在編譯時由初值列表的個數決定,也可以是一個常量。總之,這種類型的數組,其數組大小必須在編譯時決定。動態數組由new的方式在運行時創建。動態數組在定義的時候就可以通過{}來賦初值。程序中,代表動態數組的是一個對應類型的指針變量。所以,動態數組和指針變量有著天然的關系。

 

和Java比較

Java中,數組的定義方式是T[]name。筆者覺得這種書寫方式比C++的書寫方式要形象一些。

另外,Java中的數組都是動態數組。

了解完數據類型後,我們來看看C++中源碼構成及編譯相關的知識。

1.2 C++源碼構成及編譯

源碼構成是指如何組織、管理和編譯源碼文件。作為對比,我們先來看Java是怎麼處理的:

 

Java中,代碼只能書寫在以.java為後綴的源文件中。Java中,每一個Java源文件必須包含一個和文件同名的class。比如A.java必須定義公開的class A(或者是interface A)。絕大部分情況下,class A隸屬於一個package。所以class A的全路徑名為xx.yy.zz.A。其中,xx.yy.zz是包名。同一個package下的class B如果要使用class A的話,可以直接使用類A。如果class B位於別的package下的話,那麼必須使用A的全路徑名xx.yy.zz.A。當然,為了減少書寫A所屬包名的工作量,class B會通過import xx.yy.zz.A引入全路徑名。然後,B也能直接使用類A了。

 

綜其所述,源碼構成主要討論兩個問題:

 

代碼寫在什麼地方?Java中是放入.java為後綴的文件中。如何解決不同源碼文件中的代碼之間相互引用的問題?Java中,同package下,源文件A的代碼可以直接使用源文件B的內容。不同package下,則必須通過全路徑名訪問另外一個Package下的源文件A的內容(通過import可以減少書寫包名的工作量)。

 

現在來看C++的做法:

 

在C++中,承載代碼的文件有頭文件和源文件的區別。頭文件的後綴名一般為.h。也可以.hpp.hxx結尾。源文件以.cpp.cxx.cc結尾。只要開發者之間約定好,采用什麼形式的後綴都可以。筆者個人喜歡使用.h.cpp做後綴名,而art源碼則以.h.cc為後綴名。一般而言,頭文件裡聲明需要公開的變量,函數或者類。源文件則定義(或者說實現)這些變量,函數或者類。那些需要使用這些公開內容的代碼可以通過#include方式將其包含進來。注意,由於C++中頭文件和源文件都可以承載代碼,所以頭文件和源文件都可以使用#include指令。比如,源文件a.cpp可以#include"b.h",從而使用b.h裡聲明的函數,變量或者類。頭文件c.h也可以#include "b.h"

 

下面我們分別通過頭文件和源文件的幾個示例來強化對它們的認識。

1.2.1 頭文件示例

圖10所示為一個非常簡單頭文件示例:

 

\

圖10 Type.h示例

下面來分析圖10中的Type.h:

 

首先,C++中,頭文件的寫法有一定規則需要遵循。比如圖10中的#ifndef _TYPE_H_:ifndef是if not define之意。_TYPE_H_是宏的名稱。#define _TYPE_H_:表示定義一個名為_TYPE_H_的宏、#endif:和前面的#ifndef對應。

 

這三個宏合起來的意思是,如果沒有定義_TYPE_H_,則定義它。宏的名字可以任意取,但一般是和頭文件的文件名相關,並且該宏不要和其他宏重名。為什麼要定義一個這樣的宏呢?其目的是為了防止頭文件的重復包含。

探討:如何防止頭文件重復包含

編譯器處理#include命令的方式就是將被包含的頭文件的內容全部讀取進來。一般而言,這種包含關系非常復雜。比如,a.h可以直接包含b.h和c.h,而b.h也可以直接包含c.h。如此,a.h相當於直接包含c.h一次,並間接包含c.h(通過b.包含c.h的方式)一次。假設c.h采用和圖10一樣的做法,則編譯器在第一次包含c.h(因為a.h直接#include"c.h")的時候將定義_C_H_宏,當編譯器第二次嘗試包含c.h的時候(因為在處理#include "b.h"的時候,會將b.h所include的文件依次包含進來)會發現這個宏已經定義了。由於頭文件中所有有價值的內容都是寫在#ifndef#endif之間的,也就是只有在沒有定義_C_H_宏的時候,這個頭文件的內容才會真正被包含進去。通過這種方式,c.h雖然被include兩次,但是只有第一次包含會加載其內容,後續include等於沒有真正加載其內容。

當然,現在的編譯器比較高級,或許可以處理這種重復包含頭文件的問題,但是建議讀者自己寫頭文件的時候還是要定義這樣的宏。

除了宏定義之外,圖10中還定義了一個命名空間,名字為my_type。並且在命名空間裡還聲明了一個test函數:

 

C++中的命名空間和Java中的package類似,但是要求上要簡單很多。命名空間是一個范圍(Scope),可以出現在任意頭文件,源文件裡。凡是放在某個命名空間裡的函數,類,變量等就屬於這個命名空間。Type.h只是聲明(declare)了test函數,但沒有這個函數的實現。聲明僅是告訴編譯器,我們有一個名叫test的函數。但是這個函數在什麼地方呢?這時就需要有一個源文件來定義test函數,也就是實現test函數。

 

下面我們來看一個源文件示例:

1.2.2 源文件示例

源文件示例一如圖11所示:

 

\

圖11Test.cpp示例

圖11是一個名為Test.cpp的示例,在這個示例中:

 

包含Type.h和TypeClass.h。調用兩個函數,其中一個函數是Type.h裡聲明的test。由於test位於my_type命名空間裡,所以需要通過my_type::test方式來調用它。

 

接著來看圖12:

 

\

圖12Type.cpp

圖12所示為Type.cpp:

 

從文件名上看,Type.cpp和Type.h可能會有些關系。確實如此。正如前文所說,頭文件一般做聲明用,而真正的實現往往放在源文件中。出於文件管理方便性的考慮,頭文件和對應的源文件有著相同的文件名。Type.cpp還包含了iostreamiomanip兩個頭文件。需要特別注意的是,這兩個include使用的是尖括號<>,而不是""。根據約定俗成的習慣,尖括號中的頭文件往往是操作系統和C++標准庫提供的頭文件。包含這些頭文件時不用攜帶.h的後綴。比如,#include 這條語句無需寫成#include 。這是因為C++標准庫的實現是由不同廠商來完成的。具體實現的時候可能頭文件沒有後綴名,或者後綴名不是.h。所以,C++規范將這個問題交給編譯器來處理,它會根據情況找到正確的文件。C++標准庫裡的內容都定義在一個獨立的命名空間裡,這個命名空間叫std。如果需要使用某個命名空間裡的東西,比如圖12中的代表標准輸出對象的cout,可以通過std::cout來訪問它,或者像圖12一樣,通過using std::cout的方式來避免每次都書寫"std::"。當然,也可以一次性將某個命名空間裡的所有內容全部包含進來,方法就是usingnamespace std。這種做法和java的import非常類似。my_type命名空間裡包含testchangeRef兩個函數。其中,test函數實現了Type.h中聲明的那個test函數。而由於changeRef完全是在Type.cpp中定義的,所以只有Type.cpp內部才知道這個函數,而外界(其他源文件,頭文件)不知道這個世界上還有一個changeRef函數。在此請讀者注意,一般而言,include指令用於包含頭文件,極少用於包含源文件。Type.cpp還定義了一個changeNoRef函數,此函數是在my_type命名空間之外定義的,所以它不屬於my_type命名空間。

 

到此,我們通過幾個示例向讀者展示了C++中頭文件和源文件的構成和一些常用的代碼寫法。現在看看如何編譯它們。

1.2.3 編譯

C/C++程序一般是通過編寫Makefile來編譯的。Makefile其實就是一個命令的組合,它會根據情況執行不同的命令,包括編譯,鏈接等。Makefile不是C++學習的必備知識點,筆者不擬討論太多,讀者通過圖13做簡單了解即可:

 

\

圖13Makefile示例

圖13中,真正的編譯工作還是由編譯器來完成的。圖13中展示了編譯器的工作步驟以及對應的參數。此處筆者僅強調三點:

 

Makefile是一個文件的文件名,該文件由make命令解析並處理。所以,我們可認為Makefile是專門供make命令使用的腳本文件。其內容的書寫規則遵守make命令的要求。C++中,編譯單元是源文件(即.cpp文件)。如圖中①所示的內容,編譯命令的輸入都是xxx.cpp源文件,極少有單獨編譯.h頭文件的。筆者習慣先編譯單個源文件以得到對應的obj文件,然後再鏈接這些obj文件得到最終的目標文件。鏈接的步驟也是由編譯器來完成,只不過其輸入文件從源文件變成了obj文件。

 

make命令如何執行呢?很簡單:

 

進入到包含Makfile文件的目錄下,執行make。如果沒有指明Makefile文件名的話,它會以當前目錄下的Makefile文件為輸入。make將解析Makefile文件裡定義的任務以及它們的依賴關系,然後對任務進行處理。如果沒有指明任務名的話,則執行Makefile中定義的第一個任務。可以通過make任務名來執行Makefile中的指定任務。比如,圖13中最後兩行定義了clean任務。通過make clean可執行它。clean任務的目標就是刪除臨時文件(比如obj文件)和上一次編譯得到的目標文件。

 

提示

Makefile和make是一個獨立的知識點,關於它們的故事可以寫出一整本書了。不過,就實際工作而言,開發者往往會把Makefile寫好,或者可借助一些工具以自動生成Makefile。所以,如果讀者不了解Makefile的話也不用擔心,只要會執行make命令就可以了。

1.3 Class介紹

本節介紹C++中面向對象的核心知識點——類(Class)。筆者對類有三點認識:

 

Class是C++構造面向對象世界的核心單元。面向對象在編碼中的直觀體現就是程序員可以用Class封裝成員變量和成員函數。以前用C寫程序的時候,是面向過程的思維方法,考慮的是函數和函數之間的調用和跳轉關系。C++出現後,我們看待問題和解決問題的思路發生了很大的變化,更多考慮是設計合適的類並處理對象和對象之間的關系。當然,面向對象並不是說程序就沒有過程了。程序總還是有順序,有流程的。但是在這個流程裡,開發者更多關注的是對象以及對象之間的交互,而不是孤零零的函數。另外,Class還支持抽象,繼承和多態。這些概念完全就是圍繞面向對象來設計和考慮的,它關注的是類和類之間的關系。最後,從類型的角度來看,和C++基礎內置數據類型一樣,類也是一種數據類型,只不過它是一種可由開發者自定義的數據類型罷了。

 

探討:

筆者以前幾乎沒有從類型的角度來看待過類。直到接觸模板編程後,才發現類型和類型推導在模板中的重要作用。關於這個問題,我們留待後續介紹模板編程時再繼續討論。

下面我們來看看C++中的Class該怎麼實現。先來看圖14所示的TypeClass.h,它聲明了一個名為Base的類。請讀者重點關注它的語法:

 

\

圖14 Base類的聲明

來看圖14的內容:

 

首先,筆者用class關鍵字聲明了一個名為Base的類。Base類位於type_class命名空間裡。C++類有和Java一樣的訪問權限控制,關鍵詞也是publicprivateprotected三種。不過其使用方法和Java略有區別。Java中,每個成員(包含函數和變量)都需要單獨聲明訪問權限,而C++則是分組控制的。例如,位於"public:"之後的成員都有相同的public訪問權限。如果沒有指明訪問權限,則默認使用private訪問權限。在類成員的構成上,C++除了有構造函數賦值函數析構函數等三大類特殊成員函數外,還可以定義其他成員函數和成員變量。成員變量如圖14中的size變量可以像Java那樣在聲明時就賦初值,但筆者感覺C++的習慣做法還是只聲明成員變量,然後到構造函數中去賦初值。C++中,函數聲明時可以指明參數的默認值,比如deleteC函數,它有三個參數,後面兩個參數均有默認值(參數b的默認值是100,參數test的默認值是true)。

 

接下來,我們先介紹C++的三大類特殊函數。

注意,

這三類特殊函數並不是都需要定義。筆者此處列舉它們僅為學習用。

1.3.1 構造,賦值和析構函數

C++類的三種特殊成員函數分別是構造、賦值和析構,其中:

 

構造函數:當創建類的實例對象時,這個對象的構造函數將被調用。一般在構造函數中做該對象的初始化工作。Java中的類也有構造函數,和C++中的構造函數類似。賦值函數:賦值函數其實就是指"="號操作符,用於將變量A賦值給同類型(不考慮類型轉換等情況)的變量B。比如,可以將整型變量(假設變量名為aInt)的值賦給另一個整型變量bInt。在此基礎上,我們也可以將類A的某個實例(假設變量名為aA)賦值給類A的另外一個實例bA。請讀者注意,1.3節一開始就強調過,類只不過是一種自定義的數據類型罷了。如果整型變量(或者其他基礎內置數據類型)可以賦值的話,類也應該支持賦值操作。析構函數:當對象的生命走向終結時,它的析構函數將被調用。一般而言,該函數內部會釋放這個對象占據的各種資源。Java中,和析構函數類似的是finalize方法。不過,由於Java實現了內存自動回收機制,所以Java程序員幾乎不需要考慮finalize的事情。

 

下面,我們分別來討論這三種特殊函數。

1. 構造函數

來看類Base的構造函數,如圖15所示:

 

\

圖15 構造函數示例

圖15中的代碼實現於TypeClass.cpp中:

 

在類聲明之外實現類的成員函數時,需要通過"類名::函數名"的方式告訴編譯器這是一個類的成員函數,比如圖15中的Base::Base(int a)。默認構造函數:默認構造函數是指不帶參數或所有參數全部有默認值的構造函數。注意,C++的函數是支持參數帶默認值的,比如圖14中Base類的deleteC函數,普通構造函數:帶參數的構造函數。拷貝構造函數:用法如圖15中的③所示。詳情可見下文介紹。

 

下面來介紹圖15中幾個值得注意的知識點:

(1) 構造函數初始值列表

構造函數主要的功能是完成類實例的初始化,也就是對象的成員變量的初始化。C++中,成員變量的初始化推薦使用初始值列表(constructor initialize list)的方法(使用方法如圖15所示),其語法格式為:

構造函數(...):

成員變量A(A的初值),成員變量B(B的初值){

...//也可以使用花括號,比如成員變量A{A的初值},成員變量B{B的初值}

}

當然,成員變量的初值設置也可以通過賦值方式來完成:

構造函數(...){

成員變量A=A的初值;

成員變量B=B的初值;

....

}

C++中,構造函數中使用初值列表和成員變量賦初值是有區別的,此處不擬詳細討論二者的差異。但推薦使用初值列表的方式,原因大致有二:

 

使用初值列表可能運行效率上會有提升。有些場合必須使用初值列表,比如子類構造函數中初始化基類的成員變量時。後文中將看到這樣的例子。

 

提示:

構造函數中請使用初值列表的方式來完成變量初始化。

(2) 拷貝構造函數

拷貝構造,即從一個已有的對象拷貝其內容,然後構造出一個新的對象。拷貝構造函數的寫法必須是:

構造函數(const 類& other)

注意,const是C++中的常量修飾符,與Java的final類似。

拷貝過程中有一個問題需要程序員特別注意,即成員變量的拷貝方式是值拷貝還是內容拷貝。以Base類的拷貝構造為例,假設新創建的對象名為B,它用已有的對象A進行拷貝構造:

 

memberA和memberB是值拷貝。所以,A對象的memberA和memberB將賦給B的memberA和memberB。此後,A、B對象的memberA和memberB值分別相同。而對pMemberC來說,情況就不一樣了。B.pMemberC和A.pMemberC將指向同一塊內存。如果A對這塊內存進行了操作,B知道嗎?更有甚者,如果A刪除了這塊內存,而B還繼續操作它的話,豈不是會崩潰?所以,對於這種情況,拷貝構造函數中使用了所謂的深拷貝(deepcopy),也就是將A.pMemberC的內容拷貝到B對象中(B先創建一個大小相同的數組,然後通過memcpy進行內存的內容拷貝),而不是簡單的進行賦值(這種方式叫淺拷貝,shallow copy)。

 

值拷貝、內容拷貝和淺拷貝、深拷貝

由上述內容可知,淺拷貝對應於值拷貝,而深拷貝對應於內容拷貝。對於非指針變量類型而言,值拷貝和內容拷貝沒有區別,但對於指針型變量而言,值拷貝和內容拷貝差別就很大了。

圖16解釋了深拷貝和淺拷貝的區別:

 

\

圖16 淺拷貝和深拷貝的區別

圖16中,淺拷貝用紅色箭頭表示,深拷貝用紫色箭頭表示:

 

淺拷貝最明顯的問題就是A和B的pMemberC將指向同一塊內存。絕大多數情況下,淺拷貝的結果絕不是程序員想要的。采用深拷貝的話,A和B將具有相同的內容,但彼此之間不再有任何糾葛。對於非指針型變量而言,深拷貝和淺拷貝沒有什麼區別,其實就是值的拷貝

 

最後,筆者還要特別說明拷貝構造函數被觸發的場合。來看代碼:

Base A; //構造A對象

Base B(A);// ①直接用A對象來構造B對象,這種情況是“直接初始化”

Base C = A;// ②定義C的時候即賦值,這是真正意義上的拷貝構造。二者的區別見下文介紹。

除了上述兩種情況外,還有一些場合也會導致拷貝構造函數被調用,比如:

 

當函數的參數為非引用的類類型時,調用這個函數並傳遞實參時,實參的拷貝構造函數被調用。函數的返回類型為一個非引用的對象時,該對象的拷貝構造函數被調用。

 

直接初始化和拷貝初始化的細微區別

Base B(A)只是導致拷貝構造函數被調用,但並不是嚴格意義上的拷貝構造,因為:

 

Base確實定義了一個形參為constB&的構造函數。而B(A)的語法恰好滿足這個函數,所以這個構造函數被調用是理所當然的。這樣的構造是很直接的,沒有任何疑義的,所以叫直接初始化。而對於Base C = A的理解卻是將A的內容拷貝到正在創建的C對象中,這裡包含了拷貝和構造兩個概念,即拷貝A的內容來構造C。所以叫拷貝構造。慚愧得說,筆者也很難描述上述內容在語法上的精確含義。不過,從使用角度來看,讀者只需記住這兩種情況均會導致拷貝構造函數被調用即可。

 

2. 拷貝賦值函數

拷貝賦值函數是賦值函數的一種,我們先來思考下賦值函數解決什麼問題。請讀者思考下面這段代碼:

int a = 0;

int b = a;//將a賦值給b

所有讀者應該對上述代碼都不會有任何疑問。是的,對於基本內置數據類型而言,賦值操作似乎是天經地義的合理,但對於類類型呢?比如下面的代碼:

Base A;//構造一個對象A

Base B; //構造一個對象B

B = A; //①A可以賦值給B嗎?

從類型的角度來看,沒有理由不允許類這種自定義數據類型的進行賦值操作。但是從面向對象角度來看,把一個對象賦值給另外一個對象會得到什麼?現實生活中似乎也難以到類似的場景來比擬它。

不管怎樣,C++是支持一個對象賦值給另一個對象的。現在把注意力回歸到拷貝賦值上來,來看圖17所示的代碼:

 

\

圖17 拷貝賦值函數示例

賦值函數本身沒有什麼難度,無非就是在准備接受另外一個對象的內容前,先把自己清理干淨。另外,賦值函數的關鍵知識點是利用了C++中的操作符重載(Java不支持操作符重載)。關於操作符重載的知識請讀者閱讀本文後續章節。

3. 移動構造和移動賦值函數

前面兩節介紹了拷貝構造和拷貝賦值函數,還了解了深拷貝和淺拷貝的區別。但關於構造和賦值的故事並沒有完。因為C++11中,除了拷貝構造和拷貝賦值之外,還有移動構造和移動賦值。

注意

這幾個名詞中:構造和賦值並沒有變,變化的是構造和賦值的方法。前2節介紹的是拷貝之法,本節來看移動之法。

(1) 移動之法的解釋

圖18展示了移動的含義:

 

\

圖18 Move的示意

對比圖16和圖18,讀者會發現移動的含義其實非常簡單,就是把A對象的內容移動到B對象中去:

 

對於memberA和memberB而言,由於它們是非指針類型的變量,移動和拷貝沒有不同。但對於pMemberC而言,差別就很大了。如果使用拷貝之法,A和B對象將各自有一塊內存。如果使用移動之法,A對象將不再擁有這塊內存,反而是B對象擁有A對象之前擁有的那塊內存。

 

移動的含義好像不是很難。不過,讓我們更進一步思考一個問題:移動之後,A、B對象的命運會發生怎樣的改變?

 

很簡單,B自然是得到A的全部內容。A則掏空自己,成為無用之物。注意,A對象還存在,但是你最好不要碰它,因為它的內容早已經移交給了B。

 

移動之後,A居然無用了。什麼場合會需要如此“殘忍”的做法?還是讓我們用示例來闡述C++11推出移動之法的目的吧:

 

\

圖19 有Move和沒有Move的區別

圖19中,左上角是示例代碼:

 

test函數:將getTemporyBase函數的返回值賦給一個名為a的Base實例。getTemporyBase函數:構造一個Base對象tmp並返回它。

 

圖19展示了沒有定義移動構造函數和定義了移動構造函數時該程序運行後打印的日志。同時圖中還解釋了執行的過程。結合前文所述內容,我們發現tmp確實是一種轉移出去(不管是采用移動還是拷貝)後就不需要再使用的對象了。對於這種情況,移動構造所帶來的好處是顯而易見的。

注意:

對於圖中的測試函數,現在的編譯器已經能做到高度優化,以至於圖中列出的移動或拷貝調用都不需要了。為了達到圖中的效果,編譯時必須加上-fno-elide-constructors標志以禁止這種優化。讀者不妨一試。

下面,我們來看看代碼中是如何體現移動的。

(2) 移動之法的代碼實現和左右值介紹

圖20所示為Base的移動構造和移動賦值函數:

 

\

圖20 移動構造和移動賦值示例

圖20中,請讀者特別注意Base類移動構造和移動賦值函數的參數的類型,它是Base&&。沒錯,是兩個&&符號:

 

如果是Base&&(兩個&&符號),則表示是Base的右值引用類型。如果是Base&(一個&符號),則表示是Base的引用類型。和右值引用相比,這種引用也叫左值引用。

 

什麼是左值,什麼是右值?筆者不擬討論它們詳細的語法和語義。不過,根據參考文獻[5]所述,讀者掌握如下識即可:

 

左值是有名字的,並且可以取地址。右值是無名的,不能取地址。比如圖19中getTemporyBase返回的那個臨時對象就是無名的,它就是右值。

 

我們通過幾行代碼來加深對左右值的認識:

int a,b,c; //a,b,c都是左值

c = a+b; //c是左值,但是(a+b)卻是右值,因為&(a+b)取地址不合法

getTemporyBase();//返回的是一個無名的臨時對象,所以是右值

Base && x = getTemoryBase();//通過定義一個右值引用類型x,getTemporyBase函數返回

//的這個臨時無名對象從此有了x這個名字。不過,x還是右值嗎?答案為

Base y = x;//此處不會調用移動構造函數,而是拷貝構造函數。因為x是有名的,所以它不再是右值。

如果讀者想了解更多關於左右值的區別,請閱讀本章所列的參考書籍。此處筆者再強調一下移動構造和賦值函數在什麼場合下使用的問題,請讀者注意把握兩個關鍵點:

 

第一,如果確定被轉移的對象(比如圖19中的tmp對象)不再使用,就可以使用移動構造/賦值函數來提升運行效率。第二,我們要保證移動構造/賦值函數被調用,而不是拷貝構造/賦值函數被調用。例如,上述代碼中Base y = x這段代碼實際上觸發了拷貝構造函數,這不是我們想要的。為此,我們需要強制使用移動構造函數,方法為Base y = std::move(x)move是std標准庫提供的函數,用於將參數類型強制轉換為對應的右值類型。通過move函數,我們表達了強制使用移動函數的想法。

 

如果沒有定義移動函數怎麼辦?

如果類沒有定義移動構造或移動賦值函數,編譯器會調用對應的拷貝構造或拷貝賦值函數。所以,使用std::move不會帶來什麼副作用,它只是表達了要使用移動之法的願望。

4. 析構函數

最後,來看類中最後一類特殊函數,即析構函數。當類的實例達到生命終點時,析構函數將被調用,其主要目的是為了清理該實例占據的資源。圖21所示為Base類的析構函數示例:

 

\

圖21 析構函數示例

Java中與析構函數類似的是finalize函數。但絕大多數情況下,Java程序員不用關心它。而C++中,我們需要知道析構函數什麼時候會被調用:

2棧上創建的類實例,在退出作用域(比如函數返回,或者離開花括號包圍起來的某個作用域)之前,該實例會被析構。

2動態創建的實例(通過new操作符),當delete該對象時,其析構函數會被調用。

 

1. 總結

1.3.1節介紹了C++中一個普通類的大致組成元素和其中一些特殊的成員函數,比如:

 

構造函數,分為默認構造,普通構造,拷貝構造和移動構造。賦值函數,分為拷貝賦值和移動賦值。請讀者先從原理上理解拷貝和移動的區別和它們的目的。析構函數。

 

1.3.2 類的派生和繼承

C++中與類的派生、繼承相關的知識比較復雜,相對瑣碎。本節中,筆者擬將精力放在一些相對基礎的內容上。先來看一個派生和繼承的例子,如圖22所示:

 

\

圖22 派生和繼承示例

圖22中:

 

右邊居中方框①定義了一個Base類,它和圖14中的內容一樣。右下方框②定義了一個VirtualBase類,它包含構造函數,虛析構函數,虛函數test1,純虛函數test2和一個普通函數test3。左邊方框③定義了一個Derived類,它同時從Base和VirtualBase類派生,屬於多重繼承。圖中給出了10個需要讀者注意的函數和它們的簡單介紹。

 

和Java比較

Java中雖然沒有類的多重繼承,但一個類可以實現多個接口(Interface),這其實也算是多重繼承了。相比Java的這種設計,筆者覺得C++中類的多重繼承太過靈活,使用時需要特別小心,否則菱形繼承的問題很難避免。

現在,先來看一下C++中派生類的寫法。如圖22所示,Derived類繼承關系的語法如下:

class Derived:private Base,publicVirtualBase{

}

其中:

 

classDerived之後的冒號是派生列表,也就是基類列表,基類之間用逗號隔開。派生有publicprivateprotected三種方式。其意義和Java中的類派生方式差不多,大抵都是用於控制派生類有何種權限來訪問繼承得到的基類成員變量和成員函數。注意,如果沒有指定派生方式的話,默認為private方式。

 

了解C++中如何編寫派生類後,下一步要關注面向對象中兩個重要特性——多態和抽象是如何在C++中體現的。

注意:

筆者此處所說的抽象是狹義的,和語言相關的,比如Java中的抽象類。

1. 虛函數、純虛函數和虛析構函數

Java語言裡,多態是借助派生類重寫(override)基類的函數來表達,而抽象則是借助抽象類(包括抽象方法)或者接口來實現。而在C++中,虛函數純虛函數就是用於描述多態和抽象的利器:

 

虛函數:基類定義虛函數,派生類可以重寫(override)它。當我們擁有一個派生類對象,但卻是通過基類引用類型或者基類指針類型的變量來調用該對象的虛函數時,被調用的虛函數是派生類重寫過的虛函數(如果該虛函數被派生類重寫了的話)。純虛函數:擁有純虛函數的類不能實例化。從這一點看,它和Java的抽象類和接口非常類似。

 

C++中,虛函數和純虛函數需要明確標示出來,以VirtualBase為例,相關語法如下:

virtual voidtest1(bool test); //虛函數由virtual標示

virtual voidtest2(int x, int y) = 0;//純虛函數由"virtual"和"=0"同時標示

派生類如何override這些虛函數呢?來看Derived類的寫法:

/*

基類裡定義的虛函數在派生類中也是虛函數,所以,下面語句中的virtual關鍵詞不是必須要寫的,

override關鍵詞是C++11新引入的標識,和Java中的@Override類似。

override也不是必須要寫的關鍵詞。但加上它後,編譯器將做一些有用的檢查,所以建議開發者

在派生類中重寫基類虛函數時都加上這個關鍵詞

*/

virtual void test1(bool test) override;//可以加virtual關鍵詞,也可以不加

void test2(int x, int y) override;//如上,建議加上override標識

注意,virtual和override標示只在類中聲明函數時需要。如果在類外實現該函數,則並不需要這些關鍵詞,比如:

TypeClass.h

class Derived ....{

.......

voidtest2(int x, int y) override;//可以不加virtual關鍵字

}

TypeClass.cpp

void Derived::test2(int x, int y){//類外定義這個函數,不能加virtual等關鍵詞

cout<<"in Derived::test2"<

}

提示:

注意,art代碼中,派生類override基類虛函數時,大都會添加virtual關鍵詞,有時候也會加上override關鍵詞。根據參考文獻[1]的建議,派生類重寫虛函數時候最好添加override標識,這樣編譯器能做一些額外檢查而能提前發現一些錯誤。

除了上述兩類虛函數外,C++中還有虛析構函數。虛析構函數其實就是虛函數,不過它稍微有一點特殊,需要開發者注意:

 

虛函數被override的時候,基類和派生類聲明的虛函數在函數名,參數等信息上需保持一致。但對析構函數而言,由於析構函數的函數名必須是"~類名",所以派生類和基類的析構函數名肯定是不同的。但是,我們又希望多態對於析構函數(注意,析構函數也是函數,和普通函數沒什麼區別)也是可行的。比如,當通過基類指針來刪除派生類對象時,是派生類對象的析構函數被調用。所以,當基類中如果有虛函數時候,一定要記得將其析構函數變成虛析構函數。

 

阻止虛函數被override

C++中,也可以阻止某個虛函數被override,方法和Java類似,就是在函數聲明後添加final關鍵詞,比如

virtual void test1(boolean test) final;//如此,test1將不能被派生類override了

最後,我們通過一段示例代碼來加深對虛函數的認識,如圖23所示:

 

\

圖23 虛函數測試示例

圖23是筆者編寫的一個很簡單的例子,左邊是代碼,右邊是運行結果。簡而言之:

 

如果想實現多態,就在基類中為需要多態的函數增加virtual關鍵詞。如果基類中有虛函數,也請同時為基類的析構函數添加virtual關鍵詞。只有這樣,指向派生類對象的基類指針變量被delete時,派生類的析構函數才能被調用。

 

提示:

1 請讀者嘗試修改測試代碼,然後觀察打印結果。

2 讀者可將圖23中代碼的最後一行改寫成pvb->~VirtualBase(),即直接調用基類的析構函數,但由於它是虛析構函數,所以運行時,~Derived()將先被調用。

 

2. 構造和析構函數的調用次序

類的構造函數在類實例被創建時調用,而析構函數在該實例被銷毀時調用。如果該類有派生關系的話,其基類的構造函數和析構函數也將被依次調用到,那麼,這個依次的順序是什麼?

 

對構造函數而言,基類的構造函數先於派生類構造函數被調用。如果派生類有多個基類,則基類按照它們在派生列表裡的順序調用各自的構造函數。比如Derived派生列表中基類的順序是:先Base,然後是VirtualBase。所以Base的構造函數先於VirtualBase調用,最後才是Derived的構造函數。析構函數則是相反的過程,即派生類析構函數先被調用,然後再調用基類的析構函數。如果是多重繼承的話,基類按照它們在派生列表裡出現的相反次序調用各自的析構函數。比如Derived類實例析構時,Derived析構函數先調用,然後VirtualBase析構,最後才是Base的析構。

 

補充內容:

如果派生類含有類類型的成員變量時,調用次序將變成:

構造函數:基類構造->派生類中類類型成員變量構造->派生類構造

析構函數:派生類析構->派生類中類類型成員變量析構->基類析構

多重派生的話,基類按照派生列表的順序/反序構造或析構

3. 編譯器合成的函數

Java中,如果程序員沒有為類編寫構造函數函數,則編譯器會為類隱式創建一個不帶任何參數的構造函數。這種編譯器隱式創建一些函數的行為在C++中也存在,只不過C++中的類有構造函數,賦值函數,析構函數,所以情況會復雜一些,圖24描述了編譯器合成特殊函數的規則:

 

\

圖24 編譯器合成特殊函數的規則

圖24的規矩可簡單總結為:

 

如果程序員定義了任何一種類型的構造函數(拷貝構造、移動構造,默認構造,普通構造),則編譯器將不再隱式創建默認構造函數。如果程序沒有定義拷貝(拷貝賦值或拷貝構造)函數或析構函數,則編譯器將隱式合成對應的函數。如果程序沒有定義移動(移動賦值或移動構造)函數,並且,程序沒有定義析構函數或拷貝函數(拷貝構造和拷貝賦值),則編譯器將合成對應的移動函數。

 

從上面的描述可知,C++中編譯器合成特殊函數的規則是比較復雜的。即使如此,圖24中展示的規則還僅是冰山一角。以移動函數的合成而言,即使圖中的條件滿足,編譯器也未必能合成移動函數,比如類中有無法移動的成員變量時。

關於編譯器合成規則,筆者個人感覺開發者應該以實際需求為出發點,如果確實需要移動函數,則在類聲明中定義就行。

(1) =default和=delete

有些時候我們需要一種方法來控制編譯器這種自動合成的行為,控制的目的無外乎兩個:

 

讓編譯器必須合成某些函數。禁止編譯器合成某些函數。

 

借助=default=delete標識,這兩個目的很容易達到,來看一段代碼:

//定義了一個普通的構造函數,但同時也想讓編譯器合成默認的構造函數,則可以使用=default標識

Base(int x); //定義一個普通構造函數後,編譯器將停止自動合成默認的構造函數

//=default後,強制編譯器合成默認的構造函數。注意,開發者不用實現該函數

Base() = default;//通知編譯器來合成這個默認的構造函數

//如果不想讓編譯器合成某些函數,則使用= delete標識

Base&operator=(const Base& other) = delete;//阻止編譯合成拷貝賦值函數

注意,這種控制行為只針對於構造、賦值和析構等三類特殊的函數。

(2) “繼承”基類的構造函數

一般而言,派生類可能希望有著和基類類似的構造方法。比如,圖25所示的Base類有3種普通構造方法。現在我們希望Derived也能支持通過這三種方式來創建Derived類實例。怎麼辦?圖25展示了兩種方法:

 

\

圖25 派生類“繼承”基類構造函數

 

第一種方法就是在Derived派生類中手動編寫三個構造函數,這三個構造函數和Base類裡的一樣。另外一種方法就是通過使用using關鍵詞“繼承”基類的那三個構造函數。繼承之後,編譯器會自動合成對應的構造函數。

 

注意,這種“繼承”其實是一種編譯器自動合成的規則,它僅支持合成普通的構造函數。而默認構造函數,移動構造函數,拷貝構造函數等遵循正常的規則來合成。

探討

前述內容中,我們向讀者展示了C++中編譯器合成一些特殊函數的做法和規則。實際上,編譯器合成的規則比本節所述內容要復雜得多,建議感興趣的讀者閱讀參考文獻來開展進一步的學習。

另外,實際使用過程中,開發者不能完全依賴於編譯器的自動合成,有些細節問題必須由開發者自己先回答。比如,拷貝構造時,我們需要深拷貝還是淺拷貝?需不需要支持移動操作?在獲得這些問題答案的基礎上,讀者再結合編譯器合成的規則,然後才選擇由編譯器來合成這些函數還是由開發者自己來編寫它們。

1.3.3 友元和類的前向聲明

前面我們提到過,C++中的類訪問其實例的成員變量或成員函數的權限控制上有著和Java類似的關鍵詞,如publicprivateprotected。嚴格遵守“信息該公開的要公開,不該公開的一定不公開”這一封裝的最高原則無疑是一件好事,但現實生活中的情況是如此變化萬端,有時候我們也需要破個例。比如,熟人之間是否可以公開一些信息以避開如果按“公事公辦”走流程所帶來的過高溝通成本的問題?

C++中,借助友元,我們可以做到小范圍的公開信息以減少溝通成本。從編程角度來看,友元的作用無非是:提供一種方式,使得類外某些函數或者某些類能夠訪問一個類的私有成員變量或成員函數。對被訪問的類而言,這些類外函數或類,就是被訪問的類的朋友

來看友元的示例,如圖26所示:

 

\

圖26 類的友元示意

圖26展示了如何為某個類指定它的“朋友們”,C++中,類的友元可以是:

 

一個類外的函數或者一個類中的某些成員函數。如果友元是函數,則必須指定該函數的完整信息,包括返回值,參數,屬於哪個類等。一個類。

 

基類的友元會變成從該基類派生得來的派生類的友元嗎?

C++中,友元關系不能繼承,也就是說:

1 基類的友元可以訪問基類非公開成員,也能訪問派生類中屬於基類的非公開成員。

2 但是不能訪問派生類自己定義的非公開成員。

友元比較簡單,此處就不擬多說。現在我們介紹下圖26中提到的類的前向聲明,先來回顧下代碼:

class Obj;//類的前向聲明

void accessObj(Obj& obj);

C++中,數據類型應該先聲明,然後再使用。但這會帶來一個“先有雞還是先有蛋”的問題:

 

accessObj函數的參數中用到了Obj。但是類Obj的聲明卻放在圖26的最後。如果把Obj的聲明放在accessObj函數的前面,這又無法把accessObj指定為Obj的友元。因為友元必須要指定完整的函數。

 

怎麼破解這個問題?這就用到了類的前向聲明,以圖26為例,Obj前向聲明的目的就是告訴類型系統,Obj是一個class,不要把它當做別的什麼東西。一般而言,類的前向聲明的用法如下:

 

假設頭文件b.h中需要引入a.h頭文件中定義的類A。但是我們不想在b.h裡包含a.h。因為a.h可能太復雜了。如果b.h裡包含a.h,那麼所有包含b.h的地方都間接包含了a.h。此時,通過引入A的前向聲明,b.h中可以使用類A。注意,類的前向聲明一種聲明,真正使用的時候還得包含類A所在的頭文件a.h。比如,b.cpp(b.h相對應的源文件)是真正使用該前向聲明類的地方,那麼只要在b.cpp裡包含a.h即可。

 

這就是類的前向聲明的用法,即在頭文件裡進行類的前向聲明,在源文件裡去包含該類的頭文件。

類的前向聲明的局限

前向聲明好處很多,但同時也有限制。以Obj為例,在看到Obj完整定義之前,不能聲明Obj類型的變量(包括類的成員變量),但是可以定義Obj引用類型或Obj指針類型的變量。比如,你無法在圖26中class Obj類代碼之前定義ObjaObj這樣的變量。只能定義Obj& refObjObj* pObj。之所以有這個限制,是因為定義Obj類型變量的時候,編譯器必須確定該變量的大小以分配內存,由於沒有見到Obj的完整定義,所以編譯器無法確定其大小,但引用或者指針則不存在此問題。讀者不妨一試。

1.3.4 explicit構造函數

explicit構造函數和類型的隱式轉換有關。什麼是類型的隱式轉換呢?來看下面的代碼:

int a, b = 0;

short c = 10;

//c是short型變量,但是在此處會先將c轉成int型變量,然後再和b進行加操作

a = b + c;

對類而言,也有這樣的隱式類型轉換,比如圖27所示的代碼:

 

\

圖27 隱式類類型轉換示例

圖27中測試代碼裡,編譯器進行了隱式類型轉換,即先用常量2構造出一個臨時的TypeCastObj對象,然後再拷貝構造為obj2對象。注意,支持這種隱式類型轉換的類的構造函數需要滿足一個條件:

 

類的構造函數必須只能有一個參數。如果構造函數有多個參數,則不能隱式轉換。

 

注意:

TypeCastObj obj3(3) ;//這樣的調用是直接初始化,不是隱式類型轉換

如果程序員不希望發生這種隱式類型轉換該怎麼辦?只需要在類聲明中構造函數前添加explicit關鍵詞即可,比如:

explicit TypeCastObj(intx) :mX(x){

cout<<"in ordinay constructor"<

}

1.3.5 C++中的struct

struct是C語言中的古老成員了,在C中它叫結構體。不過到了C++世界,struct不再是C語言中結構體了,它升級成了class。即C++中的struct就是一種class,它擁有類的全部特征。不過,struct和普通class也有一點區別,那就是struct的成員(包含函數和變量)默認都是public的訪問權限。

1.4 操作符重載

對Java程序員而言,操作符重載是一個陌生的話題,因為Java語言並不支持它[⑥]。相反,C++則靈活很多,它支持很多操作符的重載。為什麼兩種語言會有如此大相徑庭的做法呢?關於這個問題,前文也曾從面向對象和面向數據類型的角度探討過:

 

從面向對象的角度看,兩個對象進行加減乘除等操作會得到什麼?不太好回答,而且現實生活中好像也沒有可以類比的案例。但如果從數據類型的角度看,既然普通的數據類型可以支持加減乘除,類這種自定義類型為什麼又不可以呢?

 

上述“從面向對象的角度和從數據類型的角度看待是否應該支持操作符重載”的觀點只是筆者的一些看法。至於兩種語言的設計者為何做出這樣的選擇,想必其背後都有充足的理由。

言歸正傳,先來看看C++中哪些操作符支持重載,哪些不支持重載。答案如下:

/* 此處內容為筆者添加的解釋 */

可以被重載的操作符:

+ - * / % ^

&/*取地址操作符*/ | ~ ! , /*逗號運算符*/ =/*賦值運算符*/

< >< = >= ++--

<</*輸出操作符*/ >>/*輸入操作符*/== != && ||

+= -=/= %= ^=&=

|= *=<<= >>= []/*下標運算符*/ ()/*函數調用運算符*/

->/*類成員訪問運算符,pointer->member */

->*/*也是類成員訪問運算符,但是方法為pointer->*pointer-to-member*/

/*下面是內存創建和釋放運算符。其中new[]和delete[]用於數組的內存創建和釋放*/

newnew[] delete delete[]

不能被重載的操作符:

::(作用域運算符) ?:(條件運算符)

. /*類成員訪問運算符,object.member */

.* /*類成員訪問運算符,object.*pointer-to-member */

除了上面列出的操作符外,C++還可以重載類型轉換操作符,比如:

class Obj{//Obj類聲明

...

operator bool();//重載bool類型轉換操作符。注意,沒有返回值的類型

bool mRealValue;

}

Obj::operator bool(){ //bool類型轉換操作符函數的實現,沒有返回值的類型

returnmRealValue;

}

Obj obj;

bool value = (bool)obj;//將obj轉換成bool型變量

C++操作符重載機制非常靈活,絕大部分運算符都支持重載。這是好事,但同時也會因靈活過度造成理解和使用上的困難。

提示:

實際工作中只有小部分操作符會被重載。關於C++中所有操作符的知識和示例,請讀者參考http://en.cppreference.com/w/cpp/language/operators。

接著來看C++中操作符重載的實現方式。

1.4.1 操作符重載的實現方式

操作符重載說白了就是將操作符當成函數來對待。當執行某個操作符運算時,對應的操作符函數被調用。和普通函數比起來,操作符對應的函數名由“operator 操作符的符號”來標示。

既然是函數,那麼就有類的成員函數和非類的成員函數之分,C++中:

 

有一些操作符重載必須實現為類的成員函數,比如->*操作符。有一些操作符重載必須實現為非類的成員函數,比如<<>>操作符[⑦]。有一些操作符即可以實現為類的成員函數,也可以實現為非類的成員函數,比如加減乘除運算符。具體采用哪種方式,視習慣做法或者方便程度而定。

 

本節先來看一個可以采用兩種方式來重載的操作符的示例,如圖28所示:

 

\

圖28 Obj對+號的重載示例

圖28中,Obj類定義了兩個+號重載函數,分別實現一個Obj類型的變量和另外一個Obj類型變量或一個int型變量相加的操作。同時,我們還定義了一個針對Obj類型和布爾類型的+號重載函數。+號重載為類成員函數或非類成員函數均可,程序員應該根據實際需求來決定采用哪種重載方式。下面是一段測試代碼:

Obj obj1, obj2;

obj1 = obj1+obj2;//調用Obj類第一個operator+函數

int x = obj1+100;//調用Obj類第二個operator+函數

x = obj1.operator+(1000); //顯示調用Obj類第二個operator+成員函數

int z = obj1+true;//調用非類的operator+函數

強調:

實際編程中,加操作符一般會重載為類的成員函數。並且,輸入參數和返回值的類型最好都是對應的類類型。因為從“兩個整型操作數相加的結果也是整型”到“兩個Obj類型操作數相加的結果也是Obj類型”的推導是非常自然的。上述示例中,筆者有意展示了操作符重載的靈活性,故而重載了三個+操作符函數。

1.4.2 輸出和輸入操作符重載

本章很多示例代碼都用到了C++的標准輸出對象cout。和標准輸出對象相對應的是標准輸入對象cin和標准錯誤輸出對象cerr。其中,cout和cerr的類型是ostream,而cin的類型是istream。ostream和istream都是類名,它們和Java中的OutputStream和InputStream有些類似。

cout和cin如何使用呢?來看下面的代碼:

using std::cout;//cout,endl,cin都位於std命名空間中。endl代表換行符

using std::endl;

using std:cin;

int x = 0, y =1;//定義x和y兩個整型變量

cout <<”x = ” << x <<” y = ” << y << endl;

/*

上面這行代碼表示:

1 將“x = ”字符串寫到cout中

2 整型變量x的值寫到cout中

3 “ y = ”字符串寫到cout中

4 整型變量y的值寫到cout中

5 寫入換行符。最終,標准輸出設備(一般是屏幕)中將顯示:

x = 0 y = 1

*/

 

上面語句看起來比較神奇,<<操作符居然可以連起來用。這是怎麼做到的呢?來看圖29:

 

\

圖29 等價轉換

如圖29可知,只要做到operator <<函數的返回值就是第一個輸入參數本身,我們就可以進行代碼“濃縮”。那麼,operator<<函數該怎麼定義呢?非常簡單:

ostream&operator<<(ostream& os,某種數據類型 參數名){

....//輸出內容

return os;//第一個輸入參數又作為返回值返回了

}

istream&operator>>(istream& is, 某種數據類型 參數名){

....//輸入內容

return is;

}

通過上述函數定義,"cout<<....<<..."和"cin>>...>>.."這樣的代碼得以成功實現。

C++的>>和<<操作符已經實現了內置數據類型和某些類類型(比如STL標准類庫中的某些類)的輸出和輸入。如果想實現用戶自定義類的輸入和輸出則必須重載這兩個操作符。來看一個例子,如圖30所示:

 

\

圖30 <<和>>操作符重載示例

通過圖30的重載,我們可以通過標准輸入輸出來操作Obj類型的對象了。

比較:

<<輸出操作符重載有點類似於我們在Java中為某個類重載toString函數。toString的目的是將類實例的內容轉換成字符串以方便打印或者別的用途。

1.4.3 ->和*操作符重載

->*操作符重載一般用於所謂的智能指針類,它們必須實現為類的成員函數。在介紹相關示例代碼前,筆者要特別說明一點:這兩個操作符如果操作的是指針類型的對象,則並不是重載,比如下面的代碼:

//假設Object類重載->*操作符

Object *pObject =new Object();//new一個Object對象

//下面的->操作符並非重載。因為pObject是指針類型,所以->只是按照標准語義訪問它的成員

pObject->getSomethingPublic();

//同理,pObject是指針類型,故*pObject就是對該地址的解引用,不會調用重載的*操作符函數

(*pObject).getSomethingPublic();

按照上述代碼所說,對於指針類型的對象而言,->*並不能被重載,那這兩個操作符的重載有什麼作用?來看示例代碼,如圖31所示:

 

\

圖31 ->和*操作符重載示例

圖31中,筆者實現了一個用於保護某個new出來的Obj對象的SmartPointerOfObj類,通過重載SmartPointerOfObj的->*操作符,我們就好像直接在操作指針型變量一樣。在重載的->和*函數中,程序員可以做一些檢查和管理,以確保mpObj指向正確的地址,目的是避免操作無效內存。這就是一個很簡單的智能指針類的實現。

提示:

STL標准庫也提供了智能指針類。ART中大量使用了它們。本章後續將介紹STL中的智能指針類。使用智能指針還有一個好處。由於智能指針對象往往不需要用new來創建,所以智能指針對象本身的內存管理是比較簡單的,不需要考慮delete它的問題。另外,智能指針的目標是更智能得管理它所保護的對象。借助它,C++也能做到一定程度的自動內存回收管理了。比如圖31中測試代碼的spObj對象,它不是new出來的,所以當函數返回時它自動會被析構。而當它析構的時候,new出來的Obj對象又將被delete。所以這兩個對象(new出來的Obj對象和在棧上創建的spObj對象)所占據的資源都可以完美回收。

1.4.4 new和delete操作符重載

new和delete操作符的重載與其他操作符的重載略有不同。平常我們所說的new和delete實際上是指new表達式(expression)以及delete表達式,比如:

Object* pObject =new Object; //new表達式,對於數組而言就是new Object[n];

deletepObject;//delete表達式,對於數組而言就是delete[] pObject

上面這兩行代碼分別是new表達式和delete表達式,這兩個表達式是不能自定義的,但是:

2new表達式執行過程中將首先調用operator new函數。而C++允許程序員自定義operatornew函數。

2delete表達式執行過程的最後將調用operator delete函數,而程序員也可以自定義operatordelete函數。

所以,所謂new和delete的重載實際上是指operator new和operator delete函數的重載。下面我們來看一下operator new和operator delete函數如何重載。

提示:

為行文方便,下文所指的new操作符就是指operator new函數,delete操作符就是指operator delete函數。

1. new和delete操作符語法

我們先來看new操作符的語法,如圖32所示:

 

\

圖32 new的語法

new操作符一共有12種形式,用法相當靈活,其中:

 

程序員可以重載(1)到(4)這四個函數。這四個函數是全局的,即它們不屬於類的成員函數。有些new函數會拋異常,不過筆者接觸的程序中都沒有使用過C++中的異常,所以本書不擬討論它們。(5)到(8)為placement new系列函數。placement new其實就是給new操作符提供除內存大小之外(即count參數)的別的參數。比如“new(2,f)T”這樣的表達式將對應調用operatornew(sizeof(T), 2, f)函數,注意,這幾個函數也是系統全局定義的。另外,C++規定(5)和(6)這兩個函數不允許全局重載。(9)到(12)定義為類的成員函數。注意,雖然上邊的(5)和(6)不能進行全局重載,但是在類中卻可以重載它們。

 

請讀者務必注意,如果我們在類中重載了任意一種new操作符,那麼系統的new操作符函數將被隱藏。隱藏的含義是指編譯器如果在類X中找不到匹配的new函數時,它也不會去搜索系統定義的匹配的new函數,這將導致編譯錯誤。

注意:何謂“隱藏”?

http://en.cppreference.com/w/cpp/memory/new/operator_new提到了只要類重載任意一個new函數,都將導致系統定義的new函數全部被隱藏。關於“隱藏”的含義,經過筆者測試,應該是指編譯器如果在類中沒有搜索到合適的new函數後,將不會主動去搜索系統定義的new函數,如此將導致編譯錯誤。

如果不想使用類重載的new操作符的話,則必須通過::new的方式來強制使用全局new操作符。其中,::是作用域操作符,作用域可以是類(比如Obj::)、命名空間(比如stl::),或者全局(::前不帶名稱)。

綜上所述,new操作符重載很靈活,也很容易出錯。所以建議程序員盡量不要重載全局的new操作符,而是盡可能重載特定類的new操作符(圖32中的(9)到(12))。

接著來看delete操作符的語法,如圖33所示:

 

\

圖33delete操作符的語法

delete用法比new還要復雜。此處需要特別說明的是:

 

new表達式可以帶參數,比如new(2,f)T。但delete表達式不能傳遞參數。所以像圖33中帶參數的delete操作符函數,比如(7)到(10),(15)、(16)這幾個函數將如何調用呢?C++規范裡說,當使用對應形式的new操作符構造一個或一組類實例時,如果其中有一個實例的構造函數拋出異常,那麼對應形式的delete操作符函數將被調用。

 

上面的描述不太直觀,我們通過一個例子進一步來解釋它,如圖34所示:

 

\

圖34delete操作符的用法示例

圖34中:

 

類X的構造函數拋出一個異常。類X重載了一個new操作符和一個delete操作符。這兩個操作符函數最後一個參數都是bool型。main函數中,使用placementnew表達式觸發了類X的new操作符被調用。由於X構造函數拋出異常,所以系統會調用X重載的delete函數,也就是最後一個參數是bool的那個delete函數。

 

圖34中還特別指出代碼中不能直接使用delete p1這樣的表達式,這會導致編譯錯誤,提示沒有匹配的delete函數,這是因為:

 

類重載的delete函數有參數,這個函數只能在類實例構造時拋出異常時調用。而類X沒有定義如圖33中(11)或(13)所示的delete函數。並且,類只要重定義任何一個delete函數,這都將導致系統的delete函數被隱藏。

 

提示:

關於全局delete函數被隱藏的問題,讀者不妨動手一試。

2. new和delete操作符重載示例

現在我們來看new和delete操作符重載的一個簡單示例。如圖35所示:

強調:

考慮到new和delete的高度靈活性以及和它們和內存分配釋放緊密相關的重要性,程序員最好只針對特定類進行new和delete操作符的重載。

 

\

圖35new/delete操作符重載的示例

圖35中,筆者為Obj重載了兩個new操作符和兩個delete操作符:

 

當像測試代碼中那樣創建Obj實例時,這兩個new操作符重載函數分別會被調用。delete函數略有特殊,它存在優先級的問題。第一個delete函數優先級高於第二個delete函數。如果第一個delete函數被注釋,那麼第二個delete函數將被調用。

 

討論:重載new和delete操作符的好處

通過重載new和delete操作符,我們有機會在對象創建和釋放的時候做一些內存管理的工作。比如,每次new一個Obj對象,我們遞增new被調用的次數。delete的時候再遞減。當程序退出時,我們檢查該次數是否歸0。如果不為0,則表示有Obj對象沒有被delete,這很可能就是內存洩露的潛在原因。

3. 如何在指定內存中構造對象

我們用new表達式創建一個對象的時候,系統將在堆上分配一塊內存,然後這個對象在這塊內存上被構造。由於這塊內存分配在堆上,程序員一般無法指定其地址。這一點和Java中的new類似。但有時候我們希望在指定內存上創建對象,可以做到嗎?對於C++這種靈活度很高的語言而言,這個小小要求自然可以輕松滿足。只要使用特殊的new即可:

2 void* operator new(size_t count, void* ptr):它是placement new中的一種。此函數第二個參數是一個代表內存地址的指針。該函數的默認實現就是直接將ptr作為返回的內存地址,也就是將傳入的內存地址作為new的結果返回給調用者。

使用這種方式的new操作符時,由於返回的內存地址就是傳進來的ptr,這就達到了在指定內存上構造對象的功能。馬上來看一個示例,如圖36所示:

 

\

圖36new/delete示例

圖36展示了placement new的用法,即在指定內存中構造對象。這個指定內存是在棧上創建的。另外,對於這種方式創建的對象,如果要delete的話必需小心,因為系統提供的delete函數將回收內存。在本例中,對象是構造在棧上的,其占據的內存隨testPlacementNew函數返回後就自動回收了,所以圖35中沒有使用delete。不過請讀者務必注意,這種情況下內存不需要主動回收,但是對象是需要析構的。

顯然,這種只有new沒有delete的使用方法和平常用法不太匹配,有點別扭。如何改進呢?方法很簡單,我們只要按如下方式重載delete操作符,就可以在圖35的實例中使用delete了:

//Class Obj重載delete操作符

void operator delete(void* obj){

cout<<"delete--"<

//return ::operator delete(obj);屏蔽內存釋放,因為本例中內存在棧上分配的

}//讀者可以自行修改測試案例以加深對new和delete的體會。

如果Obj類按如上方式重載了delete函數,我們在圖36的代碼中就可以“delete pObj1”了。

探討:重載new和delete的好處

一般情況下,我們重載new和delete的目的是將內存創建和對象構造分隔開來。這樣有什麼好處呢?比如我們可以先創建一個大的內存,然後通過重載new函數將對象構造在這塊內存中。當程序退出後,我們只要釋放這個大內存即可。

另外,由於內存創建和釋放與對象構造和析構分離了開來,對象構造完之後切記要析構,delete表達式只是幫助我們調用了對象的析構函數。如果像本例那樣根本不調用delete的話,就需要程序員主動析構對象。

ART中,有些基礎性的類重載了new和delete操作符,它們的實例就是用類似方式來創建的。以後我們會見到它們。

最後,new和delete是C++中比較復雜的一個知識點。關於這一塊的內容,筆者覺得參考文獻裡列的幾本書都沒有說太清楚和全面。請意猶未盡的讀者閱讀如下兩個鏈接的內容:

http://en.cppreference.com/w/cpp/memory/new/operator_new

http://en.cppreference.com/w/cpp/memory/new/operator_delete

 

1.4.5 函數調用運算符重載

函數調用運算符使得對象能像函數一樣被調用,什麼意思呢?我們知道C++和Java一樣,函數調用的寫法是“函數名(參數)”。如果我們把函數名換成某個類的對象,即“對象(參數)”,就達到了對象像函數一樣被調用的目的。這個過程得以順利實施的原因是C++支持函數調用運算符的重載,函數調用運算符就是“()”。

來看一個例子,如圖37所示:

 

\

圖37operator ()重載示例

圖37展示了operator ()重載的示例:

2此操作符的重載比較簡單,就和定義函數一樣可以根據需要定義參數和返回值。

2函數調用操作符重載後,Obj類的實例對象就可以像函數一樣被調用了。我們一般將這種能像函數一樣被調用的對象叫做函數對象。圖37也提到,普通函數是沒有狀態的,但是函數對象卻不一樣。函數對象首先是對象,然後才是可以像函數一樣被調用。而對象是有所謂的“狀態”的,比如圖中的obj和obj1,兩個對象的mX取值不同,這將導致外界傳入一樣的參數卻得到不同的調用結果。

 

1.5 函數模板與類模板

模板是C++語言中比較高級的一個話題。慚愧得講,筆者使用C++、Java這麼些年,極少自己定義模板,最多就是在使用容器類的時候會接觸它們。因為日常工作中用得很少,所以對它的認識並不深刻。這一次由於ART代碼中大量使用了模板,所以筆者也算是被逼上梁山,從頭到尾仔細研究了C++中的模板。介紹模板具體知識之前,筆者先分享幾點關於模板的非常重要的學習心得:

 

C++是面向對象的語言。面向對象最重要的一個特點就是抽象,即將公共的屬性、公共的行為抽象到基類中去。這種抽象非常好理解,現實生活中也無處不在。反觀模板,它其實也是一種抽象,只不過這種抽象的關注點不在屬性,不在行為,而在於數據類型。比如,有一個返回兩個操作數相加之和的函數,它即可以處理int型操作數,也可以處理long型操作數。那麼,從數據類型的角度進行抽象的話,我們可以用一個代表通用數據類型的T做為該函數的參數類型,該函數內部只對T類型的變量進行相加。至於T具體是什麼,此時不用考慮。而使用這個函數的時候,當傳入int型變量時,T就變成int。當傳入long型變量時,T就變成long。所以,模板的重點在於將它所操作的數據的類型抽象出來!C++是強類型的語言,即所有變量(包括函數參數,返回值)都需要有一個明確的類型。這個要求對於模板這種基於數據類型的抽象方式有重大和直接的影響。對於模板而言,定義函數模板或類模板時所用的數據類型只是一個標示,比如前面提到的T。而真正的數據類型只有等使用者用具體的數據類型來使用模板時才能確定。相比非模板編程,模板編程多了一個非常關鍵的步驟,即模板實例化(英文叫instantiation)。模板實例化是編譯器發現使用者用具體的數據類型來使用模板時,它就會將模板裡的通用數據類型替換成具體的數據類型,從而生成實際的函數或類。比如前面提到的兩個操作數相加的模板函數,當傳入int型變量時,模板會實例化出一個參數為int型的函數,當傳入long型變量時,模板又會實例化出一個參數為long型的函數。當然,如果沒有地方用具體數據類型來使用這個模板,則編譯器不會生成任何函數。注意,模板的實例化是由編譯器來做的,但觸發實例化的原因是因為使用者用具體數據類型來使用了某個模板。

 

簡而言之,對於模板而言,程序員需要重點關注兩個事情,一個是對數據類型進行抽象,另一個是利用具體數據類型來綁定某個模板以將其實例化。

好了,讓我們正式進入模板的世界,故事先從簡單的函數模板開始。

提示:

模板編程是C++中非常難的部分,參考文獻[4]用了六章來介紹與之相關的知識點。不管怎樣,模板的核心依然是筆者前面提到的兩點,一個是數據類型抽象,一個是實例化。

1.5.1 函數模板

1. 函數模板的定義

先來看函數模板的定義方法,如圖38所示:

 

\

圖38 函數模板的定義

圖38所示為兩個函數模板的定義,其中有幾點需要讀者注意:

 

函數模板一般在頭文件中定義,這和普通函數不太一樣。普通函數一般在頭文件中聲明,在源文件中定義。對函數模板而言,因為編譯器在實例化一個模板的時候需要知道函數模板的全部內容(再次強調,實例化就是編譯器用具體數據類型套用到模板上去,然後生成具體函數的過程),所以實例化過程中只知道函數模板的聲明是不夠的。更進一步得說,其實函數模板並不是真正的函數,只有編譯器用具體數據類型套用到函數模板時才會生成實際的函數。模板的關鍵詞是template,其後通過<>符號包含一個或多個模板參數。模板參數列表不能為空。模板參數和函數參數有些類似,可以定義默認值。比如圖中add123最後一個模板參數T3,其默認值是long。

 

提示:

圖38中的函數模板定義中,template可以和其後的代碼位於同一行,比如:

template T add(const T&a1,const T& a2);

建議開發者將其分成兩行,因為這樣的代碼閱讀起來會更容易一些。

下面繼續討論template和模板參數:

首先,可以定義任意多個模板參數,模板參數也可以像函數參數那樣有默認值。

其次,函數的參數都有數據類型。類似,模板參數(如上面的T)也有類型之分:

2代表數據類型的模板參數:用typename關鍵詞標示,表示該參數代表數據類型,實例化時應傳入具體的數據類型。比如typename T是一個代表數據類型的模板參數,實例化的時候必須用數據類型來替代T(或者說,T的取值為數據類型,比如int,long之類的)。另外,typename關鍵詞也可以用class關鍵詞替代,所以"template"和"template"等價。建議讀者盡量使用typename作為關鍵詞。

2非數據類型參數:非數據類型的參數支持整型、指針(包括函數指針)、引用。但是這些參數的值必須在實例化期間(也就是編譯期)就能確定。

關於非類型參數,此處先展示一個簡單的示例,後續介紹類模板時會碰到具體用法。

//下面這段代碼中,T是代表數據類型的模板參數,N是整型,compare則是函數指針

//它們都是模板參數。

template<typename T,int N,bool (*compare)(constT & a1,const T &a2)>

void comparetest(const T& a1,const T& a2){

cout<<"N="<

compare(a1,a2);//調用傳入的compare函數

}

 

2. 函數模板的實例化

圖39所示為圖38所定義的兩個函數模板的實例化示例:

 

\

圖39 函數模板的實例化

圖39所示為add和add123這兩個函數模板的實例化示意。結合前文反復強調的內容,函數模板的實例化就是當程序用具體數據類型來使用函數模板時,編譯器將生成具體的函數:

 

比如①,編譯器根據傳入的函數實參推導出數據類型為T,從而會生成一個"intadd(const int &b,const int &b)"函數,最終調用的也是這個生成的函數。這是編譯器根據函數實參自動推導出來的,叫模板實參推導。推導過程有一些規則,屬於比較高級的話題,筆者不擬討論。不過,不論推導規則有多復雜,其目的就是為了確定模板參數的具體取值情況,這一點請讀者牢記。使用者也可以顯示實例化,即顯示指明模板參數的類型。比如②中所示的三個函數。編譯器將生成三個不同的add123函數。add123函數模板也可以隱式實例化,比如③所示。但請讀者注意,模板實參的推導只能根據傳入的函數參數來確定,不能根據函數的返回值來確定。如果add123函數模板中沒有為T3設置默認類型的話,編譯將出錯。

 

3. 函數模板的特例化

上文介紹了函數模板的實例化,實例化就是指編譯器進行類型推導,然後得到具體的函數。實例化得到的這些函數除了數據類型不一樣之外,函數內部的功能是完全一樣的。有沒有可能為某些特定的數據類型提供不一樣的函數功能?

顯然,C++是支持這種做法的,這也被稱為模板的特例化(英文簡稱specialization)。特例化就是當函數模板不太適合某些特定數據類型時,我們單獨為它指定一套代碼實現。

讀者可能會覺得很奇怪,為什麼會有這種需求?以圖38中的add123為例,如果程序員傳入的參數類型是指針的話,顯然我們不能直接使用add123原函數模板的內容(那樣就變成了兩個指針值的相加),而應該單獨實現一個針對指針類型的函數實現。要達到這個目的就需要用到特例化了。來看具體的做法,如圖40所示:

 

\

圖40特例化示例

1.5.2 類模板

1. 類模板定義和特例化

類模板的規則比函數模板要復雜,我們來看一個例子,如圖41所示:

 

\

圖41 類模板示例

圖41中定義一個類模板,其語法格式和函數模板類型,class關鍵字前需要由template<模板參數>來修飾。另外,類模板中可以包含普通的成員函數,也可以有成員模板。這導致類模板的復雜度(包括程序員閱讀代碼的難度)大大增加。

注意:

普通類也能包含成員模板,這和函數模板類似,此處不擬詳述。

接著來看類模板的特例化,它分為全特化和偏特化兩種情況,如圖42所示:

 

\

圖42 類模板的全特化和偏特化

圖42展示了類模板的全特化和偏特化,其中:

 

全特化和前文介紹的函數模板的特例化類似,即所有模板參數都指定具體類型或值。全特化類模板得到的是一個實例化的類。偏特化就是為模板參數中的幾個參數指定具體類型或值,剩下的模板參數依然由使用者來指定。注意,偏特化一個類模板得到的依然是類模板。

 

偏特化也叫部分特例化(partial specialization)。但筆者覺得“部分特例化”有些言不盡意,因為偏特化不僅僅包括“為部分模板參數指定具體類型”這一種情況,它還可以為模板參數指定某些特殊類型,比如:

template class Test{}//定義類模板Test,包含一個模板參數

//偏特化Test類模板,模板參數類型變成了T*。這就是偏特化的第二種表現形式

template class Test{}

 

2. 類模板的使用

類模板的使用如圖43所示:

 

\

圖43 類模板使用示例

圖43展示了類模式的使用示例。其中,值得關注的是C++11中程序員可通過using關鍵詞定義類模板的別名。並且,使用類模板別名的時候可以指定一個或多個模板參數。

最後,類模板的成員函數也可以在類外(即源文件)中定義,不過這會導致代碼有些難閱讀,圖44展示了如何在類外定義accessObj和compare函數:

 

\

圖44 在源文件中定義類模板中的成員函數

圖44中:

 

源文件中定義類模板的成員函數時需要攜帶類模板的模板參數信息。如果成員函數又是函數模板的話,還得加上函數模板的模板參數信息。這些模板信息放在一起很容易讓代碼閱讀者頭暈。類模板全特化後得到是具體的類,所以它的成員函數前不需要template關鍵詞來修飾。類模板成員函數內部如果需要定義該類模板類型的變量時,只需使用類名,而不需要再攜帶模板信息了。

 

最後,關於類模板還有很多知識,比如友元、繼承等在類模板中的使用。本書對於這些內容就不擬一一道來,讀者以後可在碰到它們的時候再去了解。

1.6 lambda表達式

C++11引入了lambda表達式(lambda expression),這比Java直到Java 8才正式在規范層面推出lambda表達式要早三年左右。lambda表達式和另一個耳熟能詳的概念closure(閉包)密切相關,而closure最早被提出來的目的也是為了解決數學中的lambda演算(λ calculus)問題[⑧]。從嚴格語義上來說,closure和lambda表達式並不完全相同,不過一般我們可以認為二者描述得是同一個東西。

提示:closure和lambda的區別

關於二者的區別,讀者可參考Effective C++作者Scott Meyers的一篇博文,地址如下:

http://scottmeyers.blogspot.com/2013/05/lambdas-vs-closures.html

我們在“函數調用運算符重載”一節中曾介紹過函數對象,函數對象是那些重載了函數調用操作符的類的實例,和普通函數比起來:

 

函數對象首先是一個對象,所以它可以通過成員變量來記錄狀態,保存信息。然後,函數對象可以被執行。

 

通過上面的描述,我們知道函數對象的兩個特點,一個是可以保存狀態,另外一個是可以執行。不過,和函數一樣,程序員要使用函數對象的話,首先要定義對應的類,然後才能創建該類的實例並使用它們。

現在我們來思考這樣一個問題,可不可以不定義類,而是直接創建某種東西,然後可以執行它們?

 

Java中有匿名內部類可以做到類似的效果。但Java中的類無法重載函數調用操作符,所以匿名內部類不能像函數調用那樣執行。Java的匿名內部類給了C++一個很好的啟示,由於C++是支持重載函數調用操作符的,如果我們能在C++中定義匿名函數對象,就能達到所要求的目標了。

 

以上問題的討論就引出了C++中的lambda表達式,規范中沒有明確說明lambda表達式是什麼,但實際上它就是匿名函數對象。下面的代碼展示了創建一個lambda表達式的語法結構:

auto f = [ 捕獲列表,英文叫capture list ] ( 函數參數 ) ->返回值類型 { 函數體 }

其中:

 

=號右邊是lambda表達式,左邊是變量定義,變量名為f。lambda表達式創建之後將得到一個匿名函數對象,規范中並沒有明確說明這個對象的具體數據類型是什麼,所以一般用auto來表示它的類型。注意,auto並不是類型名,它僅表示把具體類型的推導交給編譯器來做。簡而言之,lambda表達式得到的這個匿名對象是有類型的,但是類型叫什麼不知道,所以程序員只好用auto來表示它的類型,反正它的具體類型會由編譯器在編譯時推導出來[⑨]。捕獲列表:lambda表達式一般在函數內部創建。它要捕獲的東西也就是函數內能訪問的變量(比如函數的參數,在lambda表達式創建之前所定義的變量,全局變量等)。之所以要捕獲它們是因為這些變量代表了lambda創建時所對應的上下文信息,而lambda表達式執行的時候很可能要利用這些信息。所以,捕獲這個詞的使用是非常傳神的。函數參數、返回值類型以及函數體:這和普通函數的定義一樣。不過,lambda表達式必須使用尾置形式的函數返回聲明。尾置形式的函數返回聲明即是把原來位於函數參數左側的返回值類型放到函數參數的右側。比如,"int func(int a){...} "的尾置聲明形式為"autofunc(int a ) -> int {...}"。其中,auto是關鍵詞,用在此處表明該函數將采用尾置形式的函數返回聲明。

 

下面我們通過例子進一步來認識lambda表達式,來看圖45:

 

\

圖45lambda表達式示例(1)

 

\

圖45lambda表達式示例(2)

圖45展示了lambda表達式的用法:

 

lambda表達式實際上就是匿名函數對象,但是一般不知道它到底是什麼類型,所以通過auto關鍵詞把這個問題答案交給編譯器來回答。捕獲列表可以按值按引用兩種方式來捕獲信息。按引用方式進行捕獲時需要考慮該變量生命周期的問題。因為lambda表達式作為一個對象是可以當做函數返回值跳出創建它的函數的范圍。如果它通過引用方式捕獲了一個函數內部的局部變量時,這個變量在跳出函數范圍後將變得毫無意義,並且其占據的內存都可能不復存在了。

 

圖45所示例子的捕獲列表顯示指定了要捕獲的變量。如果變量比較多的話,要一個一個寫上變量名會變得很麻煩,所以lambda表達式還有更簡單的方法來捕獲所有變量,如下所示:

此處僅關注捕獲列表中的內容

[=,&變量a,&變量b] = 號表示按值的方式捕獲該lambda創建時所能看到的全部變量。如果有些變量需要通過引用方式來捕獲的話就把它們單獨列出來(變量前帶上&符號

[&,變量a,變量b] &號表示按引用方式捕獲該lambda創建時所能看到的全部變量。如果有些變量需要通過按值方式來捕獲的話就把它們單獨列出來(變量前不用帶上=號

 

1.7 STL介紹

STL是StandardTemplate Library的縮寫,英文原意是標准模板庫。由於STL把自己的類和函數等都定義在一個名為std(std即standard之意)的命名空間裡,所以一般也稱其為標准庫。標准庫的重要意義在於它提供了一套代碼實現非常高效,內容涵蓋許多基礎功能的類和函數,比如字符串類,容器類,輸入輸出類,多線程並發類,常用算法函數等。雖然和Java比起來,C++標准庫涵蓋的功能並不算多,但是用法卻非常靈活,學習起來有一定難度。

熟練掌握和使用C++標准庫是一個合格C++程序員的重要標志。對於標准庫,筆者感覺是越了解其內部的實現機制越能幫助程序員更好得使用它。所以,參考文獻[2]幾乎是C++程序員入門後的必讀書了。

STL的內容非常多,本節僅從API使用的角度來介紹其中一些常用的類和函數,包括:

 

string類,和Java中的String類似。容器類,包括動態數組vector,鏈表list,map類、set類和對應的迭代器。算法和函數,比如搜索,遍歷算法,STL中的函數對象,綁定等。智能指針類。

 

1.7.1 string類

STL string類和Java String類很像。不過,STL的string類其實只是模板類basic_string的一個實例化產物,STL為該模板類一共定義了四種實例化類,如圖46所示:

 

\

圖46string的家族

圖46中:

 

如果要使用其中任何一種類的話,需要包含頭文件。string對應的模板參數類型為char,也就是單字節字符。而如果要處理像UTF-8/UTF-16這樣的多字節字符,程序員可酌情選用其他的實例化類。

 

string類的完整API可參考http://www.cplusplus.com/reference/string/string/?kw=string。其使用和Java String有些類似,所以上手難度並不大。圖47中的代碼展示了string類的使用:

 

\

圖47string類的使用

1.7.2 容器類

好在Java中也有容器類,所以C++的容器類不會讓大家感到陌生,表1對比了兩種語言中常見的容器類。

表1 容器類對比

容器類型

STL類名

Java類(僅用於參考)

說明

動態數組

vector

ArrayList

動態大小的數組,隨機訪問速度快

鏈表

list

LinkedList

一般實現為雙向鏈表

集合

set,multiset

SortedSet

有序集合,一般用紅黑樹來實現。set中沒有值相同的多個元素,而multiset允許存儲值相同的多個元素

映射表

map、multimap

SortedMap

按Key排序,一般用紅黑樹來實現。map中不允許有Key相同的多個元素,而multimap允許存儲Key相同的多個元素

哈希表

unordered_map

HashedMap

映射表中的一種,對Key不排序

本節主要介紹表1中vector、map這兩種容器類的用法以及Allocator的知識。關於list、set和unordered_map的詳細用法,讀者可閱讀參考文獻[2]。

提示:

list、set和unordered_map的在線API查詢鏈接:

list的API:http://en.cppreference.com/w/cpp/container/list

set的API:http://en.cppreference.com/w/cpp/container/set

unordered_map的API:http://en.cppreference.com/w/cpp/container/unordered_map

1. vector類

vector是模板類,使用它之前需要包含頭文件。圖48展示了vector的一些常見用法:

 

\

圖48vector用法示例

圖48中有三個知識點需要讀者注意:

 

vector是模板類,本例用int作為模板參數實例化後得到一個名為vector的類。這個類的名字寫起來比較麻煩,所以我們通過using關鍵詞為它定義了一個類型別名IntVectorIntVectorvector的別名,凡是出現IntVector的地方其實都是vector。大部分STL容器類中都定義了相對應的迭代器,其類型名為Iterator。C++中沒有通用的Iterator類(Java有Iterator接口類),而是需要通過容器類::Iterator的方式定義該容器類對應的迭代器變量。迭代器用於訪問容器的元素,其作用和Java中的迭代器類似。圖48中再次展示了auto的用法。auto關鍵詞的出現使得程序員不用再寫冗長的類型名了,一切交由編譯器來完成。

 

關於vector的知識我們就介紹到此。

注意:

再次提醒讀者,STL容器類的學習絕非知道幾個API就可以的,其內部有相當多的知識點需要注意才能真正用好它們。強烈建議有進一步學習欲望的讀者研讀參考文獻[2]。

2. map類

map也叫關聯數組。圖49展示了map類的情況:

 

\

圖49 map類

圖49中:

 

map是模板類,使用它之前需要包含頭文件。map模板類包含四個模板參數,第一個模板參數Key代表鍵值對中鍵的類型,第二個模板參數T代表鍵值對中值的類型,第三個模板參數Compare,它用於比較Key大小的,因為map是一種按key進行排序的容器。第四個參數Allocator用於分配存儲鍵值對的內存。STL中,鍵值對用pair類來描述。使用map的時候離不開pair。pair定義在頭文件。pair也是模板類,有兩個模板參數T1和T2。

 

討論:Compare和Allocator

map類的聲明中,Compare和Allocator雖然都是模板參數,但很明顯不能隨便給它們設置數據類型,比如Compare和Allocator都取int類型可以嗎?當然不行。實際上,Compare應該被設置成這樣一種類型,這個類型的變量是一個函數對象,該對象被執行時將比較兩個Key的大小。map為Compare設置的默認類型為std::less。less將按以小到大順序對Key進行排序。除了std::less外,還有std::greater,std::less_equal,std::greater_equal等。

同理,Allocator模板參數也不能隨便設置成一種類型。後文將繼續介紹Allocator。

圖50展示了map類的用法:

 

\

圖50 map的用法展示

圖50定義了一個key和value類型都是string的map對象,有兩種方法為map添加元素:

 

通過索引Key的方式可添加或訪問元素。比如stringMap["4"]="four",如果stringMap["4"]所在的元素已經存在,則是對它重新設置新的值,否則是添加一個新的鍵值對元素。該元素的鍵為"4",值為"four"。通過insert添加一個元素。再次強調,map中元素的類型是pair,所以必須構造一個pair對象傳遞給insert。C++11前可利用輔助函數make_pair來構造一個pair對象,C++11之後可以利用{}花括號來隱式構造一個pair對象了。

 

map默認的Compare模板參數是std::less,它將按從小到大對key進行排序,如何為map指定其他的比較方式呢?來看圖51:

 

\

圖51 map的用法之Compare

圖51展示了map中和Compare模板參數有關的用法,其中:

 

decltype(表達式):用於推導表達式的數據類型。比如decltype(5)得到的是int,decltype(true)得到的是bool。decltypeauto都是C++11中的關鍵詞,它們的真實類型在編譯期間由編譯器推導得到。std::function是一個模板類,它可以將一個函數(或lambda表達式)封裝成一個重載了函數操作符的類。這個類的函數操作符的信息(也就是函數返回值和參數的信息)和function的模板信息一樣。比如圖51中"function"將得到一個類,該類重載的函數操作符為"booloperator() (int,int)"。

 

 

3. allocator介紹

Java程序員在使用容器類的時候從來不會考慮容器內的元素的內存分配問題。因為Java中,所有元素(除int等基本類型外)都是new出來的,容器內部無非是保存一個類似指針這樣的變量,這個變量指向了真實的元素位置。

這個問題在C++中的容器類就沒有這麼簡單了。比如,我們在棧上構造一個string對象,然後把它加到一個vector中去。vector內部是保存這個string變量的地址,還是在內部構造一個新的存儲區域,然後將string對象的內容保存起來呢?顯然,我們應該選擇在內部構造一個區域,這個區域存儲string對象的內容。

STL所有容器類的模板參數中都有一個Allocator(譯為分配器),它的作用包括分配內存、構造對應的對象,析構對象以及釋放內存。STL為容器類提供了一個默認的類,即std::allocator。其用法如圖52所示:

 

\

圖52allocator的用法

圖52展示了allocator模板類的用法,我們可以為容器類指定自己的分配器,它只要定義圖52中的allocate、construct、destory和deallocate函數即可。當然,自定義的分配器要設計好如何處理內存分配、釋放等問題也是一件很考驗程序員功力的事情。

提示:

ART中也定義了類似的分配器,以後我們會碰到它們。

 

1.7.3 算法和函數對象介紹

STL還為C++程序員提供了諸如搜索、排序、拷貝、最大值、最小值等算法操作函數以及一些諸如less、great這樣的函數對象。本節先介紹算法操作函數,然後介紹STL中的函數對象。

1. 算法

STL中要使用算法相關的API的話需要包含頭文件,如果要使用一些專門的數值處理函數的話則需額外包含頭文件。參考文獻[2]在第11章中對STL算法函數進行了細致的分類。不過本節不打算從這個角度、大而全得介紹它們,而是將ART中常用的算法函數挑選出來介紹,如表2所示。

表2 ART源碼中常用的算法函數

函數名

作用

fill

fill_n

fill:為容器中指定范圍的元素賦值

fill_n:為容器內指定的n個元素賦值

min/max

返回容器某范圍內的最小值或最大值

copy

拷貝容器指定范圍的元素到另外一個容器

accumulate

定義於,計算指定范圍內元素之和

sort

對容器類的元素進行排序

binary_search

對已排序的容器進行二分查找

lexicographical_compare

按字典序對兩個容器內內指定范圍的元素進行比較

equal

判斷兩個容器是否相同(元素個數是否相等,元素內容是否相同)

remove_if

從容器中刪除滿足條件的元素

count

統計容器類滿足條件的元素的個數

replace

替換容器類舊元素的值為指定的新元素

swap

交換兩個元素的內容

圖53展示了表2中一些函數的用法:

 

\

圖53 fill、copy和accumulate等算法函數示例

圖53中包含一些知識點需要讀者了解:

 

對於操作容器的算法函數而言,它並不會直接操作具體的容器類,而是借助Iterator來遍歷一個范圍(一般是前開後閉)內的元素。這種方式將算法和容器進行了最大程度的解耦,從此,算法無需關心容器,而是只通過迭代器來獲取、操作元素。對初學者而言,算法函數並不像它的名字一樣看起來那麼容易使用。以copy為例,它將源容器指定范圍元素拷貝到目標容器中去。不過,目標容器必須要保證有足夠的空間能夠容納待拷貝的源元素。比如圖中aIntVector有6個元素,但是bIntVector只有0個元素,aIntVector這6個元素能拷貝到bIntVector裡嗎?copy函數不能回答這個問題,只能由程序員來保證目標容器有足夠的空間。這導致程序員使用copy的時候就很頭疼了。為此,STL提供了一些輔助性的迭代器封裝類,比如back_inserter函數將返回這樣一種迭代器,它會往容器尾部添加元素以自動擴充容器的大小。如此,使用copy的時候我們就不用擔心目標容器容量不夠的問題了。有些算法函數很靈活,它可以讓程序員指定一些判斷、操作規則。比如第二個accumulate函數的最後一個參數,我們為其指定了一個lambda表達式用於計算兩個元素之和。

 

提示:

STL的迭代器也是非常重要的知識點,由於本書不擬介紹它。請讀者閱讀相關參考文獻。

接著來看圖54,它繼續展示了算法函數的使用方法:

 

\

圖54 sort、binary_search等函數使用示例

圖54中remove_if函數向讀者生動展示了要了解STL細節的重要性:

 

remove_if將vector中值為-1的元素remove。但是這個元素會被remove到哪去?該元素所占的內存會不會被釋放?STL中,remove_if函數只是將符合remove條件的元素挪到容器的後面去,而將不符合條件的元素往前挪。所以,vector最終的元素布局為前面是無需移動的元素,後面是被remove的元素。但是請注意,vector的元素個數並不會發生改變。所以,remove_if將返回一個迭代器位置,這個迭代器的位置指向被移動的元素的起始位置。即vector中真正有效的元素存儲在begin()newEnd之間,newEndend()之間是邏輯上被remove的元素。如果初學者不知道remove_if並不會改變vector元素個數的話,就會出現圖54中最後一個for循環的結果,vector的元素還是有6個,就好像沒有被remove一樣。

 

是不是有種要抓狂的感覺?這個問題怎麼破解呢?當使用者remove_if調用完畢後,務必要通過erase來移除容器中邏輯上不再需要的元素,代碼如下:

//newEnd和end()之間是邏輯上被remove的元素,我們需要把它從容器裡真正移除!

aIntVector.erase(newEnd,aIntVector.end());

最後,關於的全部內容請讀者參考:

http://en.cppreference.com/w/cpp/header/algorithm

2. 函數對象

STL中要使用函數對象相關的API的話需要包含頭文件,ART中常用的函數對象如表3所示。

表2 ART源碼中常用的算法函數

類或函數名

作用

bind

對可調用對象進行參數綁定以得到一個新的可調用對象。詳情見正文

function

模板類,圖51中介紹過,用於得到一個重載了函數調用對象的類

hash

模板類,用於計算哈希值

plus/minus/multiplies

模板類,用於計算兩個變量的和,差和乘積

equal_to/greater/less

模板類,用於比較兩個數是否相等或大小

函數對象的使用相對比較簡單,圖55、圖56給出了幾個示例:

 

\

圖55 bind函數使用示例

圖55重點介紹了bind函數的用法。如圖中所說,bind是一個很奇特的函數,其主要作用就是對原可調用對象進行參數綁定從而得到一個新的可調用對象。bind的參數綁定規則需要了解。另外,占位符_X定義在std下的placeholders命名空間中,所以一般要用placeholders::_X來訪問占位符。

圖56展示了有關函數對象的其他一些簡單示例:

 

\

圖56 函數對象的其他用例

圖56展示了:

 

mutiplies模板類:它是一個重載了函數操作符的模板類,用於計算兩個輸入參數的乘積。輸入參數的類型就是模板參數的類型。less模板類,和mutiplies類似,它用於比較兩個輸入參數的大小。

 

最後,關於的全部內容,請讀者參考:

http://en.cppreference.com/w/cpp/header/functional

提示:

從容器類和算法以及函數對象來看,STL的全稱標准模板庫是非常名符其實的,它充分利用了和發揮了模板的威力。

 

1.7.4 智能指針類

我們在本章1.3.3“->和*操作符重載”一節中曾介紹過智能指針類。C++11此次在STL中推出了兩個比較常用的智能指針類:

 

shared_ptr:共享式指針管理類。內部有一個引用計數,每當有新的shared_ptr對象指向同一個被管理的內存資源時,其引用計數會遞增。該內存資源直到引用計數變成0時才會被釋放。unique_ptr:獨占式指針管理類。被保護的內存資源只能賦給一個unique_ptr對象。當unique_ptr對象銷毀、重置時,該內存資源被釋放。一個unique_ptr源對象賦值給一個unique_ptr目標對象時,內存資源的管理從源對象轉移到目標對象。

 

shared_ptr和unqiue_ptr的思想其實都很簡單,就是借助引用計數的概念來控制內存資源的生命周期。相比shared_ptr的共享式指針管理,unique_ptr的引用計數最多只能為1罷了。

注意:環式引用問題

雖然有shared_ptr和unique_ptr,但是C++的智能指針依然不能做到Java那樣的內存自動回收。並且,shared_ptr的使用也必須非常小心,因為單純的借助引用計數無法解決環式引用的問題,即A指向B,B指向A,但是沒有別的其他對象指向A和B。這時,由於引用計數不為0,A和B都不能被釋放。

下面分別來看shared_ptr和unique_ptr的用法。

1. shared_ptr介紹

圖57為shared_ptr的用法示例,難度並不大:

 

\

圖57shared_ptr用法示例

圖57中:

 

STL提供一個幫助函數make_shared來構造被保護的內存對象以及一個的shared_ptr對象。當item0賦值給item1時,引用計數(通過use_count函數返回)遞增。reset函數可以遞減原被保護對象的引用計數,並重新設置新的被保護對象。

 

關於shared_ptr更多的信息,請參考:http://en.cppreference.com/w/cpp/memory/shared_ptr

 

2. unique_ptr介紹

ART中使用unique_ptr遠比shared_ptr多,它的用法比shared_ptr更簡單,如圖58所示:

 

\

圖58unique_ptr用法示例

關於unique_ptr完整的API列表,請參考http://en.cppreference.com/w/cpp/memory/unique_ptr

1.7.5 探討STL的學習

本章對STL進行了一些非常粗淺的介紹。結合筆者個人的學習和使用經驗,STL初看起來是比較容易學的。因為它更多關注的是如何使用STL定義好的類或者函數。從“使用現成的API”這個角度來看,有Java經驗的讀者應該毫不陌生。因為Java平台從誕生之初就提供了大量的功能類,熟練的java程序員使用它們時早已能做到信手拈來。同理,C++程序員初學STL時,最開始只要做到會查閱API文檔,了解API的用法即可。

但是,正如前面介紹copy、remove_if函數時提到的那樣,STL的使用遠比掌握API的用法要復雜得多。STL如果要真正學好、用好,了解其內部大概的實現是非常重要的。並且,這個重要性不僅停留在“可以寫出更高效的代碼”這個層面上,它更可能涉及到“避免程序出錯,內存崩潰等各種莫名其妙的問題”上。這也是筆者反復強調要學習參考文獻[2]的重要原因。另外,C++之父編寫的參考文獻[3]在第IV部分也對STL進行了大量深入的介紹,讀者也可以仔細閱讀。

要研究STL的源碼嗎?

對絕大部分開發者而言,筆者覺得研究STL的源碼必要性不大。http://en.cppreference.com網站中會給出有些API的可能實現,讀者查找API時不妨了解下它們。

1.8 其他常用知識

本節介紹ART代碼中其他一些常見知識。

1.8.1initializer_list

initializer_list和C++11中的一種名為“列表初始化”的技術有關。什麼是列表初始化呢?來看一段代碼:

vectorintvec = {1,2,3,4,5};

vectorstrvec{”one”,”two”,”three”};”

上面代碼中,intvect和strvect的初值由兩個花括號{}和裡邊的元素來指定。C++11中,花括號和其中的內容就構成一個列表對象,其類型是initializer_list,也屬於STL標准庫。

initializer_list是一個模板類,花括號中的元素的類型就是模板類型。並且,列表中的元素的數據類型必須相同。

另外,如果類創建的對象實例構造時想支持列表方式的話,需要單獨定義一個構造函數。我們來看幾段代碼:

class Test{

public:

//定義一個參數為initializer_list的構造函數

Test(initializer_list a_list){

//遍歷initializer_list,它也是一種容器

for(auto item:a_list){

cout<<”item=”<

} } }

Test a = {1,2,3,4};//只有Test類定義了,才能使用列表初始化構造對象

initializer_list strlist ={”1”,”2”,”3”};

using ILIter =initializer_list::iterator;

//通過iterator遍歷initializer_list

for(ILIter iter =strlist.begin();iter != strlist.end();++iter){

cout<<”item = ” << *iter<< endl;

}

1.8.2 帶作用域的enum

enum應該是廣大程序員的老相識了,它是一個非常古老,使用廣泛的關鍵詞。不過,C++11中enum有了新的變化,我們通過兩段代碼來了解它:

//C++11之前的傳統enum,C++11繼續支持

enum Color{red,yellow,green};

//C++11之後,enum有一個新的形式:enum class或者enum struct

enum class ColorWithScope{red,yellow,green}

由上述代碼可知,C++11為古老的enum添加了一種新的形式,叫enum class(或enum struct)。enum class和Java中的enum類似,它是有作用域的,比如:

//對傳統enum而言:

int a_red = red;//傳統enum定義的color僅僅是把一組整型值放在一起罷了

//對enum class而言,必須按下面的方式定義和使用枚舉變量。

//注意,green是屬於ColorWithScope范圍內的

ColorWithScopea_green = ColorWithScope::green;//::是作用域符號

//還可以定義另外一個NewColor,這裡的green則是屬於AnotherColorWithScope范圍內

enum class AnotherColorWithScope{green,red,yellow};

//同樣的做法對傳統enum就不行,比如下面的enum定義將導致編譯錯誤,

//因為green等已經在enum Color中定義過了

enum AnotherColor{green,red,yellow};

 

1.8.3 constexpr

const一般翻譯為常量,它和Java中的final含義一樣,表示該變量定義後不能被修改。但C++11在const之外又提出了一個新的關鍵詞constexpr,它是constexpression(常量表達式)的意思。constexpr有什麼用呢?很簡單,就是定義一個常量

讀者一定會覺得奇怪,const不就是用於定義常量的嗎,為什麼要再來一個constexpr呢?關於這個問題的答案,讓我們通過例子來回答。先看下面兩行代碼:

const int x = 0;//定義一個整型常量x,值為0

constexpr int y =1; //定義一個整型常量y,值為1

上面代碼中,x和y都是整型常量,但是這種常量的初值是由字面常量(0和1就是字面常量)直接指定的。這種情況下,const和constexpr沒有什麼區別(注意,const和constexpr的變量在指向指針或引用型變量時,二者還是有差別,此處不表)。

不過,對於下面一段代碼,二者的區別立即顯現了:

int expr(int x){//測試函數

if(x == 1) return 0;

if(x == 2) return 1;

return -1;

}

const int x = expr(9);

x = 8;//編譯錯誤,不能對只讀變量進行修改

constexpr int y = expr(1);//編譯錯誤,因為expr函數不是常量表達式

上面代碼中:

 

x定義為一個const整型變量,但因為expr函數會根據輸入參數的不同而返回不同的值,所以x其實只是一個不能被修改的量,而不是嚴格意義上的常量常量的含義不僅僅是它的值不能被改變,並且它的值必須是固定的。對於這種情況,我們可以使用constexpr來定義一個貨真價實的常量。constexpr將告知編譯器對expr函數進行推導,判斷它到底是不是一個常量表達式。很顯然,編譯器判斷expr不是常量表達式,因為它的返回值受輸入參數的影響。所以上述y變量定義的那行代碼將無法通過編譯。

 

所以,constexpr關鍵詞定義的變量一定是一個常量。如果等號右邊的表達式不是常量,那麼編譯器會報錯。

提示:

常量表達式的推導工作是在編譯期決定的。

 

1.8.4 static_assert

assert,也叫斷言。程序員一般在代碼中一些關鍵地方加上assert語句用以檢查參數等信息是否滿足一定的要求。如果要求達不到,程序會輸出一些警告語(或者直接異常退出)。總之,assert是一種程序運行時做檢查的方法。

有沒有一種方法可以讓程序員在代碼的編譯期也能做一些檢查呢?為此,C++11推出了static_assert,它的語法如下:

static_assert (bool_constexpr , message )

當bool_constexpr返回為false的時候,編譯器將報錯,報錯的內容就是message。注意,這都是在編譯期間做的檢查。

讀者可能會好奇,什麼場合需要做編譯期檢查呢?舉個最簡單的例子。假設我們編寫了一段代碼,並且希望它只能在32位的機器上才能編譯。這時就可以利用static_assert了,方法如下:

static_assert(sizeof(void*) == 4,”can only be compiled in32bit machine”);

包含上述語句的源碼文件在64位機器上進行編譯將出錯,因為64位機器上指針的字節數是8,而不是4。

 

1.9 參考文獻

本章對C++語言(以C++11的名義)進行了浮光掠影般的介紹。其內容不全面,細節不深入,描述更談不上精准。不過,本章的目的在於幫助Java程序員、不熟悉C++11但是接觸過C++98/03的程序員對C++11有一個直觀的認識和了解,這樣我們將來分析ART代碼時才不會覺得陌生。對於那些有志於更進一步學習C++的讀者們,下面列出的五本參考書則是必不可少的。

[1] C++ Primer中文版第5版

作者是Stanley B.Lippman等人,譯者為王剛,楊巨峰等,由電子工業出版社出版。如果對C++完全不熟悉,建議從這本書入門。

[2] C++標准庫第二版

作者是Nicolai M.Josuttis,此書中文版譯者是台灣著名的IT作家侯捷。C++標准庫即是TL(Standard Template Library,標准模板庫)。相比Java這樣的語言,C++其實也提供了諸如容器,字符串,多線程操作(C++11才正式提供)等這樣的標准庫。

[3] The C++Programming Language 4th Edition

作者是C++之父Bjarne Stroustrup,目前只有英文版。這本書寫得很細,由於是英文版,所以讀起來也相對費事。另外,書裡的示例代碼有些小錯誤。

[4] C++ ConcurrencyIn Action

作者Anthony Williams。C++11標准庫增加了對多線程編程的支持,如果打算用C++11標准庫裡的線程庫,請讀者務必閱讀此書。這本書目前只有英文版。說實話,筆者看完這本書前5章後就不打算繼續看下去了。因為C++11標准庫對多線程操作進行了高度抽象的封裝,這導致用戶在使用它的時候還要額外去記住C++11引入的特性,非常麻煩。所以,我們在ART源碼中發現谷歌並未使用C++11多線程標准庫,而是直接基於操作系統提供的多線程API進行了簡單的,面向對象的類封裝。

[5] 深入理解C++11:C++11新特性解析與應用

作者是Mical Wang和IBM XL編譯器中國開發團隊,機械工業出版社出版。

[6] 深入應用C++11代碼優化與工程級應用

作者祁宇,機械工業出版社出版

[5],[6]這兩本書都是由國人原創,語言和行文邏輯更符合國人習慣。相比前幾本而言,這兩本書主要集中在C++11的新特性和應用上,讀者最好先有C++11基礎再來看這兩本書。

建議讀者先閱讀[5]。注意,[5]還貼心得指出每一個C++11的新特性適用於那種類別的開發者,比如所有人,部分人,類開發者等。所有,讀者應該根據自己的需要,選擇學習相關的新特性,而不是嘗試一股腦把所有東西都學會。

 



[①] C++98規范是於1998年落地的關於C++語言的第一個國際標准(ISO/IEC15882:1998)。而C++03則是於2003年定稿的第二個C++語言國際標准(ISO/IEC15882:2003)。由於C++03只是在C++98上增加了一些內容(主要是新增了技術勘誤表,Technical Corrigendum 1,簡稱TC1),所以之後很長一段時間內,人們把C++規范通稱為C++98/03。

[②] 無獨有偶,C++之父Bjarne Stroustrup也曾說過“C++11看起來像一門新的語言”[3]。

[③] 什麼樣的系統算業務系統呢?筆者也沒有很好的劃分標准。不過以Android為例,LinuxKernel,視音頻底層(Audio,Surface,編解碼),OpenGLES等這些對性能要求非常高,和硬件平台相關的系統可能都不算是業務系統。

[④] 沒有nullptr之前,系統或程序員往往會定義一個NULL宏,比如#define NULL (0),不過這種方式存在一些問題,所以C++11推出了nullptr關鍵詞。

[⑤] 雖然代碼中使用的是引用,但很多編譯器其實是將引用變成了對應的指針操作。

[⑥] Java中,String對象是支持+操作的,這或許是Java中唯一的“操作符重載”的案例。

[⑦] 此處描述並不完全准確。對於STL標准庫中某些類而言,<<和>>是可以實現為類的成員函數的。但對於其他類,則不能實現為類的成員函數。

[⑧] 關於closure的歷史,請閱讀https://en.wikipedia.org/wiki/Closure_(computer_programming)

[⑨] 編譯器可能會將lambda表達式轉換為一個重載了函數調用操作符的類。如此,變量f就是該類的實例,其數據類型隨之確定。


  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved