Unlearning Class Hierarchies, Dynamic Dispatch vtables, and Mastering Zero-Cost Field Promotion & Static Dispatch
ในฐานะวิศวกรที่ย้ายมาจาก Kotlin / Spring Boot เราคุ้นเคยกับโมเดล Object-Oriented Programming แบบดั้งเดิม: การสร้างคลาสฐาน (Base Class) แล้วสืบทอดด้วยคีย์เวิร์ด extends หรือ open class พร้อมอาศัยการ Overwrite เมธอดแบบ Polymorphic Dynamic Dispatch
ใน JVM เมื่อเราเรียกเมธอดบน Object ที่สืบทอดมา Runtime จะต้องวิ่งผ่าน vtable (Virtual Method Table) ชี้ข้าม Pointer หลายระดับเพื่อค้นหาว่า Implementation ไหนคือตัวจริง ทำให้เกิด Pointer Indirection, ขัดขวาง Compiler ในการทำ Function Inlining และเพิ่ม Cache Misses ใน L1 Instruction Cache
นอกจากนี้ การเปลี่ยนแปลงฟิลด์หรือเมธอดเพียงบรรทัดเดียวใน Base Class อาจพังระบบที่สืบทอดต่อไปอีก 5 ชั้นโดยไม่รู้ตัว (Fragile Base Class Problem)
Go ตัด Class Inheritance ทิ้งทั้งหมด ไม่มีคำว่า extends, implements หรือ super ภาษา Go ใช้หลักการ "Composition over Inheritance" อย่างหมดจดผ่านกลไก Struct Embedding (Anonymous Fields) ซึ่งเป็นการรวม Struct หนึ่งไว้ในอีก Struct หนึ่งโดยตรง พร้อมสนับสนุน Field/Method Promotion ในระดับคอมไพเลอร์
| คำศัพท์ทางเทคนิค (Go) | นิยามและพฤติกรรมระดับ Runtime/Hardware | แนวคิดเทียบเท่าใน Kotlin / Spring |
|---|---|---|
| Struct Embedding | การนำ Struct เข้ามาใส่ใน Struct อื่นเป็น Anonymous Field ทำให้ Memory ถูกวางต่อกันแบบ Flat Contiguous Layout | Class Delegation (by keyword) หรือการสร้าง Composition field ธรรมดา |
| Field/Method Promotion | คอมไพเลอร์อนุญาตให้เรียกใช้ Field หรือ Method ของ Embedded Struct ได้เสมือนเป็นของตัวเอง โดยคอมไพล์เป็น Static Dispatch ตรงๆ | Inheritance Access (การเรียก this.field ของ Base Class) |
| Static Dispatch | ตำแหน่งของ Function Address ถูกคำนวณและ Resolve เสร็จตั้งแต่ขั้นตอน Compile-Time โดย CPU ไม่ต้องค้นหา vtable | Final Methods หรือ Kotlin Inlined Functions |
| Method Shadowing | Outer Struct ประกาศ Method ชื่อเดียวกับ Inner Struct ทำให้การเรียก Outer Method บดบัง Inner Method (แต่ไม่ได้ Overwrite) | Method Overriding (แต่ใน Go เมธอดเดิมยังอยู่และเรียกผ่าน Inner ได้) |
| Implicit Interface | Struct ถือว่า Implement Interface อัตโนมัติทันทีที่ Implement เมธอดครบตาม Signature โดยไม่ต้องมีคีย์เวิร์ดบอก | Interface Implementation (class Foo : Bar) แต่เป็น Duck Typing แบบ Type-Safe ตอน Compile |
การประกอบร่าง Struct ใน Go ไม่ต้องใช้คีย์เวิร์ดพิเศษใดๆ เพียงแค่ประกาศชื่อ Type ภายใน Struct โดยไม่ต้องตั้งชื่อตัวแปรฟิลด์ (Anonymous Field):
package main
import "fmt"
// Base Entity สำหรับระบบบัญชี Ledger
type BaseEntity struct {
ID string
CreatedAt int64
}
// PaymentTransaction ทำการ "Embed" BaseEntity เข้ามาตรงๆ
type PaymentTransaction struct {
BaseEntity // Anonymous Field (Embedding)
Amount int64
DestinationIBAN string
}
func main() {
// กำหนดค่าแบบ Struct Literal ต้องระบุชื่อ Embedded Type
tx := PaymentTransaction{
BaseEntity: BaseEntity{
ID: "TX-8839102",
CreatedAt: 1711920000,
},
Amount: 500000, // 5,000.00 บาท (เซนต์/สตางค์)
DestinationIBAN: "DE89370400440532013000",
}
// 1. Field Promotion: เรียกตรงๆ ได้เลยเสมือนเป็นฟิลด์ของตัวเอง
fmt.Println("Transaction ID:", tx.ID)
// 2. Explicit Access: เรียกผ่านชื่อ Type ข้างในได้เช่นกัน (ผลลัพธ์เหมือนกัน)
fmt.Println("Explicit ID:", tx.BaseEntity.ID)
}
ใน Kotlin เมื่อคุณ override fun audit() โค้ดของ Base Class จะถูกแทนที่ใน vtable แต่ใน Go หาก Outer Struct ประกาศชื่อเมธอดซ้ำ มันจะแค่ Shadow (บัง) เมธอดเดิมเท่านั้น:
// Method บน BaseEntity
func (b *BaseEntity) Audit() string {
return fmt.Sprintf("Base audit: ID=%s", b.ID)
}
// Method บน PaymentTransaction ที่มีชื่อเดียวกัน (Shadowing)
func (p *PaymentTransaction) Audit() string {
return fmt.Sprintf("Payment audit: ID=%s, Amount=%d", p.ID, p.Amount)
}
func RunAudit() {
tx := &PaymentTransaction{
BaseEntity: BaseEntity{ID: "TX-9901"},
Amount: 2500,
}
// เรียก Outer Method ที่บังอยู่
fmt.Println(tx.Audit()) // "Payment audit: ID=TX-9901, Amount=2500"
// เมธอดของ Base ไม่ได้หายไปไหน! เรียกผ่าน Inner ได้เสมอ
fmt.Println(tx.BaseEntity.Audit()) // "Base audit: ID=TX-9901"
}
หาก BaseEntity มีเมธอด PrintAll() ที่ข้างในสั่งเรียก b.Audit() แม้คุณจะเรียก tx.PrintAll() ตัวคอมไพเลอร์จะส่ง b เข้าไปใน Audit() ของ BaseEntity เสมอ! มันจะไม่เด้งกลับมาเรียก Audit() ของ PaymentTransaction เหมือน this.audit() ใน Java เพราะ Go ส่ง Receiver แบบชัดแจ้ง (Static Dispatch)
เราสามารถเลือก Embed เป็นแบบ Value หรือ Pointer ก็ได้ ซึ่งให้ผลลัพธ์ในหน่วยความจำต่างกันอย่างสิ้นเชิง:
// 1. Value Embedding: ข้อมูล BaseEntity ฝังตัวเป็นเนื้อเดียวกับ Struct ใน RAM
type IngestBuffer struct {
BaseEntity // Contiguous memory
Capacity int
}
// 2. Pointer Embedding: Struct เก็บแค่ Pointer 8 ไบต์ ชี้ไปยัง Object อื่น
type RaceTracker struct {
*BaseEntity // 8-byte pointer ชี้ไปหา BaseEntity ที่อื่น
RunnerBib int
}
เพื่อความเข้าใจระดับ Bare-Metal เราจะผ่า Struct ของระบบจับเวลางานวิ่ง (Race Timing RFID Engine) ว่าในหน่วยความจำระดับ 64-bit Hardware มีการจัดวาง Memory Layout อย่างไรเมื่อเกิดการ Embed
// โครงสร้างข้อมูลพื้นฐานของตัวอ่านสัญญาณ RFID
type ReaderMeta struct {
GateID uint32 // 4 Bytes
AntennaID uint16 // 2 Bytes
// 2 Bytes Padding เพื่อ Align ให้ครบ 8 Bytes Boundary
}
// การประมวลผลสัญญาณชิปนักวิ่ง
type ChipPassEvent struct {
ReaderMeta // Embedded Struct (Value): กินเนื้อที่ 8 Bytes พอดี
TimestampNano int64 // 8 Bytes
RunnerBib int32 // 4 Bytes
RSSI int16 // 2 Bytes
// 2 Bytes Padding ต่อท้ายเพื่อทำให้ผลรวมขนาด Struct หาร 8 ลงตัว
}
สังเกตว่า ReaderMeta ไม่ได้ถูกจองเป็น Pointer หรือ Object แยกใน Heap เหมือนใน Java! มันถูกวางแนบสนิทอยู่ภายใน Memory Block เดียวกัน (Zero Pointer Overhead):
หากเราเปลี่ยนเป็น *ReaderMeta (Pointer Embedding) โครงสร้าง RAM จะเปลี่ยนไปทันที:
ทำไมการใช้ Struct Embedding ใน Go จึงเร็วกว่า Polymorphism ใน JVM? มาดูลำดับขั้นตอนการทำงานจริงระดับ Assembly และ CPU Pipeline
เมื่อเราเรียกเมธอดบน Embedded Struct คอมไพเลอร์ของ Go จะทำการแปลงคำสั่งเสมือนเป็นการเรียกฟังก์ชันธรรมดาที่มีตัวแปรแรกเป็น Parameter:
// เมื่อคุณเขียน:
tx.BaseEntity.Audit()
// หรือเขียนแบบ Promoted:
tx.Audit() // (หากไม่ได้นิยาม Audit() บน tx ไว้)
// คอมไพเลอร์จะเขียนโค้ด Assembly เทียบเท่ากับการเรียก:
BaseEntity.Audit(&tx.BaseEntity)
สังเกตว่า Address ที่ส่งเข้าไปคือ Address ของ tx.BaseEntity โดยตรง ไม่มีการสร้าง Object Wrapper ใหม่ขึ้นมาเลยแม้แต่ไบต์เดียว
หาก Outer Struct ทำการ Embed Struct สองตัวที่มี Field หรือ Method ชื่อซ้ำกันในระดับเดียวกัน (Same Depth) คอมไพเลอร์จะไม่ยอมให้เรียกใช้แบบ Promoted เพราะถือว่า Ambiguous (กำกวม) แต่จะยอมให้คอมไพล์ผ่านได้ตราบใดที่คุณไม่ได้เรียกแบบสั้น:
type SourceAccount struct {
Balance int64
}
type TargetAccount struct {
Balance int64
}
type InternalTransfer struct {
SourceAccount
TargetAccount
}
func Process() {
transfer := InternalTransfer{}
// คอมไพล์ไม่ผ่านทันที: ambiguous selector transfer.Balance
// fmt.Println(transfer.Balance)
// วิธีแก้: ต้องระบุ Explicit Qualifier
fmt.Println(transfer.SourceAccount.Balance)
fmt.Println(transfer.TargetAccount.Balance)
}
หากคุณ Embed Struct แบบ Pointer (เช่น *BaseEntity) แต่ไม่ได้ทำการ Allocate หน่วยความจำให้มัน (มีค่าเป็น nil) เมื่อคุณเรียกใช้ Field หรือ Method ที่ถูก Promoted ขึ้นมา จะเกิด Runtime Panic: invalid memory address or nil pointer dereference ทันที!
type AuditLogger struct {
Prefix string
}
func (a *AuditLogger) Log(msg string) { fmt.Println(a.Prefix, msg) }
type SettlementBatch struct {
*AuditLogger // Embedded Pointer
BatchID string
}
func RunBatch() {
batch := SettlementBatch{BatchID: "B-100"}
// พังทันที! เพราะ batch.AuditLogger มีค่าเป็น nil
// batch.Log("Starting...")
}
sync.Mutex หรือ sync.RWMutex เข้าไปใน Struct เพื่อให้ Struct นั้นมีคุณสมบัติ Thread-Safe (Lock/Unlock) ได้โดยไม่ต้องตั้งชื่อฟิลด์ใหม่:
type SafeLedger struct {
sync.Mutex // Promotes Lock() and Unlock()
balances map[string]int64
}
func (l *SafeLedger) Deposit(acc string, amt int64) {
l.Lock() // เรียกตรงๆ ได้เลย
defer l.Unlock()
l.balances[acc] += amt
}
ทดสอบความเข้าใจเชิงลึกระดับ Runtime & Architecture ทุกข้อมีคะแนนสะสมและอธิบายเหตุผลอย่างละเอียด
เขียนโค้ด Go ตามหลัก Idiomatic Architecture ตรวจสอบความถูกต้องด้วย Verification Engine
โจทย์: สร้าง Struct AuditMeta (มี CreatedBy string และ CreatedAt int64) พร้อม Method AuditInfo() string ที่คืนค่า fmt.Sprintf("%s at %d", a.CreatedBy, a.CreatedAt) จากนั้นสร้าง LedgerEntry ที่ Embed AuditMeta แบบ Value และมีฟิลด์ Amount int64
โจทย์: มี Struct BaseDetector ที่มี Method Detect() string ที่คืนค่า "base pass" ให้สร้าง FraudDetector ที่ Embed BaseDetector แล้วทำการ Shadow เมธอด Detect() string ให้คืนค่า "fraud blocked: " + f.BaseDetector.Detect()
โจทย์: สร้าง Struct ChipCounter ที่ Embed sync.Mutex โดยตรง และมีฟิลด์ TotalScans int จากนั้นสร้าง Method Increment() ที่เรียก c.Lock() และ defer c.Unlock() เพื่อเพิ่มค่า TotalScans ขึ้นทีละ 1