ข้าม​ไป​ยัง​เนื้อหา

ชนิด​ข้อมูล zero value และ struct

บท​ที่​แล้ว​จบ​ที่ hello world บท​นี้ orderctl เริ่ม​มี​ของ​จริง

เรา​จะ​สร้าง package internal/order ที่​เก็บ​ชนิด​ข้อมูล​ขอ​งอ​อเด​อร์​ทั้ง​ใบ แล้ว​เขียน​เทสต์​ตัว​แรก ให้​มัน ระหว่าง​ทาง​มี​สาม​จุด​ที่​คน​ย้าย​มา​จาก C#, Java หรือ TypeScript เดา​ผิด​เกือบ​ทุก​ครั้ง

Go ไม่มี​สถานะ “ยัง​ไม่​กำหนด​ค่า” ตัวแปร​ที่​ประกาศ​เปล่า​จึง​แตะ field ได้​เลย​โดย​ไม่ panic

zero valuezero valueค่าที่​ตัวแปร​ได้​ทันที​ที่​ประกาศ​โดย​ไม่​กำหนด​ค่า — `0` `false` `""` หรือ `nil` ตาม​ชนิด Go ไม่มี​สถานะ "ยัง​ไม่​กำหนด​ค่า" จึง​ไม่มี NullReferenceException แบบ​ที่ C# มี คือ​ค่าที่​ตัวแปร​ได้​ทันที​ที่​ประกาศ​โดย​ไม่​กำหนด​ค่า มัน​ไม่ใช่​ค่า​ขยะ​ที่​ค้าง​อยู่​ใน หน่วย​ความ​จำ และ​ไม่ใช่​สถานะ​พิเศษ​ที่​ต้อง​เช็​กก่อน​ใช้

package main
import "fmt"
type Line struct {
SKU string
Qty int
Note *string
}
func main() {
var l Line
fmt.Printf("%q %d %v\n", l.SKU, l.Qty, l.Note == nil)
fmt.Printf("%+v\n", l)
}
$ go run .
"" 0 true
{SKU: Qty:0 Note:<nil>}

l.SKU เป็น​สตริง​ว่าง​จริงๆ ไม่ใช่ nil ที่​แกล้ง​เป็น​สตริง l.Qty เป็น 0 จริงๆ ส่วน l.Note เป็น pointer จึง​เป็น nil เพราะ​นั่น​คือ zero value ของ pointer

ค่า​เริ่ม​ต้น​ตาม​ชนิด​มี​ไม่​กี่​แบบ จำ​ได้​ทั้ง​ชุด​ใน​บรรทัด​เดียว

  • ชนิด​ตัวเลข​ทุก​ตัว เป็น 0
  • bool เป็น false
  • string เป็น​สตริง​ว่าง ""
  • pointer, slice, map, channel, function และ interface เป็น nil
`var o Order` ไม่ใช่ `Order o = null`

ใน C# หรือ Java การ​ประกาศ​ตัวแปร​ของ reference type แล้ว​แตะ​สมาชิก​ทันที คือ NullReferenceException ที่​รอ​เกิด คุณ​จึง​ติด​นิสัย​เช็ก null ก่อน​แตะ​ทุก​ครั้ง

ใน Go var o Order ให้ struct ที่​มี​ค่า​จริง​มา​เลย ทุก field ถูก​ตั้ง​เป็น zero value ของ ชนิด​ตัว​เอง และ​สเปก​เขียน​ไว้​ว่าการ​ตั้ง​ค่า​นี้​ทำ​แบบ​เรียก​ซ้ำ​ลง​ไป​ทุก​ชั้น

นั่น​ลบ bug ทั้ง​ตระกูล​หนึ่ง​ทิ้ง แต่​สร้าง​อีก​ตระกูล​ขึ้น​มา​แทน — ค่าที่​ผิด​จะ​ไม่​ร้อง มัน​จะ​เงียบ และ​เป็น 0 ออ​เด​อร์​ที่​ลืม​ตั้ง Table จึง​กลาย​เป็น​ออ​เด​อร์​ของ​โต๊ะ​ศูนย์​โดย​ไม่มี​ใคร​รู้

ข้อ​แลก​นี้​เปลี่ยน​วิธี​ออกแบบ​ชนิด ถ้า 0 เป็น​ค่าที่​ผิด​สำหรับ field ไหน คุณ​ต้อง​มี​จุด​ตรวจ ของ​ตัว​เอง ไม่มี compiler มา​ช่วย บท​ที่ 6 จะ​สร้าง​จุด​ตรวจ​นั้น​ให้ orderctl

type Baht int64 สร้าง​ชนิด​ที่​ระบบ​ชนิด​แยก​จาก int64 จริง แปลง​ชัด​ทุก​ครั้ง และ​ผูก methodได้

เงิน​เก็บ​เป็น float64 ไม่​ได้ orderctl จึง​เก็บ​เป็น​สตางค์​ใน​จำนวนเต็ม แต่​ถ้า​เก็บ​เป็น int64 เปล่าๆ มัน​จะ​บวก​กับ int64 ตัว​อื่น​ใน​โปรแกรม​ได้​เงียบ​สนิท ไม่​ว่า​อีก​ฝั่ง​จะ​เป็น​ส่วนลด จำนวน​วินาที หรือ​รหัส​อะไร​ก็ตาม

defined typedefined typeชนิด​ใหม่​ที่​สร้าง​ด้วย `type A B` ระบบ​ชนิด​แยก​จาก `B` ต้อง​แปลง​ชัด​ทุก​ครั้ง และ​ไม่​รับ method ของ `B` มา​ด้วย แก้​ข้อ​นี้​ให้ ชื่อ​ใหม่​ที่​สร้าง​ด้วย type A B ไม่ใช่​คำ​พ้อง​ของ B มัน​คือ​ชนิด​ใหม่​ที่​ระบบ​ชนิด​แยก​ออก

package main
import "fmt"
// Baht เก็บเงินเป็นสตางค์ ไม่ใช่ float64
type Baht int64
func (b Baht) String() string {
sign := ""
if b < 0 { // การหารและ mod ของ Go ตัดเข้าหาศูนย์ ทั้งสองผลจึงติดลบพร้อมกัน
sign, b = "-", -b
}
return fmt.Sprintf("%s%d.%02d บาท", sign, b/100, b%100)
}
func main() {
var unit Baht = 6000
qty := 2
total := unit * Baht(qty) // Baht(qty) ละไม่ได้
fmt.Println(total)
}
$ go run .
120.00 บาท

Baht(qty) ตรง​นั้น​ละ​ไม่​ได้ ถ้า​เขียน unit * qty เฉยๆ compiler ตอบ​ว่า invalid operation: unit * qty (mismatched types Baht and int) หัวข้อ Operators ของ​สเปก บังคับ​ว่า​ตัว​ถูก​ดำเนิน​การ​สอง​ฝั่ง​ต้อง​เป็น​ชนิด​เดียวกัน ยกเว้น​การ​เลื่อน​บิต​และ​ค่า​คงที่​ที่​ยัง​ไม่มี​ชนิด

unit * 2 จึง​คอม​ไพล์​ผ่าน เพราะ 2 เป็น​ค่า​คงที่​ที่​ยัง​ไม่มี​ชนิด compiler แปลง​ให้​เป็น Baht ให้​เอง ส่วน qty เป็น​ตัวแปร​ที่​มี​ชนิด​แล้ว จึง​ไม่มี​การ​แปลง​ให้​ฟรี

เปลี่ยน qty เป็น int64 ก็​ยัง​ไม่​ผ่าน ทั้ง​ที่ int64 คือ​ชนิด​ฐาน​ของ Baht เพราะ​หัวข้อ Assignability ยอม​ข้าม​ชื่อ​ให้​ต่อ​เมื่อ​ทั้ง​สอง​ฝั่ง​มี​ชนิด​ฐาน​ตรง​กัน และ อย่าง​น้อยห​นึ่ง​ฝั่ง ไม่ใช่ named type — Baht กับ int64 เป็น named type ทั้ง​คู่

ความ​น่า​รำคาญ​ตรง​นี้​คือ​สิ่ง​ที่​คุณ​จ่าย และ​สิ่ง​ที่​ได้​กลับ​มา​คือ​การ​เอา​เงิน​ไป​บวก​กับ​จำนวน​ชิ้น โดย​ไม่​ตั้งใจ​นั้น ทำ​ไม่​ได้​เลย​ตั้งแต่​ตอน​คอม​ไพล์

`type Baht = int64` เป็น​คนละ​เรื่อง​กัน​สิ้น​เชิง

เครื่องหมาย = ตัว​เดียว​เปลี่ยน​ความ​หมาย​ทั้งหมด type Baht = int64 คือ alias — เป็น​ชื่อ​ที่​สอง​ของ​ชนิด​เดิม ไม่ใช่​ชนิด​ใหม่

alias ไม่​แยก​จาก​ชนิด​เดิม​ใน​สายตา​ระบบ​ชนิด และ​คุณ​ผูก methodกับ​มัน​ไม่​ได้ เพราะ methodต้อง​ผูก กับ​ชนิด​ที่​นิยาม​ใน package เดียวกัน

ถ้า​คุณ​มา​จาก TypeScript ที่ type A = B คือ​เครื่อง​มือ​ปกติ ระวัง​ข้อ​นี้​ให้​มาก สิ่ง​ที่​คุณ​ต้องการ​เกือบ​ทุก​ครั้ง​ใน Go คือ​แบบ​ไม่มี =

method String() ที่​ผูก​กับ Baht ทำให้ fmt.Println พิมพ์ 120.00 บาท แทน​เลข 12000 นี่​คือ​ของ​แถม​ที่ alias ให้​ไม่​ได้

ทำไม `String` ต้อง​แยก​เครื่องหมาย​ลบ​ออก​มา​ก่อน

การ​หาร​จำนวนเต็ม​กับ % ของ Go ตัด​เข้าหา​ศูนย์ ทั้ง​คู่ · -12345 / 100 จึง​ได้ -123 และ -12345 % 100 ได้ -45 ติดลบ​ตาม​กัน

เขียน​ตรงๆ ว่า fmt.Sprintf("%d.%02d", b/100, b%100) จะ​ได้ -123.-45 ซึ่ง​ไม่ใช่​จำนวน​เงิน · ภาษา​ที่ % คืน​ค่า​ไม่​ติดลบ​เสมอ อย่าง Python จะ​ไม่มี​ปัญหา​นี้ Go ไม่ใช่​ภาษา​นั้น

orderctl ไม่​เคย​ผลิต​ยอด​ติดลบ เพราะ​กฎ​ส่วนลด​หยุด​ที่​ศูนย์ · แต่ String เป็น method ที่​คน​อื่น​เรียก​ได้​ทุก​เมื่อ และ​ค่าที่​ยัง​ไม่​ผ่าน​การ​ตรวจ​ก็​ติดลบ​ได้

Go ไม่มี enum แต่​มี const block กับ​ตัว​นับ​ที่​ทำงาน​แทน​ได้ พร้อม​หลัก​ว่า​ค่า 0 ต้อง​ปลอดภัย

iotaiotaตัว​นับ​ที่​รีเซ็ต​เป็น 0 ที่​ต้น​ทุก block `const` และ​เพิ่ม​ที​ละ​หนึ่ง​ต่อ​บรรทัด ใช้​ตั้ง​ค่า​คงที่​ชุด​ที่​เรียง​กัน คือ​ตัว​นับ​ที่​รีเซ็ต​เป็น 0 ที่​ต้น​ทุก block const แล้ว​เพิ่ม​ที​ละ​หนึ่ง​ต่อ​บรรทัด

package main
import (
"fmt"
"strconv"
)
type Status int
const (
StatusDraft Status = iota // 0 และเป็น zero value ของ Status ด้วย
StatusPaid
StatusCancelled
)
var statusName = [...]string{"draft", "paid", "cancelled"}
func (s Status) String() string {
if s < 0 || int(s) >= len(statusName) {
return "Status(" + strconv.Itoa(int(s)) + ")"
}
return statusName[s]
}
func main() {
var s Status
fmt.Println(s, StatusPaid, StatusCancelled, Status(9))
}
$ go run .
draft paid cancelled Status(9)

สอง​บรรทัด​ล่าง​ของ const ไม่​ต้อง​เขียน Status = iota ซ้ำ block const ทำ​ซ้ำ​นิพจน์​ของ บรรทัด​ก่อน​หน้า​ให้​เอง เมื่อ​บรรทัด​นั้น​เว้น​ค่า​ไว้

ลำดับ​ใน​นี้​ไม่​ได้​เรียง​ตาม​ความ​สวยงาม StatusDraft อยู่​บน​สุด​เพราะ​มัน​จะ​ได้​ค่า 0 และ 0 คือ zero value ของ Status ทุก​ตัว​ที่​ยัง​ไม่​ตั้ง​ค่า

ให้​ค่า 0 เป็น​สถานะ​ที่​ปลอดภัย​ที่สุด​เสมอ

var o Order ให้ o.Status เป็น 0 โดย​อัตโนมัติ ถ้า​คุณ​เผลอ​วาง StatusPaid ไว้​บรรทัด​แรก ออ​เด​อร์​ที่​ยัง​ไม่มี​ใคร​แตะ​จะ​กลาย​เป็น​ออ​เด​อร์​ที่​จ่าย​เงิน​แล้ว

นี่​ไม่ใช่​ข้อ​ควร​ระวัง​ทาง​ทฤษฎี มัน​คือ​กฎ​การ​ออกแบบ​ที่​ใช้​กับ​ทุก const block ที่​ผูก​กับ struct ค่า 0 ต้อง​แปล​ว่า “ยัง​ไม่​เกิด​อะไร​ขึ้น” เสมอ

String() ที่​เขียน​เอง​รับ​ค่า​นอก​ช่วง​ด้วย ถ้า​ใคร​แปลง Status(9) เข้า​มา มัน​คืน Status(9) แทนที่​จะ panic ตอน​อ่าน statusName[9]

`String()` ไม่มี​ผล​กับ `encoding/json`

คน​ที่มา​จาก C# หรือ Java คาด​ว่า​เขียน String() แล้ว JSON จะ​ได้​ชื่อ​สถานะ​ไป​ด้วย ลอง​จริง แล้ว​ผล​ไม่ใช่​อย่าง​นั้น

package main
import (
"encoding/json"
"fmt"
)
type Status int
const (
StatusDraft Status = iota
StatusPaid
)
func (s Status) String() string { return [...]string{"draft", "paid"}[s] }
func main() {
b, err := json.Marshal(map[string]Status{"status": StatusPaid})
if err != nil {
panic(err)
}
fmt.Println(string(b))
fmt.Println(StatusPaid)
}
$ go run .
{"status":1}
paid

String() มี​ผล​กับ fmt เท่านั้น encoding/json มอง​หา MarshalJSON ของ json.Marshaler หรือ MarshalText ของ encoding.TextMarshaler แทน ไม่มี​ตัว​ไหน​เกี่ยว​กับ String() เลย

ชนิด​ที่​สร้าง​มา​ทั้งหมด​ประกอบ​กัน​เป็น internal/order/order.go ของ orderctl

Line คือ​หนึ่ง​รายการ​อาหาร Order คือ​ใบ​เดียว​ที่​ถือ​หลาย​รายการ ทั้ง​คู่​เป็น struct ธรรมดา ที่​ไม่มี class ฐาน ไม่มี constructor และ​ไม่มี​อะไร​ซ่อน​อยู่

// Package order เก็บชนิดข้อมูลของออเดอร์
package order
import (
"fmt"
"strconv"
)
// Baht เก็บเงินเป็นสตางค์ ไม่ใช่ float64
type Baht int64
func (b Baht) String() string {
sign := ""
if b < 0 { // การหารและ mod ของ Go ตัดเข้าหาศูนย์ ทั้งสองผลจึงติดลบพร้อมกัน
sign, b = "-", -b
}
return fmt.Sprintf("%s%d.%02d บาท", sign, b/100, b%100)
}
// Status คือสถานะของออเดอร์ ค่า 0 คือ StatusDraft
type Status int
const (
StatusDraft Status = iota
StatusPaid
StatusCancelled
)
var statusName = [...]string{"draft", "paid", "cancelled"}
func (s Status) String() string {
if s < 0 || int(s) >= len(statusName) {
return "Status(" + strconv.Itoa(int(s)) + ")"
}
return statusName[s]
}
// Line คือหนึ่งรายการในออเดอร์
type Line struct {
SKU string
Category string
Qty int
Unit Baht
}
// Order คือออเดอร์หนึ่งใบ
type Order struct {
ID string
Table int
Lines []Line
Status Status
}
// Subtotal ใช้ value receiver เพราะอ่านอย่างเดียว
func (o Order) Subtotal() Baht {
var sum Baht
for _, l := range o.Lines {
sum += l.Unit * Baht(l.Qty) // Baht(l.Qty) ละไม่ได้
}
return sum
}
// AddLine ใช้ pointer receiver เพราะแก้ค่าใน receiver
func (o *Order) AddLine(l Line) { o.Lines = append(o.Lines, l) }

ชื่อ​ที่​ขึ้น​ต้น​ด้วย​ตัว​ใหญ่​มอง​เห็น​ได้​จาก package อื่น ชื่อ​ที่​ขึ้น​ต้น​ด้วย​ตัว​เล็ก​มอง​ไม่​เห็น statusName จึง​เป็น​ของ​ภายใน​ที่​ไม่มี​ใคร​นอก package แตะ​ได้

method ใน Go ไม่​ได้​อยู่​ข้าง​ใน​วงเล็บ​ปีกกา​ของ struct มัน​เป็น​ฟังก์ชัน​ที่​มี​ตัว​รับ​เพิ่ม​มา​ข้าง​หน้า​ชื่อ ผล​คือ​คุณ​ผูก methodกับ​ชนิด​ไหน​ก็ได้​ที่​นิยาม​ใน package เดียวกัน ไม่​จำกัด​แค่ struct

Subtotal รับ​สำเนา​แล้ว​อ่าน​อย่าง​เดียว AddLine รับ​ที่​อยู่​เพราะ​ต้อง​แก้​ของ​จริง

Go ส่ง​ทุก​อย่าง​แบบ​คัด​ลอก​ค่า value receiver จึง​ได้ struct ก้อน​ใหม่​มา​ทั้ง​ใบ การ​แก้​อะไร​ใน​นั้น หาย​ไป​พร้อม​สำเนา​เมื่อ methodจบ

pointer receiverpointer receivermethod ที่​รับ `*T` แก้​ค่า​ใน receiver ได้ และ​ทำให้​ค่า​แบบ value ไม่​เข้า​กับ interface ที่​มี method นั้น รับ​ที่​อยู่​ของ​ตัว​จริง​แทน การ​แก้​จึง​ถึง​ตัว​จริง ลอง​เทียบ​สอง​แบบ​ตรงๆ

package main
import "fmt"
type Line struct {
SKU string
Qty int
}
type Order struct {
ID string
Lines []Line
}
// ❌ แบบที่ผิด: value receiver ได้สำเนามา แก้แล้วไม่ถึงตัวจริง
func (o Order) addLineBroken(l Line) { o.Lines = append(o.Lines, l) }
// pointer receiver: แก้ตัวจริง
func (o *Order) AddLine(l Line) { o.Lines = append(o.Lines, l) }
func main() {
o := Order{ID: "A1"}
o.addLineBroken(Line{SKU: "phad-thai", Qty: 1})
fmt.Println(len(o.Lines))
o.AddLine(Line{SKU: "phad-thai", Qty: 1})
fmt.Println(len(o.Lines))
}
$ go run .
0
1

addLineBroken ไม่​แจ้ง​เตือน​อะไร​เลย มัน​คอม​ไพล์​ผ่าน รัน​ผ่าน และ​ไม่​ทำ​อะไร​ที่​มอง​เห็น​ได้ นี่​คือ​รูป​แบบ​ความ​ผิด​พลาด​ที่​เงียบ​ที่สุด​ของ Go

flowchart TB
  A["o := Order{ID: A1}<br/>ตัวจริงอยู่ในตัวแปร o"] --> B["เรียก o.addLineBroken(l)<br/>value receiver"]
  A --> C["เรียก o.AddLine(l)<br/>pointer receiver"]
  B --> D["Go คัดลอก Order ทั้งก้อน<br/>method แก้สำเนา"]
  D --> E["สำเนาถูกทิ้งเมื่อ methodจบ<br/>o ไม่เปลี่ยน"]
  C --> F["Go ส่งที่อยู่ของ o ให้เอง<br/>method แก้ที่เดียวกับตัวจริง"]
  F --> G["o เปลี่ยนจริง"]

คำ​บรรยาย​ภาพ: การ​เรียก​ผ่าน value receiver ทำให้ Go คัด​ลอก Order ทั้ง​ก้อน method จึง​แก้​สำเนา​ที่​ถูก​ทิ้ง​เมื่อ​จบ · การ​เรียก​ผ่าน pointer receiver ส่ง​ที่​อยู่​ของ​ตัว​จริง​ให้ การ​แก้​จึง​อยู่​กับ o ต่อ​ไป

สังเกต​ว่า​เรา​เขียน o.AddLine(...) ไม่ใช่ (&o).AddLine(...) เมื่อ​ตัวแปร​หา​ที่​อยู่​ได้ Go เติม & ให้​เอง ทิศ​กลับ​กัน​ก็​ทำให้ method แบบ value เรียก​ผ่าน pointer ได้​เช่น​กัน

กฎ​ที่​ใช้​ตัดสิน​ใจ​ได้​ทุก​ครั้ง

Go Code Review Comments ให้​หลัก​ไว้​สั้นๆ ว่า “when in doubt, use a pointer receiver” และ “Don’t mix receiver types”

ข้อ​หลัง​สำคัญ​กว่า​ที่​คิด method ที่​รับ pointer ทำให้ method set ของ Order กับ *Order ต่าง​กัน AddLine จึง​อยู่​ใน method set ของ *Order เท่านั้น

การ​ปน​สอง​แบบ​ทำให้​คุณ​เผลอ​คิด​ว่า Order มี methodครบ เพราะ​บาง methodเรียก​จาก​ค่า​ธรรมดา​ได้​จริง แล้ว​บท​ที่ 5 คุณ​จะ​เจอ​ว่า​ค่า​แบบ value ไม่​เข้า​กับ interface ที่​คุณ​เพิ่ง​ประกาศ ทั้ง​ที่ methodมี​ครบ

คอร์ส​นี้​ปน​สอง​แบบ​ใน Order โดย​ตั้งใจ เพื่อ​ให้​บท​ที่ 5 มี​ของ​จริง​ให้​ชี้

ตัว​รัน​เทสต์​อยู่​ใน​กล่อง​เดียว​กับ compiler และ​สำนวน​มาตรฐาน​คือ​เทียบ got กับ want เอง

เทสต์​ของ Go อยู่​ใน file ที่​ลงท้าย​ด้วย _test.go ใน​ไดเรกทอรี​เดียว​กับ​ของ​ที่​มัน​ทดสอบ ฟังก์ชัน​ต้อง​ขึ้น​ต้น​ด้วย Test และ​รับ *testing.T ตัว​เดียว

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
// internal/order/order_test.go — อยู่ package order เดียวกับ order.go ข้างบน
package order
import "testing"
func TestSubtotal(t *testing.T) {
o := Order{Lines: []Line{{SKU: "phad-thai", Qty: 2, Unit: 6000}}}
if got, want := o.Subtotal(), Baht(12000); got != want {
t.Errorf("Subtotal() = %v, want %v", got, want)
}
}
func TestAddLine(t *testing.T) {
var o Order
o.AddLine(Line{SKU: "tom-yum", Qty: 1, Unit: 12000})
if got, want := len(o.Lines), 1; got != want {
t.Errorf("len(Lines) = %d, want %d", got, want)
}
}
$ go test ./internal/order
ok example.com/orderctl/internal/order 0.150s

ไม่มี assertThat ไม่มี Should().Be() มี​แค่ if กับ t.Errorf และ​นี่​คือ​สำนวน​ที่ standard library เอง​ใช้ ไม่ใช่​ทาง​ลัด​สำหรับ​ผู้​เริ่ม​ต้น

ชื่อ​ตัวแปร got กับ want เป็น​ธรรมเนียม​ที่​ทั้ง​ชุมชน​ใช้ ข้อความ​ความ​ผิด​พลาด​จึง​อ่าน​ตรง​กัน ทุก repository และ​สมอง​คุณ​ไม่​ต้อง​แปล​ใหม่​ทุก​ครั้ง

TestAddLine เริ่ม​ด้วย var o Order ได้​เลย เพราะ zero value ของ Order มี Lines เป็น nil slice และ append ทำงาน​กับ nil slice ได้​ตาม​ปกติ

`t.Errorf` กับ `t.Fatalf` ต่าง​กัน​ตรง​ไหน

t.Errorf บันทึก​ว่า​เทสต์​นี้​ล้ม แล้ว เดิน​ต่อ ใน​ฟังก์ชัน​เดิม เหมาะ​กับ​กรณี​ที่​ยัง​ตรวจ​ข้อ​อื่น​ได้​อีก

t.Fatalf บันทึก​แล้ว หยุด​ฟังก์ชัน​นั้น​ทันที ใช้​เมื่อ​บรรทัด​ถัด​ไป​จะ panic แน่ๆ ถ้า​เดิน​ต่อ เช่น​ตอน​ที่​เพิ่ง​ได้ error กลับ​มา​แทน​ค่าที่​ต้อง​ใช้

เลือก​ผิด​ข้าง​ไหน​ก็​เสีย​อย่าง Errorf ที่​ควร​เป็น Fatalf ทำให้​ผลก​ลาย​เป็น panic ที่​อ่าน​ยาก ส่วน Fatalf ที่​ควร​เป็น Errorf ปิดบัง​ความ​ผิด​พลาด​ข้อ​อื่น​ที่​อยู่​ถัด​ไป

orders["A1"].Status = StatusPaid เป็น​ความ​ผิด​พลาด​ตอน​คอม​ไพล์ ไม่ใช่​ตอน​รัน

orderctl จะ​เก็บ​ออ​เด​อร์​หลาย​ใบ​ใน map ใน​บท​หลัง และ​บรรทัด​แรก​ที่​ทุก​คน​เขียน​คือ​บรรทัด​ที่​ผิด

package main
import "fmt"
type Status int
const (
StatusDraft Status = iota
StatusPaid
)
type Order struct {
ID string
Status Status
}
var orders = map[string]Order{"A1": {ID: "A1"}}
func main() {
// orders["A1"].Status = StatusPaid
// cannot assign to struct field orders["A1"].Status in map
o := orders["A1"] // สมาชิกของ map ไม่ addressable ต้องคัดออกมา
o.Status = StatusPaid
orders["A1"] = o // แล้วเขียนกลับ
fmt.Println(orders["A1"].Status == StatusPaid)
}
$ go run .
true

เหตุผล​อยู่​ที่ map ย้าย​ค่า​ของ​ตัว​เอง​ได้​เมื่อ​มัน​โต ที่​อยู่​ของ​สมาชิก​จึง​ไม่​คงที่ และ​ภาษา​ไม่​ยอม​ให้ คุณ​ถือ​ที่​อยู่​นั้น​ไว้ compiler เลย​ปฏิเสธ​ตั้งแต่​ต้น

ทางออก​มี​สอง​ทาง​ที่​ใช้​จริง เลือก​ตาม​ว่า​คุณ​อยาก​ให้​คน​อ่าน​เห็น​อะไร

  • คัด​ค่า​ออก​มา​แก้​แล้ว​เขียน​กลับ ชัดเจน​ว่า​มี​การ​เขียน​กลับ และ​ค่า​ใน map ยัง​เป็น struct เต็ม​ใบ
  • เก็บ​เป็น map[string]*Order แทน แก้​ผ่าน pointer ได้​ตรงๆ แต่​ต้อง​รับ​ผิด​ชอบ​เรื่อง​ค่า nil และ​เรื่อง​ที่​หลาย​จุด​ถือ​ของ​ชิ้น​เดียวกัน​อยู่

orderctl เลือก​ทาง​แรก​เสมอ เพราะ​ข้อ​มูล​ออ​เด​อร์​ใน​คอร์ส​นี้​เดิน​ทาง​ข้าม package บ่อย และ​การ​แชร์ pointer ทำให้​บท​ที่ 8 เรื่อง​ทำงาน​พร้อม​กัน​ยาก​ขึ้น​โดย​ไม่​จำเป็น

internal/order มี​ชนิด​ครบ​และ​มี​เทสต์​แล้ว แต่​ยัง​อ่า​นอ​อเด​อร์​จาก​ที่ไหน​ไม่​ได้

คุณ​สร้าง​ชนิด​โดเมน​ของ​ตัว​เอง​แล้ว รู้​ว่า​ทำไม Baht ต้อง​เป็น​ชนิด​ใหม่ ไม่ใช่ int64 เปล่าๆ และ​รู้​ว่า​ทำไม​บรรทัด​แรก​ของ const block ต้อง​เป็น​สถานะ​ที่​ปลอดภัย

คุณ​เลือก receiver ได้​ด้วย​เหตุผล ไม่ใช่​ด้วย​ความ​เคยชิน และ​เขียน​เทสต์​ที่​ไม่​พึ่ง library ใด​เลย

บท​หน้า orderctl จะ​อ่า​นอ​อเด​อร์​จริง​จาก JSON บน disk แล้ว​พิมพ์​ยอด​ออก​มา ตรง​นั้น​คุณ​จะ​เจอ struct tag, การ​ห่อ​ความ​ผิด​พลาด​ด้วย %w และ defer ที่​ปิด​ของ​ให้​ทุก​ทางออก


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • The Go Programming Language Specification (Language version go1.27, 2026-05-26) — หัวข้อ The zero value, Type definitions, Iota และ Method sets
  • Go Specification — Operators (Language version go1.27, 2026-05-26) — “For other binary operators, the operand types must be identical unless the operation involves shifts or untyped constants.”
  • Go Specification — Assignability (Language version go1.27, 2026-05-26) — ข้อ​ที่​ยอม​ข้าม​ชื่อ​ให้ ต้องการ​ทั้ง​ชนิด​ฐาน​ที่​ตรง​กัน และ​อย่าง​น้อยห​นึ่ง​ฝั่ง​ที่​ไม่ใช่ named type
  • Go Code Review Comments — receiver type (เข้า​ถึง 2026-08-27) — “when in doubt, use a pointer receiver” และ “Don’t mix receiver types”

เช็กความเข้าใจ — บทที่ 2

ข้อ 1 / 3

ประกาศ `var o Order` แล้วอ่าน field ของมันทันที เกิดอะไรขึ้น