Day 9: Struct Embedding & Composition
Quiz: 0/16 | Score: 0 pts

Day 9: Struct Embedding, Composition & Polymorphism

Unlearning Class Hierarchies, Dynamic Dispatch vtables, and Mastering Zero-Cost Field Promotion & Static Dispatch

Section 0: The "Why" & Core Vocabulary

ในฐานะวิศวกรที่ย้ายมาจาก Kotlin / Spring Boot เราคุ้นเคยกับโมเดล Object-Oriented Programming แบบดั้งเดิม: การสร้างคลาสฐาน (Base Class) แล้วสืบทอดด้วยคีย์เวิร์ด extends หรือ open class พร้อมอาศัยการ Overwrite เมธอดแบบ Polymorphic Dynamic Dispatch

The JVM Trap: The Fragile Base Class Problem & Dynamic Dispatch Overhead

ใน 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

Section 1: Go Syntax & Command Blueprint

การประกอบร่าง Struct ใน Go ไม่ต้องใช้คีย์เวิร์ดพิเศษใดๆ เพียงแค่ประกาศชื่อ Type ภายใน Struct โดยไม่ต้องตั้งชื่อตัวแปรฟิลด์ (Anonymous Field):

1.1 พื้นฐาน Struct Embedding และ Field Promotion

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)
}

1.2 Method Promotion & Shadowing (การบังตา ไม่ใช่การ Overwrite)

ใน 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"
}
จุดตายสำคัญ: Go ไม่มี "Dynamic Virtual Dispatch" ภายใน Embedded Struct

หาก BaseEntity มีเมธอด PrintAll() ที่ข้างในสั่งเรียก b.Audit() แม้คุณจะเรียก tx.PrintAll() ตัวคอมไพเลอร์จะส่ง b เข้าไปใน Audit() ของ BaseEntity เสมอ! มันจะไม่เด้งกลับมาเรียก Audit() ของ PaymentTransaction เหมือน this.audit() ใน Java เพราะ Go ส่ง Receiver แบบชัดแจ้ง (Static Dispatch)

1.3 Embedding Pointer vs Value

เราสามารถเลือก 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
}

Section 2: Memory Architecture & Full Struct Definitions

เพื่อความเข้าใจระดับ Bare-Metal เราจะผ่า Struct ของระบบจับเวลางานวิ่ง (Race Timing RFID Engine) ว่าในหน่วยความจำระดับ 64-bit Hardware มีการจัดวาง Memory Layout อย่างไรเมื่อเกิดการ Embed

2.1 โครงสร้าง Struct ในระบบ Race Timing

// โครงสร้างข้อมูลพื้นฐานของตัวอ่านสัญญาณ 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 ลงตัว
}

2.2 ASCII Memory Layout (Contiguous Memory Allocation)

สังเกตว่า ReaderMeta ไม่ได้ถูกจองเป็น Pointer หรือ Object แยกใน Heap เหมือนใน Java! มันถูกวางแนบสนิทอยู่ภายใน Memory Block เดียวกัน (Zero Pointer Overhead):

+---------------------------------------------------------------------------------------+ | ChipPassEvent Memory Layout (Total: 24 Bytes, 64-bit Aligned) | +---------------------------------------------------------------------------------------+ | Byte Offset | Field Name | Type | Size (Bytes) | Padding Hole | +-------------+------------------------+-----------+--------------+---------------------+ | 0x00 - 0x03 | GateID (from Embedded) | uint32 | 4 Bytes | - | | 0x04 - 0x05 | AntennaID (Embedded) | uint16 | 2 Bytes | - | | 0x06 - 0x07 | [Compiler Padding] | - | 2 Bytes | [2 Bytes Pad] | +-------------+------------------------+-----------+--------------+---------------------+ | 0x08 - 0x0F | TimestampNano | int64 | 8 Bytes | - | +-------------+------------------------+-----------+--------------+---------------------+ | 0x10 - 0x13 | RunnerBib | int32 | 4 Bytes | - | | 0x14 - 0x15 | RSSI | int16 | 2 Bytes | - | | 0x16 - 0x17 | [Trailing Padding] | - | 2 Bytes | [2 Bytes Pad] | +---------------------------------------------------------------------------------------+ Total Struct Footprint = 24 Bytes (สามารถโหลดเข้าสู่ L1 Data Cache Line 64 Bytes ได้พร้อมกันเกือบ 3 Events!)

2.3 Pointer Embedding Memory Breakdown

หากเราเปลี่ยนเป็น *ReaderMeta (Pointer Embedding) โครงสร้าง RAM จะเปลี่ยนไปทันที:

+---------------------------------------------------------------------------------------+ | ChipPassEventWithPtr Memory Layout (Total: 24 Bytes) | +---------------------------------------------------------------------------------------+ | 0x00 - 0x07 | *ReaderMeta (Pointer) | uintptr | 8 Bytes (ชี้ไป Heap Address อื่น) | | 0x08 - 0x0F | TimestampNano | int64 | 8 Bytes | | 0x10 - 0x13 | RunnerBib | int32 | 4 Bytes | | 0x14 - 0x15 | RSSI | int16 | 2 Bytes | | 0x16 - 0x17 | [Trailing Padding] | - | 2 Bytes | +---------------------------------------------------------------------------------------+ | +---> [Heap Object ReaderMeta]: 8 Bytes แยกต่างหาก (ทำให้เกิด Pointer Chasing)

Section 3: Visual Workflow & Assembly/RAM Mechanics

ทำไมการใช้ Struct Embedding ใน Go จึงเร็วกว่า Polymorphism ใน JVM? มาดูลำดับขั้นตอนการทำงานจริงระดับ Assembly และ CPU Pipeline

3.1 Static Dispatch vs Dynamic vtable Lookup

[Kotlin / Java Dynamic Dispatch (vtable)] Code: val id = tx.id 1. โหลด Object Pointer จาก Stack ไปยัง CPU Register 2. Dereference Address เพื่ออ่าน Object Header (Mark Word + Klass Word) 3. อ่าน Address ของ vtable จาก Klass Pointer 4. คำนวณ Offset ของเมธอดใน vtable 5. กระโดดข้าม Memory (Indirect Call) ไปยัง Machine Code ** ผลลัพธ์: เสี่ยงเกิด CPU Branch Misprediction และ L1 Cache Misses ** -------------------------------------------------------------------------------- [Go Field Promotion (Static Dispatch)] Code: id := tx.ID 1. Compiler คำนวณ Memory Offset ของฟิลด์ ID ตั้งแต่ตอนคอมไพล์ (Offset = 0x00) 2. CPU ประมวลผลคำสั่งเดียวตรงๆ: MOVQ 0(SP), AX (Zero Indirection!) ** ผลลัพธ์: ทำงานใน 1 CPU Clock Cycle, ไม่มี Pointer Dereference, Inlining ได้ 100% **

3.2 Receiver Passing Mechanics

เมื่อเราเรียกเมธอดบน Embedded Struct คอมไพเลอร์ของ Go จะทำการแปลงคำสั่งเสมือนเป็นการเรียกฟังก์ชันธรรมดาที่มีตัวแปรแรกเป็น Parameter:

// เมื่อคุณเขียน:
tx.BaseEntity.Audit()

// หรือเขียนแบบ Promoted:
tx.Audit() // (หากไม่ได้นิยาม Audit() บน tx ไว้)

// คอมไพเลอร์จะเขียนโค้ด Assembly เทียบเท่ากับการเรียก:
BaseEntity.Audit(&tx.BaseEntity)

สังเกตว่า Address ที่ส่งเข้าไปคือ Address ของ tx.BaseEntity โดยตรง ไม่มีการสร้าง Object Wrapper ใหม่ขึ้นมาเลยแม้แต่ไบต์เดียว

Section 4: Edge Cases, Hazards & Idiomatic Patterns

Hazard #1: The Ambiguous Selector Collision (ชื่อชนกันที่ระดับเดียวกัน)

หาก 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)
}
Hazard #2: The Nil Pointer Embedding Panic

หากคุณ 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...") 
}

Idiomatic Go Patterns: Composition in Production

Section 5: Knowledge Verification Quiz (16 Challenges)

ทดสอบความเข้าใจเชิงลึกระดับ Runtime & Architecture ทุกข้อมีคะแนนสะสมและอธิบายเหตุผลอย่างละเอียด

Section 6: Hands-on Engineering Labs

เขียนโค้ด Go ตามหลัก Idiomatic Architecture ตรวจสอบความถูกต้องด้วย Verification Engine

Lab 1: Banking Ledger - Entity Embedding & Method Promotion

โจทย์: สร้าง Struct AuditMeta (มี CreatedBy string และ CreatedAt int64) พร้อม Method AuditInfo() string ที่คืนค่า fmt.Sprintf("%s at %d", a.CreatedBy, a.CreatedAt) จากนั้นสร้าง LedgerEntry ที่ Embed AuditMeta แบบ Value และมีฟิลด์ Amount int64

Lab 2: Method Shadowing & Explicit Inner Invocation

โจทย์: มี Struct BaseDetector ที่มี Method Detect() string ที่คืนค่า "base pass" ให้สร้าง FraudDetector ที่ Embed BaseDetector แล้วทำการ Shadow เมธอด Detect() string ให้คืนค่า "fraud blocked: " + f.BaseDetector.Detect()

Lab 3: Thread-Safe Race Ingestor with Embedded Mutex

โจทย์: สร้าง Struct ChipCounter ที่ Embed sync.Mutex โดยตรง และมีฟิลด์ TotalScans int จากนั้นสร้าง Method Increment() ที่เรียก c.Lock() และ defer c.Unlock() เพื่อเพิ่มค่า TotalScans ขึ้นทีละ 1