บทเรียน 09 · Day 4 · Type System

Underlying Type: สร้าง type ใหม่ให้ compiler ช่วยจับบั๊ก

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

type Score float64 ดูเหมือนแค่ตั้งชื่อเล่นให้ float64 แต่จริง ๆ แล้วคุณสร้าง type ใหม่ที่ compiler ถือว่าคนละชนิดกันเลย ต่างกันแค่เครื่องหมาย = ตัวเดียว — และความต่างนั้นคือสิ่งที่กัน "ส่ง ProductID เข้าฟังก์ชันที่ขอ UserID" ได้ตั้งแต่ตอน compile

1 · ทุก type มี underlying type

ใน Go ทุก type มี underlying type — โครงสร้างข้อมูลจริงที่อยู่ข้างใต้ ต่อให้คุณตั้งชื่อใหม่และแปะ method เข้าไป underlying type ก็ยังเป็นตัวเดิม
(method = ฟังก์ชันที่ผูกติดอยู่กับ type ตัวหนึ่ง เรียกใช้ด้วยจุด เช่น score.Grade() — หัวข้อ 5 จะอธิบายเต็ม ตอนนี้อ่านผ่านไปก่อนได้)

type Score float64

// มองแบบนี้:
Score
  │
  └── underlying type → float64

underlying type เป็นตัวกำหนดว่าทำอะไรกับค่านั้นได้บ้าง — Score บวกลบคูณหารได้เพราะข้างใต้เป็น float64 ถ้าข้างใต้เป็น string ก็จะ + ได้อย่างเดียว (ต่อ string)

type Score float64

a := Score(80.5)
b := Score(10.0)

total := a + b
fmt.Println(total)   // 90.5
fmt.Printf("%T", total)   // main.Score  ← ไม่ใช่ float64

สังเกต %T — บวกกันแล้วยังเป็น Score ไม่ได้กลายเป็น float64 การคำนวณระหว่าง type เดียวกัน ให้ผลลัพธ์เป็น type เดิมเสมอ

2 · Same underlying type ≠ same type

นี่คือหัวใจของบทนี้ type ที่มี underlying type เดียวกัน ยังเป็นคนละ type

type Score       float64
type Temperature float64
type Price       float64
type Distance    float64

// ทั้งหมดมี underlying type เป็น float64
// แต่ compiler ถือว่าเป็น 4 type ที่ไม่เกี่ยวกันเลย
🧂 เทียบให้เห็นภาพ — ขวดเครื่องปรุงบนโต๊ะก๋วยเตี๋ยว

บนโต๊ะก๋วยเตี๋ยวมีขวดใสหน้าตาเหมือนกันเป๊ะ 4 ขวด — น้ำปลา น้ำส้มสายชู ซีอิ๊ว น้ำเชื่อม
ทุกขวดเทได้ ตวงได้เหมือนกัน แต่ใช้แทนกันไม่ได้ หยิบผิดขวดทีเดียวชามพัง

· ของเหลวข้างในขวด = underlying type — บอกว่าทำอะไรกับมันได้บ้าง (ข้างในเป็น float64 ก็เลยบวกลบคูณหารได้)
· ป้ายที่แปะข้างขวด = ชื่อ type — บอกว่าใช้แทนกันได้หรือไม่ได้
ในบทนี้กฎที่ใช้คือ: ป้ายคนละชื่อ และทั้งสองฝั่งต่างมีชื่อของตัวเอง (Score กับ float64) → assign ตรง ๆ ไม่ได้ ต้อง convert
Go เข้มกว่าโต๊ะก๋วยเตี๋ยวตรงที่ ถ้าสูตรขอ "น้ำปลา" แล้วยื่นขวด "น้ำส้มสายชู" ให้ มันปฏิเสธตั้งแต่ยังไม่ทันเท

ข้อยกเว้นที่จะเจอทีหลัง: ถ้าอีกฝั่งไม่มีชื่อ — เขียนเป็นรูปร่างดิบ ๆ อย่าง []int — Go ยอมให้ assign ตรง ๆ ทั้งที่ป้ายต่างกัน เช่น type MySlice []int แล้ว var s MySlice = []int{1, 2, 3} และ var raw []int = s ผ่านทั้งคู่ (ผมรันจริงแล้ว) จำกฎว่า "ป้ายต่างกัน = ห้ามเสมอ" จึงแคบเกินไป

ที่ไม่เหมือนกัน: ตอนเขียน float64(score) เราไม่ได้เทของข้ามขวด ข้อมูลข้างในยังเป็นตัวเดิมเป๊ะ เปลี่ยนแค่ป้ายที่ compiler อ่าน — การ convert ระหว่าง type ที่ underlying type เหมือนกันแบบนี้จึงไม่เสียเวลาเพิ่มตอนรันจริงเลย
แต่ conversion ที่ข้ามชนิด เช่น int(3.9) หรือ []byte(s) ไม่ใช่การเปลี่ยนป้าย — มันแปลงข้อมูลจริงและมีค่าใช้จ่ายจริง (อ่านต่อในกล่องศัพท์ถัดไป)

type Score float64

var score Score = 95.5
var value float64 = score

fmt.Println(value)

โค้ดนี้ compile ผ่านไหม?

ไม่ผ่าน — ผมรันทดสอบแล้ว ได้ข้อความนี้:

cannot use score (variable of float64 type Score)
    as float64 value in variable declaration

สังเกตข้อความ compiler เอง: "of float64 type Score" — มันรู้ว่า underlying เป็น float64 แต่ก็ยังไม่ยอมให้ผ่าน ต้องเขียน float64(score) เอง นี่คือกฎเดียวกับ บทเรียน 02: Go ไม่แปลง type ให้อัตโนมัติ ไม่ว่ากรณีไหน

// ถ้าจะเปรียบเทียบ ต้อง convert ก่อน:
value == float64(score)   // ✓
score == Score(value)     // ✓ ทำทางไหนก็ได้

3 · นี่แหละคือประโยชน์ — compiler จับบั๊กให้

ฟังดูน่ารำคาญใช่ไหม ทำไมต้อง convert ให้ยุ่งยาก ลองดูตัวอย่างนี้แล้วจะเห็นว่ามันช่วยอะไร:

type UserID    int64
type ProductID int64

func FindUser(id UserID) {
    fmt.Println(id)
}

productID := ProductID(101)
FindUser(productID)   // ← ส่งผิดตัว

บรรทัดสุดท้ายเกิดอะไรขึ้น — พิมพ์ 101, compile error หรือ panic?

Compile error — จับได้ตั้งแต่ยังไม่ได้รัน:

cannot use productID (variable of int64 type ProductID)
    as UserID value in argument to FindUser

ถ้าใช้ int64 เปล่า ๆ ทั้งคู่ โค้ดนี้จะผ่านฉลุย แล้วไปพังตอน production ตอนที่ query หา user id 101 แล้วเจอคนอื่น — บั๊กประเภทนี้หาสาเหตุยากมาก เพราะไม่มีอะไร crash

แนวคิดที่ควรติดหัวไป

type ที่มีความหมายต่างกัน ควรเป็นคนละ type ต่อให้เก็บข้อมูล หน้าตาเหมือนกัน — UserID/ProductID, Baht/Satang, Celsius/Fahrenheit การพิมพ์เพิ่มบรรทัดเดียวแลกกับการที่ compiler กันบั๊กให้ทั้งโปรเจกต์

🗣 ศัพท์ที่เพิ่งเรียนไป

ใช้คำให้ตรง แล้วจะถาม senior หรือถาม AI ได้ตรงจุดขึ้นมาก

underlying type
"ของเหลวข้างในขวด" — โครงสร้างข้อมูลจริงใต้ชื่อ type · ถามว่า "type นี้ underlying type เป็นอะไร" จะสื่อกว่า "จริง ๆ มันคือ float64 ใช่ไหม"
defined type (บางคนเรียก custom type)
type ใหม่ที่เราประกาศเอง แบบไม่มี = · "ตรงนี้เป็น defined type เลยต้อง convert ก่อน"
เกร็ดไว้กันเถียงแพ้: ตาม spec ของ Go แล้ว int string float64 ก็เป็น defined type เหมือนกัน (spec เขียนตรง ๆ ว่า "all numeric types are defined types") · เพียงแต่เวลาคุยงานคนมักใช้คำนี้เรียก type ที่เราประกาศเอง
type conversion
การเขียน T(x) เพื่อเปลี่ยนค่าให้เป็น type T · คนจากภาษาอื่นเรียกว่า "cast" แต่ Go เรียก conversion
· ถ้า underlying type เหมือนกัน (Score → float64) = เปลี่ยนป้ายเฉย ๆ ข้อมูลไม่ขยับ
· แต่ถ้าข้ามชนิด conversion แปลงข้อมูลจริง และทำข้อมูลหายได้ — ผมรันจริงด้วย Go 1.26: int(3.9) ได้ 3, int(-2.7) ได้ -2 (ตัดทศนิยมทิ้ง ไม่ปัดเศษ) และ []byte("hi") ได้ [104 105] ซึ่งคือ copy ข้อมูลชุดใหม่
เจอ int(price) แล้วเงินหายไปครึ่งบาท สาเหตุอยู่ตรงนี้
compile time / run time
ตอนแปลโค้ด กับ ตอนโปรแกรมทำงานจริง · คำถามแรกที่ dev ถามกันเสมอคือ "error นี้ขึ้นตอน compile หรือตอน run" เพราะแก้คนละวิธี
type safety
การที่ compiler กันไม่ให้เอาของผิดชนิดมาปนกัน · "ผมแยก UserID กับ ProductID เป็นคนละ type เพื่อ type safety ครับ"

4 · Custom type ≠ Alias — ต่างกันที่ =

👯 เทียบให้เห็นภาพ — ชื่อเล่น กับ ฝาแฝด

· type Score = float64 (มี =) = ชื่อเล่น · "หมู" กับ "สมชาย" คือคนเดียวกัน เรียกชื่อไหนก็ได้คนเดิมเดินมา ใช้แทนกันได้ทุกที่ และเวลากรอกเอกสารราชการ (%T) ก็ยังขึ้นชื่อจริงอยู่ดี
· type Score float64 (ไม่มี =) = ฝาแฝด · หน้าเหมือนกันทุกกระเบียดนิ้ว แต่เป็นคนละคน มีบัตรประชาชนคนละใบ ไปสอบแทนกันไม่ได้

ทีนี้จะเห็นเองว่าทำไมชื่อเล่นที่ชี้ไป type ของคนอื่น (เช่น float64 ของ Go) แปะ method ไม่ได้ — เพิ่มความสามารถให้ "หมู" ก็คือเพิ่มให้ "สมชาย" นั่นแหละ เท่ากับไปแก้ของกลางของทั้งภาษา Go เลยไม่ยอม ผมรันจริงแล้วได้ cannot define new methods on non-local type Score

ระวังจำเหมารวม: ถ้าชื่อเล่นนั้นชี้ไป type ที่เราประกาศเองใน package เดียวกัน Go ยอมให้แปะ — type Foo struct{ N int } แล้ว type Bar = Foo ตามด้วย func (b Bar) Hi() string คอมไพล์ผ่าน (ผมรันแล้ว) แต่ผลลัพธ์คือ method นั้นไปเกาะกับ Foo ตัวจริง เรียก f.Hi() ผ่านตัว Foo ได้เลย — ไม่ได้เกิด type ใหม่ขึ้นมาสักตัว ตรงกับเรื่อง "ชื่อเล่นคือคนเดิม" พอดี

ที่ไม่เหมือนกัน: ฝาแฝดที่เป็นคนจริง ๆ โตไปนิสัยต่างกันได้ แต่ Score กับ float64 เก็บข้อมูลเหมือนกันเป๊ะตลอดไป ต่างกันเฉพาะตอน compiler ตรวจชื่อเท่านั้น

type Score float64type Score = float64
เรียกว่าDefined type (custom type)Alias (ชื่อเล่น)
เป็น type ใหม่ไหม✅ ใช่ — คนละ type กับ float64❌ ไม่ — คือ float64 ตัวเดียวกัน
assign ตรง ๆ ได้ไหม❌ ต้อง convert✅ ได้เลย
แปะ method ได้ไหม✅ ได้❌ ไม่ได้
%T พิมพ์ว่าmain.Scorefloat64
ใช้ตอนไหนอยากให้ compiler แยกความหมายตอนย้าย/เปลี่ยนชื่อ package โดยไม่พังของเก่า
// Alias — ผมรันทดสอบแล้ว assign ได้ตรง ๆ ไม่ต้อง convert:
type ScoreAlias = float64

var alias ScoreAlias = 95.5
var plain float64 = alias   // ✓ ผ่าน
fmt.Printf("%T", alias)      // float64  ← ไม่ใช่ ScoreAlias

alias ที่คุณใช้อยู่แล้วโดยไม่รู้ตัว: any คือ alias ของ interface{} และ byte คือ alias ของ uint8 ส่วน rune คือ alias ของ int32 — ทั้งหมดนี้ประกาศด้วย = ใน standard library จริง ๆ

🗣 ศัพท์ที่เพิ่งเรียนไป
type alias
"ชื่อเล่น" ของ type ที่มีอยู่แล้ว — ประกาศด้วย = · ถามว่า "ตัวนี้เป็น alias หรือ defined type" คำถามเดียวตัดจบได้เลยว่าต้อง convert ไหม แปะ method ได้ไหม
identical (เป็น type เดียวกัน)
คำที่ Go spec ใช้เรียกเรื่องนี้ตรง ๆ · alias ทำให้สองชื่อ identical กัน ส่วน defined type ไม่ · พิมพ์ถาม AI ได้เลยว่า "in Go, are these two types identical or just sharing the same underlying type?"
byte · rune · any
alias ที่ใช้อยู่ทุกวันโดยไม่รู้ตัว · รู้ข้อนี้แล้วจะเข้าใจทันทีว่าทำไม []byte กับ []uint8 ถึงใช้แทนกันได้โดยไม่ต้อง convert

5 · ของแถมที่ได้จาก custom type: แปะ method ได้

พอเป็น type ของเราเองแล้ว ก็นิยาม method ได้เหมือน struct — แม้ underlying type จะเป็นแค่ float64 ก็ตาม

type Score float64

func (s Score) Passed() bool {
    return s >= 50
}

func (s Score) Grade() string {
    switch {
    case s >= 80: return "A"
    case s >= 70: return "B"
    case s >= 60: return "C"
    case s >= 50: return "D"
    default:      return "F"
    }
}

// ใช้งาน — ผลลัพธ์จริงจากการรัน:
Score(75).Passed()      // true
Score(85.5).Grade()     // A

เทียบกับการเขียน func isPassed(s float64) bool ลอย ๆ — แบบ method ผูกตรรกะไว้กับ type ที่มันเป็นเจ้าของ ใครเห็น Score ก็กด . แล้วเห็นเลยว่าทำอะไรได้บ้าง

ข้อจำกัดที่ต้องรู้

แปะ method ได้เฉพาะ type ที่ประกาศใน package เดียวกัน — เขียน func (s string) Reverse() string ไม่ได้ เพราะ string เป็นของ Go ไม่ใช่ของคุณ ทางแก้คือ type MyString string ก่อน

🚗 เทียบให้เห็นภาพ — รถเช่า กับ รถที่จอดในบ้านเรา

รถเช่าขับได้ เติมน้ำมันได้ แต่จะเจาะติดสปอยเลอร์หรือพ่นสีใหม่ — ไม่ได้ อยากแต่งต้องมีรถที่จอดอยู่ในบ้านเรา (คือประกาศไว้ใน package เดียวกับ method) ก่อน

string int float64 คือรถเช่า — ใช้ได้แต่แต่งไม่ได้ · func (s string) Reverse() string จึงไม่ผ่าน
พอประกาศ type MyString string ใน package เดียวกัน ก็เท่ากับรถจอดอยู่ในบ้านแล้ว แปะ method ได้เต็มที่

เกณฑ์คือที่จอด ไม่ใช่ความเป็นเจ้าของ: ต่อให้ type นั้นเราเขียนเองกับมือ ถ้ามันอยู่คนละ package ก็ยังแปะไม่ได้ — ผมประกาศ type Thing struct{...} ไว้ใน package inner แล้วเขียน func (x inner.Thing) Boom() ใน main ได้ error เดียวกันเป๊ะ: cannot define new methods on non-local type inner.Thing

ที่ไม่เหมือนกัน: ซื้อรถต้องจ่ายเงินและได้รถเพิ่มมาอีกคัน แต่ประกาศ type ใหม่นั้น ค่าแต่ละตัวไม่กินหน่วยความจำเพิ่ม และไม่มีค่าใช้จ่ายตอนรัน — ตอนรันจริง MyString ก็คือ string ตัวเดิม เพิ่มมาแค่ป้ายชื่อที่ compiler เอาไว้ตรวจ (ตัวไฟล์ binary จะมีข้อมูลกำกับ type ใหม่เพิ่มขึ้นบ้าง แต่ไม่กระทบค่าที่โปรแกรมเก็บ)

🗣 ศัพท์ที่เพิ่งเรียนไป
method
ฟังก์ชันที่ผูกติดกับ type ตัวหนึ่ง เรียกด้วยจุด เช่น score.Grade() · "อันนี้เขียนเป็น method ของ Score ดีกว่าไหม จะได้กด . แล้วเจอ"
receiver
พารามิเตอร์พิเศษในวงเล็บที่อยู่หน้าชื่อ method คือ (s Score) · เวลาพูดกัน receiver หมายถึงตัว s ที่รับค่าเข้ามา ส่วน Score เรียกว่า receiver base type (คำนี้อยู่ใน spec ตรง ๆ และเป็นตัวที่ต้องประกาศใน package เดียวกัน) · คำถามที่จะเจอบ่อยมากตอนทำงานจริง: "method ตัวนี้ receiver ควรเป็น value หรือ pointer" (ดู บทเรียน 07)
non-local type
type ที่ไม่ได้ประกาศใน package ของเรา — คือ "รถเช่า" นั่นเอง · จำไว้เพราะคำนี้โผล่ในข้อความ error ตรง ๆ: cannot define new methods on non-local type เห็นเมื่อไหร่ให้รู้ทันทีว่าต้องประกาศ type ของตัวเองก่อน

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

1. type Score float64 กับ type Price = float64 อันไหนสร้าง type ใหม่?

ไม่มี = → type ใหม่ · มี = → alias จำง่าย ๆ ว่าเครื่องหมายเท่ากับแปลว่า "เท่ากันจริง ๆ" — Price คือ float64 ตัวเดียวกันเป๊ะ ส่วน Score แค่หน้าตาเหมือน

2. type Score float64 แล้ว var v float64 = score ได้ผลยังไง?

underlying type เดียวกันไม่ได้แปลว่า type เดียวกัน Go ไม่แปลง type ให้อัตโนมัติในทุกกรณี ต้องเขียน float64(score) เอง ซึ่งเป็นการบอก compiler ว่า "ฉันตั้งใจ" ไม่ใช่พิมพ์ผิด

3. type Score float64 และ type Price float64 (ทั้งคู่ไม่มี =) — score == price ได้ไหม?

ข้อความจริงจาก compiler: invalid operation: score == price (mismatched types Score and Price) — ต้อง convert ให้เป็น type เดียวกันก่อน เช่น float64(score) == float64(price)

ระวังอย่าสับสนกับข้อ 1 — ในข้อ 1 Price ประกาศด้วย = จึงคือ float64 เทียบกับ float64 ตัวอื่นได้เลย แต่ข้อนี้ไม่มี = Price จึงเป็น type ใหม่คนละตัวกับ Score

4. ทำไมถึงควรสร้าง type UserID int64 แทนใช้ int64 ตรง ๆ?

ไม่ได้เร็วขึ้นและไม่ได้ประหยัดเลย — custom type ไม่มี overhead ตอน runtime เลยแม้แต่นิดเดียว สิ่งที่ได้คือ compiler กลายเป็นคนตรวจให้ ส่ง ProductID เข้าฟังก์ชันที่ขอ UserID ไม่ได้อีกต่อไป

7 · ลงมือทำ

ภารกิจ: ระบบเงินที่ไม่มีวันคิดหน่วยผิด

บั๊กระดับตำนานในระบบการเงินคือ "เอาบาทไปบวกสตางค์" ลองใช้ type system กันดู

  1. ประกาศ type Baht int64 และ type Satang int64
  2. เขียน method func (b Baht) ToSatang() Satang (คูณ 100) และ func (s Satang) ToBaht() Baht
  3. เขียน func Charge(amount Satang) ที่พิมพ์ยอดออกมา
  4. ลองเรียก Charge(Baht(150)) ตรง ๆ — อ่าน error ให้ครบทั้งบรรทัด แล้วแก้ให้ถูก
  5. เพิ่ม method func (s Satang) String() string ที่คืนค่าเป็น "150.00 บาท" แล้ว fmt.Println(Satang(15000)) — ทำไมมันรู้ว่าต้องเรียก String()? เดี๋ยวเฉลยใน บทเรียน 11
  6. ท้าทาย: ลองเปลี่ยน type Baht int64 เป็น type Baht = int64 แล้ว build ใหม่ — ข้อไหนพัง ข้อไหนยังผ่าน และอ่าน error ทั้งสองบรรทัดให้ออกว่ามันเกี่ยวกันยังไง
    สปอยล์: ผมรันแล้ว ได้ cannot define new methods on non-local type Baht ตามด้วย Baht(150).ToSatang undefined — บรรทัดที่สองเป็นผลพวงของบรรทัดแรก

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

แหล่งหลักที่แนะนำ: Go spec — Types หัวข้อ "Underlying types" ยาวแค่ย่อหน้าเดียวแต่เป็นนิยามชี้ขาด ถ้าอยากรู้ว่าทำไม Go ถึงมี alias ตั้งแต่แรก อ่าน The Go Blog — Alias names ซึ่งเล่าเหตุผลว่ามันเกิดมาเพื่อการย้าย type ข้าม package

💬 ติดตรงไหนถามได้เลย — ข้อท้าทายท้ายภารกิจ (เปลี่ยนเป็น alias แล้ว method หายไปไหน) เป็นจุดที่คนพลาดกันบ่อยที่สุด ถ้า error อ่านไม่ออกก็ส่งมาได้

อ้างอิง