บทที่ 5 · Part 2 — Opinionated Go Style Guide
Values, Initialization and Control Flow
เลือก declaration, zero value, nil, copying, defer และ control flow โดยเห็น semantics ที่ซ่อนอยู่
bug หลายชนิดใน Go ซ่อนอยู่หลัง syntax ที่ดูเรียบง่าย := อาจสร้างตัวแปรใหม่โดยไม่ตั้งใจ, assignment ของ
slice อาจยังแชร์ backing array, nil channel อาจ block ตลอดไป และ defer ใน loop อาจกัก connection จนหมด
pool เรื่องเหล่านี้ถูกเรียกว่า style ได้เพราะมีรูปแบบที่ทีมควรใช้สม่ำเสมอ แต่เหตุผลข้างใต้คือ semantics และ
correctness ไม่ใช่ความสวยงาม
บทนี้รวมคำแนะนำด้าน declaration, zero value, nil, composite value, copying, control flow และ resource cleanup จาก Google และ Uber แล้วปรับเป็น default สำหรับ Go ปี 2026 ทุกข้อเริ่มจาก intent ที่ต้องสื่อ ไม่ใช่ จากคำว่า “always” ใน style guide
จบบทนี้คุณจะ
- เลือก
var,:=,newและmakeโดยให้ initialization บอก intent - แยก copy ของ struct ออกจาก aliasing ของ slice, map, pointer และ interface
- ออกแบบ nil/empty และ zero value เป็น boundary contract
- เขียน early return, switch และ defer โดยไม่ซ่อน cleanup หรือ error
Declaration ให้เห็น State เริ่มต้น
ใช้ := เมื่อสร้างค่าที่มีความหมายจาก expression และ type ชัดจากด้านขวา ใช้ var เมื่อ zero value คือ state
ที่ต้องการ หรือเมื่อต้องประกาศตัวแปรก่อน branch หลายทาง:
users := make([]User, 0, len(rows))
var cached Entry // zero value หมายถึงยังไม่มีค่าจาก cache
var err error
if local {
cached, err = loadLocal()
} else {
cached, err = loadRemote(ctx)
}
อย่าเขียน var ready = false, count := 0 หรือ name := "" ถ้า zero value บอก intent ได้อยู่แล้ว ในทางกลับกัน
อย่าบังคับ var client = NewClient() เพียงเพื่อ uniformity เพราะ constructor result อ่านตรงกว่าด้วย :=
ปัญหาสำคัญกว่า style คือ shadowing:
result, err := load(ctx)
if err != nil {
return Result{}, err
}
if result.NeedsRefresh() {
result, err := refresh(ctx, result) // สร้าง result และ err ใหม่ใน block
if err != nil {
return Result{}, err
}
_ = result
}
return result, nil // ยังเป็นค่าเดิม
แก้ด้วย assignment เมื่อเจตนาคือเปลี่ยนตัวแปรเดิม และประกาศค่าที่เกิดใหม่แยกให้เห็น:
if result.NeedsRefresh() {
result, err = refresh(ctx, result)
if err != nil {
return Result{}, err
}
}
Course default จึงไม่ใช่ “ใช้ := ให้มากที่สุด” แต่คือให้ scope ของค่าตรงกับ lifecycle ที่ผู้อ่านคาด
new, make และ Composite Literals
new(T) คืน *T ที่ชี้ไปยัง zero value ส่วน make สร้าง runtime representation ของ slice, map หรือ
channel เลือกจากชนิดและ semantics ไม่ใช่จากความคุ้นมือ:
cache := make(map[string]Entry, expected)
queue := make(chan Job, capacity)
batch := make([]Entry, 0, expected)
ไม่ใช้ new([]Entry) หรือ pointer-to-map เพื่อ “ประหยัด copy” เพราะ slice/map value เป็น descriptor ขนาดเล็ก
อยู่แล้ว pointer เพิ่ม nil state และ indirection โดยไม่แยก ownership
เมื่อสร้าง struct นอก package ของตัวเอง ให้ระบุชื่อ field เสมอ Positional literal ผูก caller กับ field order และอาจพังเงียบเมื่อ type เปลี่ยน สำหรับ unexported struct ใน test สั้น ๆ positional literal อาจยอมรับได้ถ้า fields น้อยและอยู่ใกล้ declaration แต่ named fields เป็น default ที่ปลอดภัยกว่า
cfg := Config{
Address: ":8080",
Timeout: 5 * time.Second,
}
ไม่ต้องใส่ field ที่เป็น zero value เพียงเพื่อให้ครบทุกช่อง เว้นแต่ zero นั้นกำกวมจนควรมี comment หรือ type ใหม่ เช่น duration = 0 อาจหมายถึง “disabled” หรือ “no timeout” ซึ่งต้องประกาศใน contract
Zero Value ที่ดีลด Constructor แต่ไม่ใช่ข้อบังคับ
zero value ที่ใช้ได้ทันทีเป็นคุณสมบัติที่ดี เช่น bytes.Buffer, sync.Mutex และ slog.Logger บางรูปแบบ แต่
ไม่ควรบิด domain เพื่อให้ทุก type มี zero value ที่ valid ถ้า Currency ว่างหรือ Endpoint ว่างผิด invariant
constructor ที่ validate ชัดกว่าการปล่อยให้ fail ใน request แรก
| Type | zero value | Course default |
|---|---|---|
| counter/buffer | ใช้งานได้ | ไม่ต้องมี constructor |
| registry ที่มี map | lazy init ใน method หรือ constructor | เลือกตาม concurrency และ failure |
| config จากภายนอก | ยังไม่ validate | parse และ validate ที่ startup |
| domain ID | มัก invalid | ให้ method IsZero หรือ constructor บอก invariant |
อย่า lazy-open database ด้วย sync.Once ถ้า operation อาจ fail แล้วต้อง retry หรือ report readiness Once เหมาะ
กับ initialization ที่ผลลัพธ์คงที่และ failure policy ชัด ไม่ใช่เครื่องมือซ่อน lifecycle
Nil เป็น State ของ Contract
nil slice อ่าน, len, range และ append ได้ แต่ nil map เขียนแล้ว panic และ nil channel ส่งหรือรับแล้ว block
ตลอดไป ความต่างนี้ต้องอยู่ใน mental model:
| ค่า nil | อ่าน/วน | เขียน/ส่ง | ความเสี่ยงหลัก |
|---|---|---|---|
[]T | ได้ | append ได้ | JSON อาจเป็น null ไม่ใช่ [] |
map[K]V | lookup/range ได้ | assignment panic | ลืม initialize ก่อน mutate |
chan T | block | block | goroutine leak หรือ shutdown ค้าง |
*T | dereference panic | dereference panic | optional state ไม่ถูกตรวจ |
ภายใน package ใช้ nil slice แทน empty ได้ถ้าไม่แยกความหมาย แต่ที่ JSON/OpenAPI boundary ต้องให้ schema
ตัดสินว่า client เห็น null, [], omitted field หรือ error อย่า normalize ตามรสนิยมของ handler แต่ละตัว
interface เป็นคู่ของ dynamic type และ value ดังนั้น interface ที่ถือ typed nil pointer ไม่เท่ากับ nil:
func handler(enabled bool) http.Handler {
if !enabled {
return nil
}
return &customHandler{}
}
อย่าประกาศ var h *customHandler แล้ว return h ในกรณี disabled เพราะ interface จะมี type อยู่แม้ value
ข้างในเป็น nil
Copy, Alias และ Ownership Boundary
Go ส่ง argument แบบ value เสมอ แต่สิ่งที่ถูก copy ต่างกัน struct และ array copy สมาชิกทั้งหมด ส่วน slice copy header, map/channel/pointer copy reference และ interface copy type/value pair จึงยังอาจเข้าถึง storage เดิม
type Config struct {
tags []string
}
func NewConfig(tags []string) Config {
return Config{tags: slices.Clone(tags)}
}
func (c Config) Tags() []string {
return slices.Clone(c.tags)
}
clone ตอนรับถ้าจะ retain input และ clone ตอนคืนถ้าไม่ต้องการเปิด internal state ถ้า performance สำคัญ อาจเลือก
immutable convention หรือ ownership transfer ได้ แต่ต้อง document และพิสูจน์ด้วย profile ไม่ใช่ปล่อย alias
แบบไม่ตั้งใจ maps.Clone และ slices.Clone ทำให้ intent ชัดขึ้น แต่เป็น shallow copy; pointer หรือ nested slice
ข้างในยังแชร์กัน
append อาจ reuse backing array การสร้าง slice ใหม่จาก input จึงต้องรู้ว่าต้องการ share หรือ independent:
out := append([]Entry(nil), in...)
// หรือ slices.Clone(in)
type ที่มี sync.Mutex, sync.Once, atomic wrapper หรือ strings.Builder ต้องไม่ถูก copy หลังเริ่มใช้งาน กรณีนี้
ใช้ pointer ไม่ใช่เพราะ struct “ใหญ่” แต่เพราะ copy ทำลาย synchronization invariant ให้ go vet ช่วยตรวจ
Pointer ไม่ใช่ Performance Switch
อย่าตั้งกฎว่า “struct ใหญ่ต้องส่ง pointer” โดยไม่มีบริบท Pointer อาจลด bytes ที่ copy แต่เพิ่ม aliasing, escape, GC work และทำให้ callee mutate ค่าได้ ก่อนเลือกให้ถาม:
- function ต้อง mutate หรือเก็บค่าไว้หลัง return หรือไม่
- type มี identity หรือ no-copy member หรือไม่
- copy cost อยู่ใน hot path ที่วัดแล้วหรือไม่
- pointer เพิ่ม nil state ที่ทุก caller ต้องจัดการหรือไม่
small immutable value เช่น time.Time, ID หรือ money value มักส่งเป็น value แม้มีหลาย field large read-only struct
ที่เรียกถี่อาจใช้ pointerหลัง benchmark ส่วน slice/map/function/channel ส่ง value อยู่แล้ว การใช้ *[]T หรือ
*map[K]V มักทำให้ API ยากขึ้นโดยไม่ลด backing-store copy เพราะเดิมก็ไม่ได้ copy elements
รายละเอียดเรื่อง receiver, escape และ benchmark อยู่ใน Values, Pointers and Ownership
Control Flow ให้ Happy Path แบน
จัดการ error และ exceptional condition ก่อนด้วย early return เพื่อลด nesting:
func process(ctx context.Context, job Job) error {
if err := validate(job); err != nil {
return fmt.Errorf("validating job: %w", err)
}
if ctx.Err() != nil {
return ctx.Err()
}
return execute(ctx, job)
}
ไม่ต้องมี else หลัง branch ที่ return แล้ว switch ไม่ต้องใส่ break ท้าย case เพราะ Go ไม่ fallthrough
โดย default ใช้ initializer ใน if เมื่อค่ามีประโยชน์เฉพาะ branch นั้น แต่ถ้าต้อง inspect หลัง branch ให้
ประกาศนอกเพื่อไม่ให้เกิด shadowing
comma-ok type assertion ใช้เมื่อ mismatch เป็น state ที่คาดหมายได้ เช่น optional capability ถ้า invariant ภายใน รับรอง type แน่นอน การ assertion ตรงพร้อม comment หรือ helper ที่ panic อาจเหมาะกว่า การบังคับ comma-ok ทุกครั้ง อาจกลืน programmer bug ให้กลายเป็น zero value
สำหรับ enum อย่ารับกฎ “เริ่มที่ 1 เสมอ” แบบอัตโนมัติ ให้กำหนดความหมายของ zero ก่อน ถ้า zero คือ unknown ให้
ตั้งชื่อ StatusUnknown; ถ้า zero เป็น state ที่ใช้ได้และช่วย zero-value type ก็เริ่มจาก state นั้นได้ ห้ามทิ้ง
zero แบบไม่มีชื่อแล้วหวังว่า validation ทุกทางจะจำได้
Defer และ Cleanup ต้องอยู่ใน Scope ที่ถูกต้อง
เรียก defer ทันทีหลัง acquire resource สำเร็จเพื่อให้ cleanup อยู่ใกล้กัน แต่จำว่า arguments ของ deferred call
ถูกประเมินตอนประกาศ และ call รันเมื่อ function คืน ไม่ใช่ท้าย loop iteration
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening %q: %w", path, err)
}
defer f.Close()
return decode(f)
}
ถ้าทำใน loop ให้แยก body เป็น function ไม่อย่างนั้น files/rows/connections จะค้างถึงท้าย outer function
สำหรับ writer, transaction, buffered writer หรือ file ที่ durability สำคัญ อย่าทิ้ง error จาก Close, Flush
หรือ Commit ใน defer แบบเงียบ ๆ อาจใช้ named error อย่างระมัดระวังหรือเรียก close ชัดก่อน return แล้วให้ defer
เป็น fallback
panic ใน cleanup ไม่ควรกลบ original error และ recover ควรอยู่ที่ process/goroutine boundary ที่สามารถแปลง
panic เป็น failure ที่มีบริบทได้ ไม่ใช้ recover เพื่อเดินหน้าต่อใน state ที่ไม่รู้ว่ายัง valid หรือไม่
Strings, Time และ Naked Arguments
ใช้ + กับข้อความสั้นที่เป็นชิ้นคงที่, fmt.Sprintf เมื่อกำลัง format ค่า และ strings.Builder เมื่อประกอบทีละ
ชิ้นใน loop การเลือกนี้สื่อ construction shape ก่อน performance; ถ้าอยู่ใน hot path ให้ benchmark จริง Format
string ควรเป็น constant เมื่อทำได้เพื่อให้ go vet ตรวจจำนวนและชนิด argument และใช้ %q ใน diagnostic/test
เมื่อ whitespace หรือ control character มีความหมาย
raw string literal เหมาะกับ SQL, regexp หรือ multiline text ที่ backslash escaping ทำให้อ่านยาก แต่ต้องรู้ว่า มันเก็บ newline/indentation ตาม source อย่าใช้เพียงเพื่อหลีกเลี่ยง quote 2 ตัว
ใช้ time.Time แทน instant และ time.Duration แทนระยะเวลา อย่าส่ง int เปล่าแล้วหวังว่าทุกคนเดาว่าเป็น
milliseconds ที่ external JSON/config boundary ต้องตั้งชื่อ unit หรือ parse duration string ให้ชัด งาน calendar
เช่น “เดือนหน้า” ใช้ AddDate; งาน elapsed time ใช้ Add กับ duration เพราะ 1 เดือนไม่ใช่จำนวนชั่วโมงคงที่
argument ที่ call site อ่านไม่รู้เรื่อง เช่น Open(path, true, false, 30) เรียกว่า naked arguments Comment
/* retry */ true ช่วยซ่อมเฉพาะจุด แต่ถ้าเป็น API ที่แก้ได้ให้ใช้ domain type, config struct หรือ named constants
แทน อย่าเปลี่ยนทุก function ที่มี bool เดียวเป็น options frameworkโดยอัตโนมัติ
size hint ของ slice/map ใช้เมื่อขนาดสุดท้ายรู้หรือประมาณได้อย่างมีเหตุผล ไม่ใช่ readability mandate Capacity ที่ มากเกินทำให้ memory retention สูง ส่วนที่น้อยกว่าจริงยังทำงานถูกต้อง เพียง allocate เพิ่ม Treat preallocation เป็น hypothesis ที่ benchmark/profile ได้ และไม่พึ่ง algorithm การโตของ runtime ซึ่งเปลี่ยนข้าม Go version ได้
Review Lab: หาความเสี่ยงที่ Formatter มองไม่เห็น
func Build(ids []string) *Plan {
var p = new(Plan)
p.ids = ids
for _, id := range ids {
f, _ := os.Open(id)
defer f.Close()
p.files = append(p.files, f)
}
return p
}
review ที่ดีควรพบ ignored error, retain caller-owned slice, resource lifetime ที่ไม่สอดคล้องกับ return type,
defer ใน loop และ constructor ที่คืน plan แม้เปิดไฟล์ไม่ครบ Contract ใหม่อาจให้ Build clone IDs, คืน
(*Plan, error), เปิด resource ตอน Run ภายใต้ context และให้ Plan.Close มี owner ชัด อย่ารีบแก้เพียง
เปลี่ยน new(Plan) เป็น &Plan{} เพราะนั่นเป็น cosmetic issue ที่เล็กกว่าปัญหา lifecycle
Checklist สำหรับ Values และ Flow
- declaration สื่อ zero/non-zero intent และไม่มี accidental shadowing
- nil/empty ที่ boundary ถูกกำหนดโดย schema หรือ API contract
- ทุก retained slice/map มี ownership decision
- type ที่มี synchronization primitive ไม่ถูก copy
- pointer ถูกเลือกเพราะ mutation, identity หรือ evidence ไม่ใช่เดาจากขนาด
- cleanup อยู่ใน scope ที่ resource ควรจบ และ error สำคัญไม่ถูกทิ้ง
- early return ลด nesting โดยยังเก็บ context ของ failure
บทนี้เรียบเรียงใหม่โดยอิง Google Go Style Decisions, Google Go Best Practices, Uber Go Style Guide, Go Specification และ Go Memory Model