กลับไปยังบทความ

Backend

Go Interfaces, Struct และ Method: เข้าใจ Receiver, Polymorphism และ Type Switch

เข้าใจ Go interfaces, struct และ method ครบในบทความเดียว ตั้งแต่ value receiver กับ pointer receiver, implicit implementation, any, type assertion, type switch ไปจนถึงกับดัก nil interface

24 กันยายน 256910 นาทีในการอ่าน

go structs methods and interfaces

Go interfaces ใช้บอก "พฤติกรรม" ไม่ใช่ข้อมูล โดย struct ทำหน้าที่เก็บข้อมูล method ผูกพฤติกรรมเข้ากับ type และ interface ระบุว่า type ต้องมี method อะไรบ้าง type ไหนมี method ครบ ก็ถือว่า implement interface นั้นทันทีโดยอัตโนมัติ Go ไม่มีคีย์เวิร์ด implements ไม่มีลำดับชั้นของ class และไม่มี inheritance

กฎไม่กี่ข้อนี้ทำหน้าที่แทน class ใน Java หรือ C# ได้เกือบทั้งหมด บทความนี้จะไล่ตั้งแต่ struct, value receiver กับ pointer receiver, interface, polymorphism, any, type assertion, type switch ไปจนถึงกับดัก nil interface ที่คนเขียน Go แทบทุกคนต้องเคยเจอ

Struct: สร้าง data type ของตัวเอง

Struct คือการรวม field ที่เกี่ยวข้องกันเป็น type ใหม่ที่มีชื่อ:

go
type User struct {
	ID    int64
	Name  string
	Email string
	Admin bool
}

func main() {
	u := User{ID: 1, Name: "Ana", Email: "[email protected]"}
	fmt.Println(u.Name) // Ana

	var empty User          // zero value: 0, "", "", false
	fmt.Println(empty.Admin) // false
}

เรื่องที่ควรรู้ตั้งแต่แรก:

  • Zero value ใช้งานได้จริง struct ที่ไม่ได้กำหนดค่าจะมีทุก field เป็น zero value ของมัน type ที่ออกแบบดีใน Go มักทำให้ zero value ใช้ได้ทันที เช่น sync.Mutex หรือ bytes.Buffer
  • ระบุชื่อ field ใน literal เสมอ การเขียนแบบเรียงตำแหน่ง เช่น User{1, "Ana", "[email protected]", false} จะพังทันทีที่มีคนเพิ่ม field
  • Exported กับ unexported Name มองเห็นได้จากนอก package ส่วน name ไม่ได้
  • Struct tag ใช้แนบ metadata ให้ package อย่าง encoding/json เช่น Email string `json:"email"`
  • Struct เป็น value การเขียน b := a คือการ copy ทุก field และ struct สองตัวเทียบกันด้วย == ได้ถ้าทุก field เทียบกันได้

นอกจากนี้ยังสร้าง type ใหม่จาก type เดิมได้ เช่น type Celsius float64 เป็น type ใหม่ที่มี float64 เป็น underlying type จะเอาไปปนกับ float64 ธรรมดาไม่ได้ถ้าไม่แปลงชนิดก่อน และสามารถเพิ่ม method ของตัวเองได้

Method และ receiver ใน Go

Method คือ function ที่มี receiver หรือค่าที่ method นั้นถูกเรียกใช้:

go
type Rect struct {
	Width, Height float64
}

// Method: belongs to Rect, called as r.Area()
func (r Rect) Area() float64 {
	return r.Width * r.Height
}

// Function: standalone, called as Area(r)
func Area(r Rect) float64 {
	return r.Width * r.Height
}

ทั้งสองคำนวณผลเหมือนกัน แต่มีแค่ method ที่อยู่ใน method set ของ type ซึ่งเป็นสิ่งที่ interface ใช้ตรวจสอบ function ธรรมดาไม่มีทาง implement interface ได้ เราสามารถประกาศ method บน named type ใดก็ได้ที่อยู่ใน package เดียวกัน (เช่น func (c Celsius) String() string) แต่เพิ่ม method ให้ type จาก package อื่นอย่าง int หรือ time.Time ไม่ได้

Value receiver กับ pointer receiver

นี่คือเรื่องที่ต้องตัดสินใจบ่อยที่สุดเวลาเขียน method:

go
type Counter struct {
	n int
}

func (c Counter) IncValue()   { c.n++ } // works on a copy, change is lost
func (c *Counter) IncPointer() { c.n++ } // works on the original

func main() {
	c := Counter{}
	c.IncValue()
	c.IncPointer() // Go takes &c for you because c is addressable
	fmt.Println(c.n) // 1
}

Value receiver ได้รับสำเนา ส่วน pointer receiver ได้รับ address ของค่าจริง การแก้ไขจึงมีผลกับฝั่งผู้เรียก

ใช้ pointer receiver เมื่อ...ใช้ value receiver ได้เมื่อ...
Method แก้ไขค่าใน receiverType มีขนาดเล็กและไม่เปลี่ยนแปลง (เช่น time.Time, Point)
Struct ใหญ่ การ copy สิ้นเปลืองType เป็น map, func หรือ channel
Struct มี sync.Mutex หรือสิ่งที่ห้าม copyอยากให้ผู้เรียกมั่นใจว่า method ไม่มี side effect
Method อื่นของ type นี้ใช้ pointer receiver อยู่แล้ว

แถวสุดท้ายสำคัญ: ให้สม่ำเสมอ ถ้ามี method ใดต้องใช้ pointer receiver ก็ให้ทุก method ของ type นั้นใช้ pointer receiver ไปเลย การปนกันทำให้ method set สับสน ดังที่จะเห็นในหัวข้อถัดไป

Go interfaces: กำหนดพฤติกรรม

Interface คือชุดของ method signature type ไหนมี method ครบทุกตัวก็ถือว่า implement interface นั้น:

go
type Generator interface {
	Generate() ([]byte, error)
}

type PDF struct{ Content string }
type CSV struct{ Lines []string }

func (p PDF) Generate() ([]byte, error) {
	return []byte("%PDF " + p.Content), nil
}

func (c CSV) Generate() ([]byte, error) {
	return []byte(strings.Join(c.Lines, "\n")), nil
}

func Export(g Generator) error {
	data, err := g.Generate()
	if err != nil {
		return fmt.Errorf("generate: %w", err)
	}
	fmt.Printf("exported %d bytes\n", len(data))
	return nil
}

PDF และ CSV ไม่ได้อ้างถึง Generator เลยสักครั้ง แต่ implement ได้เพราะมี method Generate() ([]byte, error) นี่คือ implicit implementation และมีข้อดีคือเราประกาศ interface ให้ type ที่เราไม่ได้เป็นเจ้าของได้ เช่น ถ้า client ของ library ภายนอกมี method Send(ctx, msg) error ก็ประกาศ interface ที่มี method เดียวใน package ของเรา แล้วใช้ fake แทนตอนเขียน test ได้เลย

Method set กับ pointer receiver

ถ้า Generate ใช้ pointer receiver จะมีแค่ *PDF ที่ implement interface:

go
func (p *PDF) Generate() ([]byte, error) { /* ... */ }

var g Generator = PDF{}  // compile error: method Generate has pointer receiver
var g Generator = &PDF{} // OK

กฎคือ method set ของ T มีเฉพาะ method ที่เป็น value receiver ส่วน method set ของ *T มีทั้งสองแบบ เวลา compiler บอกว่า type "does not implement" interface สาเหตุส่วนใหญ่ก็คือเรื่องนี้

ตรวจสอบการ implement ตอน compile

เมื่อ implementation เป็นแบบ implicit ถ้าพิมพ์ชื่อ method ผิด error จะโผล่เฉพาะจุดที่นำค่าไปใช้เป็น interface การ assign ไปที่ blank identifier ช่วยให้ตรวจแบบชัดเจน:

go
var _ Generator = (*PDF)(nil)
var _ Generator = CSV{}

ไม่มีต้นทุนตอน runtime และ build จะ fail ทันทีถ้า type ไม่ตรงกับ interface อีกต่อไป

ทำ interface ให้เล็ก

Interface ที่มีประโยชน์ที่สุดใน standard library มักมีแค่หนึ่งหรือสอง method เช่น io.Reader, io.Writer, fmt.Stringer, error interface เล็ก ๆ implement ง่าย ทำ fake ง่าย และนำมาประกอบกันง่าย มีสองนิสัยที่ช่วยได้:

  • ประกาศ interface ในฝั่งที่ใช้งาน ฝั่ง consumer รู้ดีที่สุดว่าต้องการ method อะไร
  • Accept interfaces, return structs รับ interface ที่แคบที่สุดเท่าที่ต้องใช้ และคืนค่าเป็น concrete type เพื่อให้ผู้เรียกใช้ API ได้ครบ

แนวคิดเดียวกันนี้คือพื้นฐานของการออกแบบแบบ ports and adapters ดูเพิ่มเติมได้ที่ hexagonal architecture แบบ ports and adapters ว่าขยายไปใช้กับทั้ง service อย่างไร

Polymorphism ด้วย interface

Polymorphism ใน Go คือโค้ดที่ทำงานได้กับทุก type ที่ implement interface เดียวกัน:

go
type Shape interface {
	Area() float64
	Perimeter() float64
}

type Circle struct{ R float64 }

func (c Circle) Area() float64      { return math.Pi * c.R * c.R }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.R }

func (r Rect) Perimeter() float64 { return 2 * (r.Width + r.Height) }

func main() {
	shapes := []Shape{Rect{Width: 3, Height: 4}, Circle{R: 1}}
	total := 0.0
	for _, s := range shapes {
		total += s.Area()
	}
	fmt.Printf("total area: %.2f\n", total)
}

Loop นี้ไม่สนว่าข้างในเป็น concrete type อะไร และถ้าเพิ่ม Triangle เข้ามา ก็ไม่ต้องแก้ loop เลย

ใช้ embedding แทน inheritance

Go นำโค้ดกลับมาใช้ซ้ำด้วย embedding โดย field และ method ของ struct ที่ฝังอยู่จะถูก promote ขึ้นมาเป็นของ type ด้านนอก:

go
type Timestamps struct {
	CreatedAt, UpdatedAt time.Time
}

func (t *Timestamps) Touch() { t.UpdatedAt = time.Now() }

type Order struct {
	Timestamps // embedded
	ID    int64
	Total int64
}

func main() {
	o := &Order{ID: 7}
	o.Touch()                  // promoted method
	fmt.Println(o.UpdatedAt)   // promoted field
}

นี่คือ composition ไม่ใช่ inheritance เราส่ง Order ไปในที่ที่ต้องการ Timestamps ไม่ได้ แต่ method ที่ถูก promote จะนับรวมใน method set ด้วย ดังนั้น *Order จึง implement ทุก interface ที่ *Timestamps implement

Interface ก็ embed กันได้ เช่น io.ReadWriter ประกาศโดยฝัง io.Reader และ io.Writer ไว้ใน interface เดียว

Empty interface และ any

Interface ที่ไม่มี method เลย interface{} จะถูก implement โดยทุก type ตั้งแต่ Go 1.18 มี any เป็น alias ในตัวภาษา และทั้งสองแบบเหมือนกันทุกประการ

go
func Describe(v any) {
	fmt.Printf("%v (%T)\n", v, v)
}

Describe(42)        // 42 (int)
Describe("hi")      // hi (string)
Describe([]int{1})  // [1] ([]int)

any เหมาะกับกรณีที่ไม่รู้ type จริงจนกว่าจะถึง runtime เช่น fmt.Println หรือการ decode JSON ลง map[string]any ข้อเสียคือ compiler ช่วยตรวจอะไรให้ไม่ได้อีก ก่อนใช้ any ให้ลองถามตัวเองว่า interface เล็ก ๆ หรือ type parameter (func Max[T cmp.Ordered](a, b T) T) จะสื่อความตั้งใจได้ดีกว่าหรือไม่

Type assertion

Type assertion ใช้ดึงค่า concrete ออกมาจาก interface:

go
var v any = "hello"

s := v.(string)      // OK: s == "hello"
n := v.(int)         // panics: interface conversion

n, ok := v.(int)     // comma-ok form never panics
if !ok {
	fmt.Println("not an int")
}

ใช้แบบค่าเดียวเฉพาะกรณีที่ type ผิดถือเป็น bug ของโปรแกรม นอกนั้นให้ใช้แบบ comma-ok

Assert ไปเป็น interface อื่นก็ได้ ซึ่งเป็นวิธีตรวจว่าค่านั้นมีความสามารถเสริมหรือไม่:

go
if s, ok := v.(fmt.Stringer); ok {
	fmt.Println(s.String())
}

ตัวอย่างเช่น io.Copy จะตรวจว่า source implement io.WriterTo หรือไม่ เพื่อเลือกใช้ทางที่เร็วกว่า

Type switch

เมื่อมี type ที่เป็นไปได้หลายแบบ type switch อ่านง่ายกว่าการ assert ต่อกันหลายชั้น:

go
func Format(v any) string {
	switch x := v.(type) {
	case nil:
		return "null"
	case string:
		return strconv.Quote(x) // x is a string here
	case int, int64:
		return fmt.Sprint(x)    // x is any here: more than one type in the case
	case fmt.Stringer:
		return x.String()
	default:
		return fmt.Sprintf("%v", x)
	}
}

ใน case ที่มี type เดียว x จะเป็น concrete type นั้น แต่ใน case ที่มีหลาย type x จะยังเป็น interface type อยู่ Go ตรวจ case ตามลำดับ จึงควรวาง type ที่เฉพาะเจาะจงไว้ก่อน interface ที่กว้าง

สำหรับ error ให้ใช้ errors.As แทน type switch เพราะมันตรวจทะลุ error ที่ถูก wrap ไว้ด้วย รายละเอียดอยู่ใน การจัดการ error และโครงสร้างโปรเจกต์ Go

กับดัก nil interface

ค่า interface เก็บสองอย่างคือ type และ value มันจะเป็น nil ก็ต่อเมื่อ ทั้งคู่ เป็น nil เรื่องนี้มักเจอตอน return error:

go
type NotFound struct{ Key string }

func (e *NotFound) Error() string { return e.Key + " not found" }

func find(key string) error {
	var err *NotFound // nil pointer
	if key == "missing" {
		err = &NotFound{Key: key}
	}
	return err // returns a non-nil error even when err is a nil pointer
}

func main() {
	if err := find("ok"); err != nil {
		fmt.Println("unexpected:", err) // this runs
	}
}

Interface ที่ return ออกมามี type เป็น *NotFound และ value เป็น nil จึงไม่เท่ากับ nil วิธีแก้คือ return nil ตรง ๆ ในเส้นทางที่สำเร็จ และประกาศตัวแปร error เป็น type error แทน pointer ของ concrete type

คำถามที่พบบ่อย

Go มี class ไหม?

ไม่มี struct, method, interface และ embedding ร่วมกันทำหน้าที่ที่ปกติเราใช้ class ทำ โดยไม่ต้องมี inheritance

ควรใช้ pointer receiver หรือ value receiver?

ใช้ pointer receiver ถ้า method แก้ไขค่าใน receiver, struct มีขนาดใหญ่ หรือมี mutex อยู่ข้างใน นอกนั้นใช้ value receiver ได้ และควรเลือกแบบเดียวต่อหนึ่ง type

จะตรวจว่า type implement interface ใน Go ได้อย่างไร?

เพิ่ม var _ MyInterface = (*MyType)(nil) ไว้ระดับ package ถ้า type ขาด method ใด build จะ fail

any กับ interface{} ต่างกันอย่างไร?

ไม่ต่างกัน any เป็น alias ของ interface{} ที่เพิ่มเข้ามาใน Go 1.18 โค้ดใหม่ใช้ any ได้เลยเพราะสั้นกว่า

ทำไม error ไม่เป็น nil ทั้งที่ return nil pointer ออกไป?

เพราะ interface เก็บ type (*MyErr) ไว้คู่กับค่า nil ให้ return nil ตรง ๆ แทน nil pointer ที่มี type

สรุปสิ่งที่ควรทำ

  • ใช้ struct เก็บข้อมูล และออกแบบให้ zero value ใช้งานได้
  • ใช้ pointer receiver เมื่อ method แก้ไข state และใช้แบบเดียวกันทั้ง type
  • ทำ interface ให้เล็ก และประกาศไว้ในฝั่งที่ใช้งาน
  • เพิ่มการตรวจตอน compile สำหรับ type ที่ต้อง implement interface
  • ใช้ comma-ok และ type switch แทน assertion ที่ panic ได้ และใช้ errors.As กับ error
  • อย่า return nil pointer ที่มี type ออกไปเป็น error

ขั้นต่อไป อ่าน slice, map และ pointer ใน Go เพื่อเข้าใจว่าอะไรถูก copy และอะไรถูกแชร์ร่วมกัน หากทีมของคุณกำลังออกแบบ service ด้วย Go และอยากได้มุมมองที่สองเรื่องโครงสร้าง Vectorkub รับพัฒนาและรีวิวระบบ backend ลักษณะนี้

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ