C++從零開始(上)

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++從零開始(十三)——C++樣例二(說明如何設計基於面向對象編程思想的C++程序)
    C++從零開始(十四)——何謂模板(說明模板技術及其存在的理由)
    C++從零開始(十五)——何謂異常(說明異常技術及其存在的理由)
    C++從零開始(十六)——何謂預編譯指令(說明預編譯指令的概念及幾個常用指令的應用)
    C++從零開始(十七)——C++中的一些生僻關鍵字(explicit、mutable、volatile等的說明)
    C++從零開始(十八)——何謂SDK(說明爲什麼沒有放音樂的指令卻依然可以編出放音樂的程序)
    C++從零開始(十九)——何謂C運行時期庫(說明C運行時期庫這一大多數問題的元兇)
    C++從零開始(二十)——關於VC的一點點基礎知識(說明VC的幾個基本概念和一些常用設置)
    C++從零開始(二十一)——C++樣例三(使用VC編寫一個通過DLL實現多態性的簡單程序)


何謂程序

    程序,即過程的順序,準確地說應該是順序排列的多個過程,其是方法的描述。比如吃菜,先用筷子夾起菜,再用筷子將菜送入嘴中,最後咀嚼併吞下。其中的夾、送、咀嚼和吞下就被稱作命令,而菜則是資源,其狀態(如形狀、位置等)隨着命令的執行而不斷髮生變化。上面就是吃菜這個方法的描述,也就是吃菜的程序。
    任何方法都是爲了改變某些資源的狀態而存在,因此任何方法的描述,也就是程序,也都一定有命令這個東西以及其所作用的資源。命令是由程序的執行者來實現的,比如上面的吃菜,其中的夾、送等都是由吃菜的人來實現的,而資源則一定是執行者可以改變的東西,而命令只是告訴執行者如何改變而已。
    電腦程序和上面一樣,是方法的描述,而這些方法就是人期望電腦能做的事(注意不是電腦要做的事,這經常一直混淆着許多人),當人需要做這些事時,人再給出某些資源以期電腦能對其做正確的改變。如計算圓周率的程序,其只是方法的描述,本身是不能發生任何效用的,直到它被執行,人爲給定它一塊內存(關於內存,請參考《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++;
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章