這篇假設大家都有學過一點點的程式,不限於任何的語言,因為這個知識在目前的程式語言雖然不會直接使用,但是還是會以其它型式出現。太過基礎程式語法我就不會再提一次了,並且本篇都會以 C 做為範例,因為 C 的指標是最有代表性而且功能最完整的!
大家第一次學程式的時候在 Hello World 之後第一個學的東西就是「變數」了吧,而之後應該會學到什麼 if、for 之類的,接著就會到第一個大魔王「陣列」,但這篇其實不是要講陣列怎麼用之類的,而是想要用一個例子來講講它的另一個化身 - 指標。
先吃個飯吧
我本來目前住在台南,而台南最有名的東西就是小吃,不管走到哪裡都可以看到各種食物,但其實很多小吃攤都是自己家的人開的,因此常常就會有一個狀況是我先找到了椅子,但是桌子還沒有清,有碗之類的東西還放在上面,然後我就先走去櫃台點餐,點完之後就會有人來幫我把桌子清乾淨接著上菜,最後吃完走人,這就是一個人吃飯的過程。
當然有時候我也會跟朋友一起吃飯,這個時候就會有好幾個人坐在同一桌,有時候還會坐到連續兩桌,其它的其實就跟一個人的時候一樣,有時候桌子一樣沒有清,然後就會有人去櫃台點餐(通常是我),最後吃完結帳離開。
上面這兩個故事發生在你我的生活當中,但你可能不知道是這其實也發生在任何一台手機、電腦、平板等等的設備裡面。
當我們今天在程式裡面宣告了一個變數(找到椅子),之後對這個變數初始化(點餐),使用完(開始吃)之後再將其釋放(結帳),整套過程就是一個變數在電腦的生命周期。如果換成陣列其實就跟很多人一起吃飯的例子一樣,我們一次宣告了一定數量的陣列(找到椅子),之後將其初始化(點餐),使用完(開始吃)之後就將其釋放(結帳)。
如果上面的例子沒有問題的話,那麼就可以思考幾個點:
- 老闆是怎麼知道你(你們)坐在哪的?
- 很多人一起吃飯的時候會所有人去寫一張單子嗎?還是寫一寫由一個人一起點餐?
一個人指來指去
在開始之前,我推薦一個工具 Online C++ compiler, visual debugger, and AI tutor,這個工具可以把非常抽象的指標變成可視化的圖片,下面的圖片都是使用這個工具製作的。
當你宣告了一個變數的時候,其實這個變數是在記憶體內要了一格對應大小的空間,想像一下有一個人去店裡吃飯,但是因為他超級胖所以需要坐到兩格位子,而有些人更胖需要四格位子,這就是在電腦內時時刻刻都在發生的事。但這裡會有一個問題,如果那個人跑去點餐,那麼要怎麼樣讓店員知道他坐在哪裡呢?通常會報桌號對吧?在電腦裡面也是一樣,變數所在的那個位子實也是有編號的,那我們要怎麼樣知道那個編號是多少呢?下面這個程式會把變數 $a$ 所在的那格的編號印出來。
|
|
下方是執行的結果
圖中的 0x7fff049aff74 就是 $a$ 這個變數當時在我電腦的位子(桌號),為什麼說當時呢?因為每次執行的結果應該都是不同的,電腦不太可能每次都分配到相同的位子給你,因此編號每次就都會不同。而如果我們在加一行程式碼,就可以把這個編號留下來。
|
|
下方是執行的結果
跟剛剛的執行結果差不多,但是輸出的值是不一樣,這也就順便應證了我剛剛說的每次給的位子都不一樣。但這裡的重點是位子是可以用變數存下來的,但我們要存下來做什麼?其實是因為指標我們還可以反向找回對應的值,就像餐廳的店員可以從桌號找到你一樣,下方這個範例可以先自己想一下再看解答 (1) 喔(解答在本頁最後)!
|
|
很多人指來指去
如果剛剛都可以理解的話,這邊升級成多人模式,也就是陣列啦!
當你在程式裡面宣告一個陣列的時候,其實電腦做的事跟剛剛的變數是差不多,但你一定會說不對啊,陣列不是一次新增很多個變數嗎,那這樣不是應該要在記憶體內要對應數量的變數,然後要使用的時候在去對應的位子拿宣告好的變數嗎?其實上面這句話描述的滿接近的,但是有幾個地方需要特別注意。第一個就是當宣告陣列的時候其實電腦是會直接包下一段空間,就跟你去餐廳不會想要分桌坐一樣,陣列也不想,而包下的那一段空間就會以第一格當代表,你可以想成幫大家點餐的那個人。
|
|
上面的程式碼宣告了一個陣列並且對 $0,1,2$ 做存取,但注意到倒數第二行對索引 $2$ 存取…沒錯這個語法是完全符合語法並且編譯器也不會報錯的寫法喔!但為什麼?
這就要說到其實我們宣告陣列的時候,電腦幫我們做的事,下方的程式碼是以指標的方式重寫一篇,跟上面的程式碼完全相同。注意到 main 函式的第一行,這其實就是當我們宣告陣列的時候電腦做的事,它幫我們建立了一個指標,並且從這個指標開始往後開了 $10$ 格,這就像是你去餐廳的時候跟店家說你要 $10$ 個位子一樣,店家就會幫你留下來並且都安排在一起一樣。最後當存取的時候電腦並不知道其它格在哪裡,而是使用「以第一格為開頭,往後第 $n$ 格」的方式來找到對應的空間,這就像是店家要上菜的時候紀錄的是幾號桌的第幾位客人一樣,而下面的程式碼同時也解釋了為什麼上面的程式碼的倒數第二行可以這樣寫,因為編譯過後誰在前其實根本沒差。
|
|
看完上方的範例,你應該會發現到其實陣列也是指標,只是這個指標往後多預留了很多空間而以。
欸等等,桌子沒收怎麼就開始吃了
想像一下你今天去一間餐廳裡面,你一樣點完餐了並且上菜了,之後你就發現到的你桌子沒有清,上面還有上一桌的碗筷等等的,這個時候能不能吃?其實也能,而且只要把那些東西推遠一點就行了,但是會不會不小心就吃到上一桌剩下的東西,其實有這種可能。
這就相當於我們的變數沒有初始化,有時候就會不小心去用到,但是要說用到會怎樣嗎?其實很多時候就剛好預設值是 $0$,但會不會哪天那格剛被其它程式用過,因此再次使用的時候就是其它數值,確實是有這種可能的。
上面還只是一個變數的狀況,如果是陣列的話就會更容易遇到沒有初始化導致 BUG 的問題,因為陣列常常都是幾千幾萬在宣告的,不遇到都難。
因此不論在使用變數或者陣列都一定要養成初始化的習慣,才不會遇到怪怪的 BUG 喔!
指來指去指出去
這就是為什麼我的標題會寫著「迷人的危險」的原因,指標的操作非常的方便而且常常是不可或缺的,但是上面的幾個例子中,你有沒有發現到一個問題,難到所有的指針都一定指向一個確定的值嗎?也就是說是不是所有的桌號都有一個對應的客人,這個答案你應該可以馬上否定,在程式中也是一樣,這是非常非常常見的問題。
這種錯誤在不同的程式語言會顯示不同的錯誤,例如 C 會顯示 Segment fault,而 Java 則是會顯示 NullPointerException,其它語言如果想知道在自行搜尋。
先從第一種開始,我能想到最常出現這個問題的地方是在實作資料結構的時候,因為一般來說我們不會去宣告一個沒有對應值的指標,但是在實作資料結構的時候常常是需要預先準備好指標,以在後序有資料進入的時候指到對應的資料,但是如果在存取的時候不小心去存取到就會發生記憶體錯誤。這種錯誤只能在寫程式的時候多用 if 檢查,通常會需要用到空指標的問題大該也沒有什麼好的解法。這種錯誤在一個很新的語言 Rust 正在慢慢的消失,因為它使用了一些特性來改善這個問題。
第二種大該最常出現的錯誤,就連程式檢定的題目本身都有出現過這錯誤。可以想像成你想要跟你的朋友去餐廳吃飯,但是因為那間餐廳要訂位,所以你就訂了 $5$ 位大人,但如果今天突然有個人想要加入一起吃,會不會有問題?可能會可能不會,如果店家剛好有位子就沒事,但如果滿了那個多的人就不能進去了。我常常在寫題目就因為這個原因卡很久,而且算就知道了是使用了宣告外的範圍也不好找出是哪一行程式碼寫錯,非常的麻煩。下面這就是一個使用了超出宣告範圍的範例。
|
|
上面的程式碼你應該一眼就能看出陣列實際上可以使用的索引只有 $0 \sim 9$,而我的迴圈卻寫成了 $10$。
猜猜我是誰
上面全部的例子都是使用 C 這個語言,但是對於現代的程式語言例如 C++、C#、Python、JavaScript 還有指標嗎?答案是肯定的,但是因為直接操作指標非常的容易出錯,所以現在都會把指標「包裝」一下,在我沒有告訴你之前一也不會發現到原來每個語言都有這個功能。
先從我最熟悉的 C++ 開始,C++ 引入了標準函式庫,當中就有非常多的功能使用到了指標來實作,而且操作上也真的很像是在操作指標一樣,但是它更安全更不容易出錯。大家在第一次從 C 換到 C++ 一定是被一個叫做 array 的類型給驚訝到了,在以前很麻煩的事都可以使用 array 來解決,而且還有加強版的 array 叫做 vector。除了上面新工具別被引入,還有一個名為 algorithm 的標頭檔,裡面就有超級多的方法用來操作 array、vector 等等的新類型。下面的範例新建了一個 vector,並且使用 sort 對其進行排序。
|
|
上面的程式碼重點在於那個 sort,可以發現到裡面的引數使用了 arr.begin() 和 arr.end(),這個其實是名為迭代器的東西,它的用法就跟原先的指標類似,但是它並不完全是指標,而這種 begin end 的方式在 C++ 20 有了重大的改變,類似指標的操作慢慢的消失了。
C++ 還有一個新的特性稱為「引用」,下面這就是一個引用的範例,這是一個更安全的方法,可以避免掉一些先前所說的錯誤,當 $ref_k$ 改變值的時候其實改變的也是 $k$ 的值。
|
|
我還記得前陣子有人給我看了一段 Python 的程式碼,裡面有一段寫到把 print 這個方法當成變數對它賦值,那個人所問的問題就是為什麼可以這樣?其實上面我一直有提到一件事,指標代表的其實就是一個變數、陣列的記憶體空間,但我沒有提到的是函數也可以是指標!這個在 C 稱為函數指標,Python 在這邊其實就有點這種概念,但因為沒有去讀過 Python 的底層實作,這點我無法確定是否真的如此。
Python 另一個讓初學者非常困惑的程式碼如下,我宣告了一個 list 之後將其傳入一個函數,那麼請問輸出為何?
|
|
下方是執行的結果
你有猜到嗎?如果你是一個初學者應該會覺得這也太奇怪了吧,為什麼函數可以操作到原來的 list?但如果上面的範例你有認真看完的話,應該就會知道這邊傳的其實是那個 arr 的指標,也就是分配到的空間的第一格的編號,所以這邊的操作其實有沒有寫成函數都是一樣的。但為什麼 list 這麼奇怪傳的是指標?其實是因為如果每呼叫一次就複製一次,那麼每次都要遍歷一次 list 的內容,而且會花費掉額外的空間,但值得注意的是變數在 Python 是會直接複製一個新的變數過去,而不是指標。下方的程式碼和上面類似,但是在 for 迴圈的地方改成另一種方式,那麼請問輸出為何?先自己想一下再看解答喔(解答在本頁最後)
|
|
到這邊你應該就知道什麼時候會傳遞指標什麼時候會傳新的變數了吧,其實這就是「傳址」跟「傳值」的差別。在很多地方都會遇到跟指標概念類似的例子,除了上面的例子還有 Javascript 的事件如果要接自訂的函數,接的函數為什麼不要加上括號?其實就是因為傳遞的是函數本身,而不是執行的結果,這邊其實也有函數指標的概念,只是被包裝起來了而以。目前現代化的語言應該都不會看到像 C 那種非常原生的指標了,但它的概念沒有消失只是以其它方式出現而以。
結論
感謝你能看到這裡,指標是一個不太容易跟初學者用懂的東西,因為概念有點抽象,但是在幾乎所有的程式語言或多或少還是可以看到指標的影子,因此這篇用了簡單易懂的例子來介紹這個抽象的概念,裡面的例子只是一小部分,一定要自己去程式語言玩玩看,自己摸摸看才能記得久並且能活用。最後,希望大家都能夠學會指標,如果有問題也歡迎留言。
解答
C++
1 2 3 4 5 6 7 8 9 10
#include <stdio.h> #include <stdlib.h> int main() { int a; int* p = &a; a = 5; *(p) = 10; printf("%d\n", a); }
上面的程式碼宣告了 $a$ 的變數指標 $p$,並且對 $p$ 改值為 $10$,因此 $a$ 的值就變成了 $10$。
這其實也是 C++ 引用的概念喔!
Python
1 2 3 4 5 6 7
def add(a: list, n: int): for i in a: i += 5 arr = [1,2,3,4,5,6] add(arr, 10) print(arr)
這題比較難,但如果你有注意到我說的「但值得注意的是變數在 Python 是會直接複製一個新的變數過去,而不是指標」這句話,那麼就會發現到陣列根本不會改變,因為這邊的 for 迴圈會直接開一個新的變數去改變,本來的程式是使用陣列的索引去改變陣列的值,而這邊是把陣列的每個元素拿出來改變,因此不會去動到本來陣列的元素。
執行結果如下