บทที่ 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 แรก

Typezero valueCourse default
counter/bufferใช้งานได้ไม่ต้องมี constructor
registry ที่มี maplazy init ใน method หรือ constructorเลือกตาม concurrency และ failure
config จากภายนอกยังไม่ validateparse และ 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]Vlookup/range ได้assignment panicลืม initialize ก่อน mutate
chan Tblockblockgoroutine leak หรือ shutdown ค้าง
*Tdereference panicdereference panicoptional 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 ค่าได้ ก่อนเลือกให้ถาม:

  1. function ต้อง mutate หรือเก็บค่าไว้หลัง return หรือไม่
  2. type มี identity หรือ no-copy member หรือไม่
  3. copy cost อยู่ใน hot path ที่วัดแล้วหรือไม่
  4. 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