Skip to main content

Go 資料與記憶體分配 (Data)

整理自 Effective Go - Data,涵蓋 new / make複合字面量陣列 / 切片 / 映射append


1. 分配:newmake

函數用途回傳型別適用類型
new(T)分配已置零的記憶體*T(指標)任意型別
make(T, args)初始化內部結構T(值,非指標)slice、map、channel

new(T)

  • 只做「置零」,不呼叫建構函式;回傳的是指向零值的指標
  • 設計資料結構時,可善用「零值即可用」:例如 bytes.Buffersync.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

欄位大小說明
Pointer8 bytes指向底層陣列的起始位置
Len8 bytes目前視窗看到的元素個數
Cap8 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 的底層陣列
  • lencapnil slice 是合法的,回傳 0。
  • 長度可變,但不超過 cap;cap 用內建函數 cap(slice) 取得:
slice = slice[0 : l+len(data)]

Append 的動態擴容

append 時若 len 超過 cap,Go 會:

  1. 配置更大的記憶體(通常是原本容量的 2 倍或 1.25 倍,視版本與大小而定)
  2. 拷貝原有資料到新記憶體
  3. 指向新陣列並回傳新的 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,長度取兩者最小
刪除索引 ia = 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)
}

參考