C++從零開始(中)

上面的第一個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++從零開始(十二)》中詳細闡述),其更適合表現收銀工作。比如收銀臺實現的是收錢的工作,客戶在櫃檯買了東西,由營業員開出單據,然後客戶將單據拿到收銀臺交錢。這裏收銀臺的工作就需要操作兩個資源——錢和單據,這時就應該將收錢這個工作映射爲如上的函數而不是成員函數,因爲在這個算法中,收銀臺沒有被映射成自定義類型的必要性,即我們對收銀的工作由誰做不關心,只關心它如何做。
    至此介紹完了自定義類型的一半內容,通過這些內容已經可以編寫出能體現較複雜語義的代碼了,下篇將說明自定義類型的後半內容,它們的提出根本可以認爲就是語義的需要,所以下篇將從剩餘內容是如何體現語義的來說明,不過依舊要說明各自是如何實現的。

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