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 ใหม่ที่มีชื่อ:
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 นั้นถูกเรียกใช้:
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:
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 แก้ไขค่าใน receiver | Type มีขนาดเล็กและไม่เปลี่ยนแปลง (เช่น 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 นั้น:
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:
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 ช่วยให้ตรวจแบบชัดเจน:
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 เดียวกัน:
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 ด้านนอก:
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 ในตัวภาษา และทั้งสองแบบเหมือนกันทุกประการ
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:
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 อื่นก็ได้ ซึ่งเป็นวิธีตรวจว่าค่านั้นมีความสามารถเสริมหรือไม่:
if s, ok := v.(fmt.Stringer); ok {
fmt.Println(s.String())
}ตัวอย่างเช่น io.Copy จะตรวจว่า source implement io.WriterTo หรือไม่ เพื่อเลือกใช้ทางที่เร็วกว่า
Type switch
เมื่อมี type ที่เป็นไปได้หลายแบบ type switch อ่านง่ายกว่าการ assert ต่อกันหลายชั้น:
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:
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 ลักษณะนี้
