บล็อกนี้เล่าถึงหลักการ Liskov Substitution Principle (LSP) หนึ่งในหลักการของ SOLID ที่ถูกเสนอครั้งแรกโดย Barbara Liskov ในปี 1987 และถูกนำมาขยายความต่อในบริบทของ SOLID โดย Robert C. Martin หรือ Uncle Bob สรุปใจความสั้น ๆ ได้ว่า "คลาสลูกจะต้องสามารถถูกนำมาใช้แทนคลาสแม่ได้โดยที่โปรแกรมยังคงทำงานได้อย่างถูกต้องสมบูรณ์"
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
หรือนิยามดั้งเดิมของ Barbara Liskov เอง:
Let $q(x)$ be a property provable about objects $x$ of type $T$. Then $q(y)$ should be true for objects $y$ of type $S$ where $S$ is a subtype of $T$.
ฟังดูเป็นทางการและแอบเข้าใจยากนิดหน่อยใช่ไหม? ถ้าแปลแบบง่าย ๆ คือ "เมื่อเราเขียนโปรแกรมโดยอ้างอิงเบสไทป์ (Base Type) เราควรส่งซับไทป์ (Subtype) เข้าไปแทนได้เลย โดยที่โปรแกรมไม่ต้องเพิ่มเงื่อนไขพิเศษหรือเกิดพฤติกรรมแปลก ๆ" เพื่อให้เห็นภาพชัดขึ้น ลองมาดูตัวอย่างพร้อมโค้ดกันดีกว่า
สารบัญ
- Go SOLID - Single Responsibility Principle
- Go SOLID - Open-closed Principle
- Go SOLID - Liskov Substitution Principle
- Go SOLID - Interface Segregation Principle
เพื่อให้เห็นภาพของ LSP ชัดขึ้น ลองมาดูตัวอย่างใกล้ตัวอย่างระบบแตะบัตรเข้า MRT กัน
สมมติว่าเรามีระบบประตูทางเข้าที่ใช้แนวคิดร่วมกันทั้ง MRT และ BTS โดยเริ่มจากออกแบบอินเตอร์เฟสกลางชื่อ TransitCard แบบนี้
type TransitCard interface {
// ChargeFare should deduct exactly fare from the card.
ChargeFare(fare float64) error
}
func EnterMRTGate(card TransitCard, fare float64) error {
return card.ChargeFare(fare)
}
หน้าตาแบบนี้ดูไม่มีปัญหาอะไร และถ้าเป็นบัตร MRT แบบเติมเงินก็อาจอิมพลิเมนต์ประมาณนี้
type MRTCard struct {
balance float64
}
func (c *MRTCard) ChargeFare(fare float64) error {
if fare <= 0 {
return errors.New("invalid fare")
}
if c.balance < fare {
return errors.New("insufficient balance")
}
c.balance -= fare
return nil
}
และถ้าต้องรองรับการแตะเข้าแบบ EMV (บัตรเครดิต/เดบิต) สำหรับ MRT ด้วย ก็ยังดูไม่มีอะไรน่าห่วง
type EMVCard struct {
balance float64
}
func (c *EMVCard) ChargeFare(fare float64) error {
if fare <= 0 {
return errors.New("invalid fare")
}
if c.balance < fare {
return errors.New("insufficient balance")
}
c.balance -= fare
return nil
}
ปัญหาเริ่มเกิดตอนทีมต้องเพิ่มฟีเจอร์เข้า BTS ซึ่ง BTS ต้องเช็คแฟลก (flag) เพิ่มเติมก่อนอนุญาตให้เข้า ทีมพัฒนาเลยอยากนำฟังก์ชันเดิมที่รับ TransitCard กลับมาใช้ และเขียนออกมาประมาณนี้
func EnterBTSGate(card TransitCard, fare float64) error {
fc, ok := card.(interface{ HasBTSFlag() bool })
if !ok || !fc.HasBTSFlag() {
return errors.New("card is not allowed on BTS")
}
return card.ChargeFare(fare)
}
ถ้าลองดูฝั่งเรียกใช้งาน จะเห็นภาพชัดขึ้น
func main() {
card := &MRTCard{
balance: 500,
}
if err := EnterBTSGate(card, 20); err != nil {
log.Println("enter BTS failed:", err)
}
}
จากโค้ดนี้จะเห็นว่า ถึงแม้ MRTCard กับ EMVCard จะใช้ได้ปกติกับ MRT แต่พอเข้าเส้นทางของ BTS ฟังก์ชันที่รับ TransitCard กลับต้องเพิ่มเงื่อนไขพิเศษ และบางบัตรก็ถูกปฏิเสธทันที ทั้งที่ลายเซ็นของเมธอดเหมือนกัน นี่เป็นสัญญาณว่าการออกแบบนามธรรมเดิมกว้างเกินไป จนผู้ใช้เบสไทป์ต้องรู้รายละเอียดของซับไทป์ ซึ่งขัดกับเจตนาของ LSP
วิธีแก้คือแยกสัญญาให้ชัดตั้งแต่แรกว่า บัตรที่เข้า BTS ได้ต้องมีความสามารถเช็คแฟลกเพิ่มเติม ไม่ใช่รวมทุกอย่างไว้ใน TransitCard ตัวเดียว
type TransitCard interface {
// ChargeFare should deduct exactly fare from the card.
ChargeFare(fare float64) error
}
type FlagCheckedTransitCard interface {
TransitCard
HasBTSFlag() bool
}
func EnterMRTGate(card TransitCard, fare float64) error {
return card.ChargeFare(fare)
}
func EnterBTSGate(card FlagCheckedTransitCard, fare float64) error {
if !card.HasBTSFlag() {
return errors.New("card is not allowed on BTS")
}
return card.ChargeFare(fare)
}
type EMVCard struct {
balance float64
btsFlag bool
}
func (c *EMVCard) HasBTSFlag() bool {
return c.btsFlag
}
func (c *EMVCard) ChargeFare(fare float64) error {
if fare <= 0 {
return errors.New("invalid fare")
}
if c.balance < fare {
return errors.New("insufficient balance")
}
c.balance -= fare
return nil
}
เมื่อแก้แบบนี้ ฟังก์ชันแต่ละตัวจะพึ่งพาเฉพาะสิ่งที่จำเป็นจริง ๆ
EnterMRTGateรับแค่TransitCardEnterBTSGateรับFlagCheckedTransitCard
ผลคือผู้ใช้แต่ละฟังก์ชันไม่ต้องเดาแล้วว่าซับไทป์ตัวไหนมีพฤติกรรมพิเศษแอบอยู่ และไม่ต้องเขียน type assertion แทรกกลางทางตลอดเวลา
อย่างไรก็ตาม LSP ไม่ได้หมายถึงแค่ลายเซ็นเมธอดที่ตรงกันเท่านั้น แต่ยังรวมถึงเงื่อนไขก่อนใช้งานและผลลัพธ์หลังใช้งานด้วย เช่น
- subtype ไม่ควรต้องการเงื่อนไขก่อนใช้งานที่เข้มงวดกว่าเบสไทป์
- subtype ไม่ควรคืนค่าผลลัพธ์ที่ทำให้สัญญาเดิมของระบบอ่อนลง
- subtype ไม่ควรมีผลข้างเคียงแปลก ๆ ที่ผู้ใช้เบสไทป์ไม่ได้คาดคิด
ยกตัวอย่างเช่น ถ้าสัญญาของ ChargeFare คือ “ตัดค่าโดยสารตามจำนวนที่ระบุหรือล้มเหลว” แต่มี implementation ไหนสักตัวแอบต้องการเงื่อนไขพิเศษที่อินเตอร์เฟสไม่ได้บอกไว้ แบบนั้นต่อให้โค้ดคอมไพล์ผ่าน ก็ถือว่าน่าสงสัยว่าอาจละเมิด LSP
ในโลกของ Go เราไม่ได้มี inheritance แบบคลาสเหมือนภาษา OOP ดั้งเดิม แต่เรายังเจอปัญหาแบบ LSP ได้เสมอผ่านการใช้อินเตอร์เฟส เพราะอินเตอร์เฟสไม่ได้บอกแค่ว่า “ต้องมีเมธอดอะไรบ้าง” มันยังมีสัญญาเรื่องพฤติกรรมแนบมาด้วย
ดังนั้นเวลาออกแบบอินเตอร์เฟสหรืออ้างอิงสิ่งอื่นเข้ามาใช้งาน อย่าหยุดแค่ถามว่า “type นี้มีเมธอดครบไหม” แต่ควรถามต่อด้วยว่า “มันมีพฤติกรรมเหมือนที่ผู้ใช้ type นี้คาดหวังจริงหรือเปล่า” ถ้าคำตอบคือไม่ นั่นอาจเป็นสัญญาณว่าการออกแบบเริ่มหลุดจากหลัก LSP แล้ว