C++從零開始
——何謂編程?
引言
曾經有些人問我問題,問得都是一些很基礎的問題,但這些人卻已經能使用VC編一個對話框界面來進行必要的操作或者是文檔/視界面來實時接收端口數據并動態顯示曲線(還使用了多線程技術),卻連那些基礎的問題都不清楚,并且最嚴重的后果就是導致編寫出拙劣的代碼(雖然是多線程,但真不敢恭維),不清楚類的含義,混雜使用各種可用的技術來達到目的(連用異常代替選擇語句都弄出來了),代碼邏輯混亂,感覺就和金山快譯的翻譯效果一樣。
我認為任何事情,基礎都是最重要的,并且在做完我自定的最后一個項目后我就不再做編程的工作,守著這些經驗也沒什么意義,在此就用本系列說說我對編程的理解,幫助對電腦編程感興趣的人快速入門(不過也許并不會想象地那么快)。由于我從沒正經看完過一本C++的書(都是零碎偶爾翻翻的),并且本系列并不是教條式地將那些該死的名詞及其解釋羅列一遍,而是希望讀者能夠理解編程,而不是學會一門語言(即不止會英翻漢,還會漢翻英)。整個系列全用我自己的理解來寫的,并無參考其他教材(在一些基礎概念上還是參考了MSDN),所以本系列中的內容可能有和經典教材不一致的地方,但它們的本質應該還是一樣的,只是角度不同而已。本系列不會仔細講解C++的每個關鍵字(有些并不重要),畢竟目的不是C++語言參考,而是編程入門。如果本系列文章中有未提及的內容,還請參考MSDN中的C++語言參考(看完本系列文章后應該有能力做這件事了),而本系列給出的內容均是以VC編譯器為基礎,基于32位Windows操作系統的。
下面羅列一下各文章的標題和主要內容,紅色修飾的文章標題表示我認為的重點。
C++從零開始(一)——何謂編程(說明編程的真正含義及兩個重要卻容易被忽略的基礎概念)
C++從零開始(二)——何謂表達式(說明各操作符的用處,但不是全部,剩余的會在其它文章提到)
C++從零開始(三)——何謂變量(說明電腦的工作方式,闡述內存、地址等極其重要的概念)
C++從零開始(四)——賦值操作符(《C++從零開始(二)》的延續,并為指針的解釋打一點基礎)
C++從零開始(五)——何謂指針(闡述指針、數組等重要的概念)
C++從零開始(六)——何謂語句(講解C++提供的各個語句,說明各自存在的理由)
C++從零開始(七)——何謂函數(說明函數及其存在的理由)
C++從零開始(八)——C++樣例一(給出一兩個簡單算法,一步步說明如何從算法編寫出C++代碼)
C++從零開始(九)——何謂結構(簡要說明結構、枚舉等及其存在的理由)
C++從零開始(十)——何謂類(說明類及其存在的理由,以及聲明、定義、頭文件等概念)
C++從零開始(十一)——類的相關知識(說明派生、繼承、名字空間、操作符重載等)
C++從零開始(十二)——何謂面向對象編程思想(闡述何謂編程思想,重點講述面向對象編程思想)
??
何謂程序
程序,即過程的順序,準確地說應該是順序排列的多個過程,其是方法的描述。比如吃菜,先用筷子夾起菜,再用筷子將菜送入嘴中,最后咀嚼并吞下。其中的夾、送、咀嚼和吞下就被稱作命令,而菜則是資源,其狀態(如形狀、位置等)隨著命令的執行而不斷發生變化。上面就是吃菜這個方法的描述,也就是吃菜的程序。
任何方法都是為了改變某些資源的狀態而存在,因此任何方法的描述,也就是程序,也都一定有命令這個東西以及其所作用的資源。命令是由程序的執行者來實現的,比如上面的吃菜,其中的夾、送等都是由吃菜的人來實現的,而資源則一定是執行者可以改變的東西,而命令只是告訴執行者如何改變而已。
電腦程序和上面一樣,是方法的描述,而這些?方法就是人期望電腦能做的事(注意不是電腦要做的事,這經常一直混淆著許多人),當人需要做這些事時,人再給出某些資源以期電腦能對其做正確的改變。如計算圓周率的程序,其只是方法的描述,本身是不能發生任何效用的,直到它被執行,人為給定它一塊內存(關于內存,請參考《C++從零開始(三)》),告訴它計算結果的精度及計算結果的存放位置后,其才改變人為給定的這塊內存的狀態以表現出計算結果。
因此,對于電腦程序,命令就是CPU的指令,而執行者也就由于是CPU的指令而必須是CPU了,而最后的資源則就是CPU可以改變其狀態的內存(當然不止,如端口等,不過一般應用程序都大量使用內存罷了)。所以,電腦程序就是電腦如何改變給定資源(一般是內存,也可以是其他硬件資源)的描述,注意是描述,本身沒有任何意義,除非被執行。
何謂編程
編程就是編寫程序,即制訂方法。為什么要有方法?方法是為了說明。而之所以要有說明就有很多原因了,但電腦編程的根本原因是因為語言不同,且不僅不同,連概念都不相通。
人類的語言五花八門,但都可以通過翻譯得到正解,因為人類生存在同一個四維物理空間中,具有相同或類似的感知。而電腦程序執行時的CPU所能感受到的空間和物理空間嚴重不同,所以是不可能將電腦程序翻譯成人類語言的描述的。這很重要,其導致了大部分程序員編寫出的拙劣代碼,因為人想的和電腦想的沒有共性,所以他們在編寫程序時就隨機地無目的地編寫,進而導致了拙劣卻可以執行的代碼。
電腦的語言就是CPU的指令,因為CPU就這一個感知途徑(準確地說還有內存定位、中斷響應等感知途徑),不像人類還能有肢體語言,所以電腦編程就是將人類語言書寫的方法翻譯成相應的電腦語言,是一個翻譯過程。這完全不同于一般的翻譯,由于前面的紅字,所以是不可能翻譯的。!!是翻譯但是是不同于任何兩個語種之間翻譯的一種翻譯。
既然不可能翻譯,那電腦編程到底是干甚?考慮一個木匠,我是客人。我對木匠說我要一把搖椅,躺著很舒服的那種。然后木匠開始刨木頭,按照一個特殊的曲線制作搖椅下面的曲木以保證我搖的時候重心始終不變以感覺很舒服。這里我編了個簡單的程序,只有一條指令——做一把搖著很舒服的搖椅。而木匠則將我的程序翻譯成了刨木頭、設計特定的曲木等一系列我看不懂的程序。之所以會這樣,在這里就是因為我生活的空間和木工(是木工工藝,不是木匠)沒有共性。這里木匠就相當于電腦程序員兼CPU(因為最后由木匠來制作搖椅),而木匠的手藝就是CPU的指令定義,而木匠就將我的程序翻譯成了木工的一些規程,由木匠通過其手藝來實現這些規程,也就是執行程序。
上面由于我生活的空間和木工(指木工工藝,不是工人)沒有共性,所以是不可能翻譯的,但上面翻譯成功了,實際是沒有翻譯的。在木工眼中,那個搖椅只是一些直木和曲木的拼接而已,因為木工空間中根本沒有搖椅的概念,只是我要把那堆木頭當作搖椅,進而使用。如果我把那堆木頭當作兇器,則它就是兇器,不是什么搖椅了。
“廢話加荒謬加放屁!”,也許你會這么大叫,但電腦編程就是這么一回事。CPU只能感知指令和改變內存的狀態(不考慮其他的硬件資源及響應),如果我們編寫了一個計算圓周率的程序,給出了一塊內存,并執行,完成后就看見電腦的屏幕顯示正確的結果。但一定注意,這里電腦實際只是將一些內存的數值復制、加減、乘除而已,電腦并不知道那是圓周率,而如果執行程序的人不把它說成是圓周率那么那個結果也就不是圓周率了,可能是一個隨機數或其他什么的,只是運氣極好地和圓周率驚人地相似。
上面的東西我將其稱為語義,即語言的意義,其不僅僅可應用在電腦編程方面,實際上許多技術,如機械、電子、數學等都有自己的語言,而那些設計師則負責將客戶的簡單程序翻譯成相應語言描述的程序。作為一個程序員是極其有必要了解到語義的重要性的(我在我的另一篇文章《語義的需要》中對代碼級的語義做過較詳細的闡述,有興趣可以參考之),在后續的文章中我還將提到語義以及其對編程的影響,如果你還沒有理解編程是什么意思,隨著后續文章的閱讀應該能夠越來越明了。
!!編程是什么,現在的理解,利用編程語言去翻譯現實生活的需要。
電腦編程的基礎知識——編譯器和連接器
我從沒見過(不過應該有)任何一本C++教材有講過何謂編譯器(Compiler)及連接器(Linker)(倒是在很老的C教材中見過),現在都通過一個類似VC這樣的編程環境隱藏了大量東西,將這些封裝起來。在此,對它們的理解是非常重要的,本系列后面將大量運用到這兩個詞匯,其決定了能否理解如聲明、定義、外部變量、頭文件等非常重要的關鍵。
前面已經說明了電腦編程就是一個“翻譯”過程,要把用戶的程序翻譯成CPU指令,其實也就是機器代碼。所謂的機器代碼就是用CPU指令書寫的程序,被稱作低級語言。而程序員的工作就是編寫出機器代碼。由于機器代碼完全是一些數字組成(CPU感知的一切都是數字,即使是指令,也只是1代表加法、2代表減法這一類的數字和工作的映射),人要記住1是代表加法、2是代表減法將比較困難,并且還要記住第3塊內存中放的是圓周率,而第4塊內存中放的是有效位數。所以發明了匯編語言,用一些符號表示加法而不再用1了,如用ADD表示加法等。
由于使用了匯編語言,人更容易記住了,但是電腦無法理解(其只知道1是加法,不知道ADD是加法,因為電腦只能看見數字),所以必須有個東西將匯編代碼翻譯成機器代碼,也就是所謂的編譯器。即編譯器是將一種語言翻譯成另一種語言的程序。
即使使用了匯編語言,但由于其幾乎只是將CPU指令中的數字映射成符號以幫助記憶而已,還是使用的電腦的思考方式進行思考的,不夠接近人類的思考習慣,故而出現了紛繁復雜的各種電腦編程語言,如:PASCAL、BASIC、C等,其被稱作高級語言,因為比較接近人的思考模式(尤其C++的類的概念的推出),而匯編語言則被稱作低級語言(C曾被稱作高級的低級語言),因為它們不是很符合人類的思考模式,人類書寫起來比較困難。由于CPU同樣不認識這些PASCAL、BASIC等語言定義的符號,所以也同樣必須有一個編譯器把這些語言編寫的代碼轉成機器代碼。對于這里將要講到的C++語言,則是C++語言編譯器(以后的編譯器均指C++語言編譯器)。
因此,這里所謂的編譯器就是將我們書寫的C++源代碼轉換成機器代碼。由于編譯器執行一個轉換過程,所以其可以對我們編寫的代碼進行一些優化,也就是說其相當于是一個CPU指令程序員,將我們提供的程序翻譯成機器代碼,不過它的工作要簡單一些了,因為從人類的思考方式轉成電腦的思考方式這一過程已經由程序員完成了,而編譯器只是進行翻譯罷了(最多進行一些優化)。
還有一種編譯器被稱作翻譯器(Translator),其和編譯器的區別就是其是動態的而編譯器是靜態的。如前面的BASIC的編譯器在早期版本就被稱為翻譯器,因為其是在運行時期即時進行翻譯工作的,而不像編譯器一次性將所有代碼翻成機器代碼。對于這里的“動態”、“靜態”和“運行時期”等名詞,不用刻意去理解它,隨著后續文章的閱讀就會了解了。
編譯器把編譯后(即翻譯好的)的代碼以一定格式(對于VC,就是COFF通用對象文件格式,擴展名為.obj)存放在文件中,然后再由連接器將編譯好的機器代碼按一定格式(在Windows操作系統下就是Portable?Executable?File?Format——PE文件格式)存儲在文件中,以便以后操作系統執行程序時能按照那個格式找到應該執行的第一條指令或其他東西,如資源等。至于為什么中間還要加一個連接器以及其它細節,在后續文章中將會進一步說明。
也許你還不能了解到上面兩個概念的重要性,但在后續的文章中,你將會發現它們是如此的重要以至于完全有必要在這嘮叨一番。
C++從零開始(二)?
——何謂表達式?
本篇是此系列的開頭,在學英語時,第一時間學的是字母,其是英語的基礎。同樣,在C++中,所有的代碼都是通過標識符(Identifier)、表達式(Expression)和語句(Statement)及一些必要的符號(如大括號等)組成,在此先說明何謂標識符。
標識符
標識符是一個字母序列,由大小寫英文字母、下劃線及數字組成,用于標識。標識就是標出并識別,也就是名字。其可以作為后面將提到的變量或者函數或者類等的名字,也就是說用來標識某個特定的變量或者函數或者類等C++中的元素。
比如:abc就是一個合法的標識符,即abc可以作為變量、函數等元素的名字,但并不代表abc就是某個變量或函數的名字,而所謂的合法就是任何一個標識符都必須不能以數字開頭,只能包括大小寫英文字母、下劃線及數字,不能有其它符號,如,!^等,并且不能與C++關鍵字相同。也就是我們在給一個變量或函數起名字的時候,必須將起的名字看作是一個標識符,并進而必須滿足上面提出的要求。如12ab_C就不是一個合法的標識符,因此我們不能給某個變量或函數起12ab_C這樣的名字;ab_12C就是合法的標識符,因此可以被用作變量或函數的名字。
前面提到關鍵字,在后續的語句及一些聲明修飾符的介紹中將發現,C++提供了一些特殊的標識符作為語句的名字,用以標識某一特定語句,如if、while等;或者提供一些修飾符用以修飾變量、函數等元素以實現語義或給編譯器及連接器提供一些特定信息以進行優化、查錯等操作,如extern、static等。因此在命名變量或函數或其他元素時,不能使用if、extern等這種C++關鍵字作為名字,否則將導致編譯器無法確認是一個變量(或函數或其它C++元素)還是一條語句,進而無法編譯。
如果要讓某個標識符是特定變量或函數或類的名字,就需要使用聲明,在后續的文章中再具體說明。
數字
C++作為電腦編程語言,電腦是處理數字的,因此C++中的基礎東西就是數字。C++中提供兩種數字:整型數和浮點數,也就是整數和小數。但由于電腦實際并不是想象中的數字化的(詳情參見《C++從零開始(三)》中的類型一節),所以整型數又分成了有符號和無符號整型數,而浮點數則由精度的區別而分成單精度和雙精度浮點數,同樣的整型數也根據長度分成長整型和短整型。
要在C++代碼中表示一個數字,直接書寫數字即可,如:123、34.23、-34.34等。由于電腦并非以數字為基礎而導致了前面數字的分類,為了在代碼中表現出來,C++提供了一系列的后綴進行表示,如下:
?u或U??表示數字是無符號整型數,如:123u,但并不說明是長整型還是短整型?
?l或L??表示數字是長整型數,如:123l;而123ul就是無符號長整型數;而34.4l就是長雙精度浮點數,等效于雙精度浮點數?
?i64或I64??表示數字是長長整型數,其是為64位操作系統定義的,長度比長整型數長。如:43i64?
?f或F??表示數字是單精度浮點數,如:12.3f??
?e或E??表示數字的次冪,如:34.4e-2就是0.344;0.2544e3f表示一個單精度浮點數,值為254.4?
當什么后綴都沒寫時,則根據有無小數點及位數來決定其具體類型,如:123表示的是有符號整型數,而12341434則是有符號長整型數;而34.43表示雙精度浮點數。
為什么要搞這么多事出來,還分什么有符號無符號之類的?這全是因為電腦并非基于數字的,而是基于狀態的,詳情在下篇中將詳細說明。
作為科學計算,可能經常會碰到使用非十進制數字,如16進制、8進制等,C++也為此提供了一些前綴以進行支持。
在數字前面加上0x或0X表示這個數字是16進制表示的,如:0xF3Fa、0x11cF。而在前面加一個0則表示這個數字是用8進制表示的,如:0347,變為十進制數就為231。但16進制和8進制都不能用于表示浮點數,只能表示整型數,即0x34.343是錯誤的。
字符串
C++除了提供數字這種最基礎的表示方式外,還提供了字符及字符串。這完全只是出于方便編寫程序而提供的,C++作為電腦語言,根本沒有提供字符串的必要性。不過由于人對電腦的基本要求就是顯示結果,而字符和字符串都由于是人易讀的符號而被用于顯示結果,所以C++專門提供了對字符串的支持。
前面說過,電腦只認識數字,而字符就是文字符號,是一種圖形符號。為了使電腦能夠處理符號,必須通過某種方式將符號變成數字,在電腦中這通過在符號和數字之間建立一個映射來實現,也就是一個表格。表格有兩列,一列就是我們欲顯示的圖形符號,而另一列就是一個數字,通過這么一張表就可以在圖形符號和數字之間建立映射。現在已經定義出一標準表,稱為ASCII碼表,幾乎所有的電腦硬件都支持這個轉換表以將數字變成符號進而顯示計算結果。
有了上面的表,當想說明結果為“A”時,就查ASCII碼表,得到“A”這個圖形符號對應的數字是65,然后就告訴電腦輸出序號為65的字符,最后屏幕上顯示“A”。
這明顯地繁雜得異常,為此C++就提供了字符和字符串。當我們想得到某一個圖形符號的ASCII碼表的序號時,只需通過單引號將那個字符括起來即可,如:'A',其效果和65是一樣的。當要使用不止一個字符時,則用雙引號將多個字符括起來,也就是所謂的字符串了,如:"ABC"。因此字符串就是多個字符連起來而已。但根據前面的說明易發現,字符串也需要映射成數字,但它的映射就不像字符那么簡單可以通過查表就搞定的,對于此,將在后續文章中對數組作過介紹后再說明。
操作符
電腦的基本是數字,那么電腦的所有操作都是改變數字,因此很正常地C++提供了操作數字的一些基本操作,稱作操作符(Operator),如:+?-?*?/?等。任何操作符都要返回一個數字,稱為操作符的返回值,因此操作符就是操作數字并返回數字的符號。作為一般性地分類,按操作符同時作用的數字個數分為一元、二元和三元操作符。
一元操作符有:
?+??其后接數字,原封不動地返回后接的數字。如:?+4.4f的返回值是4.4;+-9.3f的返回值是-9.3。完全是出于語義的需要,如表示此數為正數。?
?-??其后接數字,將后接的數字的符號取反。如:?-34.4f的返回值是-34.4;-(-54)的返回值是54。用于表示負數。?
?!??其后接數字,邏輯取反后接的數字。邏輯值就是“真”或“假”,為了用數字表示邏輯值,在?C++中規定,非零值即為邏輯真,而零則為邏輯假。因此3、43.4、'A'都表示邏輯真,而0則表示邏輯假。邏輯值被應用于后續的判斷及循環語句中。而邏輯取反就是先判斷“!”后面接的數字是邏輯真還是邏輯假,然后再將相應值取反。如:
!5的返回值是0,因為先由5非零而知是邏輯真,然后取反得邏輯假,故最后返回0。
!!345.4的返回值是1,先因345.4非零得邏輯真,取反后得邏輯假,再取反得邏輯真。雖然只要非零就是邏輯真,但作為編譯器返回的邏輯真,其一律使用1來代表邏輯真。?
?~??其后接數字,取反后接的數字。取反是邏輯中定義的操作,不能應用于數字。為了對數字應用取反操作,電腦中將數字用二進制表示,然后對數字的每一位進行取反操作(因為二進制數的每一位都只能為1或0,正好符合邏輯的真和假)。如~123的返回值就為-124。先將123轉成二進制數01111011,然后各位取反得10000100,最后得-124。
這里的問題就是為什么是8位而不是16位二進制數。因為123小于128,被定位為char類型,故為8位(關于char是什么將下篇介紹)。如果是~123ul,則返回值為4294967172。
為什么要有數字取反這個操作?因為CPU提供了這樣的指令。并且其還有著很不錯且很重要的應用,后面將介紹。?
關于其他的一元操作符將在后續文章中陸續提到(但不一定全部提到)。
二元操作符有:
?+
?-
?*
?/
?%??其前后各接一數字,返回兩數字之和、差、積、商、余數。如:
34+4.4f的返回值是38.4;3+-9.3f的返回值是-6.3。
34-4的返回值是30;5-234的返回值是-229。
3*2的返回值是6;10/3的返回值是3。
10%3的返回值是1;20%7的返回值是6。?
?&&
?||??其前后各接一邏輯值,返回兩邏輯值之“與”運算邏輯值和“或”運算邏輯值。如:
'A'&&34.3f的返回值是邏輯真,為1;34&&0的返回值是邏輯假,為0。
0||'B'的返回值是邏輯真,為?1;0||0的返回值是邏輯假,為0。?
?&
?|
?^??其前后各接一數字,返回兩數字之“與”運算、“或”運算、“異或”運算值。如前面所說,先將兩側的數字轉成二進制數,然后對各位進行與、或、異或操作。如:
4&6的返回值是4,4轉為00000100,6轉為00000110各位相與得,00000100,為4。
4|6的返回值是6,4轉為00000100,6轉為00000110各位相或得,00000110,為6。
4^6的返回值是2,4轉為00000100,6轉為00000110各位相異或得,00000010,為2。?
?>
?<
?==
?>=
?<=
?!=??其前后各接一數字,根據兩數字是否大于、小于、等于、大于等于、小于等于及不等于而返回相應的邏輯值。如:
34>34的返回值是0,為邏輯假;32<345的返回值為1,為邏輯真。
23>=23和23>=14的返回值都是1,為邏輯真;54<=4的返回值為0,為邏輯假。
56==6的返回值是0,為邏輯假;45==45的返回值是1,為邏輯真。
5!=5的返回值是0,為邏輯假;5!=35的返回值是真,為邏輯真。?
?>>
?<<??其前后各接一數字,將左側數字右移或左移右側數字指定的位數。與前面的?~、&、|等操作一樣,之所以要提供左移、右移操作主要是因為CPU提供了這些指令,主要用于編一些基于二進制數的算法。
<<將左側的數字轉成二進制數,然后將各位向左移動右側數值的位數,如:4,轉為00000100,左移2位,則變成00010000,得16。
>>與<<一樣,只不過是向右移動罷了。如:6,轉為00000110,右移1位,變成00000011,得3。如果移2位,則有一位超出,將截斷,則6>>2的返回值就是00000001,為1。
左移和右移有什么用?用于一些基于二進制數的算法,不過還可以順便作為一個簡單的優化手段。考慮十進制數3524,我們將它左移2位,變成352400,比原數擴大了100倍,準確的說應該是擴大了10的2次方倍。如果將3524右移2位,變成35,相當于原數除以100的商。
同樣,前面4>>2,等效于4/4的商;32>>3相當于32/8,即相當于32除以2的3次方的商。而4<<2等效于4*4,相當于4乘以2的2次方。因此左移和右移相當于乘法和除法,只不過只能是乘或除相應進制數的次方罷了,但它的運行速度卻遠遠高于乘法和除法,因此說它是一種簡單的優化手段。?
?,??其前后各接一數字,簡單的返回其右側的數字。如:
34.45f,54的返回值是54;-324,4545f的返回值是4545f。
那它到底有什么用?用于將多個數字整和成一個數字,在《C++從零開始(四)》中將進一步說明。?
關于其他的二元操作符將在后續文章中陸續提到(但不一定全部提到)。
三元操作符只有一個,為?:,其格式為:<數字1>?<數字2>:<數字3>。它的返回值為:如果<數字1>是邏輯真,返回<數字2>,否則返回<數字3>。如:
34?4:2的返回值就是4,因為34非零,為邏輯真,返回4。而0?4:2的返回值就是2,因為0為邏輯假,返回2。
表達式
你應該發現前面的荒謬之處了——12>435返回值為0,那為什么不直接寫0還吃飽了撐了寫個12>435在那?這就是表達式的意義了。
前面說“>”的前后各接一數字,但是操作符是操作數字并返回數字的符號,因為它返回數字,因此可以放在上面說的任何一個要求接數字的地方,也就形成了所謂的表達式。如:23*54/45>34的返回值就是0,因為23*54的返回值為1242;然后又將1242作為“/”的左接數字,得到新的返回值27.6;最后將27.6作為“>”的左接數字進而得到返回值0,為邏輯假。
因此表達式就是由一系列返回數字的東西和操作符組合而成的一段代碼,其由于是由操作符組成的,故一定返回值。而前面說的“返回數字的東西”則可以是另一個表達式,或者一個變量,或者一個具有返回值的函數,或者具有數字類型操作符重載的類的對象等,反正只要是能返回一個數字的東西。如果對于何謂變量、函數、類等這些名詞感到陌生,不需要去管它們,在后繼的文章中將會一一說明。
因此34也是一個表達式,其返回值為34,只不過是沒有操作符的表達式罷了(在后面將會了解到34其實是一種操作符)。故表達式的概念其實是很廣的,只要有返回值的東西就可以稱為表達式。
由于表達式里有很多操作符,執行操作符的順序依賴于操作符的優先級,就和數學中的一樣,*、/的優先級大于+、-,而+、-又大于>、<等邏輯操作符。不用去刻意記住操作符的優先級,當不能確定操作符的執行順序時,可以使用小括號來進行指定。如:
((1+2)*3)+3)/4的返回值為3,而1+2*3+3/4的返回值為7。注意3/4為0,因為3/4的商是0。當希望進行浮點數除法或乘法時,只需讓操作數中的某一個為浮點數即可,如:3/4.0的返回值為0.75。
&?|?^?~等的應用
前面提過邏輯操作符“&&”、“||”、“!”等,作為表示邏輯,其被C++提供一點都不值得驚奇。但是為什么要有一個將數字轉成二進制數,然后對二進制數的各位進行邏輯操作的這么一類操作符呢?首先是CPU提供了相應的指令,并且其還有著下面這個非常有意義的應用。
考慮一十字路口,每個路口有三盞紅綠燈,分別指明能否左轉、右轉及直行。共有12盞,現在要為它編寫一個控制程序,不管這程序的功能怎樣,首先需要將紅綠燈的狀態轉化為數字,因為電腦只知道數字。所以用3個數字分別表示某路口的三盞紅綠燈,因此每個紅綠燈的狀態由一個數字來表示,假設紅燈為0,綠燈為1(不考慮黃燈或其他情況)。
后來忽然發現,其實也可以用一個數字表示一個路口的三盞紅綠燈狀態,如用110表示左轉綠燈、直行綠燈而右轉紅燈。上面的110是一個十進制數字,它的每一位實際都可以為0~9十個數字,但是這里只應用到了兩個:0和1,感覺很浪費。故選擇二進制數來表示,還是110,但是是二進制數了,轉成十進制數為6,即使當為111時轉成十進制數也只是7,比前面的110這個十進制數小多了,節約了……??什么??
我們在紙上寫數字235425234一定比寫134這個數字要更多地占用紙張(假設字都一樣大)。因此記錄一個大的數比記錄一個小的數要花費更多的資源。簡直荒謬!不管是100還是1000,都只是一個數字,為什么記錄大的數字就更費資源?因為電腦并不是數字計算機,而是電子計算機,它是基于狀態而不是基于數字的,這在下篇會詳細說明。電腦必須使用某種表示方式來代表一個數字,而那個表示方式和二進制很像,但并不是二進制數,故出現記錄大的數較小的數更耗資源,這也就是為什么上面整型數要分什么長整型短整型的原因了。
下面繼續上面的思考。使用了110這個二進制數來表示三盞紅綠燈的狀態,那么現在要知道110這個數字代表左轉紅綠燈的什么狀態。以數字的第三位表示左轉,不過電腦并不知道這個,因此如下:110&100。這個表達式的返回值是100,非零,邏輯真。假設某路口的狀態為010,則同樣的010&100,返回值為0,邏輯假。因此使用“&”操作符可以將二進制數中的某一位或幾位的狀態提取出來。所以我們要了解一個數字代表的紅綠燈狀態中的左轉紅綠燈是否綠燈時,只需讓它和100相與即可。
現在要保持其他紅綠燈的狀態不變,僅僅使左轉紅綠燈為綠燈,如當前狀態為010,為了使左轉紅綠燈為綠燈,值應該為110,這可以通過010|100做到。如果當前狀態是001,則001|100為101,正確——直行和右轉的紅綠燈狀態均沒有發生變化。因此使用“|”操作符可以給一個二進制數中的某一位或幾位設置狀態,但只能設置為1,如果想設置為0,如101,要關掉左轉的綠燈,則101&~100,返回值為001。
上面一直提到的路口紅綠燈的狀態實際編寫時可以使用一個變量來表示,而上面的100也可以用一個標識符來表示,如state&TS_LEFT,就可以表示檢查變量state所表示的狀態中的左轉紅綠燈的狀態。
上面的這種方法被大量地運用,如創建一個窗口,一個窗口可能有二三十個風格,則通過上面的方法,就可以只用一個32位長的二進制數字就表示了窗口的風格,而不用去弄二三十個數字來分別代表每種風格是否具有。
C++從零開始(三)?
——何謂變量?
本篇說明內容是C++中的關鍵,基本大部分人對于這些內容都是昏的,但這些內容又是編程的基礎中的基礎,必須詳細說明。
數字表示
數學中,數只有數值大小的不同,絕不會有數值占用空間的區別,即數學中的數是邏輯上的一個概念,但電腦不是。考慮算盤,每個算盤上有很多列算子,每列都分成上下兩排算子。上排算子有2個,每個代表5,下排算子有4個,每個代表1(這并不重要)。因此算盤上的每列共有6個算子,每列共可以表示0到14這15個數字(因為上排算子的可能狀態有0到2個算子有效,而下排算子則可能有0到4個算子有效,故為3×5=15種組合方式)。
上面的重點就是算盤的每列并沒有表示0到14這15個數字,而是每列有15種狀態,因此被人利用來表示數字而已(這很重要)。由于算盤的每列有15個狀態,因此用兩列算子就可以有15×15=225個狀態,因此可以表示0到224。阿拉伯數字的每一位有0到9這10個圖形符號,用兩個阿拉伯數字圖形符號時就能有10×10=100個狀態,因此可以表示0到99這100個數。
這里的算盤其實就是一個基于15進制的記數器(可以通過維持一列算子的狀態來記錄一位數字),它的一列算子就相當于一位阿拉伯數字,每列有15種狀態,故能表示從0到14這15個數字,超出14后就必須通過進位來要求另一列算子的加入以表示數字。電腦與此一樣,其并不是數字計算機,而是電子計算機,電腦中通過一根線的電位高低來表示數字。一根線中的電位規定只有兩種狀態——高電位和低電位,因此電腦的數字表示形式是二進制的。
和上面的算盤一樣,一根電線只有兩個狀態,當要表示超出1的數字時,就必須進位來要求另一根線的加入以表示數字。所謂的32位電腦就是提供了32根線(被稱作數據總線)來表示數據,因此就有2的32次方那么多種狀態。而16根線就能表示2的16次方那么多種狀態。
所以,電腦并不是基于二進制數,而是基于狀態的變化,只不過這個狀態可以使用二進制數表示出來而已。即電腦并不認識二進制數,這是下面“類型”一節的基礎。
內存
內存就是電腦中能記錄數字的硬件,但其存儲速度很快(與硬盤等低速存儲設備比較),又不能較長時間保存數據,所以經常被用做草稿紙,記錄一些臨時信息。
前面已經說過,32位計算機的數字是通過32根線上的電位狀態的組合來表示的,因此內存能記錄數字,也就是能維持32根線上各自的電位狀態(就好象算盤的算子撥動后就不會改變位置,除非再次撥動它)。不過依舊考慮上面的算盤,假如一個算盤上有15列算子,則一個算盤能表示15的15次方個狀態,是很大的數字,但經常實際是不會用到變化那么大的數字的,因此讓一個算盤只有兩列算子,則只能表示225個狀態,當數字超出時就使用另一個或多個算盤來一起表示。
上面不管是2列算子還是15列算子,都是算盤的粒度,粒度分得過大造成不必要的浪費(很多列算子都不使用),太小又很麻煩(需要多個算盤)。電腦與此一樣。2的32次方可表示的數字很大,一般都不會用到,如果直接以32位存儲在內存中勢必造成相當大的資源浪費。于是如上,規定內存的粒度為8位二進制數,稱為一個內存單元,而其大小稱為一個字節(Byte)。就是說,內存存儲數字,至少都會記錄8根線上的電位狀態,也就是2的8次方共256種狀態。所以如果一個32位的二進制數要存儲在內存中,就需要占據4個內存單元,也就是4個字節的內存空間。
我們在紙上寫字,是通過肉眼判斷出字在紙上的相對橫坐標和縱坐標以查找到要看的字或要寫字的位置。同樣,由于內存就相當于草稿紙,因此也需要某種定位方式來定位,在電腦中,就是通過一個數字來定位的。這就和旅館的房間號一樣,內存單元就相當于房間(假定每個房間只能住一個人),而前面說的那個數字就相當于房間號。為了向某塊內存中寫入數據(就是使用某塊內存來記錄數據總線上的電位狀態),就必須知道這塊內存對應的數字,而這個數字就被稱為地址。而通過給定的地址找到對應的內存單元就稱為尋址。
因此地址就是一個數字,用以唯一標識某一特定內存單元。此數字一般是32位長的二進制數,也就可以表示4G個狀態,也就是說一般的32位電腦都具有4G的內存空間尋址能力,即電腦最多裝4G的內存,如果電腦有超過4G的內存,此時就需要增加地址的長度,如用40位長的二進制數來表示。
類型
在本系列最開頭時已經說明了何謂編程,而剛才更進一步說明了電腦其實連數字都不認識,只是狀態的記錄,而所謂的加法也只是人為設計那個加法器以使得兩個狀態經過加法器的處理而生成的狀態正好和數學上的加法的結果一樣而已。這一切的一切都只說明一點:電腦所做的工作是什么,全視使用的人以為是什么。
因此為了利用電腦那很快的“計算”能力(實際是狀態的變換能力),人為規定了如何解釋那些狀態。為了方便其間,對于前面提出的電位的狀態,我們使用1位二進制數來表示,則上面提出的狀態就可以使用一個二進制數來表示,而所謂的“如何解釋那些狀態”就變成了如何解釋一個二進制數。
C++是高級語言,為了幫助解釋那些二進制數,提供了類型這個概念。類型就是人為制訂的如何解釋內存中的二進制數的協議。C++提供了下面的一些標準類型定義。
?signed?char??表示所指向的內存中的數字使用補碼形式,表示的數字為-128到+127,長度為1個字節?
?unsigned?char??表示所指向的內存中的數字使用原碼形式,表示的數字為0到255,長度為1個字節?
?signed?short??表示所指向的內存中的數字使用補碼形式,表示的數字為–32768到+32767,長度為2個字節?
?unsigned?short??表示所指向的內存中的數字使用原碼形式,表示的數字為0到65535,長度為2個字節?
?signed?long??表示所指向的內存中的數字使用補碼形式,表示的數字為-2147483648到+2147483647,長度為4個字節?
?unsigned?long??表示所指向的內存中的數字使用原碼形式,表示的數字為0到4294967295,長度為4個字節?
?signed?int?
?表示所指向的內存中的數字使用補碼形式,表示的數字則視編譯器。如果編譯器編譯時被指明編譯為在16位操作系統上運行,則等同于signed?short;如果是編譯為32位的,則等同于signed?long;如果是編譯為在64位操作系統上運行,則為8個字節長,而范圍則如上一樣可以自行推算出來。?
?unsigned?int??表示所指向的內存中的數字使用原碼形式,其余和signed?int一樣,表示的是無符號數。?
?bool??表示所指向的內存中的數字為邏輯值,取值為false或true。長度為1個字節。?
?float??表示所指向的內存按IEEE標準進行解釋,為real*4,占用4字節內存空間,等同于上篇中提到的單精度浮點數。?
?double??表示所指向的內存按IEEE標準進行解釋,為real*8,可表示數的精度較float高,占用8字節內存空間,等同于上篇提到的雙精度浮點數。?
?long?double??表示所指向的內存按IEEE標準進行解釋,為real*10,可表示數的精度較double高,但在為32位Windows操作系統編寫程序時,仍占用8字節內存空間,等效于double,只是如果CPU支持此類浮點類型則還是可以進行這個精度的計算。?
標準類型不止上面的幾個,后面還會陸續提到。
上面的長度為2個字節也就是將兩個連續的內存單元中的數字取出并合并在一起以表示一個數字,這和前面說的一個算盤表示不了的數字,就進位以加入另一個算盤幫助表示是同樣的道理。
上面的signed關鍵字是可以去掉的,即char等同于signed?char,用以簡化代碼的編寫。但也僅限于signed,如果是unsigned?char,則在使用時依舊必須是unsigned?char。
現在應該已經了解上篇中為什么數字還要分什么有符號無符號、長整型短整型之類的了,而上面的short、char等也都只是長度不同,這就由程序員自己根據可能出現的數字變化幅度來進行選用了。
類型只是對內存中的數字的解釋,但上面的類型看起來相對簡單了點,且語義并不是很強,即沒有什么特殊意思。為此,C++提供了自定義類型,也就是后繼文章中將要說明的結構、類等。
變量
在本系列的第一篇中已經說過,電腦編程的絕大部分工作就是操作內存,而上面說了,為了操作內存,需要使用地址來標識要操作的內存塊的首地址(上面的long表示連續的4個字節內存,其第一個內存單元的地址稱作這連續4個字節內存塊的首地址)。為此我們在編寫程序時必須記下地址。
做5+2/3-5*2的計算,先計算出2/3的值,寫在草稿紙上,接著算出5*2的值,又寫在草稿紙上。為了接下來的加法和減法運算,必須能夠知道草稿紙上的兩個數字哪個是2/3的值哪個是5*2的值。人就是通過記憶那兩個數在紙上的位置來記憶的,而電腦就是通過地址來標識的。但電腦只會做加減乘除,不會去主動記那些2/3、5*2的中間值的位置,也就是地址。因此程序員必須完成這個工作,將那兩個地址記下來。
問題就是這里只有兩個值,也許好記一些,但如果多了,人是很難記住哪個地址對應哪個值的,但人對符號比對數字要敏感得多,即人很容易記下一個名字而不是一個數字。為此,程序員就自己寫了一個表,表有兩列,一列是“2/3的值”,一列是對應的地址。如果式子稍微復雜點,那么那個表可能就有個二三十行,而每寫一行代碼就要去翻查相應的地址,如果來個幾萬行代碼那是人都不能忍受。
C++作為高級語言,很正常地提供了上面問題的解決之道,就是由編譯器來幫程序員維護那個表,要查的時候是編譯器去查,這也就是變量的功能。
變量是一個映射元素。上面提到的表由編譯器維護,而表中的每一行都是這個表的一個元素(也稱記錄)。表有三列:變量名、對應地址和相應類型。變量名是一個標識符,因此其命名規則完全按照上一篇所說的來。當要對某塊內存寫入數據時,程序員使用相應的變量名進行內存的標識,而表中的對應地址就記錄了這個地址,進而將程序員給出的變量名,一個標識符,映射成一個地址,因此變量是一個映射元素。而相應類型則告訴編譯器應該如何解釋此地址所指向的內存,是2個連續字節還是4個?是原碼記錄還是補碼?而變量所對應的地址所標識的內存的內容叫做此變量的值。
有如下的變量解釋:“可變的量,其相當于一個盒子,數字就裝在盒子里,而變量名就寫在盒子外面,這樣電腦就知道我們要處理哪一個盒子,且不同的盒子裝不同的東西,裝字符串的盒子就不能裝數字。”上面就是我第一次學習編程時,書上寫的(是BASIC語言)。對于初學者也許很容易理解,也不能說錯,但是造成的誤解將導致以后的程序編寫地千瘡百孔。
上面的解釋隱含了一個意思——變量是一塊內存。這是嚴重錯誤的!如果變量是一塊內存,那么C++中著名的引用類型將被棄置荒野。變量實際并不是一塊內存,只是一個映射元素,這是致關重要的。
內存的種類
前面已經說了內存是什么及其用處,但內存是不能隨便使用的,因為操作系統自己也要使用內存,而且現在的操作系統正常情況下都是多任務操作系統,即可同時執行多個程序,即使只有一個CPU。因此如果不對內存訪問加以節制,可能會破壞另一個程序的運作。比如我在紙上寫了2/3的值,而你未經我同意且未通知我就將那個值擦掉,并寫上5*2的值,結果我后面的所有計算也就出錯了。
因此為了使用一塊內存,需要向操作系統申請,由操作系統統一管理所有程序使用的內存。所以為了記錄一個long類型的數字,先向操作系統申請一塊連續的4字節長的內存空間,然后操作系統就會在內存中查看,看是否還有連續的4個字節長的內存,如果找到,則返回此4字節內存的首地址,然后編譯器編譯的指令將其記錄在前面提到的變量表中,最后就可以用它記錄一些臨時計算結果了。
上面的過程稱為要求操作系統分配一塊內存。這看起來很不錯,但是如果只為了4個字節就要求操作系統搜索一下內存狀況,那么如果需要100個臨時數據,就要求操作系統分配內存100次,很明顯地效率低下(無謂的99次查看內存狀況)。因此C++發現了這個問題,并且操作系統也提出了相應的解決方法,最后提出了如下的解決之道。
棧(Stack)??任何程序執行前,預先分配一固定長度的內存空間,這塊內存空間被稱作棧(這種說法并不準確,但由于實際涉及到線程,在此為了不將問題復雜化才這樣說明),也被叫做堆棧。那么在要求一個4字節內存時,實際是在這個已分配好的內存空間中獲取內存,即內存的維護工作由程序員自己來做,即程序員自己判斷可以使用哪些內存,而不是操作系統,直到已分配的內存用完。
很明顯,上面的工作是由編譯器來做的,不用程序員操心,因此就程序員的角度來看什么事情都沒發生,還是需要像原來那樣向操作系統申請內存,然后再使用。
但工作只是從操作系統變到程序自己而已,要維護內存,依然要耗費CPU的時間,不過要簡單多了,因為不用標記一塊內存是否有人使用,而專門記錄一個地址。此地址以上的內存空間就是有人正在使用的,而此地址以下的內存空間就是無人使用的。之所以是以下的空間為無人使用而不是以上,是當此地址減小到0時就可以知道堆棧溢出了(如果你已經有些基礎,請不要把0認為是虛擬內存地址,關于虛擬內存將會在《C++從零開始(十八)》中進行說明,這里如此解釋只是為了方便理解)。而且CPU還專門對此法提供了支持,給出了兩條指令,轉成匯編語言就是push和pop,表示壓棧和出棧,分別減小和增大那個地址。
而最重要的好處就是由于程序一開始執行時就已經分配了一大塊連續內存,用一個變量記錄這塊連續內存的首地址,然后程序中所有用到的,程序員以為是向操作系統分配的內存都可以通過那個首地址加上相應偏移來得到正確位置,而這很明顯地由編譯器做了。因此實際上等同于在編譯時期(即編譯器編譯程序的時候)就已經分配了內存(注意,實際編譯時期是不能分配內存的,因為分配內存是指程序運行時向操作系統申請內存,而這里由于使用堆棧,則編譯器將生成一些指令,以使得程序一開始就向操作系統申請內存,如果失敗則立刻退出,而如果不退出就表示那些內存已經分配到了,進而代碼中使用首地址加偏移來使用內存也就是有效的),但壞處也就是只能在編譯時期分配內存。
堆(Heap)??上面的工作是編譯器做的,即程序員并不參與堆棧的維護。但上面已經說了,堆棧相當于在編譯時期分配內存,因此一旦計算好某塊內存的偏移,則這塊內存就只能那么大,不能變化了(如果變化會導致其他內存塊的偏移錯誤)。比如要求客戶輸入定單數據,可能有10份定單,也可能有100份定單,如果一開始就定好了內存大小,則可能造成不必要的浪費,又或者內存不夠。
為了解決上面的問題,C++提供了另一個途徑,即允許程序員有兩種向操作系統申請內存的方式。前一種就是在棧上分配,申請的內存大小固定不變。后一種是在堆上分配,申請的內存大小可以在運行的時候變化,不是固定不變的。
那么什么叫堆?在Windows操作系統下,由操作系統分配的內存就叫做堆,而棧可以認為是在程序開始時就分配的堆(這并不準確,但為了不復雜化問題,故如此說明)。因此在堆上就可以分配大小變化的內存塊,因為是運行時期即時分配的內存,而不是編譯時期已計算好大小的內存塊。
變量的定義
上面說了那么多,你可能看得很暈,畢竟連一個實例都沒有,全是文字,下面就來幫助加深對上面的理解。
定義一個變量,就是向上面說的由編譯器維護的變量表中添加元素,其語法如下:
long?a;
先寫變量的類型,然后一個或多個空格或制表符(\t)或其它間隔符,接著變量的名字,最后用分號結束。要同時定義多個變量,則各變量間使用逗號隔開,如下:
long?a,?b,?c;?unsigned?short?e,?a_34c;
上面是兩條變量定義語句,各語句間用分號隔開,而各同類型變量間用逗號隔開。而前面的式子5+2/3-5*2,則如下書寫。
long?a?=?2/3,?b?=?5*2;?long?c?=?5?+?a?–?b;
可以不用再去記那煩人的地址了,只需記著a、b這種簡單的標識符。當然,上面的式子不一定非要那么寫,也可以寫成:long?c?=?5?+?2?/?3?–?5?*?2;?而那些a、b等中間變量編譯器會自動生成并使用(實際中編譯器由于優化的原因將直接計算出結果,而不會生成實際的計算代碼)。
下面就是問題的關鍵,定義變量就是添加一個映射。前面已經說了,這個映射是將變量名和一個地址關聯,因此在定義一個變量時,編譯器為了能將變量名和某個地址對應起來,幫程序員在前面提到的棧上分配了一塊內存,大小就視這個變量類型的大小。如上面的a、b、c的大小都是4個字節,而e、a_34c的大小都是2個字節。
假設編譯器分配的棧在一開始時的地址是1000,并假設變量a所對應的地址是1000-56,則b所對應的地址就是1000-60,而c所對應的就是1000-64,e對應的是1000-66,a_34c是1000-68。如果這時b突然不想是4字節了,而希望是8字節,則后續的c、e、a_34c都將由于還是原來的偏移位置而使用了錯誤的內存,這也就是為什么棧上分配的內存必須是固定大小。
考慮前面說的紅色文字:“變量實際并不是一塊內存,只是一個映射元素”。可是只要定義一個變量,就會相應地得到一塊內存,為什么不說變量就是一塊內存?上面定義變量時之所以會分配一塊內存是因為變量是一個映射元素,需要一個對應地址,因此才在棧上分配了一塊內存,并將其地址記錄到變量表中。但是變量是可以有別名的,即另一個名字。這個說法是不準確的,應該是變量所對應的內存塊有另一個名字,而不止是這個變量的名字。
為什么要有別名?這是語義的需要,表示既是什么又是什么。比如一塊內存,里面記錄了老板的信息,因此起名為Boss,但是老板又是另一家公司的行政經理,故變量名應該為Manager,而在程序中有段代碼是老板的公司相關的,而另一段是老板所在公司相關的,在這兩段程序中都要使用到老板的信息,那到底是使用Boss還是Manager?其實使用什么都不會對最終生成的機器代碼產生什么影響,但此處出于語義的需要就應該使用別名,以期從代碼上表現出所編寫程序的意思。
在C++中,為了支持變量別名,提供了引用變量這個概念。要定義一個引用變量,在定義變量時,在變量名的前面加一個“&”,如下書寫:
long?a;?long?&a1?=?a,?&a2?=?a,?&a3?=?a2;
上面的a1、a2、a3都是a所對應的內存塊的別名。這里在定義變量a時就在棧上分配了一塊4字節內存,而在定義a1時卻沒有分配任何內存,直接將變量a所映射的地址作為變量a1的映射地址,進而形成對定義a時所分配的內存的別名。因此上面的Boss和Manager,應該如下(其中Person是一個結構或類或其他什么自定義類型,這將在后繼的文章中陸續說明):
Person?Boss;?Person?&Manager?=?Boss;
由于變量一旦定義就不能改變(指前面說的變量表里的內容,不是變量的值),直到其被刪除,所以上面在定義引用變量的時候必須給出欲別名的變量以初始化前面的變量表,否則編譯器編譯時將報錯。
現在應該就更能理解前面關于變量的紅字的意思了。并不是每個變量定義時都會分配內存空間的。而關于如何在堆上分配內存,將在介紹完指針后予以說明,并進而說明上一篇遺留下來的關于字符串的問題。
C++從零開始(四)?
——賦值操作符?
本篇是《C++從零開始(二)》的延續,說明《C++從零開始(二)》中遺留下來的關于表達式的內容,并為下篇指針的運用做一點鋪墊。雖然上篇已經說明了變量是什么,但對于變量最關鍵的東西卻由于篇幅限制而沒有說明,下面先說明如何訪問內存。
賦值語句
前面已經說明,要訪問內存,就需要相應的地址以表明訪問哪塊內存,而變量是一個映射,因此變量名就相當于一個地址。對于內存的操作,在一般情況下就只有讀取內存中的數值和將數值寫入內存(不考慮分配和釋放內存),在C++中,為了將一數值寫入某變量對應的地址所標識的內存中(出于簡便,以后稱變量a對應的地址為變量a的地址,而直接稱變量a的地址所標識的內存為變量a),只需先書寫變量名,后接“=”,再接欲寫入的數字(關于數字,請參考《C++從零開始(二)》)以及分號。如下:
a?=?10.0f;?b?=?34;
由于接的是數字,因此就可以接表達式并由編譯器生成計算相應表達式所需的代碼,也就可如下:
c?=?a?/?b?*?120.4f;
上句編譯器將會生成進行除法和乘法計算的CPU指令,在計算完畢后(也就是求得表達式a?/?b?*?120.4f的值了后),也會同時生成將計算結果放到變量c中去的CPU指令,這就是語句的基本作用(對于語句,在《C++從零開始(六)》中會詳細說明)。
上面在書寫賦值語句時,應該確保此語句之前已經將使用到的變量定義過,這樣編譯器才能在生成賦值用的CPU指令時查找到相應變量的地址,進而完成CPU指令的生成。如上面的a和b,就需要在書寫上面語句前先書寫類似下面的變量定義:
float?a;?long?b;
直接書寫變量名也是一條語句,其導致編譯器生成一條讀取相應變量的內容的語句。即可以如下書寫:
a;
上面將生成一條讀取內存的語句,即使從內存中讀出來的數字沒有任何應用(當然,如果編譯器開了優化選項,則上面的語句將不會生成任何代碼)。從這一點以及上面的c?=?a?/?b?*?120.4f;語句中,都可以看出一點——變量是可以返回數字的。而變量返回的數字就是按照變量的類型來解釋變量對應內存中的內容所得到的數字。這句話也許不是那么容易理解,在看過后面的類型轉換一節后應該就可以理解了。
因此為了將數據寫入一塊內存,使用賦值語句(即等號);要讀取一塊內存,書寫標識內存的變量名。所以就可以這樣書寫:a?=?a?+?3;
假設a原來的值為1,則上面的賦值語句將a的值取出來,加上3,得到結果4,將4再寫入a中去。由于C++使用“=”來代表賦值語句,很容易使人和數學中的等號混淆起來,這點應注意。
而如上的float?a;語句,當還未對變量進行任何賦值操作時,a的值是什么?上帝才知道。當時的a的內容是什么(對于VC編譯器,在開啟了調試選項時,將會用0xCCCCCCCC填充這些未初始化內存),就用IEEE的real*4格式來解釋它并得到相應的一個數字,也就是a的值。因此應在變量定義的時候就進行賦值(但是會有性能上的影響,不過很小),以初始化變量而防止出現莫名其妙的值,如:float?a?=?0.0f;。
賦值操作符
上面的a?=?a?+?3;的意思就是讓a的值增加3。在C++中,對于這種情況給出了一種簡寫方案,即前面的語句可以寫成:a?+=?3;。應當注意這兩條語句從邏輯上講都是使變量a的值增3,但是它們實際是有區別的,后者可以被編譯成優化的代碼,因為其意思是使某一塊內存的值增加一定數量,而前者是將一個數字寫入到某塊內存中。所以如果可能,應盡量使用后者,即a?+=?3;。這種語句可以讓編譯器進行一定的優化(但由于現在的編譯器都非常智能,能夠發現a?=?a?+?3;是對一塊內存的增值操作而不是一塊內存的賦值操作,因此上面兩條語句實際上可以認為完全相同,僅僅只具有簡寫的功能了)。
對于上面的情況,也可以應用在減法、乘法等二元非邏輯操作符(不是邏輯值操作符,即不能a?&&=?3;)上,如:a?*=?3;?a?-=?4;?a?|=?34;?a?>>=?3;等。
除了上面的簡寫外,C++還提供了一種簡寫方式,即a++;,其邏輯上等同于a?+=?1;。同上,在電腦編程中,加一和減一是經常用到的,因此CPU專門提供了兩條指令來進行加一和減一操作(轉成匯編語言就是Inc和Dec),但速度比直接通過加法或減法指令來執行要快得多。為此C++中也就提供了“++”和“—”操作符來對應Inc和Dec。所以a++;雖然邏輯上和a?=?a?+?1;等效,實際由于編譯器可能做出的優化處理而不同,但還是如上,由于編譯器的智能化,其是有可能看出a?=?a?+?1;可以編譯成Inc指令進而即使沒有使用a++;卻也依然可以得到優化的代碼,這樣a++;將只剩下簡寫的意義而已。
應當注意一點,a?=?3;這句語句也將返回一個數字,也就是在a被賦完值后a的值。由于其可以返回數字,按照《C++從零開始(二)》中所說,“=”就屬于操作符,也就可以如下書寫:
c?=?4?+?(?a?=?3?);
之所以打括號是因為“=”的優先級較“+”低,而更常見和正常的應用是:c?=?a?=?3;
應該注意上面并不是將c和a賦值為3,而是在a被賦值為3后再將a賦值給c,雖然最后結果和c、a都賦值為3是一樣的,但不應該這樣理解。由于a++;表示的就是a?+=?1;就是a?=?a?+?1;,因此a++;也將返回一個數字。也由于這個原因,C++又提供了另一個簡寫方式,++a;。
假設a為1,則a++;將先返回a的值,1,然后再將a的值加一;而++a;先將a的值加一,再返回a的值,2。而a—和—a也是如此,只不過是減一罷了。
上面的變量a按照最上面的變量定義,是float類型的變量,對它使用++操作符并不能得到預想的優化,因為float類型是浮點類型,其是使用IEEE的real*4格式來表示數字的,而不是二進制原碼或補碼,而前面提到的Inc和Dec指令都是出于二進制的表示優點來進行快速增一和減一,所以如果對浮點類型的變量運用“++”操作符,將完全只是簡寫,沒有任何的優化效果(當然,如果CPU提供了新的指令集,如MMX等,以對real*4格式進行快速增一和減一操作,且編譯器支持相應指令集,則還是可以產生優化效果的)。
賦值操作符的返回值
在進一步了解++a和a++的區別前,先來了解何謂操作符的計算(Evaluate)。操作符就是將給定的數字做一些處理,然后返回一個數字。而操作符的計算也就是執行操作符的處理,并返回值。前面已經知道,操作符是個符號,其一側或兩側都可以接數字,也就是再接其他操作符,而又由于賦值操作符也屬于一種操作符,因此操作符的執行順序變得相當重要。
對于a?+?b?+?c,將先執行a?+?b,再執行(?a?+?b?)?+?c的操作。你可能覺得沒什么,那么如下,假設a之前為1:
c?=?(?a?*=?2?)?+?(?a?+=?3?);
上句執行后a為5。而c?=?(?a?+=?3?)?+?(?a?*=?2?);執行后,a就是8了。那么c呢?結果可能會大大的出乎你的意料。前者的c為10,而后者的c為16。
上面其實是一個障眼法,其中的“+”沒有任何意義,即之所以會從左向右執行并不是因為“+”的緣故,而是因為(?a?*=?2?)和(?a?+=?3?)的優先級相同,而按照“()”的計算順序,是從左向右來計算的。但為什么c的值不是預想的2?+?5和4?+?8呢?因為賦值操作符的返回值的關系。
賦值操作符返回的數字不是變量的值,而是變量對應的地址。這很重要。前面說過,光寫一個變量名就會返回相應變量的值,那是因為變量是一個映射,變量名就等同于一個地址。C++中將數字看作一個很特殊的操作符,即任何一個數字都是一個操作符。而地址就和長整型、單精度浮點數這類一樣,是數字的一種類型。當一個數字是地址類型時,作為操作符,其沒有要操作的數字,僅僅返回將此數字看作地址而標識的內存中的內容(用這個地址的類型來解釋)。地址可以通過多種途徑得到,如上面光寫一個變量名就可以得到其對應的地址,而得到的地址的類型也就是相應的變量的類型。如果這句話不能理解,在看過下面的類型轉換一節后應該就能了解了。
所以前面的c?=?(?a?+=?3?)?+?(?a?*=?2?);,由于“()”的參與改變了優先級而先執行了兩個賦值操作符,然后兩個賦值操作符都返回a的地址,然后計算“+”的值,分別計算兩邊的數字——a的地址(a的地址也是一個操作符),也就是已經執行過兩次賦值操作的a的值,得8,故最后的c為16。而另一個也由于同樣的原因使得c為10。
現在考慮操作符的計算順序。當同時出現了幾個優先級相同的操作符時,不同的操作符具有不同的計算順序。前面的“()”以及“-”、“*”等這類二元操作符的計算順序都是從左向右計算,而“!”、負號“-”等前面介紹過的一元操作符都是從右向左計算的,如:!-!!a;,假設a為3。先計算從左朝右數第三個“!”的值,導致計算a的地址的值,得3;然后邏輯取反得0,接著再計算第二個“!”的值,邏輯取反后得1,再計算負號“-”的值,得-1,最后計算第一個“!”的值,得0。
賦值操作符都是從右向左計算的,除了后綴“++”和后綴“—”(即上面的a++和a--)。因此上面的c?=?a?=?3;,因為兩個“=”優先級相同,從右向左計算,先計算a?=?3的值,返回a對應的地址,然后計算返回的地址而得到值3,再計算c?=?(?a?=?3?),將3寫入c。而不是從左向右計算,即先計算c?=?a,返回c的地址,然后再計算第二個“=”,將3寫入c,這樣a就沒有被賦值而出現問題。又:
a?=?1;?c?=?2;?c?*=?a?+=?4;
由于“*=”和“+=”的優先級相同,從右向左計算先計算a?+=?4,得a為5,然后返回a的地址,再計算a的地址得a的值5,計算“*=”以使得c的值為10。
因此按照前面所說,++a將返回a的地址,而a++也因為是賦值操作符而必須返回一個地址,但很明顯地不能是a的地址了,因此編譯器將編寫代碼以從棧中分配一塊和a同樣大小的內存,并將a的值復制到這塊臨時內存中,然后返回這塊臨時內存的地址。由于這塊臨時內存是因為編譯器的需要而分配的,與程序員完全沒有關系,因此程序員是不應該也不能寫這塊臨時內存的(因為編譯器負責編譯代碼,如果程序員欲訪問這塊內存,編譯器將報錯),但可以讀取它的值,這也是返回地址的主要目的。所以如下的語句沒有問題:
(?++a?)?=?a?+=?34;
但(?a++?)?=?a?+=?34;就會在編譯時報錯,因為a++返回的地址所標識的內存只能由編譯器負責處理,程序員只能獲得其值而已。
a++的意思是先返回a的值,也就是上面說的臨時內存的地址,然后再將變量的值加一。如果同時出現多個a++,那么每個a++都需要分配一塊臨時內存(注意前面c?=?(?a?+=?3?)?+?(?a?*=?2?);的說明),那么將有點糟糕,而且a++的意思是先返回a的值,那么到底是什么時候的a的值呢?在VC中,當表達式中出現后綴“++”或后綴“—”時,只分配一塊臨時內存,然后所有的后綴“++”或后綴“—”都返回這個臨時內存的地址,然后在所有的可以計算的其他操作符的值計算完畢后,再將對應變量的值寫入到臨時內存中,計算表達式的值,最后將對應變量的值加一或減一。
因此:a?=?1;?c?=?(?a++?)?+?(?a++?);執行后,c的值為2,而a的值為3。而如下:
a?=?1;?b?=?1;?c?=?(?++a?)?+?(?a++?)?+?(?b?*=?a++?)?+?(?a?*=?2?)?+?(?a?*=?a++?);
執行時,先分配臨時內存,然后由于5個“()”,其計算順序是從左向右,
計算++a的值,返回增一后的a的地址,a的值為2
計算a++的值,返回臨時內存的地址,a的值仍為2
計算b?*=?a++中的a++,返回臨時內存的地址,a的值仍為2
計算b?*=?a++中的“*=”,將a的值寫入臨時內存,計算得b的值為2,返回b的地址
計算a?*=?2的值,返回a的地址,a的值為4
計算a?*=?a++中的a++,返回臨時內存的地址,a的值仍為4
計算a?*=?a++中的“*=”,將a的值寫入臨時內存,返回a的地址,a的值為16
計算剩下的“+”,為了進行計算,將a的值寫入臨時內存,得值16?+?16?+?2?+?16?+?16為66,寫入c中
計算三個a++欠下的加一,a最后變為19。
上面說了那么多,無非只是想告誡你——在表達式中運用賦值操作符是不被推崇的。因為其不符合平常的數學表達式的習慣,且計算順序很容易搞混。如果有多個“++”操作符,最好還是將表達式分開,否則很容易導致錯誤的計算順序而計算錯誤。并且導致計算順序混亂的還不止上面的a++就完了,為了讓你更加地重視前面的紅字,下面將介紹更令人火大的東西,如果你已經同意上面的紅字,則下面這一節完全可以跳過,其對編程來講可以認為根本沒有任何意義(要不是為了寫這篇文章,我都不知道它的存在)。
序列點(Sequence?Point)和附加效果(Side?Effect)
在計算c?=?a++時,當c的值計算(Evaluate)出來時,a的值也增加了一,a的值加一就是計算前面表達式的附加效果。有什么問題?它可能影響表達式的計算結果。
對于a?=?0;?b?=?1;?(?a?*=?2?)?&&?(?b?+=?2?);,由于兩個“()”優先級相同,從左向右計算,計算“*=”而返回a的地址,再計算“+=”而返回b的地址,最后由于a的值為0而返回邏輯假。很正常,但效率低了點。
如果“&&”左邊的數字已經是0了,則不再需要計算右邊的式子。同樣,如果“||”左邊的數字已經非零了,也不需要再計算右邊的數字。因為“&&”和“||”都是數學上的,數學上不管先計算加號左邊的值還是右邊的值,結果都不會改變,因此“&&”和“||”才會做剛才的解釋。這也是C++保證的,既滿足數學的定義,又能提供優化的途徑(“&&”和“||”右邊的數字不用計算了)。
因此上面的式子就會被解釋成——如果a在自乘了2后的值為0,則b就不用再自增2了。這很明顯地違背了我們的初衷,認為b無論如何都會被自增2的。但是C++卻這樣保證,不僅僅是因為數學的定義,還由于代碼生成的優化。但是按照操作符的優先級進行計算,上面的b?+=?2依舊會被執行的(這也正是我們會書寫上面代碼的原因)。為了實現當a為0時b?+=?2不會被計算,C++提出了序列點的概念。
序列點是一些特殊位置,由C++強行定義(C++并未給出序列點的定義,因此不同的編譯器可能給出不同的序列點定義,VC是按照C語言定義的序列點)。當在進行操作符的計算時,如果遇到序列點,則序列點處的值必須被優先計算,以保證一些特殊用途,如上面的保證當a為0時不計算b?+=?2,并且序列點相關的操作符(如前面的“&&”和“||”)也將被計算完畢,然后才恢復正常的計算。
“&&”的左邊數字的計算就是一個序列點,而“||”的左邊數字的計算也是。C++定義了多個序列點,包括條件語句、函數參數等條件下的表達式計算,在此,不需要具體了解有哪些序列點,只需要知道由于序列點的存在而可能導致賦值操作符的計算出乎意料。下面就來分析一個例子:
a?=?0;?b?=?1;?(?a?*=?2?)?&&?(?b?+=?++a?);
按照優先級的順序,編譯器發現要先計算a?*=?2,再計算++a,接著“+=”,最后計算“&&”。然后編譯器發現這個計算過程中,出現了“&&”左邊的數字這個序列點,其要保證被優先計算,這樣就有可能不用計算b?+=?++a了。所以編譯器先計算“&&”的數字,通過上面的計算過程,編譯器發現就要計算a?*=?2才能得到“&&”左邊的數字,因此將先計算a?*=?2,返回a的地址,然后計算“&&”左邊的數字,得a的值為0,因此就不計算b?+=?++a了。而不是最開始想象的由于優先級的關系先將a加一后再進行a的計算,以返回1。所以上面計算完畢后,a為0,b為1,返回0,表示邏輯假。
因此序列點的出現是為了保證一些特殊規則的出現,如上面的“&&”和“||”。再考慮“,”操作符,其操作是計算兩邊的值,然后返回右邊的數字,即:a,?b?+?3將返回b?+?3的值,但是a依舊會被計算。由于“,”的優先級是最低的(但高于前面提到的“數字”操作符),因此如果a?=?3,?4;,那么a將為3而不是4,因為先計算“=”,返回a的地址后再計算“,”。又:
a?=?1;?b?=?0;?b?=?(?a?+=?2?)?+?(?(?a?*=?2,?b?=?a?-?1?)?&&?(?c?=?a?)?);
由于“&&”左邊數字是一個序列點,因此先計算a?*=?2,?b的值,但根據“,”的返回值定義,其只返回右邊的數字,因此不計算a?*=?2而直接計算b?=?a?–?1得0,“&&”就返回了,但是a?*=?2就沒有被計算而導致a的值依舊為1,這違背了“,”的定義。為了消除這一點(當然可能還有其他應用“,”的情況),C++也將“,”的左邊數字定為了序列點,即一定會優先執行“,”左邊的數字以保證“,”的定義——計算兩邊的數字。所以上面就由于“,”左邊數字這個序列點而導致a?*=?2被優先執行,并導致b為1,因此由于“&&”是序列點且其左邊數字非零而必須計算完右邊數字后才恢復正常優先級,而計算c?=?a,得2,最后才恢復正常優先級順序,執行a?+=?2和“+”。結果就a為4,c為2,b為5。
所以前面的a?=?3,?4;其實就應該是編譯器先發現“,”這個序列點,而發現要計算“,”左邊的值,必須先計算出a?=?3,因此才先計算a?=?3以至于感覺序列點好像沒有發生作用。下面的式子請自行分析,執行后a為4,但如果將其中的“,”換成“&&”,a為2。
a?=?1;?b?=?(?a?*=?2?)?+?(?(?a?*=?3?),?(?a?-=?2?)?);
如果上面你看得很暈,沒關系,因為上面的內容根本可以認為毫無意義,寫在這里也只是為了進一步向你證明,在表達式中運用賦值運算符是不好的,即使它可能讓你寫出看起來簡練的語句,但它也使代碼的可維護性降低。
類型轉換
在《C++從零開始(二)》中說過,數字可以是浮點數或是整型數或其他,也就是說數字是具有類型的。注意《C++從零開始(三)》中對類型的解釋,類型只是說明如何解釋狀態,而在前面已經說過,出于方便,使用二進制數來表示狀態,因此可以說類型是用于告訴編譯器如何解釋二進制數的。
所以,一個長整型數字是告訴編譯器將得到的二進制數表示的狀態按照二進制補碼的格式來解釋以得到一個數值,而一個單精度浮點數就是告訴編譯器將得到的二進制數表示的狀態按照IEEE的real*4的格式來解釋以得到一個是小數的數值。很明顯,同樣的二進制數表示的狀態,按照不同的類型進行解釋將得到不同的數值,那么編譯器如何知道應該使用什么類型來進行二進制數的解釋?
前面已經說過,數字是一種很特殊的操作符,其沒有操作數,僅僅返回由其類型而定的二進制數表示的狀態(以后為了方便,將“二進制數表示的狀態”稱作“二進制數”)。而操作符就是執行指令并返回數字,因此所有的操作符到最后一定執行的是返回一個二進制數。這點很重要,對于后面指針的理解有著重要的意義。
先看15;,這是一條語句,因為15是一個數字。所以15被認為是char類型的數字(因為其小于128,沒超出char的表示范圍),將返回一個8位長的二進制數,此二進制數按照補碼格式編寫,為00001111。
再看15.0f,同上,其由于接了“f”這個后綴而被認為是float類型的數字,將返回一個32位長的二進制數,此二進制數按照IEEE的real*4格式編寫,為1000001011100000000000000000000。
雖然上面15和15.0f的數值相等,但由于是不同的類型導致了使用不同的格式來表示,甚至連表示用的二進制數的長度都不相同。因此如果書寫15.0f?==?15;將返回0,表示邏輯假。但實際卻返回1,為什么?
上面既然15和15.0f被表示成完全不同的兩個二進制數,但我們又認為15和15.0f是相等的,但它們的二進制表示不同,怎么辦?將表示15.0f的二進制數用IEEE的real*4格式解釋出15這個數值,然后再將其按8位二進制補碼格式編寫出二進制數,再與原來的表示15的二進制數比較。
為了實現上面的操作,C++提供了類型轉換操作符——“()”。其看起來和括號操作符一樣,但是格式不同:(<類型名>)<數字>或<類型名>(<數字>)。
上面類型轉換操作符的<類型名>不是數字,因此其將不會被操作,而是作為一個參數來控制其如何操作后面的<數字>。<類型名>是一個標識符,其唯一標識一個類型,如char、float等。類型轉換操作符的返回值就如其名字所示,將<數字>按照<類型名>標識的類型來解釋,返回類型是<類型名>的數字。因此,上面的例子我們就需要如下編寫:15?==?(?char?)15.0f;,現在其就可以返回1,表示邏輯真了。但是即使不寫(?char?),前面的語句也返回1。這是編譯器出于方便的緣故而幫我們在15前添加了(?float?),所以依然返回1。這被稱作隱式類型轉換,在后面說明類的時候,還將提到它。
某個類型可以完全代替另一個類型時,編譯器就會進行上面的隱式類型轉換,自動添加類型轉換操作符。如:char只能表示-128到127的整數,而float很明顯地能夠表示這些數字,因此編譯器進行了隱式類型轉換。應當注意,這個隱式轉換是由操作符要求的,即前面的“==”要求兩面的數字類型一致,結果發現兩邊不同,結果編譯器將char轉成float,然后再執行“==”的操作。注意:在這種情況下,編譯器總是將較差的類型(如前面的char)轉成較好的類型(如前面的float),以保證不會發生數值截斷問題。如:-41?==?3543;,左邊是char,右邊是short,由于short相對于char來顯得更優(short能完全替代char),故實際為:(?short?)-41?==?3543;,返回0。而如果是-41?==?(?char?)3543;,由于char不能表示3543,則3543以補碼轉成二進制數0000110111010111,然后取其低8位,而導致高8位的00001101被丟棄,此被稱為截斷。結果(?char?)3543的返回值就是類型為char的二進制數11010111,為-41,結果-41?==?(?char?)3543;的返回值將為1,表示邏輯真,很明顯地錯誤。因此前面的15?==?15.0f;實際將為(?float?)15?==?15.0f;(注意這里說15被編譯器解釋為char類型并不準確,更多的編譯器是將它解釋成int類型)。
注意前面之所以會朝好的方向發展(即char轉成float),完全是因為“==”的緣故,其要求這么做。下面考慮“=”:short?b?=?3543;?char?a?=?b;。因為b的值是short類型,而“=”的要求就是一定要將“=”右邊的數字轉成和左邊一樣,這樣才能進行正確的內存的寫入(簡單地將右邊數字返回的二進制數復制到左邊的地址所表示的內存中)。因此a將為-41。但是上面是編譯器按照“=”的要求自行進行了隱式轉換,可能是由于程序員的疏忽而沒有發現這個錯誤(以為b的值一定在-128到127的范圍內),因此編譯器將對上面的情況給出一個警告,說b的值可能被截斷。為了消除編譯器的疑慮,如下:char?a?=?(?char?)b;。這樣稱為顯示類型轉換,其告訴編譯器——“我知道可能發生數據截斷,但是我保證不會截斷”。因此編譯器將不再發出警告。但是如下:char?a?=?(?char?)3543;,由于編譯器可以肯定3543一定會被截斷而導致錯誤的返回值,因此編譯器將給出警告,說明3543將被截斷,而不管前面的類型轉換操作符是否存在。
現在應該可以推出——15?+?15.0f;返回的是一個float類型的數字。因此如果如下:char?a?=?15?+?15.0f;,編譯器將發出警告,說數據可能被截斷。因此改成如下:char?a?=?(?char?)15?+?15.0f;,但類型轉換操作符“()”的優先級比“+”高,結果就是15先被轉換為char然后再由于“+”的要求而被隱式轉成float,最后返回float給“=”而導致編譯器依舊發出警告。為此,就需要提高“+”的優先級,如下:char?a?=?(?char?)(?15?+?15.0f?);就沒事了(或char(?15?+?15.0f?)),其表示我保證15?+?15.0f不會導致數據截斷。
應該注意類型轉換操作符“()”和前綴“++”、“!”、負號“-”等的優先級一樣,并且是從右向左計算的,因此(?char?)-34;將會先計算-34的值,然后再計算(?char?)的值,這也正好符合人的習慣。
下篇將針對數字這個特殊操作符而提出一系列的東西,因此如果理解了數字的意思,那么指針將很容易理解。
C++從零開始(五)?
——何謂指針?
本篇說明C++中的重中又重的關鍵——指針類型,并說明兩個很有意義的概念——靜態和動態。
數組
前面說了在C++中是通過變量來對內存進行訪問的,但根據前面的說明,C++中只能通過變量來操作內存,也就是說要操作某塊內存,就必須先將這塊內存的首地址和一個變量名綁定起來,這是很糟糕的。比如有100塊內存用以記錄100個工人的工資,現在要將每個工人的工資增加5%,為了知道各個工人增加了后的工資為多少,就定義一個變量float?a1;,用其記錄第1個工人的工資,然后執行語句a1?+=?a1?*?0.05f;,則a1里就是增加后的工資。由于是100個工人,所以就必須有100個變量,分別記錄100個工資。因此上面的賦值語句就需要有100條,每條僅僅變量名不一樣。
上面需要手工重復書寫變量定義語句float?a1;100遍(每次變一個變量名),無謂的工作。因此想到一次向操作系統申請100*4=400個字節的連續內存,那么要給第i個工人修改工資,只需從首地址開始加上4*i個字節就行了(因為float占用4個字節)。
為了提供這個功能,C++提出了一種類型——數組。數組即一組數字,其中的各個數字稱作相應數組的元素,各元素的大小一定相等(因為數組中的元素是靠固定的偏移來標識的),即數組表示一組相同類型的數字,其在內存中一定是連續存放的。在定義變量時,要表示某個變量是數組類型時,在變量名的后面加上方括號,在方括號中指明欲申請的數組元素個數,以分號結束。因此上面的記錄100個工資的變量,即可如下定義成數組類型的變量:
float?a[100];
上面定義了一個變量a,分配了100*4=400個字節的連續內存(因為一個float元素占用4個字節),然后將其首地址和變量名a相綁定。而變量a的類型就被稱作具有100個float類型元素的數組。即將如下解釋變量a所對應內存中的內容(類型就是如何解釋內存的內容):a所對應的地址標識的內存是一塊連續內存的首地址,這塊連續內存的大小剛好能容納下100個float類型的數字。
因此可以將前面的float?b;這種定義看成是定義了一個元素的float數組變量b。而為了能夠訪問數組中的某個元素,在變量名后接方括號,方括號中放一數字,數字必須是非浮點數,即使用二進制原碼或補碼進行表示的數字。如a[?5?+?3?]?+=?32;就是數組變量a的第5?+?3個元素的值增加32。又:
long?c?=?23;?float?b?=?a[?(?c?–?3?)?/?5?]?+?10,?d?=?a[?c?–?23?];
上面的b的值就為數組變量a的第4個元素的值加10,而d的值就為數組變量a的第0個元素的值。即C++的數組中的元素是以0為基本序號來記數的,即a[0]實際代表的是數組變量a中的第一個元素的值,而之所以是0,表示a所對應的地址加上0*4后得到的地址就為第一個元素的地址。
應該注意不能這樣寫:long?a[0];,定義0個元素的數組是無意義的,編譯器將報錯,不過在結構或類或聯合中符合某些規則后可以這樣寫,那是C語言時代提出的一種實現結構類型的長度可變的技術,在《C++從零開始(九)》中將說明。
還應注意上面在定義數組時不能在方括號內寫變量,即long?b?=?10;?float?a[?b?];是錯誤的,因為編譯此代碼時,無法知道變量b的值為多少,進而無法分配內存。可是前面明明已經寫了b?=?10;,為什么還說不知道b的值?那是因為無法知道b所對應的地址是多少。因為編譯器編譯時只是將b和一個偏移進行了綁定,并不是真正的地址,即b所對應的可能是Base?-?54,而其中的Base就是在程序一開始執行時動態向操作系統申請的大塊內存的尾地址,因為其可能變化,故無法得知b實際對應的地址(實際在Windows平臺下,由于虛擬地址空間的運用,是可以得到實際對應的虛擬地址,但依舊不是實際地址,故無法編譯時期知道某變量的值)。
但是編譯器仍然可以根據前面的long?b?=?10;而推出Base?-?54的值為10啊?重點就是編譯器看到long?b?=?10;時,只是知道要生成一條指令,此指令將10放入Base?-?54的內存中,其它將不再過問(也沒必要過問),故即使才寫了long?b?=?10;編譯器也無法得知b的值。
上面說數組是一種類型,其實并不準確,實際應為——數組是一種類型修飾符,其定義了一種類型修飾規則。關于類型修飾符,后面將詳述。
字符串
在《C++從零開始(二)》中已經說過,要查某個字符對應的ASCII碼,通過在這個字符的兩側加上單引號,如'A'就等同于65。而要表示多個字符時,就使用雙引號括起來,如:"ABC"。而為了記錄字符,就需要記錄下其對應的ASCII碼,而ASCII碼的數值在-128到127以內,因此使用一個char變量就可以記錄一個ASCII碼,而為了記錄"ABC",就很正常地使用一個char的數組來記錄。如下:
char?a?=?'A';?char?b[10];?b[0]?=?'A';?b[1]?=?'B';?b[2]?=?'C';
上面a的值為65,b[0]的值為65,b[1]為66,b[2]為67。因為b為一個10元素的數組,在這其記錄了一個3個字符長度的字符串,但是當得到b的地址時,如何知道其第幾個元素才是有效的字符?如上面的b[4]就沒有賦值,那如何知道b[4]不應該被解釋為字符?可以如下,從第0個元素開始依次檢查每個char元素的值,直到遇到某個char元素的值為0(因為ASCII碼表中0沒有對應的字符),則其前面的所有的元素都認為是應該用ASCII碼表來解釋的字符。故還應b[3]?=?0;以表示字符串的結束。
上面的規則被廣泛運用,C運行時期庫中提供的所有有關字符串的操作都是基于上面的規則來解釋字符串的(關于C運行時期庫,可參考《C++從零開始(十九)》)。但上面為了記錄一個字符串,顯得煩瑣了點,字符串有多長就需要寫幾個賦值語句,而且還需要將末尾的元素賦值為0,如果搞忘則問題嚴重。對于此,C++強制提供了一種簡寫方式,如下:
char?b[10]?=?"ABC";
上面就等效于前面所做的所有工作,其中的"ABC"是一個地址類型的數字(準確的說是一初始化表達式,在《C++從零開始(九)》中說明),其類型為char[4],即一個4個元素的char數組,多了一個末尾元素用于放0來標識字符串的結束。應當注意,由于b為char[10],而"ABC"返回的是char[4],類型并不匹配,需要隱式類型轉換,但實際沒有進行轉換,而是做了一系列的賦值操作(就如前面所做的工作),這是C++硬性規定的,稱為初始化,且僅僅對于數組定義時進行初始化有效,即如下是錯誤的:
char?b[10];?b?=?"ABC";
而即使是char?b[4];?b?=?"ABC";也依舊錯誤,因為b的類型是數組,表示的是多個元素,而對多個元素賦值是未定義的,即:float?d[4];?float?dd[4]?=?d;也是錯誤的,因為沒定義d中的元素是依次順序放到dd中的相應各元素,還是倒序放到,所以是不能對一個數組類型的變量進行賦值的。
由于現在字符的增多(原來只用英文字母,現在需要能表示中文、日文等多種字符),原來使用char類型來表示字符,最多也只能表示255種字符(0用來表示字符串結束),所以出現了所謂的多字節字符串(MultiByte),用這種表示方式記錄的文本文件稱為是MBCS格式的,而原來使用char類型進行表示的字符串稱為單字節字符串(SingleByte),用這種表示方式記錄的文本文件稱為是ANSI格式的。
由于char類型可以表示負數,則當從字符串中提取字符時,如果所得元素的數值是負的,則將此元素和下一個char元素聯合起來形成一short類型的數字,再按照Unicode編碼規則(一種編碼規則,等同于前面提過的ASCII碼表)來解釋這個short類型的數字以得到相應的字符。
而上面的"ABC"返回的就是以多字節格式表示的字符串,因為沒有漢字或特殊符號,故好象是用單字節格式表示的,但如果:char?b[10]?=?"AB漢C";,則b[2]為-70,b[5]為0,而不是想象的由于4個字符故b[4]為0,因為“漢”這個字符占用了兩個字節。
上面的多字節格式的壞處是每個字符的長度不固定,如果想取字符串中的第3個字符的值,則必須從頭開始依次檢查每個元素的值而不能是3乘上某個固定長度,降低了字符串的處理速度,且在顯示字符串時由于需要比較檢查當前字符的值是否小于零而降低效率,故又推出了第三種字符表示格式:寬字節字符串(WideChar),用這種表示方式記錄的文本文件稱為是Unicode格式的。其與多字節的區別就是不管這個字符是否能夠用ASCII表示出來,都用一個short類型的數字來表示,即每個字符的長度固定為2字節,C++對此提供了支持。
short?b[10]?=?L"AB漢C";
在雙引號的前面加上“L”(必須是大寫的,不能小寫)即告訴編譯器此雙引號內的字符要使用Unicode格式來編碼,故上面的b數組就是使用Unicode來記錄字符串的。同樣,也有:short?c?=?L'A';,其中的c為65。
如果上面看得不是很明白,不要緊,在以后舉出的例子中將會逐漸了解字符串的使用的。
靜態和動態
上面依然沒有解決根本問題——C++依舊只能通過變量這個映射元素來訪問內存,在訪問某塊內存前,一定要先建立相應的映射,即定義變量。有什么壞處?讓我們先來了解靜態和動態是什么意思。
收銀員開發票,手動,則每次開發票時,都用已經印好的發票聯給客人開發票,發票聯上只印了4個格子用以記錄商品的名稱,當客人一次買的商品超過4種以上時,就必須開兩張或多張發票。這里發票聯上的格子的數量就被稱作靜態的,即無論任何時候任何客人買東西,開發票時發票聯上都印著4個記錄商品名稱用的格子。
超市的收銀員開發票,將商品名稱及數量等輸入電腦,然后即時打印出一張發票給客人,則不同的客人,打印出的發票的長度可能不同(有的客人買得多而有的少),此時發票的長度就稱為動態的,即不同時間不同客人買東西,開出的發票長度可能不同。
程序無論執行多少遍,在申請內存時總是申請固定大小的內存,則稱此內存是靜態分配的。前面提出的定義變量時,編譯器幫我們從棧上分配的內存就屬于靜態分配。每次執行程序,根據用戶輸入的不同而可能申請不同大小的內存時,則稱此內存是動態分配的,后面說的從堆上分配就屬于動態分配。
很明顯,動態比靜態的效率高(發票長度的利用率高),但要求更高——需要電腦和打印機,且需要收銀員的素質較高(能操作電腦),而靜態的要求就較低,只需要已經印好的發票聯,且也只需收銀員會寫字即可。
同樣,靜態分配的內存利用率不高或運用不夠靈活,但代碼容易編寫且運行速度較快;動態分配的內存利用率高,不過編寫代碼時要復雜些,需自己處理內存的管理(分配和釋放)且由于這種管理的介入而運行速度較慢并代碼長度增加。
靜態和動態的意義不僅僅如此,其有很多的深化,如硬編碼和軟編碼、緊耦合和松耦合,都是靜態和動態的深化。
地址
前面說過“地址就是一個數字,用以唯一標識某一特定內存單元”,而后又說“而地址就和長整型、單精度浮點數這類一樣,是數字的一種類型”,那地址既是數字又是數字的類型?不是有點矛盾嗎?如下:
浮點數是一種數——小數——又是一種數字類型。即前面的前者是地址實際中的運用,而后者是由于電腦只認識狀態,但是給出的狀態要如何處理就必須通過類型來說明,所以地址這種類型就是用來告訴編譯器以內存單元的標識來處理對應的狀態。
指針
已經了解到動態分配內存和靜態分配內存的不同,現在要記錄用戶輸入的定單數據,用戶一次輸入的定單數量不定,故選擇在堆上分配內存。假設現在根據用戶的輸入,需申請1M的內存以對用戶輸入的數據進行臨時記錄,則為了操作這1M的連續內存,需記錄其首地址,但又由于此內存是動態分配的,即其不是由編譯器分配(而是程序的代碼動態分配的),故未能建立一變量來映射此首地址,因此必須自己來記錄此首地址。
因為任何一個地址都是4個字節長的二進制數(對32位操作系統),故靜態分配一塊4字節內存來記錄此首地址。檢查前面,可以將首地址這個數據存在unsigned?long類型的變量a中,然后為了讀取此1M內存中的第4個字節處的4字節長內存的內容,通過將a的值加上4即可獲得相應的地址,然后取出其后連續的4個字節內存的內容。但是如何編寫取某地址對應內存的內容的代碼呢?前面說了,只要返回地址類型的數字,由于是地址類型,則其會自動取相應內容的。但如果直接寫:a?+?4,由于a是unsigned?long,則a?+?4返回的是unsigned?long類型,不是地址類型,怎么辦?
C++對此提出了一個操作符——“*”,叫做取內容操作符(實際這個叫法并不準確)。其和乘號操作符一樣,但是它只在右側接數字,即*(?a?+?4?)。此表達式返回的就是把a的值加上4后的unsigned?long數字轉成地址類型的數字。但是有個問題:a?+?4所表示的內存的內容如何解釋?即取1個字節還是2個字節?以什么格式來解釋取出的內容?如果自己編寫匯編代碼,這就不是問題了,但現在是編譯器代我們編寫匯編代碼,因此必須通過一種手段告訴編譯器如何解釋給定的地址所對內存的內容。
C++對此提出了指針,其和上面的數組一樣,是一種類型修飾符。在定義變量時,在變量名的前面加上“*”即表示相應變量是指針類型(就如在變量名后接“[]”表示相應變量是數組類型一樣),其大小固定為4字節。如:
unsigned?long?*pA;
上面pA就是一個指針變量,其大小因為是為32位操作系統編寫代碼故為4字節,當*pA;時,先計算pA的值,就是返回從pA所對應地址的內存開始,取連續4個字節的內容,然后計算“*”,將剛取到的內容轉成unsigned?long的地址類型的數字,接著計算此地址類型的數字,返回以原碼格式解釋其內容而得到一個unsigned?long的數字,最后計算這個unsigned?long的數字而返回以原碼格式解釋它而得的二進制數。
也就是說,某個地址的類型為指針時,表示此地址對應的內存中的內容,應該被編譯器解釋成一個地址。
因為變量就是地址的映射,每個變量都有個對應的地址,為此C++又提供了一個操作符來取某個變量的地址——“&”,稱作取地址操作符。其與“數字與”操作符一樣,不過它總是在右側接數字(而不是兩側接數字)。
“&”的右側只能接地址類型的數字,它的計算(Evaluate)就是將右側的地址類型的數字簡單的類型轉換成指針類型并進而返回一個指針類型的數字,正好和取內容操作符“*”相反。
上面正常情況下應該會讓你很暈,下面釋疑。
unsigned?long?a?=?10,?b,?*pA;?pA?=?&a;?b?=?*pA;?(?*pA?)++;
上面的第一句通過“*pA”定義了一個指針類型的變量pA,即編譯器幫我們在棧上分配了一塊4字節的內存,并將首地址和pA綁定(即形成映射)。然后“&a”由于a是一個變量,等同于地址,所以“&a”進行計算,返回一個類型為unsigned?long*(即unsigned?long的指針)的數字。
應該注意上面返回的數字雖然是指針類型,但是其值和a對應的地址相同,但為什么不直接說是unsigned?long的地址的數字,而又多一個指針類型在其中攪和?因為指針類型的數字是直接返回其二進制數值,而地址類型的數字是返回其二進制數值對應的內存的內容。因此假設上面的變量a所對應的地址為2000,則a;將返回10,而&a;將返回2000。
看下指針類型的返回值是什么。當書寫pA;時,返回pA對應的地址(按照上面的假設就應該是2008),計算此地址的值,返回數字2000(因為已經pA?=?&a;),其類型是unsigned?long*,然后對這個unsigned?long*的數字進行計算,直接返回2000所對應的二進制數(注意前面紅字的內容)。
再來看取內容操作符“*”,其右接的數字類型是指針類型或數組類型,它的計算就是將此指針類型的數字直接轉換成地址類型的數字而已(因為指針類型的數字和地址類型的數字在數值上是相同的,僅僅計算規則不同)。所以:
b?=?*pA;
返回pA對應的地址,計算此地址的值,返回類型為unsigned?long*的數字2000,然后“*pA”返回類型unsigned?long的地址類型的數字2000,然后計算此地址類型的數字的值,返回10,然后就只是簡單地賦值操作了。同理,對于++(?*pA?)(由于“*”的優先級低于前綴++,所以加“()”),先計算“*pA”而返回unsigned?long的地址類型的數字2000,然后計算前綴++,最后返回unsigned?long的地址類型的數字2000。
如果你還是未能理解地址類型和指針類型的區別,希望下面這句能夠有用:地址類型的數字是在編譯時期給編譯器用的,指針類型的數字是在運行時期給代碼用的。如果還是不甚理解,在看過后面的類型修飾符一節后希望能有所幫助。
在堆上分配內存
前面已經說過,所謂的在堆上分配就是運行時期向操作系統申請內存,而要向操作系統申請內存,不同的操作系統提供了不同的接口,具有不同的申請內存的方式,而這主要通過需調用的函數原型不同來表現(關于函數原型,可參考《C++從零開始(七)》)。由于C++是一門語言,不應該是操作系統相關的,所以C++提供了一個統一的申請內存的接口,即new操作符。如下:
unsigned?long?*pA?=?new?unsigned?long;?*pA?=?10;
unsigned?long?*pB?=?new?unsigned?long[?*pA?];
上面就申請了兩塊內存,pA所指的內存(即pA的值所對應的內存)是4字節大小,而pB所指的內存是4*10=40字節大小。應該注意,由于new是一個操作符,其結構為new?<類型名>[<整型數字>]。它返回指針類型的數字,其中的<類型名>指明了什么樣的指針類型,而后面方括號的作用和定義數組時一樣,用于指明元素的個數,但其返回的并不是數組類型,而是指針類型。
應該注意上面的new操作符是向操作系統申請內存,并不是分配內存,即其是有可能失敗的。當內存不足或其他原因時,new有可能返回數值為0的指針類型的數字以表示內存分配失敗。即可如下檢測內存是否分配成功。
unsigned?long?*pA?=?new?unsigned?long[10000];
if(?!pA?)
//?內存失敗!做相應的工作
上面的if是判斷語句,下篇將介紹。如果pA為0,則!pA的邏輯取反就是非零,故為邏輯真,進而執行相應的工作。
只要分配了內存就需要釋放內存,這雖然不是必須的,但是作為程序員,它是一個良好習慣(資源是有限的)。為了釋放內存,使用delete操作符,如下:
delete?pA;?delete[]?pB;
注意delete操作符并不返回任何數字,但是其仍被稱作操作符,看起來它應該被叫做語句更加合適,但為了滿足其依舊是操作符的特性,C++提供了一種很特殊的數字類型——void。其表示無,即什么都不是,這在《C++從零開始(七)》中將詳細說明。因此delete其實是要返回數字的,只不過返回的數字類型為void罷了。
注意上面對pA和pB的釋放不同,因為pA按照最開始的書寫,是new?unsigned?long返回的,而pB是new?unsigned?long[?*pA?]返回的。所以需要在釋放pB時在delete的后面加上“[]”以表示釋放的是數組,不過在VC中,不管前者還是后者,都能正確釋放內存,無需“[]”的介入以幫助編譯器來正確釋放內存,因為以Windows為平臺而開發程序的VC是按照Windows操作系統的方式來進行內存分配的,而Windows操作系統在釋放內存時,無需知道欲釋放的內存塊的長度,因為其已經在內部記錄下來(這種說法并不準確,實際應是C運行時期庫干了這些事,但其又是依賴于操作系統來干的,即其實是有兩層對內存管理的包裝,在此不表)。
類型修飾符(type-specifier)
類型修飾符,即對類型起修飾作用的符號,在定義變量時用于進一步指明如何操作變量對應的內存。因為一些通用操作方式,即這種操作方式對每種類型都適用,故將它們單獨分離出來以方便代碼的編寫,就好像水果。吃蘋果的果肉、吃梨的果肉,不吃蘋果的皮、不吃梨的皮。這里蘋果和梨都是水果的種類,相當于類型,而“XXX的果肉”、“XXX的皮”就是用于修飾蘋果或梨這種類型用的,以生成一種新的類型——蘋果的果肉、梨的皮,其就相當于類型修飾符。
本文所介紹的數組和指針都是類型修飾符,之前提過的引用變量的“&”也是類型修飾符,在《C++從零開始(七)》中將再提出幾種類型修飾符,到時也將一同說明聲明和定義這兩個重要概念,并提出聲明修飾符(decl-specifier)。
類型修飾符只在定義變量時起作用,如前面的unsigned?long?a,?b[10],?*pA?=?&a,?&rA?=?a;。這里就使用了上面的三個類型修飾符——“[]”、“*”和“&”。上面的unsigned?long暫且叫作原類型,表示未被類型修飾符修飾以前的類型。下面分別說明這三個類型修飾符的作用。
數組修飾符“[]”——其總是接在變量名的后面,方括號中間放一整型數c以指明數組元素的個數,以表示當前類型為原類型c個元素連續存放,長度為原類型的長度乘以c。因此long?a[10];就表示a的類型是10個long類型元素連續存放,長度為10*4=40字節。而long?a[10][4];就表示a是10個long[4]類型的元素連續存放,其長度為10*(4*4)=160字節。
相信已經發現,由于可以接多個“[]”,因此就有了計算順序的關系,為什么不是4個long[10]類型的元素連續存放而是倒過來?類型修飾符的修飾順序是從左向右進行計算的,但當出現重復的類型修飾符時,同類修飾符之間是從右向左計算以符合人們的習慣。故short?*a[10];表示的是10個類型為short*的元素連續存放,長度為10*4=40字節,而short?*b[4][10];表示4個類型為short*[10]的元素連續存放,長度為4*40=160字節。
指針修飾符“*”——其總是接在變量名的前面,表示當前類型為原類型的指針。故:
short?a?=?10,?*pA?=?&a,?**ppA?=?&pA;
注意這里的ppA被稱作多級指針,即其類型為short的指針的指針,也就是short**。而short?**ppA?=?&pA;的意思就是計算pA的地址的值,得一類型為short*的地址類型的數字,然后“&”操作符將此數字轉成short*的指針類型的數字,最后賦值給變量ppA。
如果上面很昏,不用去細想,只要注意類型匹配就可以了,下面簡要說明一下:假設a的地址為2000,則pA的地址為2002,ppA的地址為2006。
對于pA?=?&a;。先計算“&a”的值,因為a等同于地址,則“&”發揮作用,直接將a的地址這個數字轉成short*類型并返回,然后賦值給pA,則pA的值為2000。
對于ppA?=?&pA;。先計算“&pA”的值,因為pA等同于地址,則“&”發揮作用,直接將pA的地址這個數字轉成short**類型(因為pA已經是short*的類型了)并返回,然后賦值給ppA,則ppA的值為2002。
引用修飾符“&”——其總是接在變量名的前面,表示此變量不用分配內存以和其綁定,而在說明類型時,則不能有它,下面說明。由于表示相應變量不用分配內存以生成映射,故其不像上述兩種類型修飾符,可以多次重復書寫,因為沒有意義。且其一定在“*”修飾符的右邊,即可以short?**&b?=?ppA;但不能short?*&*b;或short?&**b;因為按照從左到右的修飾符計算順序,short*&*表示short的指針的引用的指針,引用只是告知編譯器不要為變量在棧上分配內存,實際與類型無關,故引用的指針是無意義的。而short&**則表示short的引用的指針的指針,同上,依舊無意義。同樣long?&a[40];也是錯誤的,因為其表示分配一塊可連續存放類型為long的引用的40個元素的內存,引用只是告知編譯器一些類型無關信息的一種手段,無法作為類型的一種而被實例化(關于實例化,請參看《C++從零開始(十)》)。
應該注意引用并不是類型(但出于方便,經常都將long的引用稱作一種類型),而long?**&rppA?=?&pA;將是錯誤的,因為上句表示的是不要給變量rppA分配內存,直接使用“=”后面的地址作為其對應的地址,而&pA返回的并不是地址類型的數字,而是指針類型,故編譯器將報類型不匹配的錯誤。但是即使long?**&rppA?=?pA;也同樣失敗,因為long*和long**是不同的,不過由于類型的匹配,下面是可以的(其中的rpA2很令人疑惑,將在《C++從零開始(七)》中說明):
long?a?=?10,?*pA?=?&a,?**ppA?=?&pA,?*&rpA1?=?*ppA,?*&rpA2?=?*(?ppA?+?1?);
類型修飾符和原類型組合在一起以形成新的類型,如long*&、short?*[34]等,都是新的類型,應注意前面new操作符中的<類型名>要求寫入類型名稱,則也可以寫上前面的long*等,即:
long?**ppA?=?new?long*[45];
即動態分配一塊4*45=180字節的連續內存空間,并將首地址返回給ppA。同樣也就可以:
long?***pppA?=?new?long**[2];
而long?*(*pA)[10]?=?new?long*[20][10];
也許看起來很奇怪,其中的pA的類型為long?*(*)[10],表示是一個有10個long*元素的數組的指針,而分配的內存的長度為(4*10)*20=800字節。因為數組修飾符“[]”只能放在變量名后面,而類型修飾符又總是從左朝右計算,則想說明是一個10個long元素的數組的指針就不行,因為放在左側的“*”總是較右側的“[]”先進行類型修飾。故C++提出上面的語法,即將變量名用括號括起來,表示里面的類型最后修飾,故:long?*(a)[10];等同于long?*a[10];,而long?*(&aa)[10]?=?a;也才能夠正確,否則按照前面的規則,使用long?*&aa[10]?=?a;將報錯(前面已說明原因)。而long?*(*pA)[10]?=?&a;也就能很正常地表示我們需要的類型了。因此還可以long?*(*&rpA)[10]?=?pA;以及long?*(**ppA)[10]?=?&pA;。
限于篇幅,還有部分關于指針的討論將放到《C++從零開始(七)》中說明,如果本文看得很暈,后面在舉例時將會盡量說明指針的用途及用法,希望能有所幫助。
C++從零開始(六)?
——何謂語句?
前面已經說過程序就是方法的描述,而方法的描述無外乎就是動作加動作的賓語,而這里的動作在C++中就是通過語句來表現的,而動作的賓語,也就是能夠被操作的資源,但非常可惜地C++語言本身只支持一種資源——內存。由于電腦實際可以操作不止內存這一種資源,導致C++語言實際并不能作為底層硬件程序的編寫語言(即使是C語言也不能),不過各編譯器廠商都提供了自己的嵌入式匯編語句功能(也可能沒提供或提供其它的附加語法以使得可以操作硬件),對于VC,通過使用__asm語句即可實現在C++代碼中加入匯編代碼來操作其他類型的硬件資源。對于此語句,本系列不做說明。
語句就是動作,C++中共有兩種語句:單句和復合語句。復合語句是用一對大括號括起來,以在需要的地方同時放入多條單句,如:{?long?a?=?10;?a?+=?34;?}。而單句都是以“;”結尾的,但也可能由于在末尾要插入單句的地方用復合語句代替了而用“}”結尾,如:if(?a?)?{?a--;?a++;?}。應注意大括號后就不用再寫“;”了,因為其不是單句。
方法就是怎么做,而怎么做就是在什么樣的情況下以什么樣的順序做什么樣的動作。因為C++中能操作的資源只有內存,故動作也就很簡單的只是關于內存內容的運算和賦值取值等,也就是前面說過的表達式。而對于“什么樣的順序”,C++強行規定只能從上朝下,從左朝右來執行單句或復合語句(不要和前面關于表達式的計算順序搞混了,那只是在一個單句中的規則)。而最后對于“什么樣的情況”,即進行條件的判斷。為了不同情況下能執行不同的代碼,C++定義了跳轉語句來實現,其是基于CPU的運行規則來實現的,下面先來看CPU是如何執行機器代碼的。
機器代碼的運行方式
前面已經說過,C++中的所有代碼到最后都要變成CPU能夠認識的機器代碼,而機器代碼由于是方法的描述也就包含了動作和動作的賓語(也可能不帶賓語),即機器指令和內存地址或其他硬件資源的標識,并且全部都是用二進制數表示的。很正常,這些代表機器代碼的二進制數出于效率的考慮在執行時要放到內存中(實際也可以放在硬盤或其他存儲設備中),則很正常地每個機器指令都能有一個地址和其相對應。
CPU內帶一種功能和內存一樣的用于暫時記錄二進制數的硬件,稱作寄存器,其讀取速度較內存要快很多,但大小就小許多了。為了加快讀取速度,寄存器被去掉了尋址電路進而一個寄存器只能存放1個32位的二進制數(對于32位電腦)。而CPU就使用其中的一個寄存器來記錄當前欲運行的機器指令的位置,在此稱它為指令寄存器。
CPU運行時,就取出指令寄存器的值,進而找到相應的內存,讀取1個字節的內容,查看此8位二進制數對應的機器指令是什么,進而做相應的動作。由于不同的指令可能有不同數量的參數(即前面說的動作的賓語)需要,如乘法指令要兩個參數以將它們乘起來,而取反操作只需要一個參數的參與。并且兩個8位二進制數的乘法和兩個16位二進制數的乘法也不相同,故不同的指令帶不同的參數而形成的機器代碼的長度可能不同。每次CPU執行完某條機器代碼后,就將指令寄存器的內容加上此機器代碼的長度以使指令寄存器指向下一條機器代碼,進而重復上面的過程以實現程序的運行(這只是簡單地說明,實際由于各種技術的加入,如高速緩沖等,實際的運行過程要比這復雜得多)。
語句的分類
在C++中,語句總共有6種:聲明語句、定義語句、表達式語句、指令語句、預編譯語句和注釋語句。其中的聲明語句下篇說明,預編譯語句將在《C++從零開始(十六)》中說明,而定義語句就是前面已經見過的定義變量,后面還將說明定義函數、結構等。表達式語句則就是一個表達式直接接一個“;”,如:34;、a?=?34;等,以依靠操作符的計算功能的定義而生成相應的關于內存值操作的代碼。注釋語句就是用于注釋代碼的語句,即寫來給人看的,不是給編譯器看的。最后的指令語句就是含有下面所述關鍵字的語句,即它們的用處不是操作內存,而是實現前面說的“什么樣的情況”。
這里的聲明語句、預編譯語句和注釋語句都不會轉換成機器代碼,即這三種語句不是為了操作電腦,而是其他用途,以后將詳述。而定義語句也不一定會生成機器代碼,只有表達式語句和指令語句一定會生成代碼(不考慮編譯器的優化功能)。
還應注意可以寫空語句,即;或{},它們不會生成任何代碼,其作用僅僅只是為了保證語法上的正確,后面將看到這一點。下面說明注釋語句和指令語句——跳轉語句、判斷語句和循環語句(實際不止這些,由于異常和模板技術的引入而增加了一些語句,將分別在說明異常和模板時說明)。
注釋語句——//、/**/
注釋,即用于解釋的標注,即一些文字信息,用以向看源代碼的人解釋這段代碼什么意思,因為人的認知空間和電腦的完全不同,這在以后說明如何編程時會具體討論。要書寫一段話用以注釋,用“/*”和“*/”將這段話括起來,如下:
long?a?=?1;
a?+=?1;??/*?a放的是人的個數,讓人的個數加一?*/
b?*=?a;??/*?b放的是人均花費,得到總的花費?*/
上面就分別針對a?+=?1;和b?*=?a;寫了兩條注釋語句以說明各自的語義(因為只要會C++都知道它們是一個變量的自增一和另一個變量的自乘a,但不知道意義)。上面的麻煩之處就是需要寫“/*”和“*/”,有點麻煩,故C++又提供了另一種注釋語句——“//”:
long?a?=?1;
a?+=?1;??//?a放的是人的個數,讓人的個數加一
b?*=?a;??//?b放的是人均花費,得到總的花費
上面和前面等效,其中的“//”表示從它開始,這一行后面的所有字符均看成注釋,編譯器將不予理會,即
long?a?=?1;?a?+=?1;??//?a放的是人的個數,讓人的個數加一?b?*=?a;
其中的b?*=?a;將不會被編譯,因為前面的“//”已經告訴編譯器,從“//”開始,這一行后面的所有字符均是注釋,故編譯器不會編譯b?*=?a;。但如果
long?a?=?1;?a?+=?1;??/*?a放的是人的個數,讓人的個數加一?*/?b?*=?a;
這樣編譯器依舊會編譯b?*=?a;,因為“/*”和“*/”括起來的才是注釋。
應該注意注釋語句并不是語句,其不以“;”結束,其只是另一種語法以提供注釋功能,就好象以后將要說明的預編譯語句一樣,都不是語句,都不以“;”結束,既不是單句也不是復合語句,只是出于習慣的原因依舊將它們稱作語句。
跳轉語句——goto
前面已經說明,源代碼(在此指用C++編寫的代碼)中的語句依次地轉變成用長度不同的二進制數表示的機器代碼,然后順序放在內存中(這種說法不準確)。如下面這段代碼:
long?a?=?1;??//?假設長度為5字節,地址為3000
a?+=?1;??//?則其地址為3005,假設長度為4字節
b?*=?a;??//?則其地址為3009,假設長度為6字節
上面的3000、3005和3009就表示上面3條語句在內存中的位置,而所謂的跳轉語句,也就是將上面的3000、3005等語句的地址放到前面提過的指令寄存器中以使得CPU開始從給定的位置執行以表現出執行順序的改變。因此,就必須有一種手段來表現語句的地址,C++對此給出了標號(Label)。
寫一標識符,后接“:”即建立了一映射,將此標識符和其所在位置的地址綁定了起來,如下:
long?a?=?1;??//?假設長度為5字節,地址為3000
P1:
a?+=?1;??//?則其地址為3005,假設長度為4字節
P2:
b?*=?a;??//?則其地址為3009,假設長度為6字節
goto?P2;
上面的P1和P2就是標號,其值分別為3005和3009,而最后的goto就是跳轉語句,其格式為goto?<標號>;。此語句非常簡單,先通過“:”定義了一個標號,然后在編寫goto時使用不同的標號就能跳到不同的位置。
應該注意上面故意讓P1和P2定義時獨占一行,其實也可以不用,即:
long?a?=?1;?P1:?a?+=?1;?P2:?b?*=?a;?goto?P2;
因此看起來“P1:”和“P2:”好象是單獨的一條定義語句,應該注意,準確地說它們應該是語句修飾符,作用是定義標號,并不是語句,即這樣是錯誤的:
long?a?=?1;?P1:?{?a?+=?1;?P2:?b?*=?a;?P3:?}?goto?P2;
上面的P3:將報錯,因為其沒有修飾任何語句。還應注意其中的P1仍然是3005,即“{}”僅僅只是其復合的作用,實際并不產生代碼進而不影響語句的地址。
判斷語句——if?else、switch
if?else??前面說過了,為了實現“什么樣的情況”做“什么樣的動作”,故C++非常正常地提供了條件判斷語句以實現條件的不同而執行不同的代碼。if?else的格式為:
if(<數字>)<語句1>else<語句2>??或者??if(<數字>)<語句1>
long?a?=?0,?b?=?1;
P1:
a++;
b?*=?a;
if(?a?<?10?)
goto?P1;
long?c?=?b;
上面的代碼就表示只有當a的值小于10時,才跳轉到P1以重復執行,最后的效果就是c的值為10的階乘。
上面的<數字>表示可以在“if”后的括號中放一數字,即表達式,而當此數字的值非零時,即邏輯真,程序跳轉以執行<語句1>,如果為零,即邏輯假,則執行<語句2>。即也可如此:if(?a?–?10?)?goto?P1;,其表示當a?–?10不為零時才執行goto?P1;。這和前面的效果一樣,雖然最后c仍然是10的階乘,但意義不同,代碼的可讀性下降,除非出于效率的考慮,不推薦如此書寫代碼。
而<語句1>和<語句2>由于是語句,也就可以放任何是語句的東西,因此也可以這樣:
if(?a?)?long?c;
上面可謂吃飽了撐了,在此只是為了說明<語句1>實際可以放任何是語句的東西,但由于前面已經說過,標號的定義以及注釋語句和預編譯語句其實都不是語句,因此下面試圖當a非零時,定義標號P2和當a為零時書寫注釋“錯誤!”的意圖是錯誤的:
if(?a?)?P2:??或者if(?!a?)??//?錯誤!
a++;?a++;
但編譯器不會報錯,因為前者實際是當a非零時,將a自增一;后者實際是當a為零時,將a自增一。還應注意,由于復合語句也是語句,因此:
if(?a?){?long?c?=?0;?c++;?}
由于使用了復合語句,因此這個判斷語句并不是以“;”結尾,但它依舊是一個單句,即:
if(?a?)
if(?a?<?10?)?{?long?c?=?0;?c++;?}
else
b?*=?a;
上面雖然看起來很復雜,但依舊是一個單句,應該注意當寫了一個“else”時,編譯器向上尋找最近的一個“if”以和其匹配,因此上面的“else”是和“if(?a?<?10?)”匹配的,而不是由于上面那樣的縮進書寫而和“if(?a?)”匹配,因此b?*=?a;只有在a大于等于10的時候才執行,而不是想象的a為零的時候。
還應注意前面書寫的if(?a?)?long?c;。這里的意思并不是如果a非零,就定義變量c,這里涉及到作用域的問題,將在下篇說明。
switch??這個語句的定義或多或少地是因為實現的原因而不是和“if?else”一樣由于邏輯的原因。先來看它的格式:switch(<整型數字>)<語句>。
上面的<整型數字>和if語句一樣,只要是一個數字就可以了,但不同地必須是整型數字(后面說明原因)。然后其后的<語句>與前相同,只要是語句就可以。在<語句>中,應該使用這樣的形式:case?<整型常數1>:。它在它所對應的位置定義了一個標號,即前面goto語句使用的東西,表示如果<整型數字>和<整型常數1>相等,程序就跳轉到“case?<整型常數1>:”所標識的位置,否則接著執行后續的語句。
long?a,?b?=?3;
switch(?a?+?3?)
case?2:?case?3:?a++;
b?*=?a;
上面就表示如果a?+?3等于2或3,就跳到a++;的地址,進而執行a++,否則接著執行后面的語句b?*=?a;。這看起來很荒謬,有什么用?一條語句當然沒意義,為了能夠標識多條語句,必須使用復合語句,即如下:
long?a,?b?=?3;
switch(?a?+?3?)
{
b?=?0;
case?2:
a++;?//?假設地址為3003
case?3:
a--;?//?假設地址為3004
break;
case?1:
a?*=?a;??//?假設地址為3006
}
b?*=?a;??//?假設地址為3010
應該注意上面的“2:”、“3:”、“1:”在這里看著都是整型的數字,但實際應該把它們理解為標號。因此,上面檢查a?+?3的值,如果等于1,就跳到“1:”標識的地址,即3006;如果為2,則跳轉到3003的地方執行代碼;如果為3,則跳到3004的位置繼續執行。而上面的break;語句是特定的,其放在switch后接的語句中表示打斷,使程序跳轉到switch以后,對于上面就是3010以執行b?*=?a;。即還可如此:
switch(?a?)?if(?a?)?break;
由于是跳到相應位置,因此如果a為-1,則將執行a++;,然后執行a--;,再執行break;而跳到3010地址處執行b?*=?a;。并且,上面的b?=?0;將永遠不會被執行。
switch表示的是針對某個變量的值,其不同的取值將導致執行不同的語句,非常適合實現狀態的選擇。比如用1表示安全,2表示有點危險,3表示比較危險而4表示非常危險,通過書寫一個switch語句就能根據某個怪物當前的狀態來決定其應該做“逃跑”還是“攻擊”或其他的行動以實現游戲中的人工智能。那不是很奇怪嗎?上面的switch通過if語句也可以實現,為什么要專門提供一個switch語句?如果只是為了簡寫,那為什么不順便提供多一些類似這種邏輯方案的簡寫,而僅僅只提供了一個分支選擇的簡寫和后面將說的循環的簡寫?因為其是出于一種優化技術而提出的,就好象后面的循環語句一樣,它們對邏輯的貢獻都可以通過if語句來實現(畢竟邏輯就是判斷),而它們的提出一定程度都是基于某種優化技術,不過后面的循環語句簡寫的成分要大一些。
我們給出一個數組,數組的每個元素都是4個字節大小,則對于上面的switch語句,如下:
unsigned?long?Addr[3];?Addr[0]?=?3006;?Addr[1]?=?3003;?Addr[2]?=?3004;
而對于switch(?a?+?3?),則使用類似的語句就可以代替:goto?Addr[?a?+?3?–?1?];
上面就是switch的真面目,應注意上面的goto的寫法是錯誤的,這也正是為什么會有switch語句。編譯器為我們構建一個存儲地址的數組,這個數組的每個元素都是一個地址,其表示的是某條語句的地址,這樣,通過不同的偏移即可實現跳轉到不同的位置以執行不同的語句進而表現出狀態的選擇。
現在應該了解為什么上面必須是<整型數字>了,因為這些數字將用于數組的下標或者是偏移,因此必須是整數。而<整型常數1>必須是常數,因為其由編譯時期告訴編譯器它現在所在位置應放在地址數組的第幾個元素中。
了解了switch的實現后,以后在書寫switch時,應盡量將各case后接的整型常數或其倍數靠攏以減小需生成的數組的大小,而無需管常數的大小。即case?1000、case1001、case?1002和case?2、case?4、case?6都只用3個元素大小的數組,而case?0、case?100、case?101就需要102個元素大小的數組。應該注意,現在的編譯器都很智能,當發現如剛才的后者這種只有3個分支卻要102個元素大小的數組時,編譯器是有可能使用重復的if語句來代替上面數組的生成。
switch還提供了一個關鍵字——default。如下:
long?a,?b?=?3;
switch(?a?+?3?)
{
case?2:
a++;
break;
case?3:
a?+=?3;
break;
default:
a--;
}
b?*=?a;
上面的“default:”表示當a?+?3不為2且不為3時,則執行a--;,即default表示缺省的狀況,但也可以沒有,則將直接執行switch后的語句,因此這是可以的:switch(?a?){}或switch(?a?);,只不過毫無意義罷了。
循環語句——for、while、do?while
剛剛已經說明,循環語句的提供主要是出于簡寫目的,因為循環是方法描述中用得最多的,且算法并不復雜,進而對編譯器的開發難度不是增加太多。
for??其格式為for(<數字1>;<數字2>;<數字3>)<語句>。其中的<語句>同上,即可接單句也可接復合語句。而<數字1>、<數字2>和<數字3>由于是數字,就是表達式,進而可以做表達式語句能做的所有的工作——操作符的計算。for語句的意思是先計算<數字1>,相當于初始化工作,然后計算<數字2>。如果<數字2>的值為零,表示邏輯假,則退出循環,執行for后面的語句,否則執行<語句>,然后計算<數字3>,相當于每次循環的例行公事,接著再計算<數字2>,并重復。上面的<語句>一般被稱作循環體。
上面的設計是一種面向過程的設計思想,將循環體看作是一個過程,則這個過程的初始化(<數字1>)和必定執行(<數字3>)都表現出來。一個簡單的循環,如下:
long?a,?b;
for(?a?=?1,?b?=?1;?a?<=?10;?a++?)
b?*=?a;
上面執行完后b是10的階乘,和前面在說明if語句時舉的例子相比,其要簡單地多,并且可讀性更好——a?=?1,?b?=?1是初始化操作,每次循環都將a加一,這些信息是goto和if語句表現不出來的。由于前面一再強調的語句和數字的概念,因此可以如下:
long?a,?b?=?1;
for(?;?b?<?100;?)
for(?a?=?1,?b?=?1;?a;?++a,?++b?)
if(?b?*=?a?)
switch(?a?=?b?)
{
case?1:
a++;?break;
case?2:
for(?b?=?10;?b;?b--?)
{
a?+=?b?*?b;
case?3:?a?*=?a;
}
break;
}
上面看著很混亂,注意“case?3:”在“case?2:”后的一個for語句的循環體中,也就是說,當a?=?b返回1時,跳到a++;處,并由于break;的緣故而執行switch后的語句,也就是if后的語句,也就是第二個for語句的++a,?++b。當返回2時,跳到第三個for語句處開始執行,循環完后同樣由break;而繼續后面的執行。當返回3時,跳到a?*=?a;處執行,然后計算b--,接著計算b的值,檢查是否非零,然后重復循環直到b的值為零,然后繼續以后的執行。上面的代碼并沒什么意義,在這里是故意寫成這么混亂以進一步說明前面提過的語句和數字的概念,如果真正執行,大致看過去也很容易知道將是一個死循環,即永遠循環無法退出的循環。
還應注意C++提出了一種特殊語法,即上面的<數字1>可以不是數字,而是一變量定義語句,即可如此:for(?long?a?=?1,?b?=?1;?a?<?10;?++a,?++b?);。其中就定義了變量a和b。但是也只能接變量定義語句,而結構定義、類定義及函數定義語句將不能寫在這里。這個語法的提出是更進一步地將for語句定義為記數式循環的過程,這里的變量定義語句就是用于定義此循環中充當計數器的變量(上面的a)以實現循環固定次數。
最后還應注意上面寫的<數字1>、<數字2>和<數字3>都是可選的,即可以:for(;;);。
while??其格式為while(<數字>)<語句>,其中的<數字>和<語句>都同上,意思很明顯,當<數字>非零時,執行<語句>,否則執行while后面的語句,這里的<語句>被稱作循環體。
do?while??其格式為do<語句>while(<數字>);。注意,在while后接了“;”以表示這個單句的結束。其中的<數字>和<語句>都同上,意思很明顯,當<數字>非零時,執行<語句>,否則執行while后面的語句,這里的<語句>被稱作循環體。
為什么C++要提供上面的三種循環語句?簡寫是一重要目的,但更重要的是可以提供一定的優化。for被設計成用于固定次數的循環,而while和do?while都是用于條件決定的循環。對于前者,編譯器就可以將前面提過的用于記數的變量映射成寄存器以優化速度,而后者就要視編譯器的智能程度來決定是否能生成優化代碼了。
while和do?while的主要區別就是前者的循環體不一定會被執行,而后者的循環體一定至少會被執行一次。而出于簡寫的目的,C++又提出了continue和break語句。如下:
for(?long?i?=?0;?i?<?10;?i++?)
{
if(?!(?i?%?3?)?)
continue;
if(?!(?i?%?7?)?)
break;
//?其他語句
}
上面當i的值能被3整除時,就不執行后面的“其他語句”,而是直接計算i++,再計算i?<?10以決定是否繼續循環。即continue就是終止當前這次循環的執行,開始下一次的循環。上面當i的值能被7整除時,就不執行后面的“其他語句”,而是跳出循環體,執行for后的語句。即break就是終止循環的運行,立即跳出循環體。如下:
while(?--i?)?do
{{
if(?i?==?10?)if(?i?==?10?)
continue;continue;
if(?i?>?20?)?if(?i?>?20?)
break;???break;
//?其他語句???//?其他語句
}}while(?--i?);
a?=?i;???a?=?i;
上面的continue;執行時都將立即計算—i以判斷是否繼續循環,而break;執行時都將立即退出循環體進而執行后繼的a?=?i;。
還應注意嵌套問題,即前面說過的else在尋找配對的if時,總是找最近的一個if,這里依舊。
long?a?=?0;
P1:
for(?long?i?=?a;?i?<?10;?i++?)
for(?long?j?=?0;?j?<?10;?j++?)
{
if(?!(?j?%?3?)?)
continue;
if(?!(?j?%?7?)?)
break;
if(?i?*?j?)
{
a?=?i?*?j;
goto?P1;
}
//?其他語句
}
上面的continue;執行后,將立即計算j++,而break;執行后,將退出第二個循環(即j的循環),進而執行i++,然后繼續由i?<?10來決定是否繼續循環。當goto?P1;執行時,程序跳到上面的P1處,即執行long?i?=?a;,進而重新開始i的循環。
上面那樣書寫goto語句是不被推薦的,因為其破壞了循環,不符合人的思維習慣。在此只是要說明,for或while、do?while等都不是循環,只是它們各自的用處最后表現出來好象是循環,實際只是程序執行位置的變化。應清楚語句的實現,這樣才能清楚地了解各種語句的實際作用,進而明確他人寫的代碼的意思。而對于自己書寫代碼,了解語句的實現,將有助于進行一定的優化。但當你寫出即精簡又執行效率高的程序時,保持其良好的可讀性是一個程序員的素養,應盡量培養自己書寫可讀性高的代碼的習慣。
上面的long?j?=?0在第一個循環的循環體內,被多次執行豈不是要多次定義?這屬于變量的作用域的問題,下篇將說明。
本篇的內容應該是很簡單的,重點只是應該理解源代碼編譯成機器指令后,在執行時也放在內存中,故每條語句都對應著一個地址,而通過跳轉語句即可改變程序的運行順序。下篇將對此提出一系列的概念,并說明聲明和定義的區別。
C++從零開始(七)?
——何謂函數?
本篇之前的內容都是基礎中的基礎,理論上只需前面所說的內容即可編寫出幾乎任何只操作內存的程序,也就是本篇以后說明的內容都可以使用之前的內容自己實現,只不過相對要麻煩和復雜許多罷了。
本篇開始要比較深入地討論C++提出的很有意義的功能,它們大多數和前面的switch語句一樣,是一種技術的實現,但更為重要的是提供了語義的概念。所以,本篇開始將主要從它們提供的語義這方面來說明各自的用途,而不像之前通過實現原理來說明(不過還是會說明一下實現原理的)。為了能清楚說明這些功能,要求讀者現在至少能使用VC來編譯并生成一段程序,因為后續的許多例子都最好是能實際編譯并觀察執行結果以加深理解(尤其是聲明和類型這兩個概念)。為此,如果你現在還不會使用VC或其他編譯器來進行編譯代碼,請先參看其他資料以了解如何使用VC進行編譯。為了后續例子的說明,下面先說明一些預備知識。
預備知識
寫出了C++代碼,要如何讓編譯器編譯?在文本文件中書寫C++代碼,然后將文本文件的文件名作為編譯器的輸入參數傳遞給編譯器,即叫編譯器編譯給定文件名所對應的文件。在VC中,這些由VC這個編程環境(也就是一個軟件,提供諸多方便軟件開發的功能)幫我們做了,其通過項目(Project)來統一管理書寫有C/C++代碼的源文件。為了讓VC能了解到哪些文件是源文件(因為還可能有資源文件等其他類型文件),在用文本編輯器書寫了C++代碼后,將其保存為擴展名為.c或.cpp(C?Plus?Plus)的文本文件,前者表示是C代碼,而后者表示C++代碼,則缺省情況下,VC就能根據不同的源文件而使用不同的編譯語法來編譯源文件。
前篇說過,C++中的每條語句都是從上朝下執行,每條語句都對應著一個地址,那么在源文件中的第一條語句對應的地址就是0嗎?當然不是,和在棧上分配內存一樣,只能得到相對偏移值,實際的物理地址由于不同的操作系統將會有各自不同的處理,如在Windows下,代碼甚至可以沒有物理地址,且代碼對應的物理地址還能隨時變化。
當要編寫一個稍微正常點的程序時,就會發現一個源文件一般是不夠的,需要使用多個源文件來寫代碼。而各源文件之間要如何連接起來?對此C++規定,凡是生成代碼的語句都要放在函數中,而不能直接寫在文本文件中。關于函數后面馬上說明,現在只需知道函數相當于一個外殼,它通過一對“{}”將代碼括起來,進而就將代碼分成了一段一段,且每一段代碼都由函數名這個項目內唯一的標識符來標識,因此要連接各段代碼,只用通過函數名即可,后面說明。前面說的“生成代碼”指的是表達式語句和指令語句,雖然定義語句也可能生成代碼,但由于其代碼生成的特殊性,是可以直接寫在源文件內(在《C++從零開始(十)》中說明),即不用被一對“{}”括起來。
程序一開始要從哪里執行?C++強行規定,應該在源文件中定義一個名為main的函數,而代碼就從這個函數處開始運行。應該注意由于C++是由編譯器實現的,而它的這個規定非常的牽強,因此縱多的編譯器都又自行提供了另外的程序入口點定義語法(程序入口點即最開始執行的函數),如VC,為了編寫DLL文件,就不應有main函數;為了編寫基于Win32的程序,就應該使用WinMain而不是main;而VC實際提供了更加靈活的手段,實際可以讓程序從任何一個函數開始執行,而不一定非得是前面的WinMain、main等,這在《C++從零開始(十九)》中說明。
對于后面的說明,應知道程序從main函數開始運行,如下:
long?a;?void?main(){?short?b;?b++;?}?long?c;
上面實際先執行的是long?a;和long?c;,不過不用在意,實際有意義的語句是從short?b;開始的。
函數(Function)
機器手焊接轎車車架上的焊接點,給出焊接點的三維坐標,機器手就通過控制各關節的馬達來使焊槍移到準確的位置。這里控制焊槍移動的程序一旦編好,以后要求機器手焊接車架上的200個點,就可以簡單地給出200個點的坐標,然后調用前面已經編好的移動程序200次就行了,而不用再對每次移動重復編寫代碼。上面的移動程序就可以用一個函數來表示。
函數是一個映射元素。其和變量一樣,將一個標識符(即函數名)和一個地址關聯起來,且也有一類型和其關聯,稱作函數的返回類型。函數和變量不同的就是函數關聯的地址一定是代碼的地址,就好像前面說明的標號一樣,但和標號不同的就是,C++將函數定義為一種類型,而標號則只是純粹的二進制數,即函數名對應的地址可以被類型修飾符修飾以使得編譯器能生成正確的代碼來幫助程序員書實現上面的功能。
由于定義函數時編譯器并不會分配內存,因此引用修飾符“&”不再其作用,同樣,由數組修飾符“[]”的定義也能知道其不能作用于函數上面,只有留下的指針修飾符“*”可以,因為函數名對應的是某種函數類型的地址類型的數字。
前面移動程序之所以能被不同地調用200次,是因為其寫得很靈活,能根據不同的情況(不同位置的點)來改變自己的運行效果。為了向移動程序傳遞用于說明情況的信息(即點的坐標),必須有東西來完成這件事,在C++中,這使用參數來實現,并對于此,C++專門提供了一種類型修飾符——函數修飾符“()”。在說明函數修飾符之前,讓我們先來了解何謂抽象聲明符(Abstract?Declarator)。
聲明一個變量long?a;(這看起來和定義變量一樣,后面將說明它們的區別),其中的long是類型,用于修飾此變量名a所對應的地址。將聲明變量時(即前面的寫法)的變量名去掉后剩下的東西稱作抽象聲明符。比如:long?*a,?&b?=?*a,?c[10],?(?*d?)[10];,則變量a、b、c、d所對應的聲明修飾符分別是long*、long&、long[10]、long(*)[10]。
函數修飾符接在函數名的后面,括號內接零個或多個抽象聲明符以表示參數的類型,中間用“,”隔開。而參數就是一些內存(分別由參數名映射),用于傳遞一些必要的信息給函數名對應的地址處的代碼以實現相應的功能。聲明一個函數如下:
long?*ABC(?long*,?long&,?long[10],?long(*)[10]?);
上面就聲明了一個函數ABC,其類型為long*(?long*,?long&,?long[10],?long(*)[10]?),表示欲執行此函數對應地址處開始的代碼,需要順序提供4個參數,類型如上,返回值類型為long*。上面ABC的類型其實就是一個抽象聲明符,因此也可如下:
long?AB(?long*(?long*,?long&,?long[10],?long(*)[10]?),?short,?long&?);
對于前面的移動程序,就可類似如下聲明它:
void?Move(?float?x,?float?y,?float?z?);
上面在書寫聲明修飾符時又加上了參數名,以表示對應參數的映射。不過由于這里是函數的聲明,上述參數名實際不產生任何映射,因為這是函數的聲明,不是定義(關于聲明,后面將說明)。而這里寫上參數名是一種語義的體現,表示第一、二、三個參數分別代表X、Y、Z坐標值。
上面的返回類型為void,前面提過,void是C++提供的一種特殊數字類型,其僅僅只是為了保障語法的嚴密性而已,即任何函數執行后都要返回一個數字(后面將說明),而對于不用返回數字的函數,則可以定義返回類型為void,這樣就可以保證語法的嚴密性。應當注意,任何類型的數字都可以轉換成void類型,即可以(?void?)(?234?);或void(?a?);。
注意上面函數修飾符中可以一個抽象修飾符都沒有,即void?ABC();。它等效于void?ABC(?void?);,表示ABC這個函數沒有參數且不返回值。則它們的抽象聲明符為void()或void(void),進而可以如下:
long*?ABC(?long*(),?long(),?long[10]?);
由函數修飾符的意義即可看出其和引用修飾符一樣,不能重復修飾類型,即不能void?A()(long);,這是無意義的。同樣,由于類型修飾符從左朝右的修飾順序,也就很正常地有:void(*pA)()。假設這里是一個變量定義語句(也可以看成是一聲明語句,后面說明),則表示要求編譯器在棧上分配一塊4字節的空間,將此地址和pA映射起來,其類型為沒有參數,返回值類型為void的函數的指針。有什么用?以后將說明。
函數定義
下面先看下函數定義,對于前面的機器手控制程序,可如下書寫:
void?Move(?float?x,?float?y,?float?z?)
{
float?temp;
//?根據x、y、z的值來移動焊槍
}
int?main()
{
float?x[200],?y[200],?z[200];
//?將200個點的坐標放到數組x、y和z中
for(?unsigned?i?=?0;?i?<?200;?i++?)
Move(?x[?i?],?y[?i?],?z[?i?]?);
return?0;
}
上面定義了一個函數Move,其對應的地址為定義語句float?temp;所在的地址,但實際由于編譯器要幫我們生成一些附加代碼(稱作函數前綴——Prolog,在《C++從零開始(十五)》中說明)以獲得參數的值或其他工作(如異常的處理等),因此Move將對應在較float?temp;之前的某個地址。Move后接的類型修飾符較之前有點變化,只是把變量名加上以使其不是抽象聲明符而已,其作用就是讓編譯器生成一映射,將加上的變量名和傳遞相應信息的內存的地址綁定起來,也就形成了所謂的參數。也由于此原因,就能如此書寫:void?Move(?float?x,?float,?float?z?)?{?}。由于沒有給第二個參數綁定變量名,因此將無法使用第二個參數,以后將舉例說明這樣的意義。
函數的定義就和前面的函數的聲明一樣,只不過必須緊接其后書寫一個復合語句(必須是復合語句,即用“{}”括起來的語句),此復合語句的地址將和此函數名綁定,但由于前面提到的函數前綴,函數名實際對應的地址在復合語句的地址的前面。
為了調用給定函數,C++提供了函數操作符“()”,其前面接函數類型的數字,而中間根據相應函數的參數類型和個數,放相應類型的數字和個數,因此上面的Move(?x[?i?],?y[?i?],?z[?i?]?);就是使用了函數操作符,用x[?i?]、y[?i?]、z[?i?]的值作為參數,并記錄下當前所在位置的地址,跳轉到Move所對應的地址繼續執行,當從Move返回時,根據之前記錄的位置跳轉到函數調用處的地方,繼續后繼代碼的執行。
函數操作符由于是操作符,因此也要返回數字,也就是函數的返回值,即可以如下:
float?AB(?float?x?)?{?return?x?*?x;?}?int?main()?{?float?c?=?AB(?10?);?return?0;?}
先定義了函數AB,其返回float類型的數字,其中的return語句就是用于指明函數的返回值,其后接的數字就必須是對應函數的返回值類型,而當返回類型為void時,可直接書寫return;。因此上面的c的值為100,函數操作符返回的值為AB函數中的表達式x?*?x返回的數字,而AB(?10?)將10作為AB函數的參數x的值,故x?*?x返回100。
由于之前也說明了函數可以有指針,將函數和變量對比,則直接書寫函數名,如:AB;。上面將返回AB對應的地址類型的數字,然后計算此地址類型數字,應該是以函數類型解釋相應地址對應的內存的內容,考慮函數的意義,將發現這是毫無意義的,因此其不做任何事,直接返回此地址類型的數字對應的二進制數,也就相當于前面說的指針類型。因此也就可以如下:
int?main()?{?float?(*pAB)(?float?)?=?AB;?float?c?=?(?*pAB?)(?10?);?return?0;?}
上面就定義了一個指針pAB,其類型為float(*)(?float?),一開始將AB對應的地址賦值給它。為什么沒有寫成pAB?=?&AB;而是pAB?=?AB;?因為前面已經說了,函數類型的地址類型的數字,將不做任何事,其效果和指針類型的數字一樣,因此pAB?=?AB;沒有問題,而pAB?=?&AB;就更沒有問題了。可以認為函數類型的地址類型的數字編譯器會隱式轉換成指針類型的數字,因此既可以(?*pAB?)(?10?);,也能(?*AB?)(?10?);,因為后者編譯器進行了隱式類型轉換。
由于函數操作符中接的是數字,因此也可以float?c?=?AB(?AB(?10?)?);,即c為10000。還應注意函數操作符讓編譯器生成一些代碼來傳遞參數的值和跳轉到相應的地址去繼續執行代碼,因此如下是可以的:
long?AB(?long?x?)?{?if(?x?>?1?)?return?x?*?AB(?x?-?1?);?else?return?1;?}
上面表示當參數x的值大于1時,將x?-?1返回的數字作為參數,然后跳轉到AB對應的地址處,也就是if(?x?>?1?)所對應的地址,重復運行。因此如果long?c?=?AB(?5?);,則c為5的階乘。上面如果不能理解,將在后面說明異常的時候詳細說明函數是如何實現的,以及所謂的堆棧溢出問題。
現在應該了解main函數的意義了,其只是建立一個映射,好讓連接器制定程序的入口地址,即main函數對應的地址。上面函數Move在函數main之前定義,如果將Move的定義移到main的下面,上面將發生錯誤,說函數Move沒定義過,為什么?因為編譯器只從上朝下進行編譯,且只編譯一次。那上面的問題怎么辦?后面說明。
重載函數
前面的移動函數,如果只想移動X和Y坐標,為了不移動Z坐標,就必須如下再編寫一個函數:
void?Move2(?float?x,?float?y?);
它為了不和前面的Move函數的名字沖突而改成Move2,但Move2也表示移動,卻非要變一個名字,這嚴重地影響語義。為了更好的從源代碼上表現出語義,即這段代碼的意義,C++提出了重載函數的概念。
重載函數表示函數名字一樣,但參數類型及個數不同的多個函數。如下:
void?Move(?float?x,?float?y,?float?z?)?{?}和void?Move(?float?x,?float?y?)?{?}
上面就定義了兩個重載函數,雖然函數名相同,但實際為兩個函數,函數名相同表示它們具有同樣的語義——移動焊槍的程序,只是移動方式不同,前者在三維空間中移動,后者在一水平面上移動。當Move(?12,?43?);時就調用后者,而Move(?23,?5,?12?);時就調用前者。不過必須是參數的不同,不能是返回值的不同,即如下將會報錯:
float?Move(?float?x,?float?y?)?{?return?0;?}和void?Move(?float?a,?float?b?)?{?}
上面雖然返回值不同,但編譯器依舊認為上面定義的函數是同一個,則將說函數重復定義。為什么?因為在書寫函數操作符時,函數的返回值類型不能保證獲得,即float?a?=?Move(?1,?2?);雖然可以推出應該是前者,但也可以Move(?1,?2?);,這樣將無法得知應該使用哪個函數,因此不行。還應注意上面的參數名字雖然不同,但都是一樣的,參數名字只是表示在那個函數的作用域內其映射的地址,后面將說明。改成如下就沒有問題:
float?Move(?float?x,?float?y?)?{?return?0;?}和void?Move(?float?a,?float?b,?float?c?)?{?}
還應注意下面的問題:
float?Move(?float?x,?char?y?);?float?Move(?float?a,?short?b?);?Move(?10,?270?);
上面編譯器將報錯,因為這里的270在計算函數操作符時將被認為是int,即整型,它即可以轉成char,也可以轉成short,結果編譯器將無法判斷應是哪一個函數。為此,應該Move(?10,?(?char?)270?);。
聲明和定義
聲明是告訴編譯器一些信息,以協助編譯器進行語法分析,避免編譯器報錯。而定義是告訴編譯器生成一些代碼,并且這些代碼將由連接器使用。即:聲明是給編譯器用的,定義是給連接器用的。這個說明顯得很模糊,為什么非要弄個聲明和定義在這攪和?那都是因為C++同意將程序拆成幾段分別書寫在不同文件中以及上面提到的編譯器只從上朝下編譯且對每個文件僅編譯一次。
編譯器編譯程序時,只會一個一個源文件編譯,并分別生成相應的中間文件(對VC就是.obj文件),然后再由連接器統一將所有的中間文件連接形成一個可執行文件。問題就是編譯器在編譯a.cpp文件時,發現定義語句而定義了變量a和b,但在編譯b.cpp時,發現使用a和b的代碼,如a++;,則編譯器將報錯。為什么?如果不報錯,說因為a.cpp中已經定義了,那么先編譯b.cpp再編譯a.cpp將如何?如果源文件的編譯順序是特定的,將大大降低編譯的靈活性,因此C++也就規定:編譯a.cpp時定義的所有東西(變量、函數等)在編譯b.cpp時將全部不算數,就和沒編譯過a.cpp一樣。那么b.cpp要使用a.cpp中定義的變量怎么辦?為此,C++提出了聲明這個概念。
因此變量聲明long?a;就是告訴編譯器已經有這么個變量,其名字為a,其類型為long,其對應的地址不知道,但可以先作個記號,即在后續代碼中所有用到這個變量的地方做上記號,以告知連接器在連接時,先在所有的中間文件里尋找是否有個叫a的變量,其地址是多少,然后再修改所有作了記號的地方,將a對應的地址放進去。這樣就實現了這個文件使用另一個文件中定義的變量。
所以聲明long?a;就是要告訴編譯器已經有這么個變量a,因此后續代碼中用到a時,不要報錯說a未定義。函數也是如此,但是有個問題就是函數聲明和函數定義很容易區別,因為函數定義后一定接一復合語句,但是變量定義和變量聲明就一模一樣,那么編譯器將如何識別變量定義和變量聲明?編譯器遇到long?a;時,統一將其認為是變量定義,為了能標識變量聲明,可借助C++提出的修飾符extern。
修飾符就是聲明或定義語句中使用的用以修飾此聲明或定義來向編譯器提供一定的信息,其總是接在聲明或定義語句的前面或后面,如:
extern?long?a,?*pA,?&ra;
上面就聲明(不是定義)了三個變量a、pA和ra。因為extern表示外部的意思,因此上面就被認為是告訴編譯器有三個外部的變量,為a、pA和ra,故被認為是聲明語句,所以上面將不分配任何內存。同樣,對于函數,它也是一樣的:
extern?void?ABC(?long?);??或??extern?long?AB(?short?b?);
上面的extern等同于不寫,因為編譯器根據最后的“;”就可以判斷出來上面是函數聲明,而且提供的“外部”這個信息對于函數來說沒有意義,編譯器將不予理會。extern實際還指定其后修飾的標識符的修飾方式,實際應為extern"C"或extern"C++",分別表示按照C語言風格和C++語言風格來解析聲明的標識符。
C++是強類型語言,即其要求很嚴格的類型匹配原則,進而才能實現前面說的函數重載功能。即之所以能幾個同名函數實現重載,是因為它們實際并不同名,而由各自的參數類型及個數進行了修飾而變得不同。如void?ABC(),?*ABC(?long?),?ABC(?long,?short?);,在VC中,其各自名字將分別被變成“?ABC@@YAXXZ”、“?ABC@@YAPAXJ@Z”、“?ABC@@YAXJF@Z”。而extern?long?a,?*pA,?&ra;聲明的三個變量的名字也發生相應的變化,分別為“?a@@3JA”、“?pA@@3PAJA”、“?ra@@3AAJA”。上面稱作C++語言風格的標識符修飾(不同的編譯器修飾格式可能不同),而C語言風格的標識符修飾就只是簡單的在標識符前加上“_”即可(不同的編譯器的C風格修飾一定相同)。如:extern"C"?long?a,?*pA,?&ra;就變成_a、_pA、_ra。而上面的extern"C"?void?ABC(),?*ABC(?long?),?ABC(?long,?short?);將報錯,因為使用C風格,都只是在函數名前加一下劃線,則將產生3個相同的符號(Symbol),錯誤。
為什么不能有相同的符號?為什么要改變標識符?不僅因為前面的函數重載。符號和標識符不同,符號可以由任意字符組成,它是編譯器和連接器之間溝通的手段,而標識符只是在C++語言級上提供的一種標識手段。而之所以要改變一下標識符而不直接將標識符作為符號使用是因為編譯器自己內部和連接器之間還有一些信息需要傳遞,這些信息就需要符號來標識,由于可能用戶寫的標識符正好和編譯器內部自己用的符號相同而產生沖突,所以都要在程序員定義的標識符上面修改后再用作符號。既然符號是什么字符都可以,那為什么編譯器不讓自己內部定的符號使用標識符不能使用的字符,如前面VC使用的“?”,那不就行了?因為有些C/C++編譯器及連接器溝通用的符號并不是什么字符都可以,也必須是一個標識符,所以前面的C語言風格才統一加上“_”的前綴以區分程序員定義的符號和編譯器內部的符號。即上面能使用“?”來作為符號是VC才這樣,也許其它的編譯器并不支持,但其它的編譯器一定支持加了“_”前綴的標識符。這樣可以聯合使用多方代碼,以在更大范圍上實現代碼重用,在《C++從零開始(十八)》中將對此詳細說明。
當書寫extern?void?ABC(?long?);時,是extern"C"還是extern"C++"?在VC中,如果上句代碼所在源文件的擴展名為.cpp以表示是C++源代碼,則將解釋成后者。如果是.c,則將解釋成前者。不過在VC中還可以通過修改項目選項來改變上面的默認設置。而extern?long?a;也和上面是同樣的。
因此如下:
extern"C++"?void?ABC(),?*ABC(?long?),?ABC(?long,?short?);
int?main(){?ABC();?}
上面第一句就告訴編譯器后續代碼可能要用到這個三個函數,叫編譯器不要報錯。假設上面程序放在一個VC項目下的a.cpp中,編譯a.cpp將不會出現任何錯誤。但當連接時,編譯器就會說符號“?ABC@@YAXXZ”沒找到,因為這個項目只包含了一個文件,連接也就只連接相應的a.obj以及其他的一些必要庫文件(后續文章將會說明)。連接器在它所能連接的所有對象文件(a.obj)以及庫文件中查找符號“?ABC@@YAXXZ”對應的地址是什么,不過都沒找到,故報錯。換句話說就是main函數使用了在a.cpp以外定義的函數void?ABC();,但沒找到這個函數的定義。應注意,如果寫成int?main()?{?void?(?*pA?)?=?ABC;?}依舊會報錯,因為ABC就相當于一個地址,這里又要求計算此地址的值(即使并不使用pA),故同樣報錯。
為了消除上面的錯誤,就應該定義函數void?ABC();,既可以在a.cpp中,如main函數的后面,也可以重新生成一個.cpp文件,加入到項目中,在那個.cpp文件中定義函數ABC。因此如下即可:
extern"C++"?void?ABC(),?*ABC(?long?),?ABC(?long,?short?);
int?main(){?ABC();?}?void?ABC(){}
如果你認為自己已經了解了聲明和定義的區別,并且清楚了聲明的意思,那我打賭有50%的可能性你并沒有真正理解聲明的含義,這里出于篇幅限制,將在《C++從零開始(十)》中說明聲明的真正含義,如果你是有些C/C++編程經驗的人,到時給出的樣例應該有50%的可能性會令你大吃一驚。
調用規則
調用規則指函數的參數如何傳遞,返回值如何傳遞,以及上述的函數名標識符如何修飾。其并不屬于語言級的內容,因為其表示編譯器如何實現函數,而關于如何實現,各編譯器都有自己的處理方式。在VC中,其定義了三個類型修飾符用以告知編譯器如何實現函數,分別為:__cdecl、__stdcall和__fastcall。三種各有不同的參數、函數返回值傳遞方式及函數名修飾方式,后面說明異常時,在說明了函數的具體實現方式后再一一解釋。由于它們是類型修飾符,則可如下修飾函數:
void?*__stdcall?ABC(?long?),?__fastcall?DE(),?*(?__stdcall?*pAB?)(?long?)?=?&ABC;
void?(?__fastcall?*pDE?)()?=?DE;
變量的作用域
前面定義函數Move時,就說void?Move(?float?a,?float?b?);和void?Move(?float?x,?float?y?);是一樣的,即變量名a和b在這沒什么意義。這也就是說變量a、b的作用范圍只限制在前面的Move的函數體(即函數定義時的復合語句)內,同樣x和y的有效范圍也只在后面的Move的函數體內。這被稱作變量的作用域。
//a.cpp//
long?e?=?10;
void?main()
{
short?a?=?10;
e++;
{
long?e?=?2;
e++;
a++;
}
e++;
}
上面的第一個e的有效范圍是整個a.cpp文件內,而a的有效范圍是main函數內,而main函數中的e的有效范圍則是括著它的那對“{}”以內。即上面到最后執行完e++;后,long?e?=?2;定義的變量e已經不在了,也就是被釋放了。而long?e?=?10;定義的e的值為12,a的值為11。
也就是說“{}”可以一層層嵌套包含,沒一層“{}”就產生了一個作用域,在這對“{}”中定義的變量只在這對“{}”中有效,出了這對“{}”就無效了,等同于沒定義過。
為什么要這樣弄?那是為了更好的體現出語義。一層“{}”就表示一個階段,在執行這個階段時可能會需要到和前面的階段具有相同語義的變量,如排序。還有某些變量只在某一階段有用,過了這個階段就沒有意義了,下面舉個例子:
float?a[10];
//?賦值數組a
for(?unsigned?i?=?0;?i?<?10;?i++?)
for(?unsigned?j?=?0;?j?<?10;?j++?)
if(?a[?i?]?<?a[?j?]?)
{
float?temp?=?a[?i?];
a[?i?]?=?a[?j?];
a[?j?]?=?temp;
}
上面的temp被稱作臨時變量,其作用域就只在if(?a[?i?]?<?a[?j?]?)后的大括號內,因為那表示一個階段,程序已經進入交換數組元素的階段,而只有在交換元素時temp在有意義,用于輔助元素的交換。如果一開始就定義了temp,則表示temp在數組元素尋找期間也有效,這從語義上說是不對的,雖然一開始就定義對結果不會產生任何影響,但應不斷地詢問自己——這句代碼能不能不要?這句代碼的意義是什么?不過由于作用域的關系而可能產生性能影響,這在《C++從零開始(十)》中說明。
下篇將舉例說明如何已知算法而寫出C++代碼,幫助讀者做到程序員的最基本的要求——給得出算法,拿得出代碼。?
C++從零開始(八)?
——C++樣例一?
前篇說明了函數的部分實現方式,但并沒有說明函數這個語法的語義,即函數有什么用及為什么被使用。對于此,本篇及后續會零散提到一些,在《C++從零開始(十二)》中再較詳細地說明。本文只是就程序員的基本要求——拿得出算法,給得出代碼——給出一些樣例,以說明如何從算法編寫出C++代碼,并說明多個基礎且重要的編程概念(即獨立于編程語言而存在的概念)。
由算法得出代碼
本系列一開頭就說明了何謂程序,并說明由于CPU的世界和人們存在的客觀物理世界的不兼容而導致根本不能將人編寫的程序(也就是算法)翻譯成CPU指令,但為了能夠翻譯,就必須讓人覺得CPU世界中的某些東西是人以為的算法所描述的某些東西。如電腦屏幕上顯示的圖片,通過顯示器對不同象素顯示不同顏色而讓人以為那是一幅圖片,而電腦只知道那是一系列數字,每個數字代表了一個象素的顏色值而已。
為了實現上面的“讓人覺得是”,得到算法后要做的的第一步就是找出算法中要操作的資源。前面已經說過,任何程序都是描述如何操作資源的,而C++語言本身只能操作內存的值這一種資源,因此編程要做的第一步就是將算法中操作的東西映射成內存的值。由于內存單元的值以及內存單元地址的連續性都可以通過二進制數表示出來,因此要做的第一步就是把算法中操作的東西用數字表示出來。
上面做的第一步就相當于數學建模——用數學語言將問題表述出來,而這里只不過是用數字把被操作的資源表述出來罷了(應注意數字和數的區別,數字在C++中是一種操作符,其有相關的類型,由于最后對它進行計算得到的還是二進制數故使用數字進行表示而不是二進制數,以增強語義)。接著第二步就是將算法中對資源的所有操作都映射成語句或函數。
用數學語言對算法進行表述時,比如將每10分鐘到車站等車的人的數量映射為一隨機變量,也就前述的第一步。隨后定此隨機變量服從泊松分布,也就是上面的第二步。到站等車的人的數量是被操作的資源,而給出的算法是每隔10分種改變這個資源,將它的值變成按給定參數的泊松函數分布的一隨機值。
在C++中,前面已經將資源映射成了數字,接著就要將對資源的操作映射成對數字的操作。C++中能操作數字的就只有操作符,也就是將算法中對資源的所有操作都映射成表達式語句。
當上面都完成了,則算法中剩下的就只有執行順序了,而執行順序在C++中就是從上朝下書寫,而當需要邏輯判斷的介入而改變執行順序時,就使用前面的if和goto語句(不過后者也可以通過if后接的語句來實現,這樣可以減少goto語句的使用,因為goto的語義是跳轉而不是“所以就”),并可考慮是否能夠使用循環語句以簡化代碼。即第三步為將執行流程用語句表示出來。
而前面第二步之所以還說可映射成函數,即可能某個操作比較復雜,還帶有邏輯的意味,不能直接找到對應的操作符,這時就只好利用萬能的函數操作符,對這個操作重復剛才上面的三個步驟以將此操作映射成多條語句(通過if等語句將邏輯信息表現出來),而將這些語句定義為一函數,供函數操作符使用以表示那個操作。
上面如果未明不要緊,后面有兩個例子,都將分別說明各自是如何進行上述步驟的。
排序
給出三張卡片,上面隨便寫了三個整數。有三個盒子,分別標號為1、2和3。將三張卡片隨機放到1、2、3這三個盒子中,現在要求排序以使得1、2、3三個盒子中裝的整數是由小到大的順序。
給出一最簡單的算法:稱1、2、3盒子中放的卡片上的整數分別為第一、二、三個數,則先將第一個數和第二個數比較,如果前者大則兩個盒子內的卡片交換;再將第一個和第三個比較,如果前者大則交換,這樣就保證第一個數是最小的。然后將第二個數和第三個數比較,如果前者大則交換,至此排序完成。
第一步:算法中操作的資源是裝在盒子中的卡片,為了將此卡片映射成數字,就注意算法中的卡片和卡片之前有什么不同。算法中區分不同卡片的唯一方法就是卡片上寫的整數,因此在這里就使用一個long類型的數字來表示一個卡片。
算法中有三張卡片,故用三個數字來表示。前面已經說過,數字是裝在內存中的,不是變量中的,變量只不過是映射地址而已。在這里需要三個long類型數字,可以借用定義變量時編譯器自動在棧上分配的內存來記錄這些數字,故可以如此定義三個變量long?a1,?a2,?a3;來記錄三個數字,也就相當于裝三張卡片的三個盒子。
第二步:算法中的操作就是對卡片上的整數的比較和交換。前者很簡單,使用邏輯操作符就可以實現(因為正好將卡片上的整數映射成變量a1、a2和a3中記錄的數字)。后者是交換兩個盒子中的卡片,可以先將一卡片從一盒子中取出來,放在桌子上或其他地方。然后將另一盒子中的卡片取出來放在剛才空出來的盒子。最后將先取出來的卡片放進剛空出來的盒子。前面說的“桌子上或其他地方”是用來存放取出的卡片,C++中只有內存能夠存放數字,因此上面就必須再分配一臨時內存來臨時記錄取出的數字。
第三步:操作和資源都已經映射好了,算法中有如果的就用if替換,由什么重復多少次的就用for替換,有什么重復直到怎樣的就用while或do?while替換,如上照著算法映射過來就完了,如下:
void?main()
{
long?a1?=?34,?a2?=?23,?a3?=?12;
if(?a1?>?a2?)
{
long?temp?=?a1;
a1?=?a2;
a2?=?temp;
}
if(?a1?>?a3?)
{
long?temp?=?a1;
a1?=?a3;
a3?=?temp;
}
if(?a2?>?a3?)
{
long?temp?=?a2;
a2?=?a3;
a3?=?temp;
}
}
上面就在每個if后面的復合語句中定義了一個臨時變量temp以借助編譯器的靜態分配內存功能來提供臨時存放卡片的內存。上面的元素交換并沒有按照前面所說映射成函數,是因為在這里其只有三條語句且容易理解。如果要將交換操作定義為一函數,則應如下:
void?Swap(?long?*p1,?long?*p2?)?void?Swap(?long?&r1,?long?&r2?)
{???{
long?temp?=?*p1;long?temp?=?r1;
*p1?=?*p2;??r1?=?r2;
*p2?=?temp;?r2?=?temp;
}???}
void?main()?void?main()
{???{
long?a1?=?34,?a2?=?23,?a3?=?12;?long?a1?=?34,?a2?=?23,?a3?=?12;
if(?a1?>?a2?)???if(?a1?>?a2?)
Swap(?&a1,?&a2?);???Swap(?a1,?a2?);
if(?a1?>?a3?)???if(?a1?>?a3?)
Swap(?&a1,?&a3?);???Swap(?a1,?a3?);
if(?a2?>?a3?)???if(?a2?>?a3?)
Swap(?&a2,?&a3?);???Swap(?a2,?a3?);
}???}
先看左側的程序。上面定義了函數來表示給定盒子之間的交換操作,注意參數類型使用了long*,這里指針表示引用(應注意指針不僅可以表示引用,還可有其它的語義,以后會提到)。
什么是引用?注意這里不是指C++提出的那個引用變量,引用表示一個連接關系。比如你有手機,則手機號碼就是“和你通話”的引用,即只要有你的手機號碼,就能夠實現“和你通話”。
再比如Windows操作系統提供的快捷方式,其就是一個“對某文件執行操作”的引用,它可以指向某個文件,通過雙擊此快捷方式的圖標就能夠對其所指的文件進行“執行”操作(可能是用某軟件打開這個文件或是直接執行此文件等),但如果刪除此快捷方式卻并不會刪除其所指向的文件,因為它只是“對某文件執行操作”的引用。
人的名字就是對“某人進行標識”的引用,即說某人考上大學通過說那個人的名字則大家就可以知道具體是哪個人。同樣,變量也是引用,它是某塊內存的引用,因為其映射了地址,而內存塊可以通過地址來被唯一表明其存在,不僅僅是標識。注意其和前面的名字不同,因為任何對內存塊的操作,只要知道內存塊的首地址就可以了,而要和某人面對面講話或吃飯,只知道他的名字是不夠的。
應注意對某個東西的引用可以不止一個,如人就可以有多個名字,變量也都有引用變量,手機號碼也可以不止一個。
注意上面引入了函數來表示交換,進而導致了盒子也就成了資源,因此必須將盒子映射成數字。而前面又將盒子里裝的卡片映射成了long類型的數字,由于“裝”這個操作,因此可以想到使用能夠標識裝某個代表卡片的數字的內存塊來作為盒子映射的數字類型,也就是內存塊的首地址,也就是long*類型(注意不是地址類型,因為地址類型的數字并不返回記錄它的內存的地址)。所以上面的函數參數類型為long*。
下面看右側的程序。參數類型變成long&,和指針一樣,依舊表示引用,但注意它們的不同。后者表示它是一個別名,即它是一個映射,映射的地址是記錄作為參數的數字的地址,也就是說它要求調用此函數時,給出的作為參數的數字一定是有地址的數字。所謂的“有地址的數字”表示此數字是程序員創建的,不是編譯器由于臨時原因而生成的臨時內存的地址,如Swap(?a1++,?a2?);就要報錯。之前已經說明,因為a1++返回的地址是編譯器內部定的,就程序邏輯而言,其是不存在的,而Swap(?++a1,?a2?);就是正確的。Swap(?1?+?3,?34?);依舊要報錯,因為記錄1?+?3返回的數字的內存是編譯器內部分配的,就程序邏輯上來說,它們并沒有被程序員用某塊內存記錄起來,也就不會有內存。
一個很簡單的判定規則就是調用時給的參數類型如果是地址類型的數字,則可以,否則不行。
還應注意上面是long&類型,表示所修飾的變量不分配內存,也就是編譯器要靜態地將參數r1、r2映射的地址定下來,對于Swap(?a1,?a2?);就分別是a1和a2的地址,但對于Swap(?a2,?a3?);就變成a2和a3的地址了,這樣是無法一次就將r1、r2映射的地址定下來,即r1、r2映射的地址在程序運行時是變化的,也就不能且無法編譯時靜態一次確定。
為了實現上面的要求,編譯器實際將會在棧上分配內存,然后將地址傳遞到函數,再編寫代碼以使得好像動態綁定了r1、r2的地址。這實際和將參數類型定為long*是一樣的效果,即上面的Swap(?long&,?long&?);和Swap(?long*,?long*?);是一樣的,只是語法書寫上不同,內部是相同的,連語義都相同,均表示引用(雖然指針不僅僅只帶有引用的語義)。即函數參數類型為引用類型時,依舊會分配內存以傳遞參數的地址,即等效于指針類型為參數。
商人過河問題
3個商人帶著3個仆人過河,過河的工具只有一艘小船,只能同時載兩個人過河,包括劃船的人。在河的任何一邊,只要仆人的數量超過商人的數量,仆人就會聯合起來將商人殺死并搶奪其財物,問應如何設計過河順序才能讓所有人都安全地過到河的另一邊。
給出最弱卻萬能的算法——枚舉法。坐船過河及劃船回來的可能方案為一個仆人、一個商人或兩個商人、兩個仆人及一個商人一個仆人。
故每次從上述的五種方案中選擇一個劃過河去,然后檢查河岸兩側的人數,看是否會發生仆人殺死商人,如果兩邊都不會,則再從上述的五個方案中選擇一個讓人把船劃回來,然后再檢查是否會發生仆人殺死商人,如果沒有就又重新從五個方案中選一個劃過河,如上重復直到所有人都過河了。
上面在選方案時除了保證商人不被殺死,還要保證此方案運行(即過河或劃回來)后,兩岸的人數布局從來都沒有出現過,否則就形成無限循環,且必須合理,即沒有負數。如果有一次的方案選擇失敗,則退回去重新選另一個方案再試。如果所有方案都失敗,則再退回到更上一次的方案選擇。如果一直退到第一次的方案選擇,并且已沒有可選的方案,則說明上題無解。
上面的算法又提出了兩個基本又重要的概念——層次及容器。下面先說明容器。
容器即裝東西的東西,而C++中操作的東西只有數字,因此容器就是裝數字的東西,也就是內存。容器就平常的理解是能裝多個東西,即能裝多個數字。這很簡單,使用之前的數組的概念就行了。但如果一個盒子能裝很多蘋果,那它一定占很大的體積,即不管裝了一個蘋果還是兩個蘋果,那盒子都要占半立方米的體積。數組就好像盒子,不管裝一個元素還是兩個元素,它都是long[10]的類型而要占40個字節。
容器是用來裝東西的,那么要取出容器中裝的東西,就必須有種手段標識容器中裝的東西,對于數組,這個東西就是數組的下標,如long?a[10];?a[3];就取出了第四個元素的值。由于有了標識,則還要有一種手段以表示哪些標識是有效的,如上面的a數組,只前面兩個元素記錄了數字,但是卻a[3];,得到的將是錯誤的值,因為只有a[0]和a[1]是有意義的。
因此上面的用數組作容器有很多的問題,但它非常簡單,并能體現各元素之間的順序關系,如元素被排序后的數組。但為了適應復雜算法,必須還要其他容器的支持,如鏈表、樹、隊列等。它們一般也被稱做集合,都是用于管理多個元素用的,并各自給出了如何從眾多的元素中快速找到給定標識所對應的元素,而且都能在各元素間形成一種關系,如后面將要提到的層次關系、前面數組的順序關系等。關于那些容器的具體實現方式,請參考其他資料,在此不表。
上面算法中提到“兩岸的人數布局從來都沒有出現過”,為了實現這點,就需要將其中的資源——人數布局映射為數字,并且還要將曾經出現過的所有人數布局全部記錄下來,也就是用一個容器記錄下來,由于還未說明結構等概念,故在此使用數組來實現這個容器。上面還提到從已有的方案中選擇一個,則可選的方案也是一個容器,同上,依舊使用一數組來實現。
層次,即關系,如希望小學的三年2班的XXX、中國的四川的成都的XXX等,都表現出一種層次關系,這種層次關系是多個元素之間的關系,因此就可以通過找一個容器,那個容器的各元素間已經是層次關系,則這個容器就代表了一種層次關系。樹這種容器就是專門對此而設計的。
上面算法中提到的“再退回到更上一次的方案選擇”,也就是說第一次過河選擇了一個商人一個仆人的方案,接著選擇了一個商人回來的方案,此時如果選擇兩個仆人過河的方案將是錯誤的,則將重新選擇過河的方案。再假設此時所有過河的方案都失敗了,則只有再向后退以重新選擇回來的方案,如選擇一個仆人回來。對于此,由于這里只要求退回到上一次的狀態,也就是人數布局及選擇的方案,則可以將這些統一放在容器中,而它們各自都只依靠順序關系,即第二次過河的方案一定在第一次過河的方案成功的前提下才可能考慮,因此使用數組這個帶有順序關系的容器即可。
第一步:上面算法的資源有兩個:坐船的方案和兩岸的人數布局。坐船的方案最多五種,在此使用一個char類型的數字來映射它,即此8位二進制數的前4位用補碼格式來解釋得到的數字代表仆人的數量,后4位則代表商人的數量。因此一個商人和一個仆人就是(?1?<<?4?)?|?1。兩岸的人數布局,即兩岸的商人數和仆人數,由于總共才3+3=6個人,這都可以使用char類型的數字就能映射,但只能映射一個人數,而兩岸的人數實際共有4個(左右兩岸的商人數和仆人數),則這里使用一個char[4]來實現(實際最好是使用結構來映射而不是char[4],下篇說明)。如char?a[4];表示一人數布局,則a[0]表示河岸左側的商人數,a[1]表示左側的仆人數,a[2]表示河岸右側的商人數,a[3]表示右側的仆人數。
注意前面說的容器,在此為了裝可選的坐船方案故應有一容器,使用數組,如char?sln[5];。在此還需要記錄已用的坐船方案,由于數組的元素具備順序關系,所以不用再生成一容器,直接使用一char數字記錄一下標,當此數字為3時,表示sln[0]、sln[1]和sln[2]都已經用過且都失敗了,當前可用的為sln[3]和sln[4]。同樣,為了裝已成功的坐船方案作用后的人數布局及當時所選的方案,就需要兩個容器,在此使用數組(實際應該鏈表)char?oldLayout[200][4],?cur[200];。oldLayout就是記錄已成功的方案的容器,其大小為200,表示假定在200次內,一定就已經得出結果了,否則就會因為超出數組上限而可能發生內存訪問違規,而為什么是可能在《C++從零開始(十五)》中說明。
前面說過數組這種容器無法確定里面的有效元素,必須依靠外界來確定,對此,使用一unsigned?char?curSln;來記錄oldLayout和cur中的有效元素的個數。規定當curSln為3時,表示oldLayout[0][0~3]、oldLayout[1][0~3]和oldLayout[2][0~3]都有效,同樣cur[0]、cur[1]和cur[2]都有效,而之后的如cur[3]等都無效。
第二步:操作有:執行過河方案、執行回來方案、檢查方案是否成功、退回到上一次方案選擇、是否所有人都過河、判斷人數布局是否相同。如下:
前兩個操作:將當前的左岸人數減去相應的方案定的人數,而右岸則加上人數。要表現當前左岸人數,可以用oldLayout[?curSln?][0]和oldLayout[?curSln?][1]表示,而相應方案的人數則為(?sln[?cur[?curSln?]?]?&?0xF0?)?>>?4和sln[?cur[?curSln?]?]?&?0xF。由于這兩個操作非常類似,只是一個是加則另一個就是減,故將其定義為函數,則為了在函數中能操作oldLayout、curSln等變量,就需要將這些變量定義為全局變量。
檢查是否成功:即看是否
oldLayout[?curSln?][1]?>?oldLayout[?curSln?][0]?&&?oldLayout[?curSln?][0]以及是否
oldLayout[?curSln?][3]?>?oldLayout[?curSln?][2]?&&?oldLayout[?curSln?][2]
并且保證各自不為負數以及沒有和原來的方案沖突。檢查是否和原有方案相同就是枚舉所有原由方案以和當前方案比較,由于比較復雜,在此將其定義為函數,通過返回bool類型來表示是否沖突。
退回上一次方案或到下一個方案的選擇,只用curSln--或curSln++即可。而是否所有人都過河,則只用oldLayout[?curSln?][0~1]都為0而oldLayout[?curSln?][2~3]都為3。而判斷人數布局是否相同,則只用相應各元素是否相等即可。
第三步:下面剩下的就沒什么東西了,只需要按照算法說的順序,將剛才的各操作拼湊起來,并注意“重復直到所有人都過河了”轉成do?while即可。如下:
#include?
//?分別表示一個商人、一個仆人、兩個商人、兩個仆人、一個商人一個仆人
char?sln[5]?=?{?(?1?<<?4?),?1,?(?2?<<?4?),?2,?(?1?<<?4?)?|?1?};
unsigned?char?curSln?=?1;
char?oldLayout[200][4],?cur[200];
void?DoSolution(?char?b?)
{
unsigned?long?oldSln?=?curSln?-?1;??//?臨時變量,出于效率
oldLayout[?curSln?][0]?=
oldLayout[?oldSln?][0]?-?b?*?(?(?sln[?cur[?curSln?]?]?&?0xF0?)?>>?4?);
oldLayout[?curSln?][1]?=
oldLayout[?oldSln?][1]?-?b?*?(?sln[?cur[?curSln?]?]?&?0xF?);
oldLayout[?curSln?][2]?=
oldLayout[?oldSln?][2]?+?b?*?(?(?sln[?cur[?curSln?]?]?&?0xF0?)?>>?4?);
oldLayout[?curSln?][3]?=
oldLayout[?oldSln?][3]?+?b?*?(?sln[?cur[?curSln?]?]?&?0xF?);
}
bool?BeRepeated(?char?b?)
{
for(?unsigned?long?i?=?0;?i?<?curSln;?i++?)
if(?oldLayout[?curSln?][0]?==?oldLayout[?i?][0]?&&??//?這里雖然4個數字比較是否相等
oldLayout[?curSln?][1]?==?oldLayout[?i?][1]?&&??//?但總共才4個字節長,實際可以
oldLayout[?curSln?][2]?==?oldLayout[?i?][2]?&&??//?通過一次4字節長數字比較替換
oldLayout[?curSln?][3]?==?oldLayout[?i?][3]?&&??//?四次1字節長數字比較來優化
(?(?i?&?1?)???1?:?-1?)?==?b?)??//?保證過河后的方案之間比較,回來后的方案之間比較
???//?i&1等效于i%2,i&7等效于i%8,i&63等效于i%64
return?true;
return?false;
}
void?main()
{
char?b?=?1;
oldLayout[0][0]?=?oldLayout[0][1]?=?3;
cur[0]?=?oldLayout[0][2]?=?oldLayout[0][3]?=?0;
for(?unsigned?char?i?=?0;?i?<?200;?i++?)??//?初始化每次選擇方案時的初始化方案為sln[0]
cur[?i?]?=?0;?//?由于cur是全局變量,在VC中,其已經被賦值為0
??//?原因涉及到數據節,在此不表
do
{
DoSolution(?b?);
if(?(?oldLayout[?curSln?][1]?>?oldLayout[?curSln?][0]?&&?oldLayout[?curSln?][0]?)?||
(?oldLayout[?curSln?][3]?>?oldLayout[?curSln?][2]?&&?oldLayout[?curSln?][2]?)?||
oldLayout[?curSln?][0]?<?0?||?oldLayout[?curSln?][1]?<?0?||
oldLayout[?curSln?][2]?<?0?||?oldLayout[?curSln?][3]?<?0?||
BeRepeated(?b?)?)
{
//?重新選擇本次的方案
P:
cur[?curSln?]++;
if(?cur[?curSln?]?>?4?)
{
b?=?-b;
cur[?curSln?]?=?0;
curSln--;
if(?!curSln?)
break;??//?此題無解
goto?P;??//?重新檢查以保證cur[?curSln?]的有效性
}
continue;
}
b?=?-b;
curSln++;
}
while(?!(?oldLayout[?curSln?-?1?][0]?==?0?&&?oldLayout[?curSln?-?1?][1]?==?0?&&
??oldLayout[?curSln?-?1?][2]?==?3?&&?oldLayout[?curSln?-?1?][3]?==?3?)?);
for(?i?=?0;?i?<?curSln;?i++?)
printf(?"%d??%d\t?%d??%d\n",
oldLayout[?i?][0],
oldLayout[?i?][1],
oldLayout[?i?][2],
oldLayout[?i?][3]?);
}
上面數組sln[5]的初始化方式下篇介紹。上面的預編譯指令#include將在《C++從零開始(十)》中說明,這里可以不用管它。上面使用的函數printf的用法,請參考其它資料,這里它只是將變量的值輸出在屏幕上而已。
前面說此法是枚舉法,其基本上屬于萬能方法,依靠CPU的計算能力來實現,一般情況下程序員第一時間就會想到這樣的算法。它的缺點就是效率極其低下,大量的CPU資源都浪費在無謂的計算上,因此也是產生瓶頸的大多數原因。由于它的萬能,編程時很容易將思維陷在其中,如求和1到100,一般就寫成如下:
for(?unsigned?long?i?=?1,?s?=?0;?i?<=?100;?i++?)?s?+=?i;
但更應該注意到還可unsigned?long?s?=?(?1?+?100?)?*?100?/?2;,不要被枚舉的萬能占據了頭腦。
上面的人數布局映射成一結構是最好的,映射成char[4]所表現的語義不夠強,代碼可讀性較差。下篇說明結構,并展示類型的意義——如何解釋內存的值。
發表于?2004年07月14日?2:55?PM?
評論
#?回復:C++從零開始(八)——C++樣例一?2004-07-23?1:10?AM?ぐ落葉ζ繽紛?
此兩個例子看后,有諸多問題,也許是前面的基礎還不夠牢固。第一個排序,只有一個問題,就是我把指針類型跟地址類型弄含糊了。忘你能在此細講一下(他們的語義和區別)。?
第二個商人過河其算法和你列出的步驟都能理解,但轉化能代碼就有點問題。哎,連你寫出來我都看不懂,有點悲哀!(主要是1由于你定義的變量比較多;2表達式的實際操作有點問題)我準備學完了再來看這個例子。?對于象我這樣的情況,你有沒有更好的方法和建議。菜鳥先謝過了~~~*_^?
?
#?回復:C++從零開始(八)——C++樣例一?2004-07-23?11:45?AM?lop5712?
抱歉,其實本系列一直基于這樣的一個思想來寫的——用盡量少的概念定義,解釋盡量多的表面現象。本系列提出了以下幾個概念——數字、類型、類型修飾符、映射元素、操作符、語句、語句修飾符和類型定義符。在后續文章中,由于要使用這些概念來解釋C++可以寫出的各種語句,我朋友對于我的解釋認為過于抽象,根本不適合初學者看。?
我提出地址類型的數字完全只是為了從語法上解釋C++的語句,在語法上要保證其嚴密性。一般的理解為要標識某內存塊,就應該給出它的首地址,而指針的意思就是裝地址的內存塊。即指針類型的變量里面裝的數字應該被編譯器理解為地址,是用于標識某塊內存的。而編譯器如何表現出它已經將某個數字理解為地址了?就通過使用取內容操作符“*”來體現,如:?
long?a,?*pA?=?&a;?*pA?=?19;?
上面的一般解釋是,因為pA的內容是一個地址,因此取內容操作符就將pA給出的地址所標識的內存得到,即a對應的內存。這樣的解釋是有邏輯漏洞的,不過它要較我在文中通過類型匹配來解釋更易理解。?
指針類型表明相應變量里裝的是一個地址(編譯器認為是個地址),而地址能夠標識內存塊,所以稱指針具有引用的語義(通過記錄某個內存塊的地址來實現引用)。因為只要給出某個指針類型的變量,就可以通過對它使用取內容操作符來得到它裝的地址所標識的內存。?
實際根本沒有地址類型這樣的東西,即無法在C++代碼上表現出地址類型(指針類型就可以,使用指針類型修飾符),而我提出它就是想從語法上去掉上面語句的常規解釋而帶來的邏輯漏洞。因此也不用非要理解它。如果你真的要理解它,我只有建議你再看下《四》了,我覺得那里已經將地址解釋得很清楚了。?
正如我上面所說,我是用另外一套概念(而不是什么數組、指針、函數、結構、類等常規概念)來解釋C++的,目的是要用盡量少的概念解釋它的所有常規概念,即認為它的常規概念之間有共性,我在此將其抽象出來而已,也因此本系列顯得較抽象。?
我認為應該先看一兩本C++的書以有感性認識,然后如果還有興趣,可以看本系列。本系列后面的《十》《十一》《十二》都比較抽象,我朋友認為并不適合初學者看,在此表示抱歉。?
C++從零開始(九)?
——何謂結構?
前篇已經說明編程時,拿到算法后該干的第一件事就是把資源映射成數字,而前面也說過“類型就是人為制訂的如何解釋內存中的二進制數的協議”,也就是說一個數字對應著一塊內存(可能4字節,也可能20字節),而這個數字的類型則是附加信息,以告訴編譯器當發現有對那塊內存的操作語句(即某種操作符)時,要如何編寫機器指令以實現那個操作。比如兩個char類型的數字進行加法操作符操作,編譯器編譯出來的機器指令就和兩個long類型的數字進行加法操作的不一樣,也就是所謂的“如何解釋內存中的二進制數的協議”。由于解釋協議的不同,導致每個類型必須有一個唯一的標識符以示區別,這正好可以提供強烈的語義。
typedef
提供語義就是要盡可能地在代碼上體現出這句或這段代碼在人類世界中的意義,比如前篇定義的過河方案,使用一char類型來表示,然后定義了一數組char?sln[5]以期從變量名上體現出這是方案。但很明顯,看代碼的人不一定就能看出sln是solution的縮寫并進而了解這個變量的意義。但更重要的是這里有點本末倒置,就好像這個東西是紅蘋果,然后知道這個東西是蘋果,但它也可能是玩具、CD或其它,即需要體現的語義是應該由類型來體現的,而不是變量名。即char無法體現需要的語義。
對此,C++提供了很有意義的一個語句——類型定義語句。其格式為typedef?<源類型名>?<標識符>;。其中的<源類型名>表示已存在的類型名稱,如char、unsigned?long等。而<標識符>就是程序員隨便起的一個名字,符合標識符規則,用以體現語義。對于上面的過河方案,則可以如下:
typedef?char?Solution;?Solution?sln[5];
上面其實是給類型char起了一個別名Solution,然后使用Solution來定義sln以更好地體現語義來增加代碼的可讀性。而前篇將兩岸的人數分布映射成char[4],為了增強語義,則可以如下:
typedef?char?PersonLayout[4];?PersonLayout?oldLayout[200];
注意上面是typedef?char?PersonLayout[4];而不是typedef?char[4]?PersonLayout;,因為數組修飾符“[]”是接在被定義或被聲明的標識符的后面的,而指針修飾符“*”是接在前面的,所以可以typedef?char?*ABC[4];但不能typedef?char?[4]ABC*;,因為類型修飾符在定義或聲明語句中是有固定位置的。
上面就比char?oldLayout[200][4];有更好的語義體現,不過由于為了體現語義而將類型名或變量名增長,是否會降低編程速度?如果編多了,將會發現編程的大量時間不是花在敲代碼上,而是調試上。因此不要忌諱書寫長的變量名或類型名,比如在Win32的Security?SDK中,就提供了下面的一個函數名:
BOOL?ConvertSecurityDescriptorToStringSecurityDescriptor(…);
很明顯,此函數用于將安全描述符這種類型轉換成文字形式以方便人們查看安全描述符中的信息。
應注意typedef不僅僅只是給類型起了個別名,還創建了一個原類型。當書寫char*?a,?b;時,a的類型為char*,b為char,而不是想象的char*。因為“*”在這里是類型修飾符,其是獨立于聲明或定義的標識符的,否則對于char?a[4],?b;,難道說b是char[4]?那嚴重不符合人們的習慣。上面的char就被稱作原類型。為了讓char*為原類型,則可以:typedef?char?*PCHAR;?PCHAR?a,?b,?*c[4];。其中的a和b都是char*,而c是char**[4],所以這樣也就沒有問題:char?**pA?=?&a;。
結構
再次考慮前篇為什么要將人數布局映射成char[4],因為一個人數可以用一個char就表示,而人數布局有四個人數,所以使用char[4]。即使用char[4]是希望只定義一個變量就代表了一個人數分布,編譯器就一次性在棧上分配4個字節的空間,并且每個字節都各自代表一個人數。所以為了表現河岸左側的商人數,就必須寫a[0],而左側的仆人數就必須a[1]。壞處很明顯,從a[0]無法看出它表示的是左岸的商人數,即這個映射意義(左岸的商人數映射為內存塊中第一個字節的內容以補碼格式解釋)無法從代碼上體現出來,降低了代碼的可讀性。
上面其實是對內存布局的需要,即內存塊中的各字節二進制數如何解釋。為此,C++提出了類型定義符“{}”。它就是一對大括號,專用在定義或聲明語句中,以定義出一種類型,稱作自定義類型。即C++原始缺省提供的類型不能滿足要求時,可自定義內存布局。其格式為:<類型關鍵字>?<名字>?{?<聲明語句>?…}。<類型關鍵字>只有三個:struct、class和union。而所謂的結構就是在<類型關鍵字>為struct時用類型定義符定義的原類型,它的類型名為<名字>,其表示后面大括號中寫的多條聲明語句,所定義的變量之間是串行關系(后面說明),如下:
struct?ABC?{?long?a,?*b;?double?c[2],?d;?}?a,?*b?=?&a;
上面是一個變量定義語句,對于a,表示要求編譯器在棧上分配一塊4+4+8*2+8=32字節長的連續內存塊,然后將首地址和a綁定,其類型為結構型的自定義類型(簡稱結構)ABC。對于b,要求編譯器分配一塊4字節長的內存塊,將首地址和b綁定,其類型為結構ABC的指針。
上面定義變量a和b時,在定義語句中通過書寫類型定義符“{}”定義了結構ABC,則以后就可以如下使用類型名ABC來定義變量,而無需每次都那樣,即:
ABC?&c?=?a,?d[2];
現在來具體看清上面的意思。首先,前面語句定義了6個映射元素,其中a和b分別映射著兩個內存地址。而大括號中的四個變量聲明也生成了四個變量,各自的名字分別為ABC::a、ABC::b、ABC::c、ABC::d;各自映射的是0、4、8和24;各自的類型分別為long?ABC::、long*?ABC::、double?(ABC::)?[2]、double?ABC::,表示是偏移。其中的ABC::表示一種層次關系,表示“ABC的”,即ABC::a表示結構ABC中定義的變量a。應注意,由于C++是強類型語言,它將ABC::也定義為類型修飾符,進而導致出現long*?ABC::這樣的類型,表示它所修飾的標識符是自定義類型ABC的成員,稱作偏移類型,而這種類型的數字不能被單獨使用(后面說明)。由于這里出現的類型不是函數,故其映射的不是內存的地址,而是一偏移值(下篇說明)。與之前不同了,類型為偏移類型的(即如上的類型)數字是不能計算的,因為偏移是一相對概念,沒有給出基準是無法產生任何意義的,即不能:ABC::a;?ABC::c[1];。其中后者更是嚴重的錯誤,因為數組操作符“[]”要求前面接的是數組或指針類型,而這里的ABC::c是double的數組類型的結構ABC中的偏移,并不是數組類型。
注意上面的偏移0、4、8、24正好等同于a、b、c、d順次安放在內存中所形成的偏移,這也正是struct這個關鍵字的修飾作用,也就是前面所謂的各定義的變量之間是串行關系。
為什么要給偏移制訂映射?即為什么將a映射成偏移0字節,b映射成偏移4字節?因為可以給偏移添加語義。前面的“左岸的商人數映射為內存塊中第一個字節的內容以補碼格式解釋”其實就是給定內存塊的首地址偏移0字節。而現在給出一個標識符和其綁定,則可以將這個標識符起名為LeftTrader來表現其語義。
由于上面定義的變量都是偏移類型,根本沒有分配內存以和它們建立映射,它們也就很正常地不能是引用類型,即struct?AB{?long?a,?&b;?};將是錯誤的。還應注意上面的類型double?(ABC::)[2],類型修飾符“ABC::”被用括號括起來,因為按照從左到右來解讀類型操作符的規則,“ABC::”實際應該最后被解讀,但其必須放在標識符的左邊,就和指針修飾符“*”一樣,所以必須使用括號將其括住,以表示其最后才起修飾作用。故也就有:double?(*ABCD::)[2]、double?(**ABCD::)[2],各如下定義:
struct?ABCD?{?double?(?*pD?)[2];?double?(?**ppD?)[2];?};
但應注意,“ABCD::”并不能直接使用,即double?(?*ABCD::?pD?)[2];是錯誤的,要定義偏移類型的變量,必須通過類型定義符“{}”來自定義類型。還應注意C++也允許這樣的類型double?(?*ABCD::*?)[2],其被稱作成員指針,即類型為double?(?*ABCD::?)[2]的指針,也就是可以如下:
double?(?**ABCD::*pPPD?)[2]?=?&ABC::ppD,?(?**ABCD::**ppPPD?)[2]?=?&pPPD;
上面很奇怪,回想什么叫指針類型。只有地址類型的數字才能有指針類型,表示不計算那個地址類型的數字,而直接返回其二進制表示,也就是地址。對于變量,地址就是它映射的數字,而指針就表示直接返回其映射的數字,因此&ABCD::ppD返回的數字其實就是偏移值,也就是4。
為了應用上面的偏移類型,C++給出了一對操作符——成員操作符“.”和“->”。前者兩邊接數字,左邊接自定義類型的地址類型的數字,而右邊接相應自定義類型的偏移類型的數字,返回偏移類型中給出的類型的地址類型的數字,比如:a.ABC::d;。左邊的a的類型是ABC,右邊的ABC::d的類型是double?ABC::,則a.ABC::d返回的數字是double的地址類型的數字,因此可以這樣:a.ABC::d?=?10.0;。假設a對應的地址是3000,則a.ABC::d返回的地址為3000+24=3024,類型為double,這也就是為什么ABC::d被叫做偏移類型。由于“.”左邊接的結構類型應和右邊的結構類型相同,因此上面的ABC::可以省略,即a.d?=?10.0;。而對于“->”,和“.”一樣,只不過左邊接的數字是指針類型罷了,即b->c[1]?=?10.0;。應注意b->c[1]實際是(?b->c?)[1],而不是b->(?c[1]?),因為后者是對偏移類型運用“[]”,是錯誤的。
還應注意由于右邊接偏移類型的數字,所以可以如下:
double?(?ABC::*pA?)[2]?=?&ABC::c,?(?ABC::**ppA?)[2]?=?&pA;
(?b->**ppA?)[1]?=?10.0;?(?a.*pA?)[0]?=?1.0;
上面之所以要加括號是因為數組操作符“[]”的優先級較“*”高,但為什么不是b->(?**ppA?)[1]而是(?b->**ppA?)[1]?前者是錯誤的。應注意括號操作符“()”并不是改變計算優先級,而是它也作為一個操作符,其優先級被定得很高罷了,而它的計算就是計算括號內的數字。之前也說明了偏移類型是不能計算的,即ABC::c;將錯誤,而剛才的前者由于“()”的加入而導致要求計算偏移類型的數字,故編譯器將報錯。
還應該注意,成員指針是偏移類型的指針,即裝的是偏移,則可以程序運行時期得到偏移,而前面通過ABC::a這種形式得到的是編譯時期,由編譯器幫忙映射的偏移,只能實現靜態的偏移,而利用成員指針則可以實現動態的偏移。不過其實只需將成員定義成數組或指針類型照樣可以實現動態偏移,不過就和前篇沒有使用結構照樣映射了人數布局一樣,欠缺語義而代碼可讀性較低。成員指針的提出,通過變量名,就可以表現出豐富的語義,以增強代碼的可讀性。現在,可以將最開始說的人數布局定義如下:
struct?PersonLayout{?char?LeftTrader,?LeftServitor,?RightTrader,?RightServitor;?};
PersonLayout?oldLayout[200],?b;
因此,為了表示b這個人數分布中的左側商人數,只需b.LeftTrader;,右側的仆人數,只需b.RightServitor;。因為PersonLayout::LeftTrader記錄了偏移值和偏移后應以什么樣的類型來解釋內存,故上面就可以實現原來的b[0]和b[3]。很明顯,前者的可讀性遠遠地高于后者,因為前者通過變量名(b和PersonLayout::LeftTrader)和成員操作符“.”表現了大量的語義——b的左邊的商人數。
注意PersonLayout::LeftTrader被稱作結構PersonLayout的成員變量,而前面的ABC::d則是ABC的成員變量,這種叫法說明結構定義了一種層次關系,也才有所謂的成員操作符。既然有成員變量,那也有成員函數,這在下篇介紹。
前篇在映射過河方案時將其映射為char,其中的前4位表示仆人數,后4位表示商人數。對于這種使用長度小于1個字節的用法,C++專門提供了一種語法以支持這種情況,如下:
struct?Solution?{?ServitorCount?:?4;?unsigned?TraderCount?:?4;?}?sln[5];
由于是基于二進制數的位(Bit)來進行操作,只準使用兩種類型來表示數字,原碼解釋數字或補碼解釋數字。對于上面,ServitorCount就是補碼解釋,而TraderCount就是原碼解釋,各自的長度都為4位,而此時Solution::ServitorCount中依舊記錄的是偏移,不過不再以字節為單位,而是位為單位。并且由于其沒有類型,故也就沒有成員指針了。即前篇的(?sln[?cur[?curSln?]?]?&?0xF0?)?>>?4等效于sln[?cur[?curSln]?].TraderCount,而sln[?cur[?curSln?]?]?&?0xF0等效于sln[?cur[?curSln]?].ServitorCount,較之前具有了更好的可讀性。
應該注意,由于struct?AB?{?long?a,?b;?};也是一條語句,并且是一條聲明語句(因為不生成代碼),但就其意義上來看,更通常的叫法把它稱為定義語句,表示是類型定義語句,但按照不生成代碼的規則來判斷,其依舊是聲明語句,并進而可以放在類型定義符“{}”中,即:
struct?ABC{?struct?DB?{?long?a,?*b[2];?};?long?c;?DB?a;?};
上面的結構DB就定義在結構ABC的聲明語句中,則上面就定義了四個變量,類型均為偏移類型,變量名依次為:ABC::DB::a、ABC::DB::b、ABC::c、ABC::a;類型依次為long?ABC::DB::、long*?(ABC::DB::)[2]、long?ABC::、ABC::DB;映射的數值依次為0、4、0、4。這里稱結構DB嵌套在結構ABC中,其體現出一種層次關系,實際中這經常被使用以表現特定的語義。欲用結構DB定義一個變量,則ABC::DB?a;。同樣也就有long*?(?ABC::DB::*pB?)[2]?=?&ABC::DB::b;?ABC?c;?c.a.a?=?10;?*(?c.a.b[0]?)?=?20;。應注意ABC::DB::表示“ABC的DB的”而不是“DB的ABC的”,因為這里是重復的類型修飾符,是從右到左進行修飾的。
前面在定義結構時,都指明了一個類型名,如前面的ABC、ABCD等,但應該注意類型名不是必須的,即可以struct?{?long?a;?double?b;?}?a;?a.a?=?10;?a.b?=?34.32;。這里就定義了一個變量,其類型是一結構類型,不過這個結構類型沒有標識符和其關聯,以至于無法對其運用類型匹配等比較,如下:
struct?{?long?a;?double?b;?}?a,?&b?=?a,?*c?=?&a;?struct?{?long?a;?double?b;?}?*d?=?&a;
上面的a、b、c都沒有問題,因為使用同一個類型來定義的,即使這個類型沒有標識符和其映射,但d將會報錯,即使后寫的結構的定義式和前面的一模一樣,但仍然不是同一個,只是長得像罷了。那這有什么用?后面說明。
最后還應該注意,當在復合語句中書寫前面的聲明語句以定義結構時,之前所說的變量作用域也同樣適用,即在某復合語句中定義的結構,出了這個復合語句,它就被刪除,等于沒定義。如下:
void?ABC()
{
struct?AB?{?long?a,?b;?};
AB?d;?d.b?=?10;
}
void?main()
{
{
struct?AB{?long?a,?b,?e;?};
AB?c;?c.e?=?23;
}
AB?a;??//?將報錯,說AB未定義,但其他沒有任何問題
}
初始化
初始化就是之前在定義變量的同時,就給在棧上分配的內存賦值,如:long?a?=?10;。當定義的變量的類型有表示多個元素時,如數組類型、上面的結構類型時,就需要給出多個數字。對此,C++專門給出了一種語法,使用一對大括號將欲賦的值括起來后,整體作為一個數字賦給數組或結構,如下:
struct?ABC?{?long?a,?b;?float?c,?d[3];?};
ABC?a?=?{?1,?2,?43.4f,?{?213.0f,?3.4f,?12.4f?}?};
上面就給出了為變量a初始化的語法,大括號將各元素括起來,而各元素之間用“,”隔開。應注意ABC::d是數組類型,其對應的初始化用的數字也必須用大括號括起來,因此出現上面的嵌套大括號。現在應該了解到“{}”只是用來構造一個具有多個元素的數字而已,因此也可以有long?a?=?{?34?};,這里“{}”就等同于沒有。還應注意,C++同意給出的大括號中的數字個數少于相應自定義類型或數組的元素個數,即:ABC?a?=?{?1,?2,?34?},?b?=?{?23,?{?34?},?65,?{?23,?43?}?},?c?=?{?1,?2,?{?3,?{?4,?5,?6?}?}?};
上面的a.d[0]、a.d[1]、a.d[2]都為0,而只有b.d[2]才為0,但c將會報錯,因為嵌套的第一個大括號將{?4,?5,?6?}也括了起來,表示c.c將被一個具有兩個元素的數字賦值,但c.c的類型是float,只對應一個元素,編譯器將說初始化項目過多。而之前的a和b未賦值的元素都將被賦值為0,但應注意并不是數值上的0,而是簡單地將未賦值的內存的值用0填充,再通過那些補碼原碼之類的格式解釋成數值后恰好為0而已,并不是賦值0這個數字。
應注意,C++同意這樣的語法:long?a[]?=?{?34,?34,?23?};。這里在定義a時并沒有給出元素個數,而是由編譯器檢查賦值用的大括號包的元素個數,由其來決定數組的個數,因此上面的a的類型為long[3]。當多維數組時,如:long?a[3][2]?=?{?{?1,?2?},?{?3,?4?},?{?5,?6?}?};。因為每個元素又是需要多個元素的數字,就和前面的ABC::d一樣。再回想類型修飾符的修飾順序,是從左到右,但當是重復類型修飾符時,就倒過來從右到左,因此上面就應該是三個long[2],而不是兩個long[3],因此這樣將錯誤:long?a[3][2]?=?{?{?1,?2,?3?},?{?4,?5,?6?}?};。
還應注意,C++不止提供了上面的“{}”這一種初始化方式,對于字符串,其專門提供如:char?a[]?=?"ABC";。這里a的類型就為char[4],因為字符串"ABC"需要占4個字節的內存空間。除了這兩種初始化方式外,C++還提供了一種函數式的初始化函數,下篇介紹。
類型的運用
char?a?=?-34;?unsigned?char?b?=?(?unsigned?char?)a;
上面的b等于222,將-34按照補碼格式寫成二進制數11011110,然后將這個二進制數用原碼格式解釋,得數值222。繼續:
float?a?=?5.6f;?unsigned?long?b?=?(?unsigned?long?)a;
這回b等于5。為什么?不是應該將5.6按照IEEE的real*4的格式寫成二進制數0X40B33333(這里用十六進制表示),然后將這個二進制數用原碼格式解釋而得數值1085485875嗎?因為類型轉換是語義上的類型轉換,而不是類型變換。
兩個類型是否能夠轉換,要視編譯器是否定義了這兩個類型之間的轉換規則。如char和unsigned?char,之所以前面那樣轉換是因為編譯器把char轉unsigned?char定義成了那樣,同樣float轉unsigned?long被編譯器定義成了取整而不是四舍五入。
為什么要有類型轉換?有什么意義?的確,像上面那樣的轉換,毫無意義,僅僅只是為了滿足語法的嚴密性而已,不過由于C++定義了指針類型的轉換,而且定義得非常地好,以至于有非常重要的意義。
char?a?=?-34;?unsigned?char?b?=?*(?unsigned?char*?)(?&a?);
上面的結果和之前的一樣,b為222,不過是通過將char*轉成unsigned?char*,然后再用unsigned?char來解釋對應的內存而得到222,而不是按照編譯器的規定來轉換的,即使結果一樣。因此:
float?a?=?5.6f;?unsigned?long?b?=?*(?unsigned?long*?)(?&a?);
上面的b為1085485875,也就是之前以為的結果。這里將a的地址所對應的內存用unsigned?long定義的規則來解釋,得到的結果放在b中,這體現了類型就是如何解釋內存中的內容。上面之所以能實現,是因為C++規定所有的指針類型之間的轉換,數字的數值沒有變化,只有類型變化(但由于類的繼承關系也是可能會改變,下篇說明),因此上面才說b的值是用unsigned?long來解釋a對應的內存的內容所得的結果。因此,前篇在比較oldLayout[?curSln?][0~3]和oldLayout[?i?][0~3]時寫了四個“==”以比較了四次char的數字,由于這四個char數字是連續存放的,因此也可如下只比較一次long數字即可,將節約多余的三次比較時間。
*(?long*?)&oldLayout[?curSln?]?==?*(?long*?)&oldLayout[?i?]
上面只是一種優化手段而已,對于語義還是沒有多大意義,不過由于有了自定義類型,因此:
struct?AB?{?long?a1;?long?a2;?};?struct?ABC?{?char?a,?b;?short?c;?long?d;?};
AB?a?=?{?53213,?32542?};?ABC?*pA?=?(?ABC*?)&a;
char?aa?=?pA->a,?bb?=?pA->b,?cc?=?pA->c;?long?dd?=?pA->d;
pA->a?=?1;?pA->b?=?2;?pA->c?=?3;?pA->d?=?4;
long?aa1?=?a.a1,?aa2?=?a.a2;
上面執行后,aa、bb、cc、dd的值依次為-35、-49、0、32542,而aa1和aa2的值分別為197121和4。相信只要稍微想下就應該能理解為什么沒有修改a.a1和a.a2,結果它們的值卻變了,因為變量只不過是個映射而已,而前面就是利用指針pA以結構ABC來解釋并操作a所對應的內存的內容。
因此,利用自定義類型和指針轉換,就可以實現以什么樣的規則來看待某塊內存的內容。有什么用?傳遞給某函數一塊內存的引用(利用指針類型或引用類型),此函數還另有一個參數,比如是long類型。當此long類型的參數為1時,表示傳過去的是一張定單;為2時,表示傳過去的是一張發貨單;為3時表示是一張收款單。如果再配上下面說明的枚舉類型,則可以編寫出語義非常完善的代碼。
應注意由于指針是可以隨便轉換的,也就有如下的代碼,實際并沒什么意義,在這只為加深對成員指針的理解:
long?AB::*p?=?(?long?AB::*?)(?&ABC::b?);?a.a1?=?a.a2?=?0;?a.*p?=?0XAB1234CD;
上面執行后,a.a1為305450240,a.a2為171,轉成十六進制分別為0X1234CD00和0X000000AB。
枚舉
上面欲說明1時為定單,2時為發貨單而3時為收款單,則可以利用switch或if語句來進行判斷,但是語句從代碼上將看見類似type?==?1或type?==?2之類,無法表現出語義。C++專門為此提供了枚舉類型。
枚舉類型的格式和前面的自定義類型很像,但意義完全不同,如下:
enum?AB?{?LEFT,?RIGHT?=?2,?UP?=?4,?DOWN?=?3?};?AB?a?=?LEFT;
switch(?a?)
{
case?LEFT:;??//?做與左相應的事
case?UP:;//?做與上相應的事
}
枚舉也要用“{}”括住一些標識符,不過這些標識符即不映射內存地址也不映射偏移,而是映射整數,而為什么是整數,那是因為沒有映射浮點數的必要,后面說明。上面的RIGHT就等同于2,注意是等同于2,相當于給2起了個名字,因此可以long?b?=?LEFT;?double?c?=?UP;?char?d?=?RIGHT;。但注意上面的變量a,它的類型為AB,即枚舉類型,其解釋規則等同于int,即編譯成在16位操作系統上運行時,長度為2個字節,編譯成在32位操作系統上運行時為4個字節,但和int是屬于不同的類型,而前面的賦值操作之所以能沒有問題,可以認為編譯器會將枚舉類型隱式轉換成int類型,進而上面沒有錯誤。但倒過來就不行了,因為變量a的類型是AB,則它的值必須是上面列出的四個標識符中的一個,而a?=?b;則由于b為long類型,如果為10,那么將無法映射上面的四個標識符中的一個,所以不行。
注意上面的LEFT沒有寫“=”,此時將會從其前面的一個標識符的值自增一,由于它是第一個,而C++規定為0,故LEFT的值為0。還應注意上面映射的數字可以重復,即:
enum?AB?{?LEFT,?RIGHT,?UP?=?5,?DOWN,?TOP?=?5,?BOTTOM?};
上面的各標識符依次映射的數值為0、1、5、6、5、6。因此,最開始說的問題就可以如下處理:
enum?OperationType?{?ORDER?=?1,?INVOICE,?CHECKOUT?};
而那個參數的類型就可以為OperationType,這樣所表現的語義就遠遠地超出原來的代碼,可讀性高了許多。因此,當將某些人類世界的概念映射成數字時,發現它們的區別不表現在數字上,比如吃飯、睡覺、玩表示一個人的狀態,現在為了映射人這個概念為數字,也需要將人的狀態這個概念映射成數字,但很明顯地沒有什么方便的映射規則。這時就強行說1代表吃飯,2代表睡覺,3代表玩,此時就可以通過將1、2、3定義成枚舉以表現語義,這也就是為什么枚舉只定義為整數,因為沒有定義成浮點數的必要性。
聯合
前面說過類型定義符的前面可以接struct、class和union,當接union時就表示是聯合型自定義類型(簡稱聯合),它和struct的區別就是后者是串行分布來定義成員變量,而前者是并行分布。如下:
union?AB?{?long?a1,?a2,?a3;?float?b1,?b2,?b3;?};?AB?a;
變量a的長度為4個字節,而不是想象的6*4=24個字節,而聯合AB中定義的6個變量映射的偏移都為0。因此a.a1?=?10;執行后,a.a1、a.a2、a.a3的值都為10,而a.b1的值為多少,就用IEEE的real*4格式來解釋相應內存的內容,該多少是多少。
也就是說,最開始的利用指針來解釋不同內存的內容,現在可以利用聯合就完成了,因此上面的代碼搬到下面,變為:
union?AB
{
struct?{?long?a1;?long?a2;?};
struct?{?char?a,?b;?short?c;?long?d;?};
};
AB?a?=?{?53213,?32542?};
char?aa?=?a.a,?bb?=?a.b,?cc?=?a.c;?long?dd?=?a.d;
a.a?=?1;?a.b?=?2;?a.c?=?3;?a.d?=?4;
long?aa1?=?a.a1,?aa2?=?a.a2;
結果不變,但代碼要簡單,只用定義一個自定義類型了,而且沒有指針變量的運用,代碼的語義變得明顯多了。
注意上面定義聯合AB時在其中又定義了兩個結構,但都沒有賦名字,這是C++的特殊用法。當在類型定義符的中間使用類型定義符時,如果沒有給類型定義符定義的類型綁定標識符,則依舊定義那些偏移類型的變量,不過這些變量就變成上層自定義類型的成員變量,因此這時“{}”等同于沒有,唯一的意義就是通過前面的struct或class或union來指明變量的分布方式。因此可以如下:
struct?AB
{
struct?{?long?a1,?a2;?};
char?a,?b;
union?{?float?b1;?double?b2;?struct?{?long?b3;?float?b4;?char?b5;?};?};
short?c;
};
上面的自定義類型AB的成員變量就有a1、a2、a、b、b1、b2、b3、b4、b5、c,各自對應的偏移值依次為0、4、8、9、10、10、10、14、18、19,類型AB的總長度為21字節。某類型的長度表示如果用這個類型定義了一個變量,則編譯器應該在棧上分配多大的連續空間,C++為此專門提供了一個操作符sizeof,其右側接數字或類型名,當接數字時,就返回那個數字的類型需要占的內存空間的大小,而接類型名時,就返回那個類型名所標識的類型需要占的內存空間的大小。
因此long?a?=?sizeof(?AB?);?AB?d;?long?b?=?sizeof?d;執行后,a和b的值都為40。怎么是40?不應該為21嗎?而之前的各成員變量對應的偏移也依次實際為0、4、8、9、16、16、16、20、24、32。為什么?這就是所謂的數據對齊。
CPU有某些指令,需要處理多個數據,則各數據間的間隔必須是4字節或8字節或16字節(視不同的指令而有不同的間隔),這被稱作數據對齊。當各個數據間的間隔不符合要求時,CPU就必須做附加的工作以對齊數據,效率將下降。并且CPU并不直接從內存中讀取東西,而要經一個高速緩沖(CPU內建的一個存取速度比內存更快的硬件)緩沖一下,而此緩沖的大小肯定是2的次方,但又比較小,因此自定義類型的大小最好能是2的次方的倍數,以便高效率的利用高速緩沖。
在自定義類型時,一個成員變量的偏移值一定是它所屬的類型的長度的倍數,即上面的a和b的偏移必須是1的倍數,而c的偏移必須是2的倍數,b1的偏移必須是4的倍數。但b2的偏移必須是8的倍數,而b1和b2由于前面的union而導致是并行布局,因此b1的偏移必須和b2及b3的相同,因此上面的b1、b2、b3的偏移變成了8的倍數16,而不是想象的10。
而一個自定義類型的長度必須是其成員變量中長度最長的那個成員變量的長度的倍數,因此struct?{?long?b3;?float?b4;?char?b5;?};的長度是4的倍數,也就是12。而上面的無名聯合的成員變量中,只有double?b2;的長度最長,為8個字節,所以它的長度為16,并進而導致c的偏移為b1的偏移加16,故為32。由于結構AB中的成員變量只有b2的長度最長,為8,故AB的長度必須是8的倍數40。因此在定義結構時應盡量將成員和其長度對應起來,如下:
struct?ABC1?{?char?a,?b;?char?d;?long?c;?};
struct?ABC2?{?char?a,?b;?long?c;?char?d;?};
ABC1的長度為8個字節,而ABC2的長度為12個字節,其中ABC1::c和ABC2::c映射的偏移都為4。
應注意上面說的規則一般都可以通過編譯選項進行一定的改變,不同的編譯器將給出不同的修改方式,在此不表。
本篇說明了如何使用類型定義符“{}”來定義自定義類型,說明了兩種自定義類型,實際還有許多自定義類型的內容未說明,將在下篇介紹,即后面介紹的類及類相關的內容都可應用在聯合和結構上,因為它們都是自定義類型。
發表于?2004年07月17日?2:32?PM?
評論
#?回復:C++從零開始(九)——何謂結構?2004-07-23?12:53?PM?ぐ落葉ζ繽紛?
現在我發現自己存在一個嚴重的問題,就是理論上完全理解但是用到實際就存在太多的問題。特別是指針和引用的應用!?
為什么*P=&a;要加&,而數組*p=a[10];就可以不加;變量名跟數組名同是映射地址或首地址。這里的&表示的是什么意思?是引用??
也許你會覺得我的問題太簡單,很幼稚。但我希望你的抽空為我這種初學者講解!(可否利用你的休息時間專門寫一篇關于指針的實際應用)?
?
#?回復:C++從零開始(九)——何謂結構?2004-07-23?2:52?PM?lop5712?
你是在哪里看到*p?=?&a;的??如果是下面的定義語句:?
int?a,?*p?=?&a;?
這里的“*”由于是在定義語句中,是指針類型修飾符,表示p的類型是int*。而如果不在定義語句中對p賦值則應該p?=?&a;而不是*p?=?&a;?
對于*p?=?a[10];,這里是個表達式語句,即整個語句最后將返回一個數字,則這里的“*”就是操作符而不是指針類型修飾符。?
后者只在說明類型時,如定義語句、類型轉換、類型定義語句等,前者只在說明數字時,如表達式語句、任何接數字的地方等。?
由于這里的“*”是取內容操作符,我在《五》中已經說明,它的操作很簡單,它右側只接指針類型的數字,將這個指針類型的數字換成地址類型的數字然后返回。而變量就是映射的一個地址類型的數字,比如:?
int?a,?*p;?//?假設a映射的地址是4000,p映射的地址是4004,注意不管那個變量的類型是什么,它一定要映射一個地址?
注意a?=?10;是一個表達式語句(因為整個語句都由操作符和數字組成,變量也算數字,因為變量名映射的是一個地址),對于賦值操作符“=”,其兩邊接數字,左側接的數字是4000,類型為int類型的地址類型;右側接的數字是10,類型為int類型,則意思就是將10放到4000所對應的內存中去。?
接著看*p?=?a[10];,“*”取內容操作符右側接指針類型的數字,對于此,其右側接的數字是4004,類型是int的指針類型(也就是int*),然后“*”返回數字4004,類型是int的地址類型,接著就符合了“=”的要求,左邊是地址類型的數字了,將“=”右邊的數字放到4004所標識的內存中。?
同樣,“&”也是有兩個:引用類型修飾符和取地址操作符,分別用于說明類型和說明數字。如何區別就和上面說的一樣,比如:?
int?a,?*p?=?&a,?&ra1?=?a,?&ra2?=?*p;?
這里的“&”就是取地址操作符而不是類型修飾符,因為這里是給變量p賦初值,“=”右側接的是數字。而ra1和ra2前面的“&”就是引用類型修飾符,因為它在定義語句中。而ra2后的“*”就由于是在“=”的右邊,要求是數字,因此是取內容操作符而不是引用類型修飾符?
至于《指針的運用》,我實際是干機械的,過幾天就工作了,到時候就沒硬件也沒環境,所以也就不會寫了。打算寫到《十三》,如果還有時間,就寫《指針的運用》,說明各種類型的指針各自有什么用.
C++從零開始(十)?
——何謂類?
前篇說明了結構只不過是定義了內存布局而已,提到類型定義符前還可以書寫class,即類型的自定義類型(簡稱類),它和結構根本沒有區別(僅有一點小小的區別,下篇說明),而之所以還要提供一個class,實際是由于C++是從C擴展而成,其中的class是C++自己提出的一個很重要的概念,只是為了與C語言兼容而保留了struct這個關鍵字。不過通過前面括號中所說的小小區別也足以看出C++的設計者為結構和類定義的不同語義,下篇說明。
暫時可以先認為類較結構的長足進步就是多了成員函數這個概念(雖然結構也可以有成員函數),在了解成員函數之前,先來看一種語義需求。
操作與資源
程序主要是由操作和被操作的資源組成,操作的執行者就是CPU,這很正常,但有時候的確存在一些需要,需要表現是某個資源操作了另一個資源(暫時稱作操作者),比如游戲中,經常出現的就是要映射怪物攻擊了玩家。之所以需要操作者,一般是因為這個操作也需要修改操作者或利用操作者記錄的一些信息來完成操作,比如怪物的攻擊力來決定玩家被攻擊后的狀態。這種語義就表現為操作者具有某些功能。為了實現上面的語義,如原來所說進行映射,先映射怪物和玩家分別為結構,如下:
struct?Monster?{?float?Life;?float?Attack;?float?Defend;?};
struct?Player?{?float?Life;?float?Attack;?float?Defend;?};
上面的攻擊操作就可以映射為void?MonsterAttackPlayer(?Monster?&mon,?Player?&pla?);。注意這里期望通過函數名來表現操作者,但和前篇說的將過河方案起名為sln一樣,屬于一種本末倒置,因為這個語義應該由類型來表現,而不是函數名。為此,C++提供了成員函數的概念。
成員函數
與之前一樣,在類型定義符中書寫函數的聲明語句將定義出成員函數,如下:
struct?ABC?{?long?a;?void?AB(?long?);?};
上面就定義了一個映射元素——第一個變量ABC::a,類型為long?ABC::;以及聲明了一個映射元素——第二個函數ABC::AB,類型為void?(?ABC::?)(?long?)。類型修飾符ABC::在此修飾了函數ABC::AB,表示其為函數類型的偏移類型,即是一相對值。但由于是函數,意義和變量不同,即其依舊映射的是內存中的地址(代碼的地址),但由于是偏移類型,也就是相對的,即是不完整的,因此不能對它應用函數操作符,如:ABC::AB(?10?);。這里將錯誤,因為ABC::AB是相對的,其相對的東西不是如成員變量那樣是個內存地址,而是一個結構指針類型的參數,參數名一定為this,這是強行定義的,后面說明。
注意由于其名字為ABC::AB,而上面僅僅是對其進行了聲明,要定義它,仍和之前的函數定義一樣,如下:
void?ABC::AB(?long?d?)?{?this->a?=?d;?}
應注意上面函數的名字為ABC::AB,但和前篇說的成員變量一樣,不能直接書寫long?ABC::a;,也就不能直接如上書寫函數的定義語句(至少函數名為ABC::AB就不符合標識符規則),而必須要通過類型定義符“{}”先定義自定義類型,然后再書寫,這會在后面說明聲明時詳細闡述。
注意上面使用了this這個關鍵字,其類型為ABC*,由編譯器自動生成,即上面的函數定義實際等同于void?ABC::AB(?ABC?*this,?long?d?)?{?this->a?=?d;?}。而之所以要省略this參數的聲明而由編譯器來代勞是為了在代碼上體現出前面提到的語義(即成員的意義),這也是為什么稱ABC::AB是函數類型的偏移類型,它是相對于這個this參數而言的,如何相對,如下:
ABC?a,?b,?c;?a.ABC::AB(?10?);?b.ABC::AB(?12?);?c.AB(?14?);
上面利用成員操作符調用ABC::AB,注意執行后,a.a、b.a和c.a的值分別為10、12和14,即三次調用ABC::AB,但通過成員操作符而導致三次的this參數的值并不相同,并進而得以修改三個ABC變量的成員變量a。注意上面書寫a.ABC::AB(?10?);,和成員變量一樣,由于左右類型必須對應,因此也可a.AB(?10?);。還應注意上面在定義ABC::AB時,在函數體內書寫this->a?=?d;,同上,由于類型必須對應的關系,即this必須是相應自定義類型的指針,所以也可省略this->的書寫,進而有void?ABC::AB(?long?d?)?{?a?=?d;?}。
注意這里成員操作符的作用,其不再如成員變量時返回相應成員變量類型的數字,而是返回一函數類型的數字,但不同的就是這個函數類型是無法用語法表示出來的,即C++并沒有提供任何關鍵字或類型修飾符來表現這個返回的類型(VC內部提供了__thiscall這個類型修飾符進行表示,不過寫代碼時依舊不能使用,只是編譯器內部使用)。也就是說,當成員操作符右側接的是函數類型的偏移類型的數字時,返回一個函數類型的數字(表示其可被施以函數操作符),函數的類型為偏移類型中給出的類型,但這個類型無法表現。即a.AB將返回一個數字,這個數字是函數類型,在VC內部其類型為void?(?__thiscall?ABC::?)(?long?),但這個類型在C++中是非法的。
C++并沒有提供類似__thiscall這樣的關鍵字以修飾類型,因為這個類型是要求編譯器遇到函數操作符和成員操作符時,如a.AB(?10?);,要將成員操作符左側的地址作為函數調用的第一個參數傳進去,然后再傳函數操作符中給出的其余各參數。即這個類型是針對同時出現函數操作符和成員操作符這一特定情況,給編譯器提供一些信息以生成正確的代碼,而不用于修飾數字(修飾數字就要求能應付所有情況)。即類型是用于修飾數字的,而這個類型不能修飾數字,因此C++并未提供類似__thiscall的關鍵字。
和之前一樣,由于ABC::AB映射的是一個地址,而不是一個偏移值,因此可以ABC::AB;但不能ABC::a;,因為后者是偏移值。根據類型匹配,很容易就知道也可有:
void?(?ABC::*p?)(?long?)?=?ABC::AB;或void?(?ABC::*p?)(?long?)?=?&ABC::AB;
進而就有:void?(?ABC::**pP?)(?long?)?=?&p;?(?c.**pP?)(?10.0f?);。之所以加括號是因為函數操作符的優先級較“*”高。再回想前篇說過指針類型的轉換只是類型變化,數值不變(下篇說明數值變化的情況),因此可以有如下代碼,這段代碼毫無意義,在此僅為加深對成員函數的理解。
struct?ABC?{?long?a;?void?AB(?long?);?};
void?ABC::AB(?long?d?)
{
this->a?=?d;
}
struct?AB
{
short?a,?b;
void?ABCD(?short?tem1,?short?tem2?);
void?ABC(?long?tem?);
};
void?AB::ABCD(?short?tem1,?short?tem2?)
{
a?=?tem1;?b?=?tem2;
}
void?AB::ABC(?long?tem?)
{
a?=?short(?tem?/?10?);
b?=?short(?tem?-?tem?/?10?);
}
void?main()
{
ABC?a,?b,?c;?AB?d;
(?c.*(?void?(?ABC::*?)(?long?)?)&AB::ABC?)(?43?);
(?b.*(?void?(?ABC::*?)(?long?)?)&AB::ABCD?)(?0XABCDEF12?);
(?d.*(?void?(?AB::*?)(?short,?short?)?)ABC::AB?)(?0XABCD,?0XEF12?);
}
上面執行后,c.a為0X00270004,b.a為0X0000EF12,d.a為0XABCD,d.b為0XFFFF。對于c的函數調用,由于AB::ABC映射的地址被直接轉換類型進而直接被使用,因此程序將跳到AB::ABC處的a?=?short(?tem?/?10?);開始執行,而參數tem映射的是傳遞參數的內存的首地址,并進而用long類型解釋而得到tem為43,然后執行。注意b?=?short(?tem?-?tem?/?10?);實際是this->b?=?short(?tem?-?tem?/?10?);,而this的值為c對應的地址,但在這里被認為是AB*類型(因為在函數AB::ABC的函數體內),所以才能this->b正常(ABC結構中沒有b這個成員變量),而b的偏移為2,所以上句執行完后將結果39存放到c的地址加2所對應的內存,并且以short類型解釋而得到的16位的二進制數存放。對于a?=?short(?tem?/?10?);也做同樣事情,故最后得c.a的值為0X0027004(十進制39轉成十六進制為0X27)。
同樣,對于b的調用,程序將跳到AB::ABCD,但生成的b的調用代碼時,將參數0XABCDEF12按照參數類型為long的格式記錄在傳遞參數的內存中,然后跳到AB::ABCD。但編譯AB::ABCD時又按照參數為兩個short類型來映射參數tem1和tem2對應的地址,因此容易想到tem1的值將為0XEF12,tem2的值為0XABCD,但實際并非如此。參數如何傳遞由之前說的函數調用規則決定,函數調用的具體實現細節在《C++從零開始(十五)》中說明,這里只需了解到成員函數映射的仍然是地址,而它的類型決定了如何使用它,后面說明。
聲明的含義
前面已經解釋過聲明是什么意思,在此由于成員函數的定義規則這種新的定義語法,必須重新考慮聲明的意思。注意一點,前面將一個函數的定義放到main函數定義的前面就可以不用再聲明那個函數了;同樣如果定義了某個變量,就不用再聲明那個變量了。這也就是說定義語句具有聲明的功能,但上面成員函數的定義語句卻不具有聲明的功能,下面來了解聲明的真正意思。
聲明是要求編譯器產生映射元素的語句。所謂的映射元素,就是前面介紹過的變量及函數,都只有3欄(或3個字段):類型欄、名字欄和地址欄(成員變量類型的這一欄就放偏移值)。即編譯器每當看到聲明語句,就生成一個映射元素,并且將對應的地址欄空著,然后留下一些信息以告訴連接器——此.obj文件(編譯器編譯源文件后生成的文件,對于VC是.obj文件)需要一些符號,將這些符號找到后再修改并完善此.obj文件,最后連接。
回想之前說過的符號的意思,它就是一字符串,用于編譯器和連接器之間的通信。注意符號沒有類型,因為連接器只是負責查找符號并完善(因為有些映射元素的地址欄還是空的)中間文件(對于VC就是.obj文件),不進行語法分析,也就沒有什么類型。
定義是要求編譯器填充前面聲明沒有書寫的地址欄。也就是說某變量對應的地址,只有在其定義時才知道。因此實際的在棧上分配內存等工作都是由變量的定義完成的,所以才有聲明的變量并不分配內存。但應注意一個重點,定義是生成映射元素需要的地址,因此定義也就說明了它生成的是哪個映射元素的地址,而如果此時編譯器的映射表(即之前說的編譯器內部用于記錄映射元素的變量表、函數表等)中沒有那個映射元素,即還沒有相應元素的聲明出現過,那么編譯器將報錯。
但前面只寫一個變量或函數定義語句,它照樣正常并沒有報錯啊?實際很簡單,只需要將聲明和定義看成是一種語句,只不過是向編譯器提供的信息不同罷了。如:void?ABC(?float?);和void?ABC(?float?){},編譯器對它們相同看待。前者給出了函數的類型及類型名,因此編譯器就只填寫映射元素中的名字和類型兩欄。由于其后只接了個“;”,沒有給出此函數映射的代碼,因此編譯器無法填寫地址欄。而后者,給出了函數名、所屬類型以及映射的代碼(空的復合語句),因此編譯器得到了所有要填寫的信息進而將三欄的信息都填上了,結果就表現出定義語句完成了聲明的功能。
對于變量,如long?a;。同上,這里給出了類型和名字,因此編譯器填寫了類型和名字兩欄。但變量對應的是棧上的某塊內存的首地址,這個首地址無法從代碼上表現出來(前面函數就通過在函數聲明的后面寫復合語句來表現相應函數對應的代碼所在的地址),而必須由編譯器內部通過計算獲得,因此才硬性規定上面那樣的書寫算作變量的定義,而要變量的聲明就需要在前面加extern。即上面那樣將導致編譯器進行內部計算進而得出相應的地址而填寫了映射元素的所有信息。
上面難免顯得故弄玄虛,那都是因為自定義類型的出現。考慮成員變量的定義,如:
struct?ABC?{?long?a,?b;?double?c;?};
上面給出了類型——long?ABC::、long?ABC::和double?ABC::;給出了名字——ABC::a、ABC::b和ABC::c;給出了地址(即偏移)——0、4和8,因為是結構型自定義類型,故由此語句就可以得出各成員變量的偏移。上面得出三個信息,即可以填寫映射元素的所有信息,所以上面可以算作定義語句。對于成員函數,如下:
struct?ABC?{?void?AB(?float?);?};
上面給出了類型——void?(?ABC::?)(?float?);給出了名字——ABC::AB。不過由于沒有給出地址,因此無法填寫映射元素的所有信息,故上面是成員函數ABC::AB的聲明。按照前面說法,只要給出地址就可以了,而無需去管它是定義還是聲明,因此也就可以這樣:
struct?ABC?{?void?AB(?float?){}?};
上面給出類型和名字的同時,給出了地址,因此將可以完全填寫映射元素的所有信息,是定義。上面的用法有其特殊性,后面說明。注意,如果這時再在后面寫ABC::AB的定義語句,即如下,將錯誤:
struct?ABC?{?void?AB(?float?){}?};
void?ABC::AB(?float?)?{}
上面將報錯,原因很簡單,因為后者只是定義,它只提供了ABC::AB對應的地址這一個信息,但映射元素中的地址欄已經填寫了,故編譯器將說重復定義。再單獨看成員函數的定義,它給出了類型void?(?ABC::?)(?float?),給出了名字ABC::AB,也給出了地址,但為什么說它只給出了地址這一信息?首先,名字ABC::AB是不符合標識符規則的,而類型修飾符ABC::必須通過類型定義符“{}”才能夠加上去,這在前面已多次說明。因此上面給出的信息是:給出了一個地址,這個地址是類型為void?(?ABC::?)(?float?),名字為ABC::AB的映射元素的地址。結果編譯器就查找這樣的映射元素,如果有,則填寫相應的地址欄,否則報錯,即只寫一個void?ABC::AB(?float?){}是錯誤的,在其前面必須先通過類型定義符“{}”聲明相應的映射元素。這也就是前面說的定義僅僅填充地址欄,并不生成映射元素。
聲明的作用
定義的作用很明顯了,有意義的映射(名字對地址)就是它來做,但聲明有什么用?它只是生成類型對名字,為什么非得要類型對名字?它只是告訴編譯器不要發出錯誤說變量或函數未定義?任何東西都有其存在的意義,先看下面這段代碼。
extern"C"?long?ABC(?long?a,?long?b?);
void?main(){?long?c?=?ABC(?10,?20?);?}
假設上面代碼在a.cpp中書寫,編譯生成文件a.obj,沒有問題。但按照之前的說明,連接時將錯誤,因為找不到符號_ABC。因為名字_ABC對應的地址欄還空著。接著在VC中為a.cpp所在工程添加一個新的源文件b.cpp,如下書寫代碼。
extern"C"?float?ABC(?float?a?){?return?a;?}
編譯并連接,現在沒任何問題了,但相信你已經看出問題了——函數ABC的聲明和定義的類型不匹配,卻連接成功了?
注意上面關于連接的說明,連接時沒有類型,只管符號。上面用extern"C"使得a.obj要求_ABC的符號,而b.cpp提供_ABC的符號,剩余的就只是連接器將b.obj中_ABC對應的地址放到a.obj以完善a.obj,最后連接a.obj和b.obj。
那么上面什么結果,由于需要考慮函數的實現細節,這在《C++從零開始(十五)》中再說明,而這里只要注意到一件事:編譯器即使沒有地址也依舊可以生成代碼以實現函數操作符的功能——函數調用。之所以能這樣就是因為聲明時一定必須同時給出類型和名字,因為類型告訴編譯器,當某個操作符涉及到某個映射元素時,如何生成代碼來實現這個操作符的功能。也就是說,兩個char類型的數字乘法和兩個long類型的數字乘法編譯生成的代碼不同;對long?ABC(?long?);的函數調用代碼和void?ABC(?float?)的不同。即,操作符作用的數字類型的不同將導致編譯器生成的代碼不同。
那么上面為什么要將ABC的定義放到b.cpp中?因為各源文件之間的編譯是獨立的,如果放在a.cpp,編譯器就會發現已經有這么個映射元素,但類型卻不匹配,將報錯。而放到b.cpp中,使得由連接器來完善a.obj,到時將沒有類型的存在,只管符號。下面繼續。
struct?ABC?{?long?a,?b;?void?AB(?long?tem1,?long?tem2?);?void?ABCD();?};
void?main(){?ABC?a;?a.AB(?10,?20?);?}
由上面的說法,這里雖然沒有給出ABC::AB的定義,但仍能編譯成功,沒有任何問題。仍假設上面代碼在a.cpp中,然后添加b.cpp,在其中書寫下面的代碼。
struct?ABC?{?float?b,?a;?void?AB(?long?tem1,?long?tem2?);?long?ABCD(?float?);?};
void?ABC::AB(?long?tem1,?long?tem2?){?a?=?tem1;?b?=?tem2;?}
這里定義了函數ABC::AB,注意如之前所說,由于這里的函數定義僅僅只是定義,所以必須在其前面書寫類型定義符“{}”以讓編譯器生成映射元素。但更應該注意這里將成員變量的位置換了,這樣b就映射的是0而a映射的是4了,并且還將a、b的類型換成了float,更和a.cpp中的定義大相徑庭。但沒有任何問題,編譯連接成功,a.AB(?10,20?);執行后a.a為0X41A00000,a.b為0X41200000,而*(?float*?)&a.a為20,*(?flaot*?)&a.b為10。
為什么?因為編譯器只在當前編譯的那個源文件中遵循類型匹配,而編譯另一個源文件時,編譯其他源文件所生成的映射元素全部無效。因此聲明將類型和名字綁定起來,而名字就代表了其所關聯的類型的地址類型的數字,而后繼代碼中所有操作這個數字的操作符的編譯生成都將受這個數字的類型的影響。即聲明是告訴編譯器如何生成代碼的,其不僅僅只是個語法上說明變量或函數的語句,它是不可或缺的。
還應注意上面兩個文件中的ABC::ABCD成員函數的聲明不同,而且整個工程中(即a.cpp和b.cpp中)都沒有ABC::ABCD的定義,卻仍能編譯連接成功,因為聲明并不是告訴編譯器已經有什么東西了,而是如何生成代碼。
頭文件
上面已經說明,如果有個自定義類型ABC,在a.cpp、b.cpp和c.cpp中都要使用它,則必須在a.cpp、b.cpp和c.cpp中,各自使用ABC之前用類型定義符“{}”重新定義一遍這個自定義類型。如果不小心如上面那樣在a.cpp和b.cpp中寫的定義不一樣,則將產生很難查找的錯誤。為此,C++提供了一個預編譯指令來幫忙。
預編譯指令就是在編譯之前執行的指令,它由預編譯器來解釋執行。預編譯器是另一個程序,一般情況,編譯器廠商都將其合并進了C++編譯器而只提供一個程序。在此說明預編譯指令中的包含指令——#include,其格式為#include?<文件名>。應注意預編譯指令都必須單獨占一行,而<文件名>就是一個用雙引號或尖括號括起來的文件名,如:#include?"abc.c"、#include?"C:\abc.dsw"或#include?<\abc.exe>。它的作用很簡單,就是將引號或尖括號中書寫的文件名對應的文件以ANSI格式或MBCS格式(關于這兩個格式可參考《C++從零開始(五)》)解釋,并將內容原封不動地替換到#include所在的位置,比如下面是文件abc的內容。
struct?ABC?{?long?a,?b;?void?AB(?long?tem1,?long?tem2?);?};
則前面的a.cpp可改為:
#include?"abc"
void?main()?{?ABC?a;?a.AB(?10,?20?);?}
而b.cpp可改為:
#include?"abc"
void?ABC::AB(?long?tem1,?long?tem2?){?a?=?tem1;?b?=?tem2;?}
這時,就不會出現類似上面那樣在b.cpp中將自定義類型ABC的定義寫錯了而導致錯誤的結果(a.a為0X41A00000,a.b為0X41200000),進而a.AB(?10,?20?);執行后,a.a為10,a.b為20。
注意這里使用的是雙引號來括住文件名的,它表示當括住的只是一個文件名或相對路徑而沒有給出全路徑時,如上面的abc,則先搜索此時被編譯的源文件所在的目錄,然后搜索編譯器自定的包含目錄(如:C:\Program?Files\Microsoft?Visual?Studio?.NET?2003\Vc7\include等),里面一般都放著編譯器自帶的SDK的頭文件(關于SDK,將在《C++從零開始(十八)》中說明),如果仍沒有找到,則報錯(注意,一般編譯器都提供了一些選項以使得除了上述的目錄外,還可以再搜索指定的目錄,不同的編譯器設定方式不同,在此不表)。
如果是用尖括號括起來,則表示先搜索編譯器自定的包含目錄,再源文件所在目錄。為什么要不同?只是為了防止自己起的文件名正好和編譯器的包含目錄下的文件重名而發生沖突,因為一旦找到文件,將不再搜索后繼目錄。
所以,一般的C++代碼中,如果要用到某個自定義類型,都將那個自定義類型的定義分別裝在兩個文件中,對于上面結構ABC,則應該生成兩個文件,分別為ABC.h和ABC.cpp,其中的ABC.h被稱作頭文件,而ABC.cpp則稱作源文件。頭文件里放的是聲明,而源文件中放的是定義,則ABC.h的內容就和前面的abc一樣,而ABC.cpp的內容就和b.cpp一樣。然后每當工程中某個源文件里要使用結構ABC時,就在那個源文件的開頭包含ABC.h,這樣就相當于將結構ABC的所有相關聲明都帶進了那個文件的編譯,比如前面的a.cpp就通過在開頭包含abc以聲明了結構ABC。
為什么還要生成一個ABC.cpp?如果將ABC::AB的定義語句也放到ABC.h中,則a.cpp要使用ABC,c.cpp也要使用ABC,所以a.cpp包含ABC.h,由于里面的ABC::AB的定義,生成一個符號?AB@ABC@@QAEXJJ@Z(對于VC);同樣c.cpp的編譯也要生成這個符號,然后連接時,由于出現兩個相同的符號,連接器無法確定使用哪一個,報錯。因此專門定義一個ABC.cpp,將函數ABC::AB的定義放到ABC.obj中,這樣將只有一個符號生成,連接時也就不再報錯。
注意上面的struct?ABC?{?void?AB(?float?){}?};。如果將這個放在ABC.h中,由于在類型定義符中就已經將函數ABC::AB的定義給出,則將會同上,出現兩個相同的符號,然后連接失敗。為了避開這個問題,C++規定如上在類型定義符中直接書寫函數定義而定義的函數是inline函數,出于篇幅,下篇介紹。
成員的意義
上面從語法的角度說明了成員函數的意思,如果很昏,不要緊,實現不能理解并不代表就不能運用,而程序員重要的是對語言的運用能力而不是語言的了解程度(雖然后者也很重要)。下面說明成員的語義。
本文一開頭提出了一種語義——某種資源具有的功能,而C++的自定義類型再加上成員操作符“.”和“->”的運用,從代碼上很容易的就表現出一種語義——從屬關系。如:a.b、c.d分別表示a的b和c的d。某種資源具有的功能要映射到C++中,就應該將這種資源映射成一自定義類型,而它所具有的功能就映射成此自定義類型的成員函數,如最開始提到的怪物和玩家,則如下:
struct?Player?{?float?Life;?float?Attack;?float?Defend;?};
struct?Monster?{?float?Life;?float?Attack;?float?Defend;?void?AttackPlayer(?Player?&pla?);?};
Player?player;?Monster?a;?a.AttackPlayer(?player?);
上面的語義就非常明顯,代碼執行的操作是怪物a攻擊玩家player,而player.Life就代表玩家player的生命值。假設如下書寫Monster::AttackPlayer的定義:
void?Monster::AttackPlayer(?Player?&pla?)
{
pla.Life?-=?Attack?-?pla.Defend;
}
上面的語義非常明顯:某怪物攻擊玩家的方法就是將被攻擊的玩家的生命值減去自己的攻擊力減被攻擊的玩家的防御力的值。語義非常清晰,代碼的可讀性好。而如原來的寫法:
void?MonsterAttackPlayer(?Monster?&mon,?Player?&pla?)
{
pla.Life?-=?mon.Attack?-?pla.Defend;
}
則代碼表現的語義:怪物攻擊玩家是個操作,此操作需要操作兩個資源,分別為怪物類型和玩家類型。這個語義就沒表現出我們本來打算表現的想法,而是怪物的攻擊功能的另一種解釋(關于這點,將在《C++從零開始(十二)》中詳細闡述),其更適合表現收銀工作。比如收銀臺實現的是收錢的工作,客戶在柜臺買了東西,由營業員開出單據,然后客戶將單據拿到收銀臺交錢。這里收銀臺的工作就需要操作兩個資源——錢和單據,這時就應該將收錢這個工作映射為如上的函數而不是成員函數,因為在這個算法中,收銀臺沒有被映射成自定義類型的必要性,即我們對收銀的工作由誰做不關心,只關心它如何做。
至此介紹完了自定義類型的一半內容,通過這些內容已經可以編寫出能體現較復雜語義的代碼了,下篇將說明自定義類型的后半內容,它們的提出根本可以認為就是語義的需要,所以下篇將從剩余內容是如何體現語義的來說明,不過依舊要說明各自是如何實現的。
C++從零開始(十一)上篇?
——類的相關知識?
前面已經介紹了自定義類型的成員變量和成員函數的概念,并給出它們各自的語義,本文繼續說明自定義類型剩下的內容,并說明各自的語義。
權限
成員函數的提供,使得自定義類型的語義從資源提升到了具有功能的資源。什么叫具有功能的資源?比如要把收音機映射為數字,需要映射的操作有調整收音機的頻率以接收不同的電臺;調整收音機的音量;打開和關閉收音機以防止電力的損耗。為此,收音機應映射為結構,類似下面:
struct?Radiogram
{
double?Frequency;??/*?頻率?*/??void?TurnFreq(?double?value?);???//?改變頻率
float??Volume;?/*?音量?*/??void?TurnVolume(?float?value?);??//?改變音量
float??Power;??/*?電力?*/??void?TurnOnOff(?bool?bOn?);??//?開關
bool???bPowerOn;???//?是否開啟
};
上面的Radiogram::Frequency、Radiogram::Volume和Radiogram::Power由于定義為了結構Radiogram的成員,因此它們的語義分別為某收音機的頻率、某收音機的音量和某收音機的電力。而其余的三個成員函數的語義也同樣分別為改變某收音機的頻率、改變某收音機的音量和打開或關閉某收音機的電源。注意這面的“某”,表示具體是哪個收音機的還不知道,只有通過成員操作符將左邊的一個具體的收音機和它們結合時才知道是哪個收音機的,這也是為什么它們被稱作偏移類型。這一點在下一篇將詳細說明。
注意問題:為什么要將剛才的三個操作映射為結構Radiogram的成員函數?因為收音機具有這樣的功能?那么對于選西瓜、切西瓜和吃西瓜,難道要定義一個結構,然后給它定義三個選、切、吃的成員函數??不是很荒謬嗎?前者的三個操作是對結構的成員變量而言,而后者是對結構本身而言的。那么改成吃快餐,吃快餐的漢堡包、吃快餐的薯條和喝快餐的可樂。如果這里的兩個吃和一個喝的操作變成了快餐的成員函數,表示是快餐的功能?!這其實是編程思想的問題,而這里其實就是所謂的面向對象編程思想,它雖然是很不錯的思想,但并不一定是合適的,下篇將詳細討論。
上面我們之所以稱收音機的換臺是功能,是因為實際中我們自己是無法直接改變收音機的頻率,必須通過旋轉選臺的那個旋鈕來改變接收的頻率,同樣,調音量也是通過調節音量旋鈕來實現的,而由于開機而導致的電力下降也不是我們直接導致,而是間接通過收聽電臺而導致的。因此上面的Radiogram::Power、Radiogram::Frequency等成員變量都具有一個特殊特性——外界,這臺收音機以外的東西是無法改變它們的。為此,C++提供了一個語法來實現這種語義。在類型定義符中,給出這樣的格式:<權限>:。這里的<權限>為public、protected和private中的一個,分別稱作公共的、保護的和私有的,如下:
class?Radiogram
{
protected:?double?m_Frequency;?float?m_Volume;?float?m_Power;
private:???bool???m_bPowerOn;
public:void?TurnFreq(?double?);?void?TurnVolume(?float?);?void?TurnOnOff(?bool?);
};
可以發現,它和之前的標號的定義格式相同,但并不是語句修飾符,即可以struct?ABC{?private:?};。這里不用非要在private:后面接語句,因為它不是語句修飾符。從它開始,直到下一個這樣的語法,之間所有的聲明和定義而產生的成員變量或成員函數都帶有了它所代表的語義。比如上面的類Radiogram,其中的Radiogram::m_Frequency、Radiogram::m_Volume和Radiogram::m_Power是保護的成員變量,Radiogram::m_bPowerOn是私有的成員變量,而剩下的三個成員函數都是公共的成員函數。注意上面的語法是可以重復的,如:struct?ABC?{?public:?public:?long?a;?private:?float?b;?public:?char?d;?};。
什么意思?很簡單,公共的成員外界可以訪問,保護的成員外界不能訪問,私有的成員外界及子類不能訪問。關于子類后面說明。先看公共的。對于上面,如下將報錯:
Radiogram?a;?a.m_Frequency?=?23.0;?a.m_Power?=?1.0f;?a.m_bPowerOn?=?true;
因為上面對a的三次操作都使用了a的保護或私有成員,編譯器將報錯,因為這兩種成員外界是不能訪問的。而a.TurnFreq(?10?);就沒有任何問題,因為成員函數Radiogram::TurnFreq是公共成員,外界可以訪問。那么什么叫外界?對于某個自定義類型,此自定義類型的成員函數的函數體內以外的一切能寫代碼的地方都稱作外界。因此,對于上面的Radiogram,只有它的三個成員函數的函數體內可以訪問它的成員變量。即下面的代碼將沒有問題。
void?Radiogram::TurnFreq(?double?value?)?{?m_Frequency?+=?value;?}
因為m_Frequency被使用的地方是在Radiogram::TurnFreq的函數體內,不屬于外界。
為什么要這樣?表現最開始說的語義。首先,上面將成員定義成public或private對于最終生成的代碼沒有任何影響。然后,我之前說的調節接收頻率是通過調節收音機里面的共諧電容的容量來實現的,這個電容的容量人必須借助元件才能做到,而將接收頻率映射成數字后,由于是數字,則CPU就能修改。如果直接a.m_Frequency?+=?10;進行修改,就代碼上的意義,其就為:執行這個方法的人將收音機的接收頻率增加10KHz,這有違我們的客觀世界,與前面的語義不合。因此將其作為語法的一種提供,由編譯器來進行審查,可以讓我們編寫出更加符合我們所生活的世界的語義的代碼。
應注意可以union?ABC?{?long?a;?private:?short?b;?};。這里的ABC::a之前沒有任何修飾,那它是public還是protected?相信從前面舉的那么多例子也已經看出,應該是public,這也是為什么我之前一直使用struct和union來定義自定義類型,否則之前的例子都將報錯。而前篇說過結構和類只有一點很小的區別,那就是當成員沒有進行修飾時,對于類,那個成員將是private而不是public,即如下將錯誤。
class?ABC?{?long?a;?private:?short?b;?};?ABC?a;?a.a?=?13;
ABC::a由于前面的class而被看作private。就從這點,可以看出結構用于映射資源(可被直接使用的資源),而類用于映射具有功能的資源。下篇將詳細討論它們在語義上的差別。
構造和析構
了解了上面所提的東西,很明顯就有下面的疑問:
struct?ABC?{?private:?long?a,?b;?};?ABC?a?=?{?10,?20?};
上面的初始化賦值變量a還正確嗎?當然錯誤,否則在語法上這就算一個漏洞了(外界可以借此修改不能修改的成員)。但有些時候的確又需要進行初始化以保證一些邏輯關系,為此C++提出了構造和析構的概念,分別對應于初始化和掃尾工作。在了解這個之前,讓我們先看下什么叫實例(Instance)。
實例是個抽象概念,表示一個客觀存在,其和下篇將介紹的“世界”這個概念聯系緊密。比如:“這是桌子”和“這個桌子”,前者的“桌子”是種類,后者的“桌子”是實例。這里有10只羊,則稱這里有10個羊的實例,而羊只是一種類型。可以簡單地將實例認為是客觀世界的物體,人類出于方便而給各種物體分了類,因此給出電視機的說明并沒有給出電視機的實例,而拿出一臺電視機就是給出了一個電視機的實例。同樣,程序的代碼寫出來了意義不大,只有當它被執行時,我們稱那個程序的一個實例正在運行。如果在它還未執行完時又要求操作系統執行了它,則對于多任務操作系統,就可以稱那個程序的兩個實例正在被執行,如同時點開兩個Word文件查看,則有兩個Word程序的實例在運行。
在C++中,能被操作的只有數字,一個數字就是一個實例(這在下篇的說明中就可以看出),更一般的,稱標識記錄數字的內存的地址為一個實例,也就是稱變量為一個實例,而對應的類型就是上面說的物體的種類。比如:long?a,?*pA?=?&a,?&ra?=?a;,這里就生成了兩個實例,一個是long的實例,一個是long*的實例(注意由于ra是long&所以并未生成實例,但ra仍然是一個實例)。同樣,對于一個自定義類型,如:Radiogram?ab,?c[3];,則稱生成了四個Radiogram的實例。
對于自定義類型的實例,當其被生成時,將調用相應的構造函數;當其被銷毀時,將調用相應的析構函數。誰來調用?編譯器負責幫我們編寫必要的代碼以實現相應構造和析構的調用。構造函數的原型(即函數名對應的類型,如float?AB(?double,?char?);的原型是float(?double,?char?))的格式為:直接將自定義類型的類型名作為函數名,沒有返回值類型,參數則隨便。對于析構函數,名字為相應類型名的前面加符號“~”,沒有返回值類型,必須沒有參數。如下:
struct?ABC?{?ABC();?ABC(?long,?long?);?~ABC();?bool?Do(?long?);?long?a,?count;?float?*pF;?};
ABC::ABC()?{?a?=?1;?count?=?0;?pF?=?0;?}
ABC::ABC(?long?tem1,?long?tem2?)?{?a?=?tem1;?count?=?tem2;?pF?=?new?float[?count?];?}
ABC::~ABC()?{?delete[]?pF;?}
bool?ABC::Do(?long?cou?)
{
float?*p?=?new?float[?cou?];
if(?!p?)
return?false;
delete[]?pF;
pF?=?p;
count?=?cou;
return?true;
}
extern?ABC?g_ABC;
void?main(){?ABC?a,?&r?=?a;?a.Do(?10?);?{?ABC?b(?10,?30?);?}?ABC?*p?=?new?ABC[10];?delete[]?p;?}
ABC?g_a(?10,?34?),?g_p?=?new?ABC[5];
上面的結構ABC就定義了兩個構造函數(注意是兩個重載函數),名字都為ABC::ABC(實際將由編譯器轉成不同的符號以供連接之用)。也定義了一個析構函數(注意只能定義一個,因為其必須沒有參數,也就無法進行重載了),名字為ABC::~ABC。
再看main函數,先通過ABC?a;定義了一個變量,因為要在棧上分配一塊內存,即創建了一個數字(創建裝數字的內存也就導致創建了數字,因為內存不能不裝數字),進而創建了一個ABC的實例,進而調用ABC的構造函數。由于這里沒有給出參數(后面說明),因此調用了ABC::ABC(),進而a.a為1,a.pF和a.count都為0。接著定義了變量r,但由于它是ABC&,所以并沒有在棧上分配內存,進而沒有創建實例而沒有調用ABC::ABC。接著調用a.Do,分配了一塊內存并把首地址放在a.pF中。
注意上面變量b的定義,其使用了之前提到的函數式初始化方式。它通過函數調用的格式調用了ABC的構造函數ABC::ABC(?long,?long?)以初始化ABC的實例b。因此b.a為10,b.count為30,b.pF為一內存塊的首地址。但要注意這種初始化方式和之前提到的“{}”方式的不同,前者是進行了一次函數調用來初始化,而后者是編譯器來初始化(通過生成必要的代碼)。由于不調用函數,所以速度要稍快些(關于函數的開銷在《C++從零開始(十五)》中說明)。還應注意不能ABC?b?=?{?1,?0,?0?};,因為結構ABC已經定義了兩個構造函數,則它只能使用函數式初始化方式初始化了,不能再通過“{}”方式初始化了。
上面的b在一對大括號內,回想前面提過的變量的作用域,因此當程序運行到ABC?*p?=?new?ABC[10];時,變量b已經消失了(超出了其作用域),即其所分配的內存語法上已經釋放了(實際由于是在棧上,其并沒有被釋放),進而調用ABC的析構函數,將b在ABC::ABC(?long,?long?)中分配的內存釋放掉以實現掃尾功能。
對于通過new在堆上分配的內存,由于是new?ABC[10],因此將創建10個ABC的實例,進而為每一個實例調用一次ABC::ABC(),注意這里無法調用ABC::ABC(?long,?long?),因為new操作符一次性就分配了10個實例所需要的內存空間,C++并沒有提供語法(比如使用“{}”)來實現對一次性分配的10個實例進行初始化。接著調用了delete[]?p;,這釋放剛分配的內存,即銷毀了10個實例,因此將調用ABC的析構函數10次以進行10次掃尾工作。
注意上面聲明了全局變量g_ABC,由于是聲明,并不是定義,沒有分配內存,因此未產生實例,故不調用ABC的構造函數,而g_a由于是全局變量,C++保證全局變量的構造函數在開始執行main函數之前就調用,所有全局變量的析構函數在執行完main函數之后才調用(這一點是編譯器來實現的,在《C++從零開始(十九)》中將進一步討論)。因此g_a.ABC(?10,?34?)的調用是在a.ABC()之前,即使它的位置在a的定義語句的后面。而全局變量g_p的初始化的數字是通過new操作符的計算得來,結果將在堆上分配內存,進而生成5個ABC實例而調用了ABC::ABC()5次,由于是在初始化g_p的時候進行分配的,因此這5次調用也在a.ABC()之前。由于g_p僅僅只是記錄首地址,而要釋放這5個實例就必須調用delete(不一定,也可不調用delete依舊釋放new返回的內存,在《C++從零開始(十九)》中說明),但上面并沒有調用,因此直到程序結束都將不會調用那5個實例的析構函數,那將怎樣?后面說明異常時再討論所謂的內存泄露問題。
因此構造的意思就是剛分配了一塊內存,還未初始化,則這塊內存被稱作原始數據(Raw?Data),前面說過數字都必須映射成算法中的資源,則就存在數字的有效性。比如映射人的年齡,則這個數字就不能是負數,因為沒有意義。所以當得到原始數據后,就應該先通過構造函數的調用以保證相應實例具有正確的意義。而析構函數就表示進行掃尾工作,就像上面,在某實例運作的期間(即操作此實例的代碼被執行的時期)動態分配了一些內存,則應確保其被正確釋放。再或者這個實例和其他實例有關系,因確保解除關系(因為這個實例即將被銷毀),如鏈表的某個結點用類映射,則這個結點被刪除時應在其析構函數中解除它與其它結點的關系。
派生和繼承
上面我們定義了類Radiogram來映射收音機,如果又需要映射數字式收音機,它和收音機一樣,即收音機具有的東西它都具有,不過多了自動搜臺、存儲臺、選臺和刪除臺的功能。這里提出了一個類型體系,即一個實例如果是數字式收音機,那它一定也是收音機,即是收音機的一個實例。比如蘋果和梨都是水果,則蘋果和梨的實例一定也是水果的實例。這里提出三個類型:水果、蘋果和梨。其中稱水果是蘋果的父類(父類型),蘋果是水果的子類(子類型)。同樣,水果也是梨的父類,梨是水果的子類。這種類型體系是很有意義的,因為人類就是用這種方式來認知世界的,它非常符合人類的思考習慣,因此C++又提出了一種特殊語法來對這種語義提供支持。
在定義自定義類型時,在類型名的后面接一“:”,然后接public或protected或private,接著再寫父類的類型名,最后就是類型定義符“{}”及相關書寫,如下:
class?DigitalRadiogram?:?public?Radiogram
{
protected:??double?m_Stations[10];
public:?void?SearchStation();void?SaveStation(?unsigned?long?);
void?SelectStation(?unsigned?long?);?void?EraseStation(?unsigned?long?);
};
上面就將Radiogram定義為了DigitalRadiogram的父類,DigitalRadiogram定義成了Radiogram的子類,被稱作類Radiogram派生了類DigitalRadiogram,類DigitalRadiogram繼承了類Radiogram。
上面生成了5個映射元素,就是上面的4個成員函數和1個成員變量,但實際不止。由于是從Radiogram派生,因此還將生成7個映射,就是類Radiogram的7個成員,但名字變化了,全變成DigitalRadiogram::修飾,而不是原來的Radiogram::修飾,但是類型卻不變化。比如其中一個映射元素的名字就為DigitalRadiogram::m_bPowerOn,類型為bool?Radiogram::,映射的偏移值沒變,依舊為16。同樣也有映射元素DigitalRadiogram::TurnFreq,類型為void?(?Radiogram::?)(?double?),映射的地址依舊沒變,為Radiogram::TurnFreq所對應的地址。因此就可以如下:
void?DigitalRadiogram::SaveStation(?unsigned?long?index?)
{
if(?index?>=?10?)?return;
m_Station[?index?]?=?m_Frequency;?m_bPowerOn?=?true;
}
DigitalRadiogram?a;?a.TurnFreq(?10?);?a.SaveStation(?3?);
上面雖然沒有聲明DigitalRadiogram::TurnFreq,但依舊可以調用它,因為它是從Radiogram派生來的。注意由于a.TurnFreq(?10?);沒有書寫全名,因此實際是a.DigitalRadiogram::TurnFreq(?10?);,因為成員操作符左邊的數字類型是DigitalRadiogram。如果DigitalRadiogram不從Radiogram派生,則不會生成上面說的7個映射,結果a.TurnFreq(?10?);將錯誤。
注意上面的SaveStation中,直接書寫了m_Frequency,其等同于this->m_Frequency,由于this是DigitalRadiogram*(因為在DigitalRadiogram::SaveStation的函數體內),所以實際為this->DigitalRadiogram::m_Frequency,也因此,如果不是派生自Radiogram,則上面將報錯。并且由類型匹配,很容易知道:void?(?Radiogram::*p?)(?double?)?=?DigitalRadiogram::TurnFreq;。雖然這里是DigitalRadiogram::TurnFreq,但它的類型是void?(?Radiogram::?)(?double?)。
應注意在SaveStation中使用了m_bPowerOn,這個在Radiogram中被定義成私有成員,也就是說子類也沒權訪問,而SaveStation是其子類的成員函數,因此上面將報錯,權限不夠。
上面通過派生而生成的7個映射元素各自的權限是什么?先看上面的派生代碼:
class?DigitalRadiogram?:?public?Radiogram?{…};
這里由于使用public,被稱作DigitalRadiogram從Radiogram公共繼承,如果改成protected則稱作保護繼承,如果是private就是私有繼承。有什么區別?通過公共繼承而生成的映射元素(指從Radiogram派生而生成的7個映射元素),各自的權限屬性不變化,即上面的DigitalRadiogram::m_Frequency對類DigitalRadiogram來說依舊是protected,而DigitalRadiogram::m_bPowerOn也依舊是private。保護繼承則所有的公共成員均變成保護成員,其它不變。即如果保護繼承,DigitalRadiogram::TurnFreq對于DigitalRadiogram來說將為protected。私有繼承則將所有的父類成員均變成對于子類來說是private。因此上面如果私有繼承,則DigitalRadiogram::TurnFreq對于DigitalRadiogram來說是private的。
上面可以看得很簡單,即不管是什么繼承,其指定了一個權限,父類中凡是高于這個權限的映射元素,都要將各自的權限降低到這個權限(注意是對子類來說),然后再繼承給子類。上面一直強調“對于子類來說”,什么意思?如下:
struct?A?{?long?a;?protected:?long?b;?private:?long?c;?};
struct?B?:?protected?A?{?void?AB();?};
struct?C?:?private?B?{?void?ABC();?};
void?B::AB()?{?b?=?10;?c?=?10;?}
void?C::ABC()?{?a?=?10;?b?=?10;?c?=?10;?AB();?}
A?a;?B?b;?C?c;?a.a?=?10;?b.a?=?10;?b.AB();?c.AB();
上面的B的定義等同于struct?B?{?protected:?long?a,?b;?private:?long?c;?public:?void?AB();?};。
上面的C的定義等同于struct?C?{?private:?long?a,?b,?c;?void?AB();?public:?void?ABC();?};
因此,B::AB中的b?=?10;沒有問題,但c?=?10;有問題,?因為編譯器看出B::c是從父類繼承生成的,而它對于父類來說是私有成員,因此子類無權訪問,錯誤。接著看C::ABC,a?=?10;和b?=?10;都沒問題,因為它們對于B來說都是保護成員,但c?=?10;將錯誤,因為C::c對于父類B來說是私有成員,沒有權限,失敗。接著AB();,因為C::AB對于父類B來說是公共成員,沒有問題。
接著是a.a?=?10;,沒問題;b.a?=?10;,錯誤,因為B::a是B的保護成員;b.AB();,沒有問題;c.AB();,錯誤,因為C::AB是C的私有成員。應注意一點:public、protected和private并不是類型修飾符,只是在語法上提供了一些信息,而繼承所得的成員的類型都不會變化,不管它保護繼承還是公共繼承,權限起作用的地方是需要運用成員的地方,與類型沒有關系。什么叫運用成員的地方?如下:
long?(?A::*p?)?=?&A::a;?p?=?&A::b;
void?(?B::*pB?)()?=?B::AB;?void?(?C::*pC?)()?=?C::ABC;?pC?=?C::AB;
上面對變量p的初始化操作沒有問題,這里就運用了A::a。但是在p?=?&A::b;時,由于運用了A::b,則編譯器就要檢查代碼所處的地方,發現對于A來說屬于外界,因此報錯,權限不夠。同樣下面對pB的賦值沒有問題,但pC?=?C::AB;就錯誤。而對于b.a?=?10;,這里由于成員操作符而運用了類B的成員B::a,所以在這里進行權限檢查,并進而發現權限不夠而報錯。
好,那為什么要搞得這么復雜?弄什么保護、私有和公共繼承?首先回想前面說的為什么要提供繼承,因為想從代碼上體現類型體系,說明一個實例如果是一個子類的實例,則它也一定是一個父類的實例,即可以按照父類的定義來操作它。雖然這也可以通過之前說的轉換指針類型來實現,但前者能直接從代碼上表現出類型繼承的語義(即子類從父類派生而來),而后者只能說明用不同的類型來看待同一個實例。
那為什么要給繼承加上權限?表示這個類不想外界或它的子類以它的父類的姿態來看待它。比如雞可以被食用,但做成標本的雞就不能被食用。因此子類“雞的標本”在繼承時就應該保護繼承父類“雞”,以表示不準外界(但準許其派生類)將它看作是雞。它已經不再是雞,但它實際是由雞轉變過來的。因此私有和保護繼承實際很適合表現動物的進化關系。比如人是猴子進化來的,但人不是猴子。這里人就應該使用私有繼承,因為并不希望外界和人的子類——黑種人、黃種人、白種人等——能夠把父類“人”看作是猴子。而公共繼承就表示外界和子類可以將子類的實例看成父類的實例。如下:
struct?A?{?long?a,?b;?};
struct?AB?:?private?A?{?long?c;?void?ABCD();?};
struct?ABB?:?public?AB?{?void?AAA();?};
struct?AC?:?public?A?{?long?c;?void?ABCD();?};
void?ABC(?A?*a?)?{?a->a?=?10;?a->b?=?20;?}
void?main()?{?AB?b;?ABC(?&b?);?AC?c;?ABC(?&c?);?}
void?AB::ABCD()?{?AB?b;?ABC(?&b?);?}
void?AC::ABCD()?{?AB?b;?ABC(?&b?);?}
void?ABB::AAA()?{?AB?b;?ABC(?&b?);?}
上面的類AC是公共繼承,因此其實例c在執行ABC(?&c?);時將由編譯器進行隱式類型轉換,這是一個很奇特的特性,本文的下篇將說明。但類AB是私有繼承,因此在ABC(?&b?);時編譯器不會進行隱式類型轉換,將報錯,類型不匹配。對于此只需ABC(?(?A*?)&b?);以顯示進行類型轉換就沒問題了。
注意前面的紅字,私有繼承表示外界和它的子類都不可以用父類的姿態來看待它,因此在ABB::AAA中,這是AB的子類,因此這里的ABC(?&b?);將報錯。在AC::ABCD中,這里對于AB來說是外界,報錯。在AB::ABCD中,這里是自身,即不是子類也不是外界,所以ABC(?&b?);將沒有問題。如果將AB換成保護繼承,則在ABB::AAA中的ABC(?&b?);將不再錯誤。
關于本文及本文下篇所討論的語義,在《C++從零開始(十二)》中會專門提出一個概念以給出一種方案來指導如何設計類及各類的關系。由于篇幅限制,本文分成了上中下三篇,剩下的內容在本文的后兩篇說明。
C++從零開始(十一)中篇?
——類的相關知識?
由于篇幅限制,本篇為《C++從零開始(十一)》的中篇,說明多重繼承、虛繼承和虛函數的實現方式。
多重繼承
這里有個有趣的問題,如下:
struct?A?{?long?a,?b,?c;?char?d;?};?struct?B?:?public?A?{?long?e,?f;?};
上面的B::e和B::f映射的偏移是多少?不同的編譯器有不同的映射結果,對于派生的實現,C++并沒有強行規定。大多數編譯器都是讓B::e映射的偏移值為16(即A的長度,關于自定義類型的長度可參考《C++從零開始(九)》),B::f映射20。這相當于先把空間留出來排列父類的成員變量,再排列自己的成員變量。但是存在這樣的語義——西紅柿即是蔬菜又是水果,鯨魚即是海洋生物又是脯乳動物。即一個實例既是這種類型又是那種類型,對于此,C++提供了多重派生或稱多重繼承,用“,”間隔各父類,如下:
struct?A?{?long?A_a,?A_b,?c;?void?ABC();?};?struct?B?{?long?c,?B_b,?B_a;?void?ABC();?};
struct?AB?:?public?A,?public?B?{?long?ab,?c;?void?ABCD();?};
void?A::ABC()?{?A_a?=?A_b?=?10;?c?=?20;?}
void?B::ABC()?{?B_a?=?B_b?=?20;?c?=?10;?}
void?AB::ABCD()?{?A_a?=?B_a?=?1;?A_b?=?B_b?=?2;?c?=?A::c?=?B::c?=?3;?}
void?main()?{?AB?ab;?ab.A_a?=?3;?ab.B_b?=?4;?ab.ABC();?}
上面的結構AB從結構A和結構B派生而來,即我們可以說ab既是A的實例也是B的實例,并且還是AB的實例。那么在派生AB時,將生成幾個映射元素?照前篇的說法,除了AB的類型定義符“{}”中定義的AB::ab和AB::c以外(類型均為long?AB::),還要生成繼承來的映射元素,各映射元素名字的修飾換成AB::,類型不變,映射的值也不變。因此對于兩個父類,則生成8個映射元素(每個類都有4個映射元素),比如其中一個的名字為AB::A_b,類型為long?A::,映射的值為4;也有一個名字為AB::B_b,類型為long?B::,映射的值依舊為4。注意A::ABC和B::ABC的名字一樣,因此其中兩個映射元素的名字都為AB::ABC,但類型則一個為void(?A::?)()一個為void(?B::?)(),映射的地址分別為A::ABC和B::ABC。同樣,就有三個映射元素的名字都為AB::c,類型則分別為long?A::、long?B::和long?AB::,映射的偏移值依次為8、0和28。照前面說的先排列父類的成員變量再排列子類的成員變量,因此類型為long?AB::的AB::c映射的值為兩個父類的長度之和再加上AB::ab所帶來的偏移。
注意問題,上面繼承生成的8個映射元素中有兩對同名,但不存在任何問題,因為它們的類型不同,而最后編譯器將根據它們各自的類型而修改它們的名字以形成符號,這樣連接時將不會發生重定義問題,但帶來其他問題。ab.ABC();一定是ab.AB::ABC();的簡寫,因為ab是AB類型的,但現在由于有兩個AB::ABC,因此上面直接書寫ab.ABC將報錯,因為無法知道是要哪個AB::ABC,這時怎么辦?
回想本文上篇提到的公共、保護、私有繼承,其中說過,公共就表示外界可以將子類的實例當作父類的實例來看待。即所有需要用到父類實例的地方,如果是子類實例,且它們之間是公共繼承的關系,則編譯器將會進行隱式類型轉換將子類實例轉換成父類實例。因此上面的ab.A_a?=?3;實際是ab.AB::A_a?=?3;,而AB::A_a的類型是long?A::,而成員操作符要求兩邊所屬的類型相同,左邊類型為AB,且AB為A的子類,因此編譯器將自動進行隱式類型轉換,將AB的實例變成A的實例,然后再計算成員操作符。
注意前面說AB::A_b和AB::B_b的偏移值都為4,則ab.A_b?=?3;豈不是等效于ab.B_b?=?3;?即使按照上面的說法,由于AB::A_b和AB::B_b的類型分別是long?A::和long?B::,也最多只是前者轉換成A的實例后者轉換成B的實例,AB::A_b和AB::B_b映射的偏移依舊沒變啊。因此變的是成員操作符左邊的數字。對于結構AB,假設先排列父類A的成員變量再排列父類B的成員變量,則AB::B_b映射的偏移就應該為16(結構A的長度加上B::c引入的偏移),但它實際映射為4,因此就將成員操作符左側的地址類型的數字加上12(結構A的長度)。而對于AB::A_b,由于結構A的成員變量先被排列,故只偏移0。假設上面ab對應的地址為3000,對于ab.B_b?=?4;,AB類型的地址類型的數字3000在“.”的左側,轉成B類型的地址類型的數字3012(因為偏移12),然后再將“.”右側的偏移類型的數字4加上3012,最后返回類型為long的地址類型的數字3016,再繼續計算“=”。同樣也可知道ab.A_a?=?3;中的成員操作符最后返回long類型的地址類型的數字3000,而ab.A_b將返回3004,ab.ab將返回3024。
同樣,這樣也將進行隱式類型轉換long?AB::*p?=?&AB::B_b;。注意AB::B_b的類型為long?B::,則將進行隱式類型轉換。如何轉換?原來AB::B_b映射的偏移為4,則現在將變成12+4=16,這樣才能正確執行ab.*p?=?10;。
這時再回過來想剛才提的問題,AB::ABC無法區別,怎么辦?注意還有映射元素A::ABC和B::ABC(兩個AB::ABC就是由于它們兩個而導致的),因此可以書寫ab.A::ABC();來表示調用的是映射到A::ABC的函數。這里的A::ABC的類型是void(?A::?)(),而ab是AB,因此將隱式類型轉換,則上面沒有任何語法問題(雖然說A::ABC不是結構AB的成員,但它是AB的父類的成員,C++允許這種情況,也就是說A::ABC的名字也作為類型匹配的一部分而被使用。如假設結構C也從A派生,則有C::a,但就不能書寫ab.C::a,因為從C::a的名字可以知道它并不屬于結構AB)。同樣ab.B::ABC();將調用B::ABC。注意上面結構A、B和AB都有一個成員變量名字為c且類型為long,那么ab.c?=?10;是否會如前面ab.ABC();一樣報錯?不會,因為有三個AB::c,其中有一個類型和ab的類型匹配,其映射的偏移為28,因此ab.c將會返回3028。而如果期望運用其它兩個AB::c的映射,則如上通過書寫ab.A::c和ab.B::c來偏移ab的地址以實現。
注意由于上面的說法,也就可以這樣:void(?AB::*pABC?)()?=?B::ABC;?(?ab.*pABC?)();。這里的B::ABC的類型為void(?B::?)(),和pABC不匹配,但正好B是AB的父類,因此將進行隱式類型轉換。如何轉換?因為B::ABC映射的是地址,而隱式類型轉換要保證在調用B::ABC之前,先將this的類型變成B*,因此要將其加12以從AB*轉變成B*。由于需要加這個12,但B::ABC又不是映射的偏移值,因此pABC實際將映射兩個數字,一個是B::ABC對應的地址,一個是偏移值12,結果pABC這個指針的長度就不再如之前所說的為4個字節,而變成了8個字節(多出來的4個字節用于記錄偏移值)。
還應注意前面在AB::ABCD中直接書寫的A_b、c、A::c等,它們實際都應該在前面加上this->,即A_b?=?B_b?=?2;實際為this->A_b?=?this->B_b?=?2;,則同樣如上,this被偏移了兩次以獲得正確的地址。注意上面提到的隱式類型轉換之所以會進行,是因為繼承時的權限滿足要求,否則將失敗。即如果上面AB保護繼承A而私有繼承B,則只有在AB的成員函數中可以如上進行轉換,在AB的子類的成員函數中將只能使用A的成員而不能使用B的成員,因為權限受到限制。如下將失敗。
struct?AB?:?protected?A,?private?B?{…};
struct?C?:?public?AB?{?void?ABCD();?};
void?C::ABCD()?{?A_b?=?10;?B_b?=?2;?c?=?A::c?=?B::c?=?24;?}
這里在C::ABCD中的B_b?=?2;和B::c?=?24;將報錯,因為這里是AB的子類,而AB私有繼承自B,其子類無權將它看作B。但只是不會進行隱式類型轉換罷了,依舊可以通過顯示類型轉換來實現。而main函數中的ab.A_a?=?3;?ab.B_b?=?4;?ab.A::ABC();都將報錯,因為這是在外界發起的調用,沒有權限,不會自動進行隱式類型轉換。
注意這里C::ABCD和AB::ABCD同名,按照上面所說,子類的成員變量都可以和父類的成員變量同名(上面AB::c和A::c及B::c同名),成員函數就更沒有問題。只用和前面一樣,按照上面所說進行類型匹配檢驗即可。應注意由于是函數,則可以參數變化而函數名依舊相同,這就成了重載函數。
虛繼承
前面已經說了,當生成了AB的實例,它的長度實際應該為A的長度加B的長度再加上AB自己定義的成員所占有的長度。即AB的實例之所以又是A的實例又是B的實例,是因為一個AB的實例,它既記錄了一個A的實例又記錄了一個B的實例。則有這么一種情況——蔬菜和水果都是植物,海洋生物和脯乳動物都是動物。即繼承的兩個父類又都從同一個類派生而來。假設如下:
struct?A?{?long?a;?};
struct?B?:?public?A?{?long?b;?};?struct?C?:?public?A?{?long?c;?};
struct?D?:?public?A,?public?C?{?long?d;?};
void?main()?{?D?d;?d.a?=?10;?}
上面的B的實例就包含了一個A的實例,而C的實例也包含了一個A的實例。那么D的實例就包含了一個B的實例和一個C的實例,則D就包含了兩個A的實例。即D定義時,將兩個父類的映射元素繼承,生成兩個映射元素,名字都為D::a,類型都為long?A::,映射的偏移值也正好都為0。結果main函數中的d.a?=?10;將報錯,無法確認使用哪個a。這不是很奇怪嗎?兩個映射元素的名字、類型和映射的數字都一樣!編譯器為什么就不知道將它們定成一個,因為它們實際在D的實例中表示的偏移是不同的,一個是0一個是8。同樣,為了消除上面的問題,就書寫d.B::a?=?1;?d.C::a?=?2;以表示不同實例中的成員a。可是B::a和C::a的類型不都是為long?A::嗎?但上面說過,成員變量或成員函數它們自身的名字也將在類型匹配中起作用,因此對于d.B::a,因為左側的類型是D,則看右側,其名字表示為B,正好是D的父類,先隱式類型轉換,然后再看類型,是A,再次進行隱式類型轉換,然后返回數字。假設上面d對應的地址為3000,則d.C::a先將d這個實例轉換成C的實例,因此將3000偏移8個字節而返回long類型的地址類型的數字3008。然后再轉換成A的實例,偏移0,最后返回3008。
上面說明了一個問題,即希望從A繼承來的成員a只有一個實例,而不是像上面那樣有兩個實例。假設動物都有個饑餓度的成員變量,很明顯地鯨魚應該只需填充一個饑餓度就夠了,結果有兩個饑餓度就顯得很奇怪。對此,C++提出了虛繼承的概念。其格式就是在繼承父類時在權限語法的前面加上關鍵字virtual即可,如下:
struct?A?{?long?a,?aa,?aaa;?void?ABC();?};?struct?B?:?virtual?public?A?{?long?b;?};
這里的B就虛繼承自A,B::b映射的偏移為多少?將不再是A的長度12,而是4。而繼承生成的3個映射元素還是和原來一樣,只是名字修飾變成B::而已,映射依舊不變。那么為什么B::b是4?之前的4個字節用來放什么?上面等同于下面:
struct?B?{?long?*p;?long?b;?long?a,?aa,?aaa;?void?ABC();?};
long?BDiff[]?=?{?0,?8?};?B::B(){?p?=?BDiff;?}
上面的B::p指向一全局數組BDiff。什么意思?B的實例的開頭4個字節用來記錄一個地址,也就相當于是一個指針變量,它記錄的地址所標識的內存中記錄著由于虛繼承而導致的偏移值。上面的BDiff[1]就表示要將B實例轉成A實例,就需要偏移BDiff[1]的值8,而BDiff[0]就表示要將B實例轉成B實例需要的偏移值0。為什么還要來個B實例轉B實例?后面說明。但為什么是數組?因為一個類可以通過多重派生而虛繼承多個類,每個類需要的偏移值都會在BDiff的數組中占一個元素,它被稱作虛類表(Virtual?Class?Table)。
因此當書寫B?b;?b.aaa?=?20;?long?a?=?sizeof(?b?);時,a的值為20,因為多了一個4字節來記錄上面說的指針。假設b對應的地址為3000。先將B的實例轉換成A的實例,本來應該偏移12而返回3012,但編譯器發現B是虛繼承自A,則通過B::p[1]得到應該的偏移值8,然后返回3008,接著再加上B::aaa映射的8而返回3016。同樣,當b.b?=?10;時,由于B::b并不是被虛繼承而來,直接將3000加上B::b映射的偏移值4得3004。而對于b.ABC();將先通過B::p[1]將b轉成A的實例然后調用A::ABC。
為什么要像上面那樣弄得那么麻煩?首先讓我們來了解什么叫做虛(Virtual)。虛就是假象,并不是真的。比如一臺老式電視機有10個頻道,即它最多能記住10個電視臺的頻率。因此可以說1頻道是中央1臺、5頻道是中央5臺、7頻道是四川臺。這里就稱頻道對我們來說代表著電臺頻率是虛假的,因為頻道并不是電臺頻率,只是記錄了電臺頻率。當我們按5頻道以換到中央5臺時,有可能有人已經調過電視使得5頻道不再是中央5臺,而是另一個電視臺或者根本就是一片雪花沒有信號。因此虛就表示不保證,其可能正確可能錯誤,因為它一定是間接得到的,其實就相當于之前說的引用。有什么好處?只用記著按5頻道就是中央5臺,當以后不想再看中央5臺而換成中央2臺,則同樣的“按5頻道”卻能得到不同的結果,但是程序卻不用再編寫了,只用記著“按5頻道”就又能實現換到中央2臺看。所以虛就是間接得到結果,由于間接,結果將不確定而顯得更加靈活,這在后面說明虛函數時就能看出來。但虛的壞處就是多了一道程序(要間接獲得),效率更低。
由于上面的虛繼承,導致繼承的元素都是虛的,即所有對繼承而來的映射元素的操作都應該間接獲得相應映射元素對應的偏移值或地址,但繼承的映射元素對應的偏移值或地址是不變的,為此紅字的要求就只有通過隱式類型轉換改變this的值來實現。所以上面說的B轉A需要的偏移值通過一個指針B::p來間接獲得以表現其是虛的。
因此,開始所說的鯨魚將會有兩個饑餓度就可以讓海洋生物和脯乳動物都從動物虛繼承,因此將間接使用脯乳動物和海洋生物的饑餓度這個成員,然后在派生鯨魚這個類時,讓脯乳動物和海洋生物都指向同一個動物實例(因為都是間接獲得動物的實例的,通過虛繼承來間接使用動物的成員),這樣當鯨魚填充饑餓度時,不管填充哪個饑餓度,實際都填充同一個。而C++也正好這樣做了。如下:
struct?A?{?long?a;?};
struct?B?:?virtual?public?A?{?long?b;?};?struct?C?:?virtual?public?A?{?long?c;?};
struct?D?:?public?B,?virtual?public?C?{?long?d;?};
void?main()?{?D?d;?d.a?=?10;?}
當從一個類虛繼承時,在排列派生類時(就是決定在派生類的類型定義符“{}”中定義的各成員變量的偏移值),先排列前面提到的虛類表的指針以實現間接獲取偏移值,再排列各父類,但如果父類中又有被虛繼承的父類,則先將這些部分剔除。然后排列派生類自己的映射元素。最后排列剛剛被剔除的被虛繼承的類,此時如果發現某個被虛繼承的類已經被排列過,則不用再重復排列一遍那個類,并且也不再為它生成相應的映射元素。
對于上面的B,發現虛繼承A,則先排列前面說過的B::p,然后排列A,但發現A需要被虛繼承,因此剔除,排列自己定義的映射元素B::b,映射的偏移值為4(由于B::p的占用)。最后排列A而生成繼承來的映射元素B::a,所以B的長度為12。
對于上面的D,發現要從C虛繼承,因此:
排列D::p,占4個字節。
排列父類B,發現其中的A是被虛繼承的,剔除,所以將繼承映射元素B::b(還有前面編譯器自動生成的B::p),生成D::b,占4個字節(編譯器將B::p和D::p合并為一個,后面說明虛函數時就了解了)。
排列父類C,發現C需要被虛繼承,剔除。
排列D自己定義的成員D::d,其映射的偏移值就為4+4=8,占4個字節。
排列A和C,先排列A,占4個字節,生成D::a。
排列C,先排列C中的A,結果發現它是虛繼承的,并發現已經排列過A,進而不再為C::a生成映射元素。接著排列C::p和C::c,占8個字節,生成D::c。
所以最后結構D的長度為4+4+4+4+8=24個字節,并且只有一個D::a,類型為long?A::,偏移值為0。
如果上面很昏,不要緊,上面只是給出一種算法以實現虛繼承,不同的編譯器廠商會給出不同的實現方法,因此上面推得的結果對某些編譯器可能并不正確。不過應記住虛繼承的含義——被虛繼承的類的所有成員都必須被間接獲得,至于如何間接獲得,則不同的編譯器有不同的處理方式。
由于需要保證間接獲得,所以對于long?D::*pa?=?&D::a;,由于是long?D::*,編譯器發現D的繼承體系中存在虛繼承,必須要保證其某些成員的間接獲得,因此pa中放的將不再是偏移值,否則d.*pa?=?10;將導致直接獲得偏移值(將pa的內容取出來即可),違反了虛繼承的含義。為了要間接訪問pa所記錄的偏移值,則必須保證代碼執行時,當pa里面放的是D::a時會間接,而D::d時則不間接。很明顯,這要更多和更復雜的代碼,大多數編譯器對此的處理就是全部都使用間接獲得。因此pa的長度將為8字節,其中一個4字節記錄偏移,還有一個4字節記錄一個序號。這個序號則用于前面說的虛類表以獲得正確的因虛繼承而導致的偏移量。因此前面的B::p所指的第一個元素的值表示B實例轉換成B實例,是為了在這里實現全部間接獲得而提供的。
注意上面的D::p對于不同的D的實例將不同,只不過它們的內容都相同(都是結構D的虛類表的地址)。當D的實例剛剛生成時,那個實例的D::p的值將是一隨機數。為了保證D::p被正確初始化,上面的結構D雖然沒有生成構造函數,但編譯器將自動為D生成一缺省構造函數(沒有參數的構造函數)以保證D::p和上面從C繼承來的C::p的正確初始化,結果將導致D?d?=?{?23,?4?};錯誤,因為D已經定義了一個構造函數,即使沒有在代碼上表現出來。
那么虛繼承有什么意義呢?它從功能上說是間接獲得虛繼承來的實例,從類型上說與普通的繼承沒有任何區別,即虛繼承和前面的public等一樣,只是一個語法上的提供,對于數字的類型沒有任何影響。在了解它的意義之前先看下虛函數的含義。
虛函數
虛繼承了一個函數類型的映射元素,按照虛繼承的說法,應該是間接獲得此函數的地址,但結果卻是間接獲得this參數的值。為了間接獲得函數的地址,C++又提出了一種語法——虛函數。在類型定義符“{}”中書寫函數聲明或定義時,在聲明或定義語句前加上關鍵字virtual即可,如下:
struct?A?{?long?a;?virtual?void?ABC(),?BCD();?};
void?A::ABC()?{?a?=?10;?}?void?A::BCD()?{?a?=?5;?}
上面等同于下面:
struct?A?{?void?(?A::*pF?)();?long?a;?void?ABC(),?BCD();?A();?};
void?A::ABC()?{?a?=?10;?}?void?A::BCD()?{?a?=?5;?}
void?(?A::*AVF[]?)()?=?{?A::ABC,?A::BCD?};?void?A::A()?{?pF?=?AVF;?}
這里A的成員A::pF和之前的虛類表一樣,是一個指針,指向一個數組,這個數組被稱作虛函數表(Virtual?Function?Table),是一個函數指針的數組。這樣使用A::ABC時,將通過給出A::ABC在A::pF中的序號,由A::pF間接獲得,因此A?a;?a.ABC();將等同于(?a.*(?a.pF[0]?)?)();。因此結構A的長度是8字節,再看下面的代碼:
struct?B?:?public?A?{?long?b;?void?ABC();?};?struct?C?:?public?A?{?long?c;?virtual?void?ABC();?};
struct?BB?:?public?B?{?long?bb;?void?ABC();?};?struct?CC?:?public?C?{?long?cc;?void?ABC();?};
void?main()?{?BB?bb;?bb.ABC();?CC?cc;?cc.cc?=?10;?}
首先,上面執行bb.ABC()但沒有給出BB::ABC或B::ABC的定義,因此上面雖然編譯通過,但連接時將失敗。其次,上面沒有執行cc.ABC();但連接時卻會說CC::ABC未定義以表示這里需要CC::ABC的地址,為什么?因為生成了CC的實例,而CC::pF就需要在編譯器自動為CC生成的缺省構造函數中被正確初始化,其需要CC::ABC的地址來填充。接著,給出如下的各函數定義。
void?B::ABC()?{?b?=?13;?}?void?C::ABC()?{?c?=?13;?}
void?BB::ABC()?{?bb?=?13;?b?=?10;?}?void?CC::ABC()?{?cc?=?13;?c?=?10;?}
如上后,對于bb.ABC();,等同于bb.BB::ABC();,雖然有三個BB::ABC的映射元素,但只有一個映射元素的類型為void(?BB::?)(),其映射BB::ABC的地址。由于BB::ABC并沒有用virtual修飾,因此上面將等同于bb.BB::ABC();而不是(?bb.*(?pF[0]?)?)();,bb將為13。對于cc.ABC();也是同樣的,cc將為13。
對于(?(?B*?)&bb?)->ABC();,因為左側類型為B*,因此將為(?(?B*?)&bb?)->B::ABC();,由于B::ABC并沒被定義成虛函數,因此這里等同于(?(?B*?)&bb?)->B::ABC();,b將為13。對于(?(?C*?)&cc?)->ABC();,同樣將為(?(?C*?)&cc?)->C::ABC();,但C::ABC被修飾成虛函數,則前面等同于C?*pC?=?&cc;?(?pC->*(?pC->pF[0]?)?)();。這里先將cc轉換成C的實例,偏移0。然后根據pC->pF[0]來間接獲得函數的地址,為CC::ABC,c將為10。因為cc是CC的實例,在其被構造時將填充cc.pF,那么如下:
void?(?CC::*CCVF[]?)()?=?{?CC::ABC,?CC::BCD?};?CC::CC()?{?cc.pF?=?&CCVF;?}
因此導致pC->ABC();結果調用的竟是CC::ABC而不是C::ABC,這正是由于虛的緣故而間接獲得函數地址導致的。同樣道理,對于(?(?A*?)&cc?)->ABC();和(?(?A*?)&bb?)->ABC();都將分別調用CC::ABC和BB::ABC。但請注意,(?pC->*(?pC->pF[0]?)?)();中,pC是C*類型的,而pC->pF[0]返回的CC::ABC是void(?CC::?)()類型的,而上面那樣做將如何進行實例的隱式類型轉換?如果不進行將導致操作錯誤的成員。可以像前面所說,讓CCVF的每個成員的長度為8個字節,另外4個字節記錄需要進行的偏移。但大多數類其實并不需要偏移(如上面的CC實例轉成A實例就偏移0),此法有些浪費資源。VC對此給出的方法如下,假設CC::ABC對應的地址為6000,并假設下面標號P處的地址就為6000,而CC::A_thunk對應的地址為5990。
void?CC::A_thunk(?void?*this?)
{
this?=?(?(?char*?)this?)?+?diff;
P:
//?CC::ABC的正常代碼
}
因此pC->pF[0]的值為5990,而并不是CC::ABC對應的6000。上面的diff就是相應的偏移,對于上面的例子,diff應該為0,所以實際中pC->pF[0]的值還是6000(因為偏移為0,沒必要是5990)。此法被稱作thunk,表示完成簡單功能的短小代碼。對于多重繼承,如下:
struct?D?:?public?A?{?long?d;?};
struct?E?:?public?B,?public?C,?public?D?{?long?e;?void?ABC()?{?e?=?10;?}?};
上面將有三個虛函數表,因為B、C和D都各自帶了一個虛函數表(因為從A派生)。結果上面等同于:
struct?E
{
void?(?E::*B_pF?)();?long?B_a,?b;
void?(?E::*C_pF?)();?long?C_a,?c;
void?(?E::*D_pF?)();?long?D_a,?d;?long?e;?void?ABC()?{?e?=?10;?}?E();
void?E_C_thunk_ABC()?{?this?=?(?E*?)(?(?(?char*?)this?)?–?12?);?ABC();?}
void?E_D_thunk_ABC()?{?this?=?(?E*?)(?(?(?char*?)this?)?–?24?);?ABC();?}
};
void?(?E::*E_BVF[]?)()?=?{?E::ABC,?E::BCD?};
void?(?E::*E_CVF[]?)()?=?{?E::E_C_thunk_ABC,?E::BCD?};
void?(?E::*E_DVF[]?)()?=?{?E::E_D_thunk_ABC,?E::BCD?};
E::E()?{?B_pF?=?E_BVF;?C_pF?=?E_CVF;?D_pF?=?E_DVF;?}
結果E?e;?C?*pC?=?&e;?pC->ABC();?D?*pD?=?&e;?pD->ABC();,假設e的地址為3000,則pC的值為3012,pD的值為3024。結果pC->pF的值就是E_CVF,pD->pF的值就是E_DVF,如此就解決了偏移問題。同樣,對于前面的虛繼承,當類里有多個虛類表時,如:
struct?A?{};
struct?B?:?virtual?public?A{};?struct?C?:?virtual?public?A{};?struct?D?:?virtual?public?A{};
struct?E?:?public?B,?public?C,?public?D?{};
這是E將有三個虛類表,并且每個虛類表都將在E的缺省構造函數中被正確初始化以保證虛繼承的含義——間接獲得。而上面的虛函數表的初始化之所以那么復雜也都只是為了保證間接獲得的正確性。
應注意上面將E_BVF的類型定義為void(?E::*[]?)()只是由于演示,希望在代碼上盡量符合語法而那樣寫,并不表示虛函數的類型只能是void(?E::?)()。實際中的虛函數表只不過是一個數組,每個元素的大小都為4字節以記錄一個地址而已。因此也可如下:
struct?A?{?virtual?void?ABC();?virtual?float?ABC(?double?);?};
struct?B?:?public?A?{?void?ABC();?float?ABC(?double?);?};
則B?b;?A?*pA?=?&b;?pA->ABC();將調用類型為void(?B::?)()的B::ABC,而pA->ABC(?34?);將調用類型為float(?B::?)(?double?)的B::ABC。它們屬于重載函數,即使名字相同也都是兩個不同的虛函數。還應注意virtual和之前的public等,都只是從語法上提供給編譯器一些信息,它們給出的信息都是針對某些特殊情況的,而不是所有在使用數字的地方都適用,因此不能作為數字的類型。所以virtual不是類型修飾符,它修飾一個成員函數只是告訴編譯器在運用那個成員函數的地方都應該間接獲得其地址。
為什么要提供虛這個概念?即虛函數和虛繼承的意義是什么?出于篇幅限制,將在本文的下篇給出它們意義的討論,即時說明多態性和實例復制等問題。
C++從零開始(十一)下篇?
——類的相關知識?
由于篇幅限制,本篇為《C++從零開始(十一)》的下篇,討論多態性及一些剩下的問題。
虛的含義
本文的中篇已經介紹了虛的意思,就是要間接獲得,并且舉例說明電視機的頻道就是讓人間接獲得電視臺頻率的,因此其從這個意義上說是虛的,因為它可能操作失敗——某個頻道還未調好而導致一片雪花。并且說明了間接的好處,就是只用編好一段代碼(按5頻道),則每次執行它時可能有不同結果(今天5頻道被設置成中央5臺,明天可以被定成中央2臺),進而使得前面編的程序(按5頻道)顯得很靈活。注意虛之所以能夠很靈活是因為它一定通過“一種手段”來間接達到目的,如每個頻道記錄著一個頻率。但這是不夠的,一定還有“另一段代碼”能改變那種手段的結果(頻道記錄的頻率),如調臺。
先看虛繼承。它間接從子類的實例中獲得父類實例的所在位置,通過虛類表實現(這是“一種手段”),接著就必須能夠有“另一段代碼”來改變虛類表的值以表現其靈活性。首先可以自己來編寫這段代碼,但就要求清楚編譯器將虛類表放在什么地方,而不同的編譯器有不同的實現方法,則這樣編寫的代碼兼容性很差。C++當然給出了“另一段代碼”,就是當某個類在同一個類繼承體系中被多次虛繼承時,就改變虛類表的值以使各子類間接獲得的父類實例是同一個。此操作的功能很差,僅僅只是節約內存而已。如:
struct?A?{?long?a;?};
struct?B?:?virtual?public?A?{?long?b;?};?struct?C?:?virtual?public?A?{?long?c;?};
struct?D?:?public?B,?public?C?{?long?d;?};
這里的D中有兩個虛類表,分別從B和C繼承而來,在D的構造函數中,編譯器會編寫必要的代碼以正確初始化D的兩個虛類表以使得通過B繼承的虛類表和通過C繼承的虛類表而獲得的A的實例是同一個。
再看虛函數。它的地址被間接獲得,通過虛函數表實現(這是“一種手段”),接著就必須還能改變虛函數表的內容。同上,如果自己改寫,代碼的兼容性很差,而C++也給出了“另一段代碼”,和上面一樣,通過在派生類的構造函數中填寫虛函數表,根據當前派生類的情況來書寫虛函數表。它一定將某虛函數表填充為當前派生類下,類型、名字和原來被定義為虛函數的那個函數盡量匹配的函數的地址。如:
struct?A?{?virtual?void?ABC(),?BCD(?float?),?ABC(?float?);?};
struct?B?:?public?A?{?virtual?void?ABC();?};
struct?C?:?public?B?{?void?ABC(?float?),?BCD(?float?);?virtual?float?CCC(?double?);?};
struct?D?:?public?C?{?void?ABC(),?ABC(?float?),?BCD(?float?);?};
在A::A中,將兩個A::ABC和一個A::BCD的地址填寫到A的虛函數表中。
在B::B中,將B::ABC和繼承來的B::BCD和B::ABC填充到B的虛函數表中。
在C::C中,將C::ABC、C::BCD和繼承來的C::ABC填充到C的虛函數表中,并添加一個元素:C::CCC。
在D::D中,將兩個D::ABC和一個D::BCD以及繼承來的D::CCC填充到D的虛函數表中。
這里的D是依次繼承自A、B、C,并沒有因為多重繼承而產生兩個虛函數表,其只有一個虛函數表。雖然D中的成員函數沒有用virtual修飾,但它們的地址依舊被填到D的虛函數表中,因為virtual只是表示使用那個成員函數時需要間接獲得其地址,與是否填寫到虛函數表中沒有關系。
電視機為什么要用頻道來間接獲得電視臺的頻率?因為電視臺的頻率人不容易記,并且如果知道一個頻率,慢慢地調整共諧電容的電容值以使電路達到那個頻率效率很低下。而做10組共諧電路,每組電路的電容值調好后就不再動,通過切換不同的共諧電路來實現快速轉換頻率。因此間接還可以提高效率。還有,5頻道本來是中央5臺,后來看膩了把它換成中央2臺,則同樣的動作(按5頻道)將產生不同的結果,“按5頻道”這個程序編得很靈活。
由上面,至少可以知道:間接用于簡化操作、提高效率和增加靈活性。這里提到的間接的三個用處都基于這么一個想法——用“一種手段”來達到目的,用“另一段代碼”來實現上面提的用處。而C++提供的虛繼承和虛函數,只要使用虛繼承來的成員或虛函數就完成了“一種手段”。而要實現“另一段代碼”,從上面的說明中可以看出,需要通過派生的手段來達到。在派生類中定義和父類中聲明的虛函數原型相同的函數就可以改變虛函數表,而派生類的繼承體系中只有重復出現了被虛繼承的類才能改變虛類表,而且也只是都指向同一個被虛繼承的類的實例,遠沒有虛函數表的修改方便和靈活,因此虛繼承并不常用,而虛函數則被經常的使用。
虛的使用
由于C++中實現“虛”的方式需要借助派生的手段,而派生是生成類型,因此“虛”一般映射為類型上的間接,而不是上面頻道那種通過實例(一組共諧電路)來實現的間接。注意“簡化操作”實際就是指用函數映射復雜的操作進而簡化代碼的編寫,利用函數名映射的地址來間接執行相應的代碼,對于虛函數就是一種調用形式表現多種執行結果。而“提高效率”是一種算法上的改進,即頻道是通過重復十組共諧電路來實現的,正宗的空間換時間,不是類型上的間接可以實現的。因此C++中的“虛”就只能增加代碼的靈活性和簡化操作(對于上面提出的三個間接的好處)。
比如動物會叫,不同的動物叫的方式不同,發出的聲音也不同,這就是在類型上需要通過“一種手段”(叫)來表現不同的效果(貓和狗的叫法不同),而這需要“另一段代碼”來實現,也就是通過派生來實現。即從類Animal派生類Cat和類Dog,通過將“叫(Gnar)”聲明為Animal中的虛函數,然后在Cat和Dog中各自再實現相應的Gnar成員函數。如上就實現了用Animal::Gnar的調用表現不同的效果,如下:
Cat?cat1,?cat2;?Dog?dog;?Animal?*pA[]?=?{?&cat1,?&dog,?&cat2?};
for(?unsigned?long?i?=?0;?i?<?sizeof(?pA?);?i++?)?pA[?i?]->Gnar();
上面的容器pA記錄了一系列的Animal的實例的引用(關于引用,可參考《C++從零開始(八)》),其語義就是這是3個動物,至于是什么不用管也不知道(就好象這臺電視機有10個頻道,至于每個是什么臺則不知道),然后要求這3個動物每個都叫一次(調用Animal::Gnar),結果依次發出貓叫、狗叫和貓叫聲。這就是之前說的增加靈活性,也被稱作多態性,指同樣的Animal::Gnar調用,卻表現出不同的形態。上面的for循環不用再寫了,它就是“一種手段”,而欲改變它的表現效果,就再使用“另一段代碼”,也就是再派生不同的派生類,并把派生類的實例的引用放到數組pA中即可。
因此一個類的成員函數被聲明為虛函數,表示這個類所映射的那種資源的相應功能應該是一個使用方法,而不是一個實現方式。如上面的“叫”,表示要動物“叫”不用給出參數,也沒有返回值,直接調用即可。因此再考慮之前的收音機和數字式收音機,其中有個功能為調臺,則相應的函數應該聲明為虛函數,以表示要調臺,就給出頻率增量或減量,而數字式的調臺和普通的調臺的實現方式很明顯的不同,但不管。意思就是說使用收音機的人不關心調臺是如何實現的,只關心怎樣調臺。因此,虛函數表示函數的定義不重要,重要的是函數的聲明,虛函數只有在派生類中實現有意義,父類給出虛函數的定義顯得多余。因此C++給出了一種特殊語法以允許不給出虛函數的定義,格式很簡單,在虛函數的聲明語句的后面加上“=?0”即可,被稱作純虛函數。如下:
class?Food;?class?Animal?{?public:?virtual?void?Gnar()?=?0,?Eat(?Food&?)?=?0;?};
class?Cat?:?public?Animal?{?public:?void?Gnar(),?Eat(?Food&?);?};
class?Dog?:?public?Animal?{?void?Gnar(),?Eat(?Food&?);?};
void?Cat::Gnar(){}?void?Cat::Eat(?Food&?){}?void?Dog::Gnar(){}?void?Dog::Eat(?Food&?){}
void?main()?{?Cat?cat;?Dog?dog;?Animal?ani;?}
上面在聲明Animal::Gnar時在語句后面書寫“=?0”以表示它所映射的元素沒有定義。這和不書寫“=?0”有什么區別?直接只聲明Animal::Gnar也可以不給出定義啊。注意上面的Animal?ani;將報錯,因為在Animal::Animal中需要填充Animal的虛函數表,而它需要Animal::Gnar的地址。如果是普通的聲明,則這里將不會報錯,因為編譯器會認為Animal::Gnar的定義在其他的文件中,后面的連接器會處理。但這里由于使用了“=?0”,以告知編譯器它沒有定義,因此上面代碼編譯時就會失敗,編譯器已經認定沒有Animal::Gnar的定義。
但如果在上面加上Animal::Gnar的定義會怎樣?Animal?ani;依舊報錯,因為編譯器已經認定沒有Animal::Gnar的定義,連函數表都不會查看就否定Animal實例的生成,因此給出Animal::Gnar的定義也沒用。但映射元素Animal::Gnar現在的地址欄填寫了數字,因此當cat.Animal::Gnar();時沒有任何問題。如果不給出Animal::Gnar的定義,則cat.Animal::Gnar();依舊沒有問題,但連接時將報錯。
注意上面的Dog::Gnar是private的,而Animal::Gnar是public的,結果dog.Gnar();將報錯,而dog.Animal::Gnar();卻沒有錯誤(由于它是虛函數結果還是調用Dog::Gnar),也就是前面所謂的public等與類型無關,只是一種語法罷了。還有class?Food;,不用管它是聲明還是定義,只用看它提供了什么信息,只有一個——有個類型名的名字為Food,是類型的自定義類型。而聲明Animal::Eat時,編譯器也只用知道Food是一個類型名而不是程序員不小心打錯字了就行了,因為這里并沒有運用Food。
上面的Animal被稱作純虛基類。基類就是類繼承體系中最上層的那個類;虛基類就是基類帶有純虛成員函數;純虛基類就是沒有成員變量和非純虛成員函數,只有純虛成員函數的基類。上面的Animal就定義了一種規則,也稱作一種協議或一個接口。即動物能夠Gnar,而且也能夠Eat,且Eat時必須給出一個Food的實例,表示動物能夠吃食物。即Animal這個類型成了一張說明書,說明動物具有的功能,它的實例變得沒有意義,而它由于使用純虛函數也正好不能生成實例。
如果上面的Gner和Eat不是純虛函數呢?那么它們都必須有定義,進而動物就不再是一個抽象概念,而可以有實例,則就可以有這么一種動物,它是動物,但它又不是任何一種特定的動物(既不是貓也不是狗)。很明顯,這樣的語義和純虛基類表現出來的差很遠。
那么虛繼承呢?被虛繼承的類的成員將被間接操作,這就是它的“一種手段”,也就是說操作這個被虛繼承的類的成員,可能由于得到的偏移值不同而操作不同的內存。但對虛類表的修改又只限于如果重復出現,則修改成間接操作同一實例,因此從根本上虛繼承就是為了解決上篇所說的鯨魚有兩個饑餓度的問題,本身的意義就只是一種算法的實現。這導致在設計海洋生物和脯乳動物時,無法確定是否要虛繼承父類動物,而要看派生的類中是否會出現類似鯨魚那樣的情況,如果有,則倒過來再將海洋生物和脯乳動物設計成虛繼承自動物,這不是好現象。
static(靜態)
在《C++從零開始(五)》中說過,靜態就是每次運行都沒有變化,而動態就是每次運行都有可能變化。C++給出了static關鍵字,和上面的public、virtual一樣,只是個語法標識而已,不是類型修飾符。它可作用于成員前面以表示這個成員對于每個實例來說都是不變的,如下:
struct?A?{?static?long?a;?long?b;?static?void?ABC();?};?long?A::a;
void?A::ABC()?{?a?=?10;?b?=?0;?};?void?main()?{?A?a;?a.a?=?10;?a.b?=?32;?}
上面的A::a就是結構A的靜態成員變量,A::ABC就是A的靜態成員函數。有什么變化?上面的映射元素A::a的類型將不再是long?A::而是long。同樣A::ABC的類型也變成void()而不是void(?A::?)()。
首先,成員要對它的類的實例來說都是靜態的,即成員變量對于每個實例所標識的內存的地址都相同,成員函數對于每個this參數進行修改的內存的地址都是不變的。上面把A::a和A::ABC變成普通類型,而非偏移類型,就消除了它們對A的實例的依賴,進而實現上面說的靜態。
由于上面對實例依賴的消除,即成員函數去掉this參數,成員變量映射的是一確切的內存地址而不再是偏移,所以struct?A?{?static?long?a;?};只是對變量A::a進行了聲明,其名字為A::a,類型為long,映射的地址并沒有給出,即還未定義,所以必須在全局空間中(即不在任何一個函數體內)再定義一遍,進而有long?A::a;。同樣A::ABC的類型為void(),被去除了this參數,進而在A::ABC中的b?=?10;等同于A::b?=?10;,發現A::b是偏移類型,需要this參數,則等同于this->A::b?=?10;。結果A::ABC沒有this參數,錯誤。而對于a?=?10;,等同于A::a?=?10;,而已經有這個變量,故沒任何問題。
注意上面的a.a?=?10;等同于a.A::a?=?10;,而A::a不是偏移類型,那這里不是應該報錯嗎?對此C++特別允許這種類型不匹配的現象,其中的“a.”等于沒有,因為這正是前面我們要表現的靜態成員。即A?a,?b;?a.a?=?10;?b.a?=?20;執行后,a.a為20,因為不管哪個實例,對成員A::a的操作都修改的同一個地址所標識的內存。
什么意義?它們和普通的變量的區別就是名字被A::限定,進而能表現出它們的是專用于類A的。比如房子,房子的門的高度和寬度都定好了,有兩個房子都是某個公司造的,它們的門的高度和寬度相同,因此門的高度和寬度就應該作為那個公司造的房子的靜態成員以記錄實際的高度和寬度,但它們并不需要因實例的不同而變化。
除了成員,C++還提供了靜態局部變量。局部變量就是在函數體內的變量,被一對“{}”括起來,被限制了作用域的變量。對于函數,每次調用函數,由于函數體內的局部變量都是分配在棧上,按照之前說的,這些變量其實是一些相對值,則每次調用函數,可能由于棧的原因而導致實際對應的地址不同。如下:
void?ABC()?{?long?a?=?0;?a++;?}?void?BCD()?{?long?d?=?0;?ABC();?}
void?main()?{?ABC();?BCD();?}
上面main中調用ABC而產生的局部變量a所對應的地址和由于調用BCD,而在BCD中調用ABC而產生的a所對應的地址就不一樣,原理在《C++從零開始(十五)》中說明。因此靜態局部變量就表示那個變量的地址不管是通過什么途徑調用它所在的函數,都不變化。如下:
void?ABC()?{?static?long?a?=?0;?a++;?}?void?BCD()?{?long?d?=?0;?d++;?ABC();?}
void?main()?{?ABC();?BCD();?}
上面的變量a的地址是固定值,而不再是原來那種相對值了。這樣從main中調用ABC和從BCD中調用ABC得到的變量a的地址是相同的。上面等同于下面:
long?g_ABC_a?=?0;?void?ABC()?{?g_ABC_a++;?}?void?BCD()?{?long?d?=?0;?d++;?ABC();?}
void?main()?{?ABC();?BCD();?}
因此上面ABC中的靜態局部變量a的初始化實際在執行main之前就已經做了,而不是想象的在第一次調用ABC時才初始化,進而上面代碼執行完后,ABC中的a的值為2,因為ABC的兩次調用。
它的意義?表示這個變量只在這個函數中才被使用,而它的生命期又需要超過函數的執行期。它并不能提供什么語義(因為能提供的“在這個函數才被使用”使用局部變量就可以做到),只是當某些算法需要使用全局變量,而此時這個算法又被映射成了一個函數,則使用靜態變量具有很好的命名效果——既需要全局變量的生存期又應該有局部變量的語義。
inline(嵌入)
函數調用的效率較低,調用前需要將參數按照調用規則存放起來,然后傳遞存放參數的內存,還要記錄調用時的地址以保證函數執行完后能回到調用處(關于細節在《C++從零開始(十五)》中討論),但它能降低代碼的長度,尤其是函數體比較大而代碼中調用它的地方又比較多,可以大幅度減小代碼的長度(就好像循環10次,如果不寫循環語句,則需要將循環體內的代碼復制10遍)。但也可能倒過來,調用次數少而函數體較小,這時之所以還映射成函數是為了語義更明確。此時可能更注重的是執行效率而不是代碼長度,為此C++提供了inline關鍵字。
在函數定義時,在定義語句的前面書寫inline即可,表示當調用這個函數時,在調用處不像原來那樣書寫存放、傳遞參數的代碼,而將此函數的函數體在調用處展開,就好像前面說的將循環體里的代碼復制10遍一樣。這樣將不用做傳遞參數等工作,代碼的執行效率將提高,但最終生成的代碼的長度可能由于過多的展開而變長。如下:
void?ABCD();?void?main()?{?ABCD();?}?inline?void?ABCD()?{?long?a?=?0;?a++;?}
上面的ABCD就是inline函數。注意ABCD的聲明并沒有書寫inline,因為inline并不是類型修飾符,它只是告訴編譯器在生成這個函數時,要多記錄一些信息,然后由連接器根據這些信息在連接前視情況展開它。注意是“視情況”,即編譯器可能足夠智能以至于在連接時發現對相應函數的調用太多而不適合展開進而不展開。對此,不同的編譯器給出了不同的處理方式,對于VC,其就提供了一個關鍵字__forceinline以表示相應函數必須展開,不用去管它被調用的情況。
前面說過,對于在類型定義符中書寫的函數定義,編譯器將把它們看成inline函數。變成了inline函數后,就不用再由于多個中間文件都給出了函數的定義而不知應該選用哪個定義所產生的地址,因為所有調用這些函數的地方都不再需要函數的地址,函數將直接在那里展開。
const(常量)
前面提到某公司造的房子的門的高度和寬度應該為靜態成員變量,但很明顯,在房子的實例存在的整個期間,門的高度和寬度都不會變化。C++對此專門提出了一種類型修飾符——const。它所修飾的類型表示那個類型所修飾的地址類型的數字不能被用于寫操作,即地址類型的數字如果是const類型將只能被讀,不能被修改。如:const?long?a?=?10,?b?=?20;?a++;?a?=?4;(注意不能cosnt?long?a;,因為后續代碼都不能修改a,而a的值又不能被改變,則a就沒有意義了)。這里a++;和a?=?4;都將報錯,因為a的類型為cosnt?long,表示a的地址所對應的內存的值不能被改變,而a++;和a?=?4;都欲改變這個值。
由于const?long是一個類型,因此也就很正常地有const?long*,表示類型為const?long的指針,因此按照類型匹配,有:const?long?*p?=?&b;?p?=?&a;?*p?=?10;。這里p?=?&a;按照類型匹配很正常,而p是常量的long類型的指針,沒有任何問題。但是*p?=?10;將報錯,因為*p將p的數字直接轉換成地址類型,也就成了常量的long類型的地址類型,因此對它進行寫入操作錯誤。
注意有:const?long*?const?p?=?&a;?p?=?&a;?*p?=?10;,按照從左到右修飾的順序,上面的p的類型為const?long*?const,是常量的long類型的指針的常量,表示p的地址所對應的內存的值不能被修改,因此后邊的p?=?&a;將錯誤,違反const的意義。同樣*p?=?10;也錯誤。不過可以:
long?a?=?3,?*const?p?=?&a;?p?=?&a;?*p?=?10;
上面的p的類型為long*?const,為long類型的常量,因此其必須被初始化。后續的p?=?&a;將報錯,因為p是long*?const,但*p?=?10;卻沒有任何問題,因為將long*轉成long后沒有任何問題。所以也有:
const?long?a?=?0;?const?long*?const?p?=?&a;?const?long*?const?*pp?=?&p;
只要按照從左到右的修飾順序,而所有的const修飾均由于取內容操作符“*”的轉換而變成相應類型中指針類型修飾符“*”左邊的類型,因此*pp的類型是const?long*?const,*p的類型是const?long。
應注意C++還允許如下使用:
struct?A?{?long?a,?b;?void?ABC()?const;?};
void?A::ABC()?const?{?a?=?10;?b?=?10;?}
上面的A::ABC的類型為void(?A::?)()?const,其等同于:
void?A_ABC(?const?A?*this?)?{?this->a?=?10;?this->b?=?10;?}
因此上面的a?=?10;和b?=?10;將報錯,因為this的類型是const?A*。上面的意思就是函數A::ABC中不能修改成員變量的值,因為各this的參數變成了const?A*,但可以修改類的靜態成員變量的值,如:
struct?A?{?static?long?c;?long?a,?b;?void?ABC()?const;?}?long?A::c;
void?A::ABC()?const?{?a?=?b?=?10;?c?=?20;?}
等同于:void?A_ABC(?const?A?*this?)?{?this->a?=?this->b?=?10;?A::c?=?20;?}。故依舊可以修改A::c的值。
有什么意義?出于篇幅,有關const的語義還請參考我寫的另一篇文章《語義的需要》。
friend(友員)
發信機具有發送電波的功能,收信機具有接收電波的功能,而發信機、收信機和電波這三個類,首先發信機由于將信息傳遞給電波而必定可以修改電波的一些成員變量,但電波的這些成員應該是protected,否則隨便一個石頭都能接收或修改電波所攜帶的信息。同樣,收信機要接收電波就需要能訪問電波的一些用protected修飾的成員,這樣就麻煩了。如果在電波中定義兩個公共成員函數,讓發信機和收信機可以通過它們來訪問被protected的成員,不就行了?這也正是許多人犯的毛病,既然發信機可以通過那個公共成員函數修改電波的成員,那石頭就不能用那個成員函數修改電波嗎?這等于是原來沒有門,后來有個門卻不上鎖。為了消除這個問題,C++提出了友員的概念。
在定義某個自定義類型時,在類型定義符“{}”中聲明一個自定義類型或一個函數,在聲明或定義語句的前面加上關鍵字friend即可,如:
class?Receiver;?class?Sender;
class?Wave?{?private:?long?b,?c;?friend?class?Receiver;?friend?class?Sender;?};
上面就聲明了Wave的兩個友員類,以表示Receiver和Sender具備了Wave的資格,即如下:
class?A?{?private:?long?a;?};?class?Wave?:?public?A?{?…?};
void?Receiver::ABC()?{?Wave?wav;?wav.a?=?10;?wav.b?=?10;?wav.A::a?=?10;?}
上面由于Receiver是Wave的友員類,所以在Receiver::ABC中可以直接訪問Wave::a、Wave::b,但wav.A::a?=?10;就將報錯,因為A::a是A的私有成員,Wave不具備反問它的權限,而Receiver的權限等同于Wave,故權限不夠。
同樣,也可有友員函數,即給出函數的聲明或定義,在語句前加上friend,如下:
class?Receiver?{?public:?void?ABC();?};
class?A?{?private:?long?a;?friend?void?Receiver::ABC();?};
這樣,就將Receiver::ABC作為了A的友員函數,則在Receiver::ABC中,具有類A具有的所有權限。
應注意按照給出信息的思想,上面還可以如下:
class?A?{?private:?long?a;?friend?void?Receiver::ABC()?{?long?a?=?0;?}?};
這里就定義了函數Receiver::ABC,由于是在類型定義符中定義的,前面已經說過,Receiver::ABC將被修飾為inline函數。
那么友員函數的意義呢?一個操作需要同時操作兩個資源中被保護了的成員,則這個操作應該被映射為友員函數。如蓋章需要用到文件和章兩個資源,則蓋章映射成的函數應該為文件和章的友員函數。
名字空間
前面說明了靜態成員變量,它的語義是專用于某個類而又獨立于類的實例,它與全局變量的關鍵不同就是名字多了個限定符(即“::”,表示從屬關系),如A::a是A的靜態成員變量,則A::a這個名字就可以表現出a從屬于A。因此為了表現這種從屬關系,就需要將變量定義為靜態成員變量。
考慮一種情況,映射采礦。但是在陸地上采礦和在海底采礦很明顯地不同,那么應該怎么辦?映射兩個函數,名字分別為MiningOnLand和MiningOnSeabed。好,然后又需要映射在陸地勘探和在海底勘探,怎么辦?映射為ProspectOnLand和ProspectOnSeabed。如果又需要映射在陸地鉆井和在海底鉆井,在陸地爆破和在海底爆破,怎么辦?很明顯,這里通過名字來表現語義已經顯得牽強了,而使用靜態成員函數則顯得更加不合理,為此C++提供了名字空間,格式為namespace?<名字>?{?<各聲明或定義語句>?}。其中的<名字>為定義的名字空間的名字,而<各聲明或定義語句>就是多條聲明或定義語句。如下:
namespace?OnLand?{?void?Mining();?void?Prospect();?void?ArtesianWell(){}?}
namespace?OnSeabed?{?void?Mining();?void?Prospect();?void?ArtesianWell(){}?}
void?OnLand::Mining()?{?long?a?=?0;?a++;?}?void?OnLand::Prospect()?{?long?a?=?0;?a++;?}
void?OnSeabed::Mining()?{?long?a?=?0;?a++;?}?void?OnSeabed::Prospect()?{?long?a?=?0;?a++;?}
上面就定義了6個元素,每個的類型都為void()。注意上面OnLand::ArtesianWell和OnSeabed::ArtesianWell的定義直接寫在“{}”中,將是inline函數。這樣定義的六個變量它們的名字就帶有限定符,能夠從名字上體現從屬關系,語義表現得比原來更好,OnSeabed::Prospect就表示在海底勘探。注意也可以如下:
namespace?A?{?long?b?=?0;?long?a?=?0;?namespace?B?{?long?B?=?0;?float?a?=?0.0f?}?}
namespace?C?{?struct?ABC?{?long?a,?b,?c,?d;?void?ABCD()?{?a?=?b?=?c?=?d?=?12;?}?}?ab;?}
namespace?D?{?void?ABC();?void?ABC()?{?long?a?=?0;?a++;?}?extern?float?bd;?}
即名字空間里面可以放任何聲明或定義語句,也可以用于修飾自定義結構,因此就可以C::ABC?a;?a.ABCD();。應注意C++還允許給名字空間別名,比如:namespace?AB?=?C;?AB::ABC?a;?a.ABCD();。這里就給名字空間C另起了個名字AB,就好像之前提過的typedef一樣。
還應注意自定義類型的定義的效果和名字空間很像,如struct?A?{?long?a;?};將生成A::a,和名字空間一樣為映射元素的名字加上了限定符,但應該了解到結構A并不是名字空間,即namespace?ABC?=?A;將失敗。名字空間就好像所有成員都是靜態成員的自定義結構。
為了方便名字空間的使用,C++提供了using關鍵字,其后面接namespace和名字空間的名字,將把相應名字空間中的所有映射元素復制一份,但是去掉了名字前的所有限定符,并且這些元素的有效區域就在using所在的位置,如:
void?main()?{?{?using?namespace?C;?ABC?a;?a.ABCD();?}?ABC?b;?b.ABCD();?}
上面的ABC?b;將失敗,因為using?namespace?C;的有效區域只在前面的“{}”內,出了就無效了,因此應該C::ABC?b;?b.ABCD();。有什么用?方便書寫。因為每次調用OnLand::Prospect時都要寫OnLand::,顯得有點煩瑣,如果知道在某個區域內并不會用到OnSeabed的成員,則可以using?namespace?OnLand;以減小代碼的繁雜度。
注意C++還提供了using更好的使用方式,即只希望去掉名字空間中的某一個映射元素的限定符而不用全部去掉,比如只去掉OnLand::Prospect而其它的保持,則可以:using?OnLand::Prospect;?Prospect();?Mining();。這里的Mining();將失敗,而Prospect();將成功,因為using?OnLand::Prospect;只去掉了OnLand::Prospect的限定符。
至此基本上已經說明了C++的大部分內容,只是還剩下模板和異常沒有說明(還有自定義類型的操作符重載,出于篇幅,在《C++從零開始(十七)》中說明),它們帶的語義都很少,很大程度上就和switch語句一樣,只是一種算法的包裝而已。下篇介紹面向對象編程思想,并給出“世界”的概念以從語義出發來說明如何設計類及類的繼承體系。