終極真相:Go 中的參數傳遞
在 Go 社區常能聽到“按值傳遞”“按引用傳遞”兩種說法:
- 基本類型、數組、結構體被稱為“按值”;
- 指針、切片、映射、通道則被稱為“按引用”。
然而,上述分類容易造成誤解。在 Go 語言中,一切函數實參都以 值 的形式被復制傳遞。區別僅在于:
- 復制的是“完整數據”(整數、數組等);
- 復制的是“描述符”或指針(切片、映射、字符串等)。
理解這一點后,再看各類示例便能水落石出。
純值類型:數組、結構體等
func do(b [3]int) int {
b[0] = 0 // 只修改副本
return b[1]
}
func main() {
a := [3]int{1, 2, 3}
v := do(a) // 傳參時完整復制
fmt.Println(a, v) // [1 2 3] 2
}
- a 在調用處被整體復制后傳入 do;
- do 內部的任何修改都作用于該副本,原始數組保持不變。
- 若想在被調函數內部改動調用者的數組,需改為 *[3]int。
描述符類型:映射(map)
func do(m map[int]int) {
m[3] = 1
m[4] = 3
}
func main() {
m := map[int]int{4: 2}
do(m) // 僅復制 map 頭部(指針)
fmt.Println(m) // map[3:1 4:3]
}
- 傳入的 是一個指向運行時哈希表的指針副本;
- 副本與原指針指向同一底層數據,因此鍵值修改對調用者可見;
- 若在函數內執行 m = make(map[int]int),只會修改本地副本,不影響調用者。
切片:三字節描述符的特殊性
func do(s []int) int {
s = append(s, 4) // 可能觸發重新分配
s[0] = 0
return s[1]
}
func main() {
a := []int{1, 2, 3} // len=3 cap=3
v := do(a) // 復制切片頭部
fmt.Println(a, v) // [1 2 3] 2
}
- 切片頭部 = 指針 + 長度 + 容量。
- append 時容量不足會 分配新數組 并返回新的切片頭部;
- 該新頭部僅存在于 do 中,調用者仍指向舊數組,因此 a[0] 未被修改。
若確需影響調用方切片的長度/容量,可顯式傳入 *[]T:
func do(s *[]int) {
*s = append(*s, 4) // 直接改寫調用者變量
(*s)[0] = 0
}
參數永遠“不是別名”
Go 永遠復制實參,將其存入被調函數棧幀;函數內變量 絕不是調用方變量的別名。想要修改調用者的數據:就傳遞能“間接定位”到它的東西(指針或接口值的內部指針)。
類型 | 傳遞時被復制的內容 | 何時能改到調用方數據 | 何時改不到 |
基本類型 | 整個值 | 無 | 總改不到 |
數組 | 整個數組 | 無 | 總改不到 |
結構體 | 整個結構體 | 無 | 總改不到 |
指針 | 指針本身 | 解引用后可改 | 修改指針本身 |
切片 | 指針+len+cap 描述符 | 修改底層數組元素 | 重新分配、改頭部 |
映射 | 指向哈希表的指針 | 改鍵值對 | 重新 make |
通道 | 指向 channel 結構的指針 | 發/收消息 | 重新 make |
字符串 | 指針+len 描述符(數據只讀共享) | 無(只讀) | 任何寫操作 |
結論與建議
(1) 牢記:Go 只有按值傳遞。不要再談“按引用”,最多是“值里裝著指針”。
(2) 修改調用者數據的途徑只有兩種:
- 傳遞指針(*T、**T……);
- 傳遞內部含指針的描述符(切片、map、通道)并操作其指向的共享數據。
(3) 重新分配(append、make 等)僅改變局部副本頭部,不會回寫調用方。
(4) 若你需要讓函數“生長”切片或重新綁定 map,使用指針語義:func f(s *[]T) 或 func g(m *map[K]V)。
正確理解參數傳遞語義,可避免誤判內存行為、消除隱蔽 bug,使代碼行為更加可預測。