ห้าม Compare Struct ใน Go

หลายเดือนก่อนทีมทำเรื่อง data encryption ใน Go ซึ่งเราจะมี struct EncryptedString ซึ่งเก็บ string แบบเข้ารหัสไว้แล้ว

ปัญหาก็คือเรากังวลว่า developer อาจจะคิดว่ามันใช้งานเหมือน string ปกติแล้วไป compare (encryptedA == encryptedB) ซึ่งมันอาจจะเท่าหรือไม่เท่ากันก็ได้ขึ้นอยู่กับ internal state ของ string ก็ต้องแจ้ง developer ไปว่าให้ใช้ Equal() เสมอ อย่าใช้ ==

บังเอิญช่วงนั้นผมเขียน Protobuf อยู่พอดีเลยไปเห็น google.golang.org/protobuf/internal/pragma ซึ่งผมว่ามันมีแต่ hack เทพๆ ที่อ่านแล้วฉลาดขึ้นเลย

DoNotCompare

Hyrum’s Law บอกว่าถ้ามีคนใช้ API มากพอ ต่อให้ไม่ได้บอกว่าใช้แบบนี้ได้ มันจะมีคนไปใช้ตามพฤติกรรมที่สังเกตได้เสมอ ซึ่งเราอาจจะเคยได้ยินว่าบางระบบอาจจะถึงกับต้อง emulate bug เพื่อให้มันใช้งานได้ เช่นใน StarCraft Remastered นั้น Blizzard ถึงกับต้องจำลอง buffer overflow เพราะ map เก่าๆ บางอันใช้

ในเคสของ Protobuf แปลว่าถ้าใช้ == กับ Protobuf message ได้แถม return ค่าถูกต้อง ก็คงจะมีคนใช้ == แน่นอน พอออก version ใหม่ที่เปลี่ยนโครงสร้างของ struct ก็พัง ทั้งๆ ที่ไม่เคยบอกว่าใช้ == ได้ ดังนั้น Go Protobuf เลยมี “pragma” หรือสูตรสำเร็จที่ฝังเข้าไปใน struct เพื่อทำให้พฤติกรรมต้องห้ามนั้นมี error หรือ warning เกิดขึ้นได้

ในบล็อคนี้ผมจะพาไล่ทั้ง 4 pragma ในแพคเกจขณะที่เขียนนี้ พร้อมทั้งเฉลยว่ามันทำได้อย่างไร อ่านแล้วผมแนะนำให้ลองคิดตามก่อนที่จะอ่านเฉลยครับ

โค้ด DoNotCompare มีอยู่ว่า

type DoNotCompare [0]func()

โดยเวลาใช้งานให้ฝังเข้าไปใน struct เช่น

type X struct {
    Value int

    DoNotCompare
}

เมื่อเรา compile จะได้ข้อความว่า

./prog.go:17:20: invalid operation: a == b (struct containing DoNotCompare cannot be compared)

เหตุผลที่เป็นอย่างนี้เพราะใน Go เราไม่สามารถเปรียบเทียบ function ได้

./prog.go:17:31: invalid operation: fmt.Println == fmt.Println (func can only be compared to nil)

การ compare struct นั้นมันจะ compare ทุก field ภายใน struct เมื่อเจอ field นี้ที่ type เป็น function ซึ่ง compare ไม่ได้แล้ว compiler จึงจะไม่ยอมให้ compile

ส่วน [0] ด้านหน้า ไว้เดี๋ยวจะเฉลยครับ…

DoNotCopy

ใน Go เราอาจจะมีการ pass by value ในบางครั้งซึ่งมันจะ copy object เข้าไปใน function เช่น

type X struct {
    Value string
}

func RecvX(v X) {
    v.Value = "changed"
}

func main() {
    x := X{value: "init"}
    RecvX(x)
    fmt.Println(x.Value)
}

ในกรณีนี้ เนื่องจาก RecvX ได้รับ X แบบ pass by value ทำให้ v เป็น copy ของ x ใน main เมื่อ print ออกมาจะได้ผลลัพท์ว่า “init” ไม่ใช่ “changed”

เพื่อป้องกันการใช้งานแบบ pass by value โค้ด DoNotCopy จึงมีดังนี้

type DoNotCopy [0]sync.Mutex

โค้ดนี้จะต่างกับ DoNotCompare คือสามารถ compile ผ่านได้ตามปกติ แต่ถ้ารัน go vet แล้วจะมี error ดังนี้

./prog.go:16:14: RecvX passes lock by value: play.X contains sync.Mutex

สำหรับคนที่เคยใช้งาน sync.Mutex แล้วน่าจะทราบดีว่าห้าม copy sync.Mutex เด็ดขาดเนื่องจากจะทำให้มี lock 2 ชุดแทนที่จะมีชุดเดียว ซึ่ง go vet จับให้อยู่แล้ว ดังนั้นการฝัง sync.Mutex เข้าไปใน struct ถึงแม้ไม่ได้ใช้ก็ทำให้ go vet ทำงาน

คำถามจากหัวเรื่องที่แล้วคือ แล้ว [0] คืออะไร? syntax นี้ผู้เริ่มต้น Go บางคนอาจจะเรียน บางคนอาจจะข้ามไปเลยเพราะไม่ค่อยได้ใช้ syntax นี้คือ fixed size array ใน Go เช่น [10]int คือ slice ที่มีสมาชิกเป็น int 10 ตัวไม่สามารถเพิ่มลดได้ (และมันเป็นคนละ type ไม่ใช่ slice ใช้แทนที่ slice ปกติไม่ได้)

เนื่องจากจำนวนสมาชิกเป็นค่าที่ทราบตั้งแต่ compile ทำให้ compiler สามารถจอง memory ให้สมาชิกทั้ง 10 ตัวได้ล่วงหน้า ในกรณีของ pragma 2 ตัวก่อนหน้านี้เราระบุเป็น [0] ก็คือไม่มีสมาชิก ทำให้ compiler ไม่จองพื้นที่สำหรับเก็บข้อมูลไว้เลย ทำให้ไม่มี overhead จากการใช้งาน

NoUnkeyedLiterals

ปัญหาถัดมาที่เจอคือมันจะมีคนมักง่าย แบบนี้…

type X struct {
    Value1 string
    Value2 int
}

func main() {
    X{"v", 1}
}

พอสร้าง struct โดยไม่ระบุชื่อ field ปัญหาที่ตามมาคือถ้าจะเพิ่ม field คนที่เพิ่มจะกรีดร้องเพราะต้องไล่ตามแก้ของเก่าให้หมด

โค้ด NoUnkeyedLiteral ง่ายๆ แค่

type NoUnkeyedLiterals struct{}

ใช่แล้ว มันคือ struct ง่ายๆ ไม่มีอะไรเลย เนื่องจากมันจะทำให้ struct มี 3 field (ไม่ใช้ 3 field) โค้ดด้านบนจึงจะเจอ

./prog.go:14:27: too few values in X{...}

ทำให้ไม่สามารถระบุแค่ 2 field ที่ใช้งานได้ ต้องระบุค่าของ NoUnkeyedLiteral ด้วย ก็คือต้องพิมพ์

X{"v", 1, NoUnkeyedLiteral{}}

ซึ่งก็ควรจะเอะใจได้แล้วนะว่าเค้าไม่ให้ทำแบบนี้ !!

DoNotImplement

Pragma ตัวสุดท้ายคือ DoNotImplement ซึ่งไม่อนุญาตให้แพคเกจอื่น implement interface นี้ เนื่องจากใน Go นั้น interface ไม่จำเป็นต้องประกาศ implement ขอแค่มี method ตรงกันเป็นอันใช้ได้ จึงไม่สามารถประกาศ interface เป็น private ได้

โค้ดของ DoNotImplement หน้าตาแบบนี้

type DoNotImplement interface{ ProtoInternal(DoNotImplement) }

โดยการใช้งานก็ให้ฝังเข้าไปใน Interface อื่น

type Printable interface {
    Print()

    DoNotImplement
}

ผมใช้เวลาหลายชั่วโมงพยายามทำความเข้าใจโค้ดนี้ ใคร declare ProtoInternal? แล้วมัน recursive มันช่วยยังไง?

ถ้าจัดรูปใหม่แล้ว มันจะเป็น

type DoNotImplement interface {
    ProtoInternal(value DoNotImplement)
}

ก็คือเป็น interface ที่มี 1 method ชื่อ ProtoInternal รับ argument 1 อย่างเป็น type ตัวเอง

แต่ เอ มันก็แก้ด้วยการเขียน implementation ง่ายๆ แบบนี้

func (x *X) ProtoInternal(value DoNotImplement) {}

หรือเปล่า….

ผมอ่าน docs อยู่ 2 ชั่วโมงก่อนจะไปสังเกตชื่อแพคเกจ google.golang.org/protobuf/internal/pragma

ใน Go แพคเกจจะไม่อนุญาตให้ import package ที่มี path internal มาจากแพคเกจอื่นๆ ดังนั้นถ้าจะ implement interface นี้ได้จริง ก็ต้อง import DoNotImplement มาใส่ใน argument ให้ได้ด้วย และเนื่องจากว่ามันเป็น internal จึงมีเฉพาะ package ที่ได้รับอนุญาตเท่านั้นจึงจะ import ได้

ทริคนี้ยังใช้ใน NoUnkeyedLiteral ในหัวข้อก่อนหน้าด้วย โดยถ้าจะดันทุรังใช้ X{"v", 1, NoUnkeyedLiteral{}} ก็จะเจอปัญหาเหมือนกันว่า import NoUnkeyedLiteral ไม่ได้

Genius

ทั้ง 4 ทริคนี้ผมว่าเป็นโค้ดที่เขียนได้ฉลาดมากๆ เหมือนเป็น puzzle และตอนอ่านผมก็คิดว่ามันลับสมองมากๆ ที่จะไล่ดูกลวิธีของผู้สร้างว่าเค้าวางกับดักโดยใช้แค่ Go standard tools ได้อย่างไร

ในทีมผมก็คุยกันแล้วว่าทริคลักษณะนี้อาจจะยังไม่อยากใช้เท่าไรนักเพราะภายในบริษัทอาจจะยังพอตกลงกันหรือตรวจกันใน code review ได้โดยไม่ต้องพึ่ง hack แต่ในระดับ protobuf ที่เป็น library ใช้กันแพร่หลายแล้วการบังคับทางเทคนิคนี้ก็เป็นเรื่องจำเป็นที่จะไม่ให้คนเขียนโค้ดแบบ genius เล่นท่าพิสดารที่อัพเกรดไม่ได้