2016年6月1日 星期三

給新手的C++教學 (上冊) - 12. 指標 (Pointer)

回到「給新手的C++教學 (上冊)」

上一章

這一章,我們將更深入地探討電腦的運作原理
進而更完善的掌控電腦的記憶體

你會發現,你將會有能力控制更多平常想不到的東西!

你有想過嗎?
當我們宣告一個變數的時候,電腦會撥出一些記憶體來讓程式儲存變數的資訊
但是,電腦的記憶體是有限的,總不可能每一塊記憶體都只有使用一次吧?
這樣的話每一台電腦的記憶體大概都只夠用10分鐘了XD
因此,允許記憶體被重複使用的機制是必要的

為了讓記憶體能夠被重複使用,我們必須確認用過的記憶體中,哪些現在還在使用中、哪些已經使用完畢了
那麼,我們的程式當然只能使用那些已經使用完畢的記憶體,否則修改到其他程式正在使用的記憶體,進而導致其他程式出錯

觀念簡單,但是要有效率地做到這件事 (避免每次找記憶體前都要老老實實的把每一塊記憶體都確認一遍),需要許多先進的演算法知識,而且程式碼寫起來非常的麻煩

放心,這件事連我都不知道怎麼做
所謂「站在巨人的肩膀上」,這種事情不用再由我們自己處理了!
在先人的努力之下,「C++」這個偉大的程式語言,已經讓電腦可以只依據簡單的幾行程式碼,就可以執行許多複雜卻需要經常執行的工作
換句話說
我們在撰寫C++程式碼的時候,只需要告訴電腦「需要使用多少記憶體」和「哪些記憶體已經使用完畢」就好了!

我們在宣告變數的時候,就等於是告訴電腦「恩,我需要這麼多記憶體來儲存這一個變數的資訊」,然後電腦就會把你要的記憶體給你

但是,我們有告訴過電腦「哪些記憶體已經使用完畢」嗎?
有的!

還記得在第十章的時候,我們有提到「每一個變數都有它自己的作用範圍」嗎?
那麼,當正在執行的程式碼位於一個變數的作用範圍外,這個變數所使用的記憶體該怎麼辦呢?
這時我們不會需要用這個變數來儲存任何資訊
也就是,這個變數目前不需要使用記憶體來儲存資訊

電腦學過C++,想當然會利用這個「作用範圍外記憶體就不需要了」的特性
不管是甚麼變數,只要程式執行到它的「作用範圍」之外
電腦就會「自動」把這個變數使用的記憶體當作「使用完畢」
也就是說,現在,這塊記憶體可能是閒置的,或者被其他的程式拿去使用了
如果我們再去修改這塊記憶體,會造成不可預知的錯誤
當然,你還沒開始學習本章的內容,不會知道怎麼在作用範圍外修改這塊記憶體

在作用範圍外修改記憶體?
有需要嗎?
是的,有時候會很需要,請看本章範例
感覺很危險?
是的,使用不當會造成其他程式的錯誤,甚至讓整台電腦當機,必須將電腦插頭拔掉再重新開機

因此,在學習本章前,請務必將重要資料存檔,以避免造成不必要的損失

嘿嘿嘿~
現在
我們開始吧XD



你知道怎麼「交換兩個變數的資訊」嗎?
我們就來試試看吧~

#include<cstdio>
int main()
{
    int a=3,b=7;
    printf("交換前a=%d, b=%d\n",a,b);
    int t=a;
    a=b;
    b=t;
    printf("交換後a=%d, b=%d\n",a,b);
    return 0;
}

我們宣告的兩個變數a、b,它們的資訊被交換了!
(為了方便理解,建議將變數「c改名為「t)
你玩過猜球遊戲嗎?
魔術師會把三個相同的杯子蓋起來,並把一顆球藏進中間那個杯子
然後用迅雷不及掩耳的速度多次快速隨機交換其中兩個杯子
最後要你猜球會在哪個杯子
↓猜球遊戲↓ (警告:影片有聲音)
video
現在,請你寫一個程式來模擬這個過程
一開始魔術師有三個杯子,球在中間那個杯子裡面
接著會有$n$次交換的動作
請你找出最後球在哪個杯子

輸入格式:
首先會有一個整數$n$,代表有幾次操作
接下來會有$n$個數字,代表每一次操作的種類
$1$代表交換左邊和中間的杯子
$2$代表交換左邊和右邊的杯子
$3$代表交換中間和右邊的杯子

輸出格式:
輸出「左邊」、「中間」或「右邊」,代表球最後會在哪一個杯子裡面

例如:
輸入「5 3 2 1 2 3」,會輸出「右邊」
輸入「6 2 3 1 1 3 1」,會輸出「左邊」
輸入「7 1 1 1 3 2 2 1」,會輸出「中間」

趕快自己做做看吧~

提示:可以利用剛剛學的「交換兩個變數的資訊」的技巧

範例程式碼:

#include<cstdio>
int main()
{
    int n;
    scanf("%d",&n);
    int a=0,b=1,c=0;
    int i=0;
    while(i<n)
    {
        int type;
        scanf("%d",&type);
        if(type==1)
        {
            int t=a;
            a=b;
            b=t;
        }
        else if(type==2)
        {
            int t=a;
            a=c;
            c=t;
        }
        else if(type==3)
        {
            int t=b;
            b=c;
            c=t;
        }
        else
        {
            printf("你輸入錯誤了XD");
        }
        i=i+1;
    }
    if(a==1) printf("左邊\n");
    else if(b==1) printf("中間\n");
    else if(c==1) printf("右邊\n");
    else printf("怎麼可能?一定是程式寫錯了Orz\n");
    return 0;
}

對於輸入「5 3 2 1 2 3」,程式正確的輸出「右邊
對於輸入「6 2 3 1 1 3 1」,程式正確的輸出「左邊」
對於輸入「7 1 1 1 3 2 2 1」,程式正確的輸出「中間」

你可能會發現,程式碼好冗長啊~
我們的程式碼重複做了三遍「交換兩個變數的資訊」的動作
就寫成一個函式吧~
對了,如果一個函式不需要回傳任何東西,應該用「void (無值)」這個型別:

#include<cstdio>
void Exchange(int x,int y)
{
    int t=x;
    x=y;
    y=t;
}
int main()
{
    int n;
    scanf("%d",&n);
    int a=0,b=1,c=0;
    int i=0;
    while(i<n)
    {
        int type;
        scanf("%d",&type);
        if(type==1)
        {
            Exchange(a,b);
        }
        else if(type==2)
        {
            Exchange(a,c);
        }
        else if(type==3)
        {
            Exchange(b,c);
        }
        else
        {
            printf("你輸入錯誤了XD");
        }
        i=i+1;
    }
    if(a==1) printf("左邊\n");
    else if(b==1) printf("中間\n");
    else if(c==1) printf("右邊\n");
    else printf("怎麼可能?一定是程式寫錯了Orz\n");
    return 0;
}

好吧,我承認程式碼還是41行
不過,至少程式碼的邏輯清楚多了,對吧?
測試一下,確定程式輸出的結果是正確的!

對於輸入「5 3 2 1 2 3」,程式錯誤的輸出「中間」
對於輸入「6 2 3 1 1 3 1」,程式錯誤的輸出「中間」
對於輸入「7 1 1 1 3 2 2 1」,程式正確的輸出「中間」

等等,怎麼三種情況都輸出「中間」?
根本形同沒有交換過嘛!

仔細想想,當我們執行「Exchange(a,b);」的時候
實際上效果會等同於以下四行程式碼:

int x=a,y=b;
int t=x;
x=y;
y=t;

雖然剛開始$x=a$、$y=b$
可是當我們交換了$x$和$y$之後,原本真正想要交換的$a$和$b$根本不受影響啊!
我們交換了$x$和$y$後,$a$和$b$還是不變,形同做白工


難道這種東西就不能寫成一個函式嗎?
當然可以!

注意到,「函式」其實是在$a$和$b$的「作用範圍」之外的
(這時候電腦不會把$a$和$b$的記憶體當作「使用完畢」,因為「Exchange(a,b);」這一行程式碼還是「執行中」的狀態,而且位於$a$和$b$的「作用範圍」裡面)
因此,我們必須想辦法在「函式的位置」直接修改「$a$和$b$這兩個變數所使用的記憶體」

方法是使用「指標 (Pointer)」

其實呢,我也不知道為甚麼這個東西叫做指標
反正原理就是把電腦的每一塊記憶體都指定一個編號
然後程式就可以依照一個編號去找到要存取的那塊記憶體了
這種「記憶體編號」就是所謂的「指標」

要怎麼取得一個變數的「指標」(就是這個變數使用的記憶體的編號) 呢?
假設你的變數名稱叫做「a」
那麼它的指標就是「&a」
也就是在前面加一個「&」就好了
簡單吧!

但是請注意,雖然「指標」是一種「編號」,不過它的「型別」並不是我們常用來儲存整數的「int」
更正確地來講,因為每台電腦的配備都不同,「指標」的儲存方式也會不同!
對於32位元的電腦來說,一個「指標」是一個「32位元」的整數 (也就是我們熟悉的『int』)
對於64位元的電腦來說,一個「指標」是一個「64位元」的整數 (也就是使用64位元的記憶體來儲存資料的『進階版int』--『long long』)

總之,你不能把「指標」當作「int」或「long long」
那麼,它的型別是甚麼呢?

其實很簡單
假如你是從一個「int」變數來取得指標,它的型別就是「int*」
假如你是從一個「float」變數來取得指標,它的型別就是「float*」
假如你是從一個「char」變數來取得指標,它的型別就是「char*」

舉個例子:

int a;
int* b=&a;

這樣一來,「b」就是「a的指標」

需要特別注意的是,當你想要「一次宣告多個指標」的時候
你應該在每個指標名稱前面都加上「*」
我也覺得滿奇怪的,不知道為甚麼這樣規定 (感謝學長補充,見註8)
因此,個人不建議上面的寫法,我會習慣寫成以下的形式:

int a,b,c;
int *d=&a,*e=&b,*f=&c;

也就是說,把每一個「*」都緊鄰著指標名稱
這樣一來,「d」就是「a的指標」、「e」就是「b的指標」、「f」就是「c的指標」
個人認為這樣的程式碼會工整許多,而且還附帶提醒用途 (提醒你宣告指標時,每個指標名稱前面都要有「*」)

好了,我們現在可以想出「交換兩個變數的資訊」的函式的大致架構了:

void Exchange(int *x,int *y)
{
    想辦法交換「x」和「y」所代表的兩塊記憶體的資訊
}

然後函式的使用方式就是:

Exchange(&a,&b);

現在,假設你有了「int *b=&a;」,要怎麼透過「b」來修改「a的記憶體」呢?
方法是在「b」前面加上「*」

注意,這和「宣告指標」時「int *b=&a;」的作用不同
在「b」前面加上「*」之後
你就可以把「*b」直接當作一個「int變數」來存取或修改了!

你看你看~~~
「*」是不是很像射飛鏢在用的「箭靶」呢?

這是「箭靶」,有三支「飛鏢」射中箭靶的中心
圖片來源:http://img3.redocn.com/20130429/Redocn_2013042413460991.jpg

「指標」就像一支「飛鏢」一樣
「指標」儲存的資訊 (記憶體編號) 就是這支「飛鏢」指向哪裡
當你寫下「int *b=&a;」的時候,「b」這支「飛鏢」就指向了「a」這個「箭靶」
當你寫下「*b」的時候,「*b」就代表「b指向的箭靶」了,也就是「a」!

具體使用方式請參考以下程式碼:

#include<cstdio>
int main()
{
    int a=2;
    printf("修改前a=%d\n",a);
    int *b=&a;
    *b=4;
    printf("修改後a=%d\n",a);
    return 0;
}

我們透過「a」的指標「b」,成功修改「a」的數值了!
所以,我們就在「*b=4;」這一行程式碼中
把「b指向的箭靶」修改成「4」了!

現在,你知道要怎麼寫出「交換兩個變數的資訊」的函式了嗎?

如果還想不出來,以下提供參考,希望你會恍然大悟 (如果還是想不通,請務必讓我知道):

void Exchange(int *x,int *y)
{
    int t=*x;
    *x=*y;
    *y=t;
}




現在,我們來嘗試寫一個「函式版」的「猜球遊戲模擬程式」吧!

#include<cstdio>
void Exchange(int *x,int *y)
{
    int t=*x;
    *x=*y;
    *y=t;
}
int main()
{
    int n;
    scanf("%d",&n);
    int a=0,b=1,c=0;
    int i=0;
    while(i<n)
    {
        int type;
        scanf("%d",&type);
        if(type==1)
        {
            Exchange(&a,&b);
        }
        else if(type==2)
        {
            Exchange(&a,&c);
        }
        else if(type==3)
        {
            Exchange(&b,&c);
        }
        else
        {
            printf("你輸入錯誤了XD");
        }
        i=i+1;
    }
    if(a==1) printf("左邊\n");
    else if(b==1) printf("中間\n");
    else if(c==1) printf("右邊\n");
    else printf("怎麼可能?一定是程式寫錯了Orz\n");
    return 0;
}

試試看,確認程式的輸出是正確的!

對於輸入「5 3 2 1 2 3」,程式正確的輸出「右邊」
對於輸入「6 2 3 1 1 3 1」,程式正確的輸出「左邊」
對於輸入「7 1 1 1 3 2 2 1」,程式正確的輸出「中間」

耶!完全正確!
我們成功了!\(^o^)/

最後,來個小提醒
不管你玩C++玩得多深
千萬不要寫出類似下面危險的程式碼:

#include<cstdio>
int main()
{
    int *a=(int*)32405;
    *a=911;
    return 0;
}

其中,「(int*)」代表強制用後面的「32405」產生一個型別為「int*」的變數
誰知道編號為「32405」的記憶體是幹嘛用的啦
你把這塊記憶體的資料修改成「911」
可能沒事
可能「IE」的名字被顯示成「JE」
可能你正在玩的鑽礦遊戲Digging Game 2掛惹
可能半小時後才出事
可能整台電腦大當機

好啦,你硬要試試看
我只能說你很有實驗精神
但是
請先將重要資料存檔
並隨時做好「必須將電腦插頭拔掉才能重新開機」的心理準備

下一章 (這還不是最後一章)

感謝:李旺陽學長
(版權所有 All copyright reserved)

6 則留言:

  1. 回覆
    1. 咦,請問您在哪裡感到困惑呢?

      刪除
    2. 那就好 ^_^
      有問題隨時歡迎再提出哦~

      刪除
  2. 所以如果真的當機重開後就會好嗎?

    回覆刪除

歡迎留言或問問題~
若您的留言中包含程式碼,請參考這篇
如果留言不見了請別慌,那是因為被google誤判成垃圾留言,小莫會盡快將其手動還原