Go 資料與記憶體分配 (Data)
整理自 Effective Go - Data,涵蓋 new / make、複合字面量、陣列 / 切片 / 映射 與 append。
1. 分配:new 與 make
| 函數 | 用途 | 回傳型別 | 適用類型 |
|---|---|---|---|
new(T) | 分配已置零的記憶體 | *T(指標) | 任意型別 |
make(T, args) | 初始化內部結構 | T(值,非指標) | 僅 slice、map、channel |
new(T)
- 只做「置零」,不呼叫建構函式;回傳的是指向零值的指標。
- 設計資料結構時,可善用「零值即可用」:例如
bytes.Buffer、sync.Mutex的零值都可直接使用。
p := new(SyncedBuffer) // type *SyncedBuffer
var v SyncedBuffer // 零值,可直接使用
make(T, args)
- slice、map、channel 底層是引用型別,必須先初始化(例如切片的 pointer、len、cap)才能用,因此用
make。 new([]int)得到的是「指向 nil slice 的指標」,實務上幾乎用make([]int, len, cap)。
// 不建議
var p *[]int = new([]int) // *p == nil
*p = make([]int, 100, 100)
// 建議
v := make([]int, 100)
2. 建構函式與複合字面量 (Composite Literals)
- 零值不夠用時,可用複合字面量建立並初始化實例。
- 回傳局部變數的位址在 Go 是安全的(編譯器會做逃逸分析)。
// 依欄位順序
f := &File{fd, name, nil, 0}
// 欄位: 值,順序可打散,未給的為零值
f := &File{fd: fd, name: name}
// 無欄位 = 零值,等價於 new(File)
&File{}
複合字面量也可用於 array、slice、map(標籤為索引或鍵):
a := [...]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
s := []string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
3. 陣列 (Arrays)
陣列是長度固定且索引連續的資料結構。與 C 的差異:
- 陣列是值:賦值會複製整個陣列;傳入函數也是複本,不是指標。
- 長度是型別的一部分:
[10]int與[20]int是不同型別。
宣告與初始化
// 先宣告後賦值(未給的為零值)
var a [3]int
a[0] = 10
// 字面量,長度寫死
b := [3]int{1, 2, 3}
// 編譯器推斷長度
c := [...]int{4, 5, 6, 7} // 長度為 4
// 指定索引賦值(複合字面量,索引為標籤)
d := [5]int{1: 10, 4: 40} // [0, 10, 0, 0, 40]
記憶體佈局
陣列在記憶體中是連續區塊,大小在編譯時就確定,存取快,但長度不可變。
遍歷
配合 for range:
arr := [3]string{"Go", "Rust", "C++"}
for i, v := range arr {
fmt.Printf("索引:%d, 數值:%s\n", i, v)
}
// 不需索引時用 _ 忽略
for _, v := range arr {
fmt.Println(v)
}
關鍵特性
- 值型別:賦值或當參數傳遞時會複製整個陣列;函數內修改不影響呼叫端。
- 長度不可變:需動態大小時應改用 Slice。
需要「像 C 一樣傳址」時,可傳指標;但慣用寫法是用 slice。
func Sum(a *[3]float64) (sum float64) {
for _, v := range *a {
sum += v
}
return
}
func main() {
array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array)
fmt.Println(x)
}
陣列 vs 切片
| 特性 | 陣列 [N]T | 切片 []T |
|---|---|---|
| 長度 | 固定 | 動態 |
| 傳遞/賦值 | 值拷貝 | 引用(header 為值) |
| 實務使用 | 固定格式、配置 | 絕大多數場景 |
Struct 陣列
標準寫法:先定義型別
type User struct {
ID int
Name string
}
users := [2]User{
{ID: 1, Name: "Alice"},
{ID: 2, Name: "Bob"},
}
匿名 Struct(臨時、單處使用)
config := [2]struct {
Endpoint string
Port int
}{
{Endpoint: "localhost", Port: 8080},
{Endpoint: "127.0.0.1", Port: 9000},
}
存放指標(大 struct 或需在傳遞後修改原物件)
type Item struct { Title string }
items := [2]*Item{
&Item{Title: "Book"},
&Item{Title: "Game"},
}
| 儲存方式 | 優點 | 缺點 |
|---|---|---|
[N]Struct | 連續記憶體、快取友善 | 大陣列賦值/傳參拷貝貴 |
[N]*Struct | 只拷貝指標 | 存取需尋址 |
巢狀初始化(內層型別可省略)
type Point struct { X, Y int }
points := [3]Point{
{1, 2}, {3, 4}, {5, 6},
}
實務建議: API、資料庫等動態資料多用
[]User(Slice);陣列適合固定長度配置或靜態資料。
4. 切片 (Slices)
- 切片是對底層陣列的引用;多個 slice 可指向同一陣列。
- 函數內對元素的修改,呼叫端會看到(等同於傳底層陣列的引用);但 slice 本身(header)是傳值,所以
append後要回傳新 slice 並重新賦值。 - 可以把 Slice 想像成一個視窗,它透視著底層的陣列。
內部結構 (The Header)
Slice 是一個很小的結構體(Header),在 64 位元架構下只佔 24 bytes:
| 欄位 | 大小 | 說明 |
|---|---|---|
| Pointer | 8 bytes | 指向底層陣列的起始位置 |
| Len | 8 bytes | 目前視窗看到的元素個數 |
| Cap | 8 bytes | 從指標位置開始算,底層陣列總共能裝多少元素 |
宣告與初始化
除了從陣列切出來,最常用的方式是 make:
// make(型別, 長度, 容量)
s := make([]int, 5, 10)
fmt.Println(len(s)) // 5
fmt.Println(cap(s)) // 10
// 快速宣告
nums := []int{1, 2, 3} // 自動建立長度 3、容量 3 的底層陣列
len、cap對 nil slice 是合法的,回傳 0。- 長度可變,但不超過 cap;cap 用內建函數
cap(slice)取得:
slice = slice[0 : l+len(data)]
Append 的動態擴容
當 append 時若 len 超過 cap,Go 會:
- 配置更大的記憶體(通常是原本容量的 2 倍或 1.25 倍,視版本與大小而定)
- 拷貝原有資料到新記憶體
- 指向新陣列並回傳新的 Header
陷阱: 多個 Slice 共享同一個底層陣列時,其中一個
append且未觸發擴容會修改到其他 Slice 的資料;觸發擴容後會「分家」,之後的修改互不影響。
三索引切片 (Full Slice Expression)
slice[low : high : max] 可限制子切片的容量,防止不小心覆蓋母切片後方的資料:
source := []int{0, 1, 2, 3, 4, 5}
// 長度 = 2 - 1 = 1, 容量 = 3 - 1 = 2
sub := source[1:2:3]
記憶體洩漏 (Memory Leak)
從巨大陣列切出一小塊時,那一小塊 Slice 仍持有對大陣列的指標,底層陣列永遠不會被 GC 回收。
錯誤範例:
var small []byte
func readHeader(f *os.File) {
bigBuf := make([]byte, 1024*1024) // 1MB
f.Read(bigBuf)
small = bigBuf[0:10] // small 雖只 10 bytes,但底層 1MB 陣列無法回收
}
解決方案(使用 copy):
small = make([]byte, 10)
copy(small, bigBuf[0:10]) // 複製後,bigBuf 可被回收
讀 取大 buffer 前 32 個 byte 的常見寫法(若只取前段且需長期持有,建議用 copy):
n, err := f.Read(buf[0:32])
常用技巧 (Cheat Sheet)
| 操作 | 寫法 | 說明 |
|---|---|---|
| 複製 | copy(dest, src) | 將 src 複製到 dest,長度取兩者最小 |
| 刪除索引 i | a = append(a[:i], a[i+1:]...) | 透過切片重組刪除中間元素 |
| 清空 | a = a[:0] | 長度歸零,保留底層陣列容量 (Reuse) |
| 檢查為空 | len(a) == 0 | 優先於 a == nil,空切片與 nil 切片長度皆 0 |
5. 二維切片 (2D Slices)
Go 只有一維的 array/slice,二維用「slice 的 slice」或「array 的 array」。
type Transform [3][3]float64 // 3x3 陣列
type LinesOfText [][]byte // 每行長度可不同
兩種分配方式:
方式一:逐行分配(每行可獨立成長/縮減)
picture := make([][]uint8, YSize)
for i := range picture {
picture[i] = make([]uint8, XSize)
}
方式二:一次分配再切(固定大小時較省、較快)
picture := make([][]uint8, YSize)
pixels := make([]uint8, XSize*YSize)
for i := range picture {
picture[i], pixels = pixels[:XSize], pixels[XSize:]
}
6. 映射 (Maps)
- 鍵:可用所有支援
==的型別(如 int、string、struct、array);slice 不可當鍵。 - 映射是引用型別:傳入函數並修改內容,呼叫端會看到變更。
- 取不存在的鍵會回傳該值型別的零值;需區分「不存在」與「值為零」時用 comma-ok。
var timeZone = map[string]int{
"UTC": 0, "EST": -5 * 3600, "CST": -6 * 3600,
}
offset := timeZone["EST"]
// 判斷鍵是否存在
seconds, ok := timeZone[tz]
if !ok {
log.Println("unknown time zone:", tz)
}
// 只檢查存在與否
_, present := timeZone[tz]
// 刪除鍵(鍵不存在也安全)
delete(timeZone, "PDT")
用 map 實作 set(值用 bool):
attended := map[string]bool{"Ann": true, "Joe": true}
if attended[person] {
fmt.Println(person, "was at the meeting")
}
7. 內建 append
簽名概念(T 由呼叫端型別決定,故為內建):
func append(slice []T, elements ...T) []T
- 在 slice 末尾追加元素,並回傳新的 slice(底層陣列可能重新分配,因此必須接收回傳值)。
- 可一次追加多個元素,或把另一個 slice 展開傳入。
x := []int{1, 2, 3}
x = append(x, 4, 5, 6) // [1 2 3 4 5 6]
y := []int{4, 5, 6}
x = append(x, y...) // 等同於上面
8. 格式化列印 (fmt) 簡要
Printf/Sprintf/Fprintf:需格式字串;Print/Println/Sprint/Sprintln:用預設格式。- 常用格式:
%v(預設值)、%+v(結構體帶欄位名)、%#v(Go 語法)、%T(型別)、%q(字串/rune 加引號)。 - 自訂型別預設輸出:實作
String() string;注意不要在String()內用%v/%s再印自己,否則會無限遞迴。
func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
參考
- Effective Go - Data (中英)
- 同目錄
heap_stack.md:Stack / Heap 與逃逸分析、new/make的記憶體面