บทเรียน 03 · Day 2 · Constants

const และ iota: สร้าง enum แบบ Go

~10 นาที · ต้องมี: บทเรียน 02

Go ไม่มี keyword enum — แต่มี iota ซึ่งทำงานเดียวกัน ด้วยกลไกที่แปลกกว่าที่คิด มันไม่ใช่ตัวนับ แต่เป็น "เลขบรรทัด" เข้าใจประโยคนี้ประโยคเดียว แล้ว iota จะเลิกลึกลับทันที

1 · const ต่างจาก var ยังไง

const Pi = 3.14          // ระดับ package ได้

func main() {
    const World = "World"
    const Truth = true
    fmt.Println("Hello", World, Pi, Truth)
}
constvar
แก้ค่าทีหลัง❌ ไม่ได้✅ ได้
ใช้ :=❌ ไม่ได้เลย✅ ได้ (ในฟังก์ชัน)
ค่าที่ใส่ได้ต้องรู้ตั้งแต่ compile timeอะไรก็ได้
ชนิดที่รองรับตัวเลข, string, bool, rune (ตัวอักษรหนึ่งตัว — ที่จริงคือ Unicode code point หนึ่งตัว)ทุกชนิด
กับดักที่ 1
const Now = time.Now()   // ✗ compile error!

time.Now() รู้ค่าตอน runtime ไม่ใช่ compile time → เป็น const ไม่ได้ const เก็บได้แค่ค่าที่ compiler คำนวณเองได้ตั้งแต่ตอน build
compile time = ตอนที่ Go แปลโค้ดของเราเป็นไฟล์ที่เครื่องรันได้ · runtime = ตอนที่โปรแกรมกำลังทำงานจริง · สองคำนี้ใช้บ่อยมากเวลาคุยกับ dev คนอื่น เช่น "มันพังตอน runtime ไม่ใช่ตอน compile"

2 · iota คืออะไรจริง ๆ

iota คือ "ลำดับของบรรทัด" ในบล็อก const นั้น — บรรทัดแรกของบล็อกคือ 0 บรรทัดถัดไปเพิ่มทีละ 1 และรีเซ็ตกลับเป็น 0 เมื่อขึ้น const block ใหม่
(ใน Go spec เรียกหนึ่งบรรทัดแบบนี้ว่า ConstSpec — ถ้าไปอ่าน spec แล้วเจอคำนี้ ให้แปลว่า "หนึ่งบรรทัดในบล็อก const")

const (
    zero = iota   // 0
    one           // 1  ← ไม่ต้องเขียน = iota ซ้ำ
    two           // 2
)

บรรทัดที่ 2 และ 3 ไม่ได้เขียนอะไรเลย แต่ Go ทำซ้ำนิพจน์จากบรรทัดบน ให้อัตโนมัติ (นิพจน์ = สูตรที่เขียนไว้ทางขวาของ =) — one ก็คือ one = iota โดยปริยาย และตอนนั้น iota มีค่า 1

🛣 เทียบให้เห็นภาพ — หลักกิโลเมตรริมถนน

หลักกิโลปักเรียงริมทางหลวง หลักแรกที่ต้นสายคือ กม.0 แล้วไล่ขึ้นทีละ 1 ตามลำดับที่ปัก
พอเลี้ยวเข้าทางหลวงสายใหม่ เลขไม่ได้นับต่อจากสายเดิม แต่เริ่ม 0 ใหม่ตั้งแต่ต้นสาย

iota คือเลขบนหลักกิโลของบรรทัดนั้น · const block หนึ่งอัน = ถนนหนึ่งสาย
และเหมือนหลักกิโลตรงที่เลขไล่ขึ้นเองทีละ 1 โดยไม่มีใครต้องไปกำหนดทีละหลัก

ระวังจุดนี้: เหตุผลที่บรรทัดที่ 2 กับ 3 ปล่อยว่างได้ เป็นคนละกลไกกับ iota — มันคือกฎ "ทำซ้ำนิพจน์จากบรรทัดบน" ที่พูดถึงไปข้างบน ซึ่งทำงานต่อให้ไม่มี iota เลยก็ได้
เขียน const ( a = 5 แล้วบรรทัดถัดไปแค่ b เฉย ๆ ) — b ก็ได้ 5 ทั้งที่ไม่มี iota อยู่ในบล็อกนั้นสักตัว
ในตัวอย่างนี้สองกลไกบังเอิญทำงานพร้อมกัน เลยดูเหมือนเป็นเรื่องเดียวกัน

ที่ไม่เหมือนกัน: เลขบนหลักกิโลคือระยะทางจริง แต่ iota เป็นแค่ลำดับ ค่าที่ได้จริงขึ้นอยู่กับสูตรที่เขียนไว้บรรทัดแรก — ถ้าสูตรคือ iota * 10 ค่าก็กระโดดเป็น 0, 10, 20 ทั้งที่ iota ยังเดินทีละ 1 (เจอของจริงในหัวข้อ 4)

ตัวอย่างที่ใช้จริง — วันในสัปดาห์

const (
    Sunday = iota   // 0
    Monday          // 1
    Tuesday         // 2
    Wednesday       // 3
    Thursday        // 4
    Friday          // 5
    Saturday        // 6
)

เขียน 8 บรรทัด ได้ค่าครบ 7 ตัว และถ้าแทรกวันใหม่กลางลิสต์ เลขข้างล่างขยับ ให้เองทั้งหมด — นี่คือเหตุผลที่ใช้ iota แทนการพิมพ์เลขเอง

3 · เครื่องหมาย _ — ข้ามเลขได้

const (
    zero = iota
    _
    two
)

fmt.Println(zero, two)

พิมพ์อะไรออกมา?

0 2

_ (blank identifier) กินเลข 1 ไปแต่ไม่สร้างชื่อ → two ได้ 2 พอดี สังเกตว่า iota ยังนับบรรทัดนั้น อยู่ แค่ไม่มีใครเก็บค่าไว้

🗣 ศัพท์ที่เพิ่งเรียนไป
iota
อ่านว่า ไอ-โอ-ตา = ลำดับของ ConstSpec (ปกติ = หนึ่งบรรทัด ตามหมายเหตุในหัวข้อ 2) ใน const block · พูดว่า “ชุดนี้ให้ iota นับให้” จะสื่อทันทีว่าจะทำอะไร
const block
กลุ่ม const ( ... ) หนึ่งวงเล็บ = “ถนนหนึ่งสาย” · เวลาเลขไม่ตรงที่คิด ให้ถามว่า “มันอยู่ const block เดียวกันหรือคนละอัน”
enum
ชุดตัวเลือกจำกัดที่ตั้งชื่อไว้ เช่น วันในสัปดาห์ สถานะออเดอร์ · Go ไม่มี keyword นี้ ถามว่า “ทำ enum ใน Go ยังไง” แล้วจะได้คำตอบเป็น iota + type ของตัวเอง (หัวข้อ 5)
blank identifier
ชื่อทางการของ _ · พูดว่า “ทิ้งค่านั้นด้วย blank identifier” ตรงกว่าพูดว่า “ใส่ขีดล่างไว้”

4 · iota ทำเลขคณิตได้ด้วย

ก่อนไปต่อ ต้องรู้จักเครื่องหมาย << ก่อนหนึ่งตัว ชื่อเต็มของมันคือ "เลื่อนบิตไปทางซ้าย" แต่จำแบบเลขคณิตง่ายกว่า: 1 << n คือ เอา 1 คูณ 2 ซ้ำ n ครั้ง (เท่ากับ 2 ยกกำลัง n)

1<<0 = 1 · 1<<1 = 2 · 1<<2 = 4 · 1<<10 = 1024

const (
    _  = 1 << (iota * 10)   // 1 << 0  = 1   (ทิ้ง)
    KB                        // 1 << 10 = ?
    MB                        // 1 << 20 = ?
    GB                        // 1 << 30 = ?
)

KB, MB, GB มีค่าเท่าไหร่?

KB = 1024
MB = 1048576
GB = 1073741824

นิพจน์ 1 << (iota * 10) ถูกทำซ้ำทุกบรรทัด โดย iota เปลี่ยนไปเรื่อย ๆ ไล่ทีละบรรทัดได้แบบนี้:

บรรทัดแรก iota = 0 → 1<<(0*10) = 1<<0 = 1 → ทิ้งด้วย _
บรรทัด KB: iota = 1 → 1<<10 = 210 = 1024
บรรทัด MB: iota = 2 → 1<<20 = 1024×1024 = 1048576
บรรทัด GB: iota = 3 → 1<<30 = 1024×1024×1024 = 1073741824

นี่คือสำนวนที่เจอในโค้ด Go จริงบ่อยมาก — เขียน 4 บรรทัด ได้ค่าที่ถูกต้อง โดยไม่ต้องพิมพ์เลข 1073741824 ด้วยมือ (ซึ่งพิมพ์ผิดง่าย)

5 · ยกระดับ: ทำให้ enum มี type ของตัวเอง

ปัญหาของ iota เปล่า ๆ คือมันเป็นแค่ int — ใครจะเอาเลขอะไรมาใส่ก็ได้ ทางแก้คือสร้าง type ขึ้นมาเอง:

type Weekday int          // ← สร้าง type ใหม่จาก int

const (
    Sunday Weekday = iota
    Monday
    Tuesday
)

func schedule(d Weekday) { ... }

schedule(Monday)          // ✓
schedule(99)              // ✓ ยังผ่านอยู่ (untyped constant)
n := 99
schedule(n)               // ✗ compile error — n เป็น int ไม่ใช่ Weekday
ทำไม 99 ผ่าน แต่ n ไม่ผ่าน

บท 02 บอกว่า Go ไม่แปลง type ให้อัตโนมัติ — ยังจริงอยู่ แต่มีข้อยกเว้นหนึ่งข้อ: ตัวเลขที่เขียนดิบ ๆ ในโค้ด อย่าง 99 ยังไม่มี type ติดตัว Go เรียกมันว่า untyped constant — มันจะกลายเป็น type อะไรก็ได้ ณ ตอนที่เอาไปใช้ ตรงนี้จึงกลายเป็น Weekday ให้เอง
แต่พอเขียน n := 99 ปุ๊บ n ถูกตีตราเป็น int ถาวรทันที — และ int กับ Weekday คนละ type กัน Go จึงไม่ยอมให้ส่ง (cannot use n (variable of type int) as Weekday value) นี่คือกฎ "ไม่แปลงอัตโนมัติ" ที่ทำงานตามปกติ

ยังไม่กันได้ 100% (untyped constant อย่าง 99 ยังผ่าน ตามกล่องข้างบน) แต่กันการส่งตัวแปร int ผิดตัวเข้าไปได้ ซึ่งเป็นบั๊กที่เจอบ่อยกว่ามาก
แถมทำให้ signature ของฟังก์ชันอ่านรู้เรื่องขึ้นเยอะ (signature = หน้าตาของฟังก์ชัน: ชื่อ + ชนิดของสิ่งที่รับเข้าและคืนออก) — เห็น schedule(d Weekday) ก็รู้เลยว่าต้องส่งวัน ไม่ใช่เลขอะไรก็ได้

🎟 เทียบให้เห็นภาพ — คูปองที่ยังไม่เขียนชื่อ

คูปองส่วนลดใบหนึ่ง ช่อง "ใช้ได้ที่เคาน์เตอร์ไหน" ยังว่างอยู่ → ยื่นที่เคาน์เตอร์ไหนก็ได้ พนักงานเติมชื่อเคาน์เตอร์ตัวเองให้ตอนรับ
แต่ถ้าเราเขียนเองไปแล้วว่า "เฉพาะเคาน์เตอร์ A" ก็หมดสิทธิ์ที่เคาน์เตอร์ B ทันที

99 ที่พิมพ์ดิบ ๆ ในโค้ด = คูปองช่องยังว่าง → schedule(99) ผ่าน เพราะมันยอมกลายเป็น Weekday ให้ตรงกับที่ฟังก์ชันขอ
n := 99 = เขียนคำว่า int ลงคูปองถาวรตั้งแต่ตอนนั้น → schedule(n) จึงไม่ผ่าน

ที่ไม่เหมือนกัน: คูปองเปล่าเขียนอะไรลงไปก็ได้ แต่ค่าที่ยังไม่มี type กลายร่างได้เฉพาะ type ที่เก็บค่านั้นไหว — 99 เป็น Weekday (ซึ่งสร้างจาก int) ได้ แต่เป็น string ไม่ได้
และคูปองใบหนึ่งใช้ได้ครั้งเดียว แต่ 99 ที่เขียนไว้ในโค้ดเหมือนคูปองที่ปั๊มใบใหม่ได้ทุกครั้งที่ใช้ — ที่จุดหนึ่งกลายเป็น Weekday อีกจุดกลายเป็น float64 ได้พร้อมกัน ไม่ได้ถูกล็อกจากการใช้ครั้งแรก (ที่ถูกล็อกคือ n เพราะมันเป็นตัวแปร ไม่ใช่ค่าคงที่)

🗣 ศัพท์ที่เพิ่งเรียนไป
untyped constant
“คูปองที่ช่องยังว่าง” — ค่าคงที่ที่ยังไม่ถูกผูก type · ครอบทั้งเลข/สตริงที่พิมพ์ตรง ๆ อย่าง 99 และ const ที่ประกาศโดยไม่ระบุ type อย่าง const MaxDay = 6 (จึงเอาไปใส่ตัวแปร float64 ได้ทั้งที่ดูเหมือน int) · ตรงข้ามกับ typed constant ที่ระบุ type ไว้แล้วอย่าง Sunday Weekday = iota ในหัวข้อ 5 · เวลางงว่าทำไมบางทีผ่านบางทีไม่ผ่าน ให้ถามว่า “ตรงนี้เป็น untyped constant หรือตัวแปรที่มี type แล้ว”
defined type
type ใหม่ที่สร้างจาก type เดิม เช่น type Weekday int · พูดว่า “ทำเป็น defined type ครอบ int ไว้” จะสื่อกว่า “ตั้งชื่อใหม่ให้ int” เพราะ Go ถือว่ามันคนละ type กับ int จริง ๆ
type safety
compiler กันไม่ให้เอาค่าคนละชนิดมาใช้ปนกัน — ทั้งตอนส่งเข้าฟังก์ชัน ตอน assign และตอนคำนวณ · ข้อดีคือบั๊กแบบนี้ตายตั้งแต่ compile time ไม่ไปโผล่ตอน runtime · ถูกถามว่าทำไมต้องสร้าง type เพิ่ม ตอบได้ว่า “ทำเพื่อ type safety”

6 · ทดสอบความจำ

1. const Now = time.Now() จะเกิดอะไรขึ้น?

const ต้องรู้ค่าตั้งแต่ compile time แต่ time.Now() เป็นการเรียกฟังก์ชันที่รู้ค่าตอน runtime → const initializer time.Now() is not a constant

2. const ( a = iota; b; c ) — c มีค่าเท่าไหร่?

iota เริ่มที่ 0 → a=0, b=1, c=2 และบรรทัดที่ไม่ได้ เขียนอะไร Go จะทำซ้ำนิพจน์ = iota จากบรรทัดบนให้เอง

3. มี const block สองอันติดกัน — iota ในอันที่สองเริ่มที่เท่าไหร่?

iota reset เป็น 0 ทุกครั้งที่เจอ const block ใหม่ — จึงแยกกลุ่ม enum ที่ไม่เกี่ยวกันด้วยการขึ้น block ใหม่ได้เลย

4. ใส่ _ แทนชื่อในบรรทัดหนึ่งของ const block มีผลยังไง?

_ กินลำดับไปด้วย แค่ไม่สร้างชื่อ — ใช้ตอนอยากให้ค่าจริงเริ่มที่ 1 หรืออยากทิ้งค่าแรก (เช่นสำนวน _ = 1 << (iota * 10) ที่ทิ้ง 1 byte เพื่อให้ KB เป็นตัวแรกที่มีความหมาย)

7 · ลงมือทำ

ภารกิจ: เขียน Rock-Paper-Scissors ด้วย iota

นี่คือ workshop จากสไลด์ Day 2 — ใช้ได้แค่ iota + if/else if/else

  1. ประกาศ type Move int แล้วสร้าง const block: Rock, Paper, Scissors ด้วย iota
  2. ประกาศ playerA และ playerB เป็น Move
  3. เขียนเงื่อนไขให้ได้ 3 ผลลัพธ์: เสมอ, ผู้เล่น A ชนะ, ผู้เล่น B ชนะ
  4. ทดสอบครบทั้ง 9 คู่ (3×3) ว่าถูกทุกกรณี
  5. ท้าทาย 1: ทำให้พิมพ์ชื่อท่าออกมาเป็นคำ ("Rock") แทนตัวเลข — คำใบ้: สร้าง "ตารางแปลงเลขเป็นชื่อ" ไว้ในฟังก์ชัน แล้วเอาค่า Move ไปเปิดตาราง โดยเขียนชื่อในวงเล็บเหลี่ยมต่อท้าย
    names := []string{"Rock", "Paper", "Scissors"}
    fmt.Println(names[playerA])   // Rock = ช่องที่ 0, Paper = ช่องที่ 1, Scissors = ช่องที่ 2
    โครงสร้าง []string นี้เรียกว่า slice — จะสอนเต็ม ๆ ใน บทเรียน 06 ตอนนี้ใช้ตามแบบนี้ไปก่อนได้เลย
  6. ท้าทาย 2: เขียนเงื่อนไขชนะให้เหลือบรรทัดเดียว — คำใบ้: ในเกมนี้ A ชนะเมื่อ (A - B + 3) % 3 == 1 ลองพิสูจน์ว่าทำไม
ชัยชนะของบทนี้

เห็น const (...iota...) ในโค้ดใครก็ตาม คุณอ่านออกทันทีว่า แต่ละบรรทัดมีค่าเท่าไหร่ โดยไม่ต้องนั่งนับนิ้ว

อ่านต่อจากต้นทาง

แหล่งหลักที่แนะนำ: Go spec — Iota สั้นมาก (ครึ่งหน้า) และมีตัวอย่างที่ตอบทุกคำถามที่จะเกิดขึ้น อ่านคู่กับ Constants ถ้าอยากเข้าใจว่า "untyped constant" ทำงานยังไง

💬 ติดตรงไหนถามได้เลย — ท้าทายข้อ 2 (สูตร modulo) ถ้าคิดไม่ออกว่าทำไมถึงใช้ได้ ถามมาได้ มันเป็นเทคนิคที่ใช้กับเกม "วงกลมแพ้ชนะ" ได้ทุกเกม

อ้างอิง