變數與資料型別
從「0.1 + 0.2 不等於 0.3」出發,看懂變數、型別系統與型別安全的全貌
為什麼 0.1 + 0.2 不等於 0.3?
打開任何一個程式語言的互動式介面,輸入 0.1 + 0.2,你很可能會看到一個令人困惑的答案:0.30000000000000004。這不是電腦壞了,也不是語言設計失誤,而是「資料型別」這個看似平凡的概念,正在悄悄影響著每一行程式的行為。當我們在程式裡寫下一個名字、給它一個值,背後其實牽動著記憶體配置、型別判定、運算規則與安全保證等一整套機制。理解變數與型別,等於拿到了理解所有程式語言的第一把鑰匙。

變數:給記憶體中的一塊空間取個名字
電腦的記憶體(memory)可以想像成一條長長的儲物櫃,每一格都有編號(也就是記憶體位址(address))。程式要記住一個數值、一段文字或一個結果,就得佔用其中某些格子。但是要程式設計師記住「我的成績存在第 0x7ffe3a 格」實在太不人道了,於是有了變數(variable)——它是一個我們取的名字,背後對應到某塊記憶體空間。
score = 95
這一行做了三件事:在記憶體中準備一塊空間、把整數 95 放進去、讓名字 score 指向這塊空間。之後我們只要寫 score,語言就會幫我們去那個位址把值取回來。這就是「具名儲存」的核心價值:用人類友善的名字,取代冷冰冰的位址。
不同語言對「變數到底是什麼」有不同的觀點。在 C 這類語言裡,變數比較像「一個盒子」,score 直接就是那塊裝著 95 的記憶體。在 Python 這類語言裡,變數更像「一張貼紙(label)」,score 是貼在某個物件上的標籤,物件本身存活在別處。這個差異會在後面討論可變性與賦值時浮現出來。
資料型別:值的「種類」決定了能對它做什麼
如果記憶體只是一堆 0 與 1,那麼同樣一段位元,要怎麼知道它代表整數 65、字元 'A'、還是某個顏色?答案是資料型別(data type)。型別替一段位元賦予意義,同時規定了「可以對它做哪些運算」。
常見的基本型別(primitive type)包括:
| 型別 | 說明 | 範例 |
|---|---|---|
| 整數(integer) | 沒有小數的數 | 42、-7 |
| 浮點數(float) | 帶小數的數,以 IEEE 754 表示 | 3.14、0.1 |
| 布林(boolean) | 真或假 | True、False |
| 字元/字串(string) | 文字 | 'A'、"hello" |
型別不只是分類標籤,它決定了運算的語意。5 + 3 是數值相加得到 8;但 "5" + "3" 在許多語言裡是字串串接,得到 "53"。同一個 + 符號,遇到不同型別就有不同行為——這正是為什麼型別系統如此重要。
回到開頭那個 0.1 + 0.2 的謎題。浮點數採用 IEEE 754 標準,用有限的二進位位元去近似一個十進位小數。問題是 0.1 在二進位裡是無限循環小數(就像 $1/3$ 在十進位裡是 $0.333\ldots$),電腦只能存一個非常接近但不完全等於 0.1 的值。兩個近似值相加,誤差就浮現了。這提醒我們:浮點數是近似值,不該用 == 直接比較,而要檢查兩數差距是否小於某個極小容忍值 $\varepsilon$。
靜態型別 vs 動態型別:什麼時候檢查型別?
程式語言對型別的「檢查時機」分成兩大陣營。
靜態型別(static typing)在程式編譯或執行前就確定每個變數的型別,例如 C、Java、Go、Rust。型別錯誤在編譯階段就會被攔下來:
int score = 95;
score = "hello"; // 編譯錯誤:不能把字串塞進整數變數
動態型別(dynamic typing)則在執行(runtime)時才檢查型別,例如 Python、JavaScript、Ruby。同一個變數可以先裝整數、後裝字串:
score = 95 # 此刻 score 是整數
score = "hello" # 完全合法,現在 score 是字串
兩者各有取捨。靜態型別能在程式上線前就抓出許多錯誤,IDE 也能據此提供精準的自動補全與重構,代價是程式碼較囉嗦、開發節奏較慢。動態型別寫起來輕快靈活、適合快速原型,代價是某些型別錯誤要等到程式跑到那一行才爆炸。值得注意的是,「靜態/動態」說的是檢查時機,和接下來要談的「強/弱」是兩個獨立的維度。
強型別 vs 弱型別:型別規則有多嚴格?
強型別(strong typing)與弱型別(weak typing)講的是:語言願意幫你做多少「悄悄的型別轉換」。
弱型別語言會在你混用型別時自作主張地轉換。JavaScript 是著名例子:
"5" + 3 // 得到 "53"(數字被轉成字串)
"5" - 3 // 得到 2 (字串被轉成數字)
true + 1 // 得到 2 (true 被當成 1)
同一批值,遇到 + 和 - 竟有截然不同的結果,這種「隱式轉換(implicit coercion)」常是 bug 的溫床。
強型別語言則拒絕這種曖昧。Python 雖然是動態型別,卻是強型別:
"5" + 3 # TypeError:不能把字串和整數相加
它寧可直接報錯,逼你明確表態到底要數值相加還是字串串接。所以我們可以把四種組合畫成一個 2×2 的座標:Python 是「動態 + 強」,C 是「靜態 + 弱(允許不少隱式轉換)」,Java 是「靜態 + 偏強」,JavaScript 是「動態 + 弱」。這兩個維度交織出每個語言獨特的「型別個性」。
運算子與優先序:先乘除後加減的程式版本
當運算式裡有多個運算子(operator)時,誰先算?這由優先序(precedence)決定,規則和數學課的「先乘除後加減」一脈相承,但範圍更廣。
result = 2 + 3 * 4 # 等於 14,不是 20,因為 * 先算
result = (2 + 3) * 4 # 用括號改變順序,等於 20
除了優先序,還有結合性(associativity)處理「同優先序時從哪邊開始算」。多數二元運算子是左結合,例如 8 - 3 - 2 解讀為 (8 - 3) - 2 = 3;但指數運算通常右結合,2 ** 3 ** 2 是 2 ** (3 ** 2) = 512。常見優先序由高到低大致是:括號 → 指數 → 乘除取餘 → 加減 → 比較 → 邏輯運算。當你不確定時,最好的策略不是去背完整的優先序表,而是主動加括號讓意圖清晰——對讀程式的人也更友善。
動手看一個例子
讓我們追蹤一段混合了型別與運算子的程式,一步步看型別如何影響結果。
a = 7
b = 2
print(a / b) # 3.5
print(a // b) # 3
print(a % b) # 1
print(a > b and b > 0) # True
逐步拆解:
a / b:在 Python 3 中,/是「真除法」,即使兩個運算元都是整數,結果也會是浮點數3.5。注意這裡發生了型別提升:整數運算的結果變成了浮點型別。a // b://是「整數除法」,捨去小數部分得到3。a % b:%是取餘數,$7 = 2 \times 3 + 1$,所以餘1。a > b and b > 0:先算兩個比較運算(True與True),因為比較的優先序高於and,最後邏輯and得到True。
光是除法就有 / 和 // 兩種,且回傳型別不同——這正凸顯了「運算子的行為深受型別與語言設計左右」。同樣一行 7 / 2,在某些舊語言或 C 的整數運算裡會直接得到 3,差異足以造成難以察覺的 bug。
型別轉換:把一種型別變成另一種
實務上我們常需要在型別之間轉換,分成兩類。
隱式轉換(implicit / coercion)由語言自動完成,例如整數參與浮點運算時自動升級為浮點數(前述 7 / 2 即是)。方便,但若發生在意料之外就是 bug 來源。
顯式轉換(explicit / casting)由程式設計師明確要求:
age_text = "25" # 字串
age = int(age_text) # 顯式轉成整數 25
age_next = age + 1 # 26,合法
price = float("19.9") # 字串轉浮點 19.9
label = str(100) # 整數轉字串 "100"
從使用者輸入、檔案或網路讀進來的資料通常是字串,要做數值運算前一定得先轉型。轉型也可能失敗:int("hello") 會丟出錯誤,所以穩健的程式會搭配例外處理。另外要警覺窄化轉換(narrowing)的資訊遺失:把浮點數 3.9 轉成整數會變 3(直接捨去小數而非四捨五入),這種隱藏的精度損失若沒注意,會在帳務或計量場景釀成問題。
重點回顧
- 變數是記憶體空間的具名標籤;C 視之為「盒子」,Python 視之為「貼在物件上的標籤」,這個心智模型差異會影響賦值與可變性的理解。
- 型別替位元賦予意義並規定可用運算;同一個
+遇到數字是相加、遇到字串是串接。浮點數是 IEEE 754 近似值,不可用==直接比較。 - 靜態/動態講的是「何時檢查型別」,強/弱講的是「容不容許隱式轉換」,兩者正交,組合出語言的型別個性(如 Python 是動態強型別)。
- 運算子優先序與結合性決定運算順序;不確定時主動加括號勝過硬背規則。
- 轉型分隱式與顯式;外部輸入多為字串需顯式轉型,並提防窄化轉換造成的精度遺失與轉型失敗例外。
深入探討(研究所視角)
從工程概念往上走,型別系統其實是一個有深厚理論根基的領域。
型別系統作為輕量級形式驗證。 在程式語言理論中,型別系統可視為一套證明系統。著名的型別安全定理「Well-typed programs cannot go wrong」(Milner, 1978)由兩個性質撐起:Progress(型別正確的程式不會卡在無意義的中間狀態)與 Preservation(求值一步後型別不變)。兩者合起來即 Soundness(健全性)——只要通過型別檢查,就保證執行期不會出現「把整數當函式呼叫」這類型別錯誤。這呼應 Curry–Howard 同構:型別即命題,程式即證明,編譯通過等於完成了一個機械化的證明。型別安全因此是一道實打實的安全防線,能在編譯期排除一整類記憶體與行為錯誤——這也是 Rust 透過所有權(ownership)型別系統,在沒有垃圾回收的前提下達成記憶體安全的理論依據。
型別推論:要安全也要不囉嗦。 靜態型別最常被詬病的是要寫一堆型別標註。型別推論(type inference)讓編譯器自己推導出型別,兼顧安全與簡潔。其經典演算法是 Hindley–Milner(Algorithm W),運作核心是統一(unification):把運算式中的未知型別當成變數,依使用方式建立一組約束方程,再解聯立。例如看到 f x = x + 1,從 +1 推出 x 必為數值,於是 f 的型別被推定為 數值 -> 數值,全程不需人工標註。Algorithm W 在多數情況下接近線性,理論最差複雜度雖達指數,但實際少見。今日 Haskell、ML、Rust、TypeScript、乃至 C++ 的 auto、Java 的 var,都是這套思想的後代。
undefined:型別系統的破口與設計教材。 JavaScript 的 undefined 是觀察型別安全如何被打破的絕佳案例。它代表「這個位置從未被賦值」——存取未宣告變數、讀取物件不存在的屬性、函式沒回傳值,結果都是 undefined。問題在於 undefined 會悄悄參與運算而不立刻報錯:undefined + 1 得到 NaN(Not a Number),錯誤被掩蓋並向後傳播,直到很遠的地方才以難以追溯的形式爆發。再加上它和 null 兩個「空值」並存,造就了無數 bug。Tony Hoare 把 null 的發明稱為「十億美元的錯誤」,正是這類問題的縮影。現代語言的回應是把「可能為空」這件事搬進型別系統強制處理:Haskell 的 Maybe、Rust 的 Option<T>、Kotlin 的可空型別 T?、TypeScript 的 strictNullChecks 與聯合型別 T | undefined,都逼迫程式設計師在編譯期就明確處理「沒有值」的分支,把執行期的隱性崩潰提前轉化為編譯期的顯性檢查。這正是型別推論與型別安全在真實語言演化中的交會點:一個好的型別系統,不只是替值分類,更是用最低的標註成本,把整類錯誤擋在程式上線之前。