Liskov Substitution Principle
ชนิดย่อยต้องแทนที่ชนิดฐานได้เสมอ
Liskov Substitution Principle (LSP) กล่าวว่า ชนิดย่อย (subtype) ต้องสามารถแทนที่ชนิดฐาน (base type) ของมันได้ โดยไม่ทำให้คุณสมบัติที่พึงประสงค์ของโปรแกรมเปลี่ยนแปลงไป เมื่อหลักการนี้ถูกละเมิด มักส่งผลให้เกิดตรรกะเงื่อนไข (conditional logic) ส่วนเกินกระจัดกระจายไปทั่ว application คอยตรวจสอบว่า object เป็นชนิดใดกันแน่ code ที่ซ้ำซ้อนและกระจัดกระจายนี้กลายเป็นแหล่งเพาะ bug เมื่อ application เติบโตขึ้น
แนวคิดหลัก
หัวข้อที่มีชื่อว่า “แนวคิดหลัก”Barbara Liskov เสนอแนวคิดนี้ครั้งแรกใน keynote ปี 1987 ชื่อ “Data Abstraction and Hierarchy” และต่อมาได้ร่วมกับ Jeannette Wing กำหนดนิยามอย่างเป็นทางการในปี 1994 ว่า หาก S เป็น subtype ของ T แล้ว object ของชนิด T ในโปรแกรมต้องสามารถถูกแทนที่ด้วย object ของชนิด S ได้ โดยไม่ทำให้คุณสมบัติที่พึงประสงค์ของโปรแกรมนั้นเปลี่ยนแปลงไป
บทเรียนแนะนำการเขียนโปรแกรมเชิงวัตถุส่วนใหญ่มักพูดถึง inheritance และอธิบายว่า object หนึ่งสืบทอดจากอีก object ได้หากมันมีความสัมพันธ์แบบ “IS-A” (เป็นชนิดหนึ่งของ) กับ object ที่สืบทอดมา อย่างไรก็ตาม เงื่อนไขนี้จำเป็นแต่ยังไม่เพียงพอ ที่ถูกต้องกว่าคือการกล่าวว่า object หนึ่งควรถูกออกแบบให้สืบทอดจากอีก object ได้ก็ต่อเมื่อมันมีความสัมพันธ์แบบ “IS-SUBSTITUTABLE-FOR” (แทนที่ได้) เสมอ
งาน design-by-contract ของ Liskov และ Wing แปลงหลักการนี้ให้เป็นกฎที่ตรวจสอบได้จริงใน code สรุปได้ดังนี้:
- Precondition ห้ามเข้มกว่าเดิม — method ใน subtype ห้ามเรียกร้องเงื่อนไขนำเข้าที่เข้มงวดกว่าที่ base type ประกาศไว้ (ห้ามลด “domain” ของ input ที่ยอมรับได้)
- Postcondition ห้ามอ่อนกว่าเดิม — method ใน subtype ต้องรับประกันผลลัพธ์อย่างน้อยเท่ากับที่ base type สัญญาไว้
- Invariant ของ base type ต้องคงอยู่ — สภาวะที่ base type รับประกันว่าจะเป็นจริงเสมอ ต้องยังเป็นจริงใน subtype ด้วย
- History constraint — subtype ห้ามอนุญาตให้เกิดการเปลี่ยนสถานะที่ base type ไม่อนุญาต (เช่น ทำให้ property ที่ base type คาดว่า immutable กลายเป็น mutable)
- Contravariance ของ parameter และ covariance ของค่าที่คืนกลับ — method ที่ override รับ parameter ที่กว้างกว่าหรือเท่าเดิมได้ และคืนค่าที่แคบกว่าหรือเท่าเดิมได้ แต่ห้ามกลับด้าน
- ห้ามโยน exception ใหม่ที่ base type ไม่เคยประกาศ — ผู้เรียกที่เขียน code catch ตาม contract ของ base type ต้องไม่พบ exception ที่ไม่คาดคิด
พูดง่าย ๆ คือ subtype ต้อง “เข้มงวดน้อยลงหรือเท่าเดิม” ในสิ่งที่มันเรียกร้อง และ “รับประกันมากขึ้นหรือเท่าเดิม” ในสิ่งที่มันส่งมอบ — ทิศทางตรงข้ามกันจะทำลายการแทนที่ได้ทันที
ทำไมถึงสำคัญ
หัวข้อที่มีชื่อว่า “ทำไมถึงสำคัญ”LSP คือกลไกที่ทำให้ polymorphism ใช้งานได้จริงในทางปฏิบัติ ถ้า subtype ไม่แทนที่ base type ได้อย่างปลอดภัย ผู้เรียกจะถูกบังคับให้เขียน code ตรวจสอบชนิด (type checking) ก่อนเรียกใช้งาน ซึ่งทำลายจุดประสงค์ของการมี abstraction ตั้งแต่แรก และยังทำให้ Open/Closed Principle พังไปด้วย เพราะทุกครั้งที่มี subtype ใหม่เพิ่มเข้ามา code ผู้เรียกเดิมที่ควร “ปิดสำหรับการแก้ไข” กลับต้องถูกแก้ไขเพื่อรองรับเงื่อนไขพิเศษของ subtype นั้น
Martin Fowler อธิบายอาการนี้ผ่าน code smell ชื่อ “Refused Bequest” — subclass ที่รับ (inherit) interface ของ parent มาทั้งหมด แต่กลับใช้เพียงบางส่วน แล้ว override ส่วนที่เหลือให้ทำอะไรที่ผิดไปจากที่ parent สัญญาไว้ หรือไม่ก็ปล่อยว่างเปล่า/โยน exception ทิ้ง
ตัวอย่างในโลกจริงที่มักถูกยกมาคือ Array ใน .NET ที่ implement IList<T> แต่ method Add กลับโยน NotSupportedException เพราะ array มีขนาดคงที่ นี่คือการละเมิด LSP แบบคลาสสิก เพราะ code ที่เขียนต่อ IList<T> ตามสัญญาปกติ ย่อมคาดหวังว่า Add จะทำงานได้ แต่กลับพังเมื่อได้รับ array เข้ามาแทน
Dino Esposito เขียนไว้ใน MSDN Magazine ว่า Liskov Substitution Principle เป็นหลักการที่ “เข้าใจน้อยที่สุดและถูกนำไปใช้น้อยที่สุด” ในบรรดา SOLID ทั้งหมด เพราะ compiler ของภาษาเชิงวัตถุทั่วไปตรวจสอบได้แค่ว่า “เป็นไปได้ (possible)” ที่จะใช้ subtype แทน base type แต่ไม่มีทางตรวจสอบว่ามัน “ปลอดภัย (safe)” ที่จะทำเช่นนั้น — ภาระนี้ตกอยู่ที่ผู้ออกแบบเสมอ
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ตัวอย่างคลาสสิก: สี่เหลี่ยมผืนผ้าและจัตุรัส
หัวข้อที่มีชื่อว่า “ตัวอย่างคลาสสิก: สี่เหลี่ยมผืนผ้าและจัตุรัส”ตัวอย่างที่นิยมใช้อธิบายคือชุด class เรขาคณิต ลองพิจารณา class Rectangle ที่มี property คือ Width และ Height ทีนี้ เพื่อจำลอง Square เราสืบทอดจาก Rectangle เพราะแน่นอนว่าจัตุรัส “IS-A” กรณีพิเศษของสี่เหลี่ยมผืนผ้า เมื่อ implement Square เราอาจบังคับความเป็นจัตุรัสด้วยการกำหนดให้ทั้ง Width และ Height ถูกตั้งค่าพร้อมกันทุกครั้งที่มีการตั้งค่าอย่างใดอย่างหนึ่ง เพื่อรับประกันว่า Square จะไม่มีทาง Width ไม่เท่ากับ Height
// ละเมิด LSP: Square เพิ่ม side effect ที่ Rectangle ไม่เคยประกาศไว้ในสัญญาpublic class Rectangle{ public virtual int Width { get; set; } public virtual int Height { get; set; }}
public class Square : Rectangle{ public override int Width { get => base.Width; set { base.Width = value; base.Height = value; } // ผู้เรียกไม่คาดคิด }
public override int Height { get => base.Height; set { base.Height = value; base.Width = value; } }}
// code ฝั่งผู้เรียกที่เขียนโดยอิงตามสัญญาของ Rectanglestatic int Area(Rectangle rect){ rect.Width = 4; rect.Height = 3; return rect.Width * rect.Height; // คาดหวังว่าต้องได้ 12 เสมอ}
var square = new Square();Area(square); // ได้ 9 ไม่ใช่ 12 เพราะการตั้ง Height ไปเขียนทับ Width ที่เพิ่งตั้งไว้ในกรณีนี้ invariant ของ Rectangle ไม่เคยถูกระบุอย่างชัดเจนใน code แต่มีความคาดหวังโดยปริยายในหมู่ผู้ใช้ class ว่า Width และ Height ตั้งค่าได้อย่างอิสระต่อกันโดยไม่มี side effect ต่อกันเอง Square ทำลายความคาดหวังนั้น จึงไม่สามารถแทนที่ Rectangle ได้อย่างปลอดภัย แม้จะมีความสัมพันธ์ “IS-A” ทางเรขาคณิตก็ตาม
แก้ด้วยการยึด contract ที่ซื่อสัตย์ต่อพฤติกรรมจริง
หัวข้อที่มีชื่อว่า “แก้ด้วยการยึด contract ที่ซื่อสัตย์ต่อพฤติกรรมจริง”ทางแก้ที่พบบ่อยคือเลิกบังคับให้ Square สืบทอดจาก Rectangle ที่มี mutable setter แล้วหันมาให้ทั้งสองชนิด implement interface แคบ ๆ ที่ไม่มีข้อขัดแย้งเรื่อง invariant แทน
// แก้ด้วยการแยก contract ที่แคบและซื่อสัตย์ต่อพฤติกรรมของแต่ละชนิดจริง ๆpublic interface IShape{ int Area { get; }}
public sealed class Rectangle : IShape{ public Rectangle(int width, int height) { Width = width; Height = height; } public int Width { get; } public int Height { get; } public int Area => Width * Height;}
public sealed class Square : IShape{ public Square(int side) { Side = side; } public int Side { get; } public int Area => Side * Side;}
// ผู้เรียกทำงานผ่าน contract เดียวที่ทุก subtype รักษาสัญญาได้จริง ไม่มี setter ที่ขัดแย้งกันstatic int TotalArea(IEnumerable<IShape> shapes) => shapes.Sum(s => s.Area);IShape ไม่ประกาศ setter ที่สร้างข้อขัดแย้งเรื่อง invariant เลย ทั้ง Rectangle และ Square จึงแทนที่กันได้อย่างสมบูรณ์ผ่าน IShape — นี่คือหลักการเดียวกับที่ Interface Segregation Principle แนะนำ: ยึด interface ให้แคบพอที่ทุก implementation จะรักษาสัญญาได้ครบถ้วนจริง ๆ
แผนภาพด้านล่างสรุปว่าการแทนที่ที่ปลอดภัยต้องไหลผ่าน contract กลางที่ subtype ทุกตัวรักษาสัญญาได้ ไม่ใช่ให้ผู้เรียกต้องแยกพฤติกรรมตามชนิดจริงของ object เอง
flowchart LR
Client[Client code] --> Contract[IShape Contract]
RectangleImpl[Rectangle] -.honors contract.-> Contract
SquareImpl[Square] -.honors contract.-> Contract
Contract --> Result[Correct behavior for every implementation]
การ implement เพียงบางส่วน
หัวข้อที่มีชื่อว่า “การ implement เพียงบางส่วน”การละเมิดหลักการนี้ที่พบบ่อยอีกแบบคือการ implement interface หรือ function ของ class ฐานเพียงบางส่วน โดยปล่อยให้ method หรือ property ที่ไม่ได้ทำโยน exception ทิ้ง (เช่น NotImplementedException หรือ NotSupportedException) ใน code ที่คุณรู้แน่ว่าจะถูกใช้โดยผู้เรียกเพียงรายเดียวที่คุณควบคุมได้ นี่ก็ไม่เป็นไร แต่หาก class เช่นนี้จะอยู่ใน codebase ที่ใช้ร่วมกัน หรือแย่กว่านั้น เป็น code framework ที่ส่งให้บุคคลที่สาม ควรหลีกเลี่ยงการ implement แบบนี้ หาก interface หนึ่งมี feature มากกว่าที่คุณต้องการ จงยึด Interface Segregation Principle แล้วสร้าง interface ใหม่ที่มีเฉพาะ function ที่ code ฝั่งผู้เรียกต้องใช้ และที่คุณ implement ได้ครบถ้วน
สัญญาณว่ากำลังละเมิดหลักการนี้
หัวข้อที่มีชื่อว่า “สัญญาณว่ากำลังละเมิดหลักการนี้”- code ตรวจสอบชนิด (
is/as/switchตาม type) อยู่ภายใน block ที่ควรจะเป็น polymorphic — เช่น วน loop collection ของชนิดFooแล้วเช็กว่า object แท้จริงเป็นBar(subtype ของFoo) หรือไม่ ก่อนตัดสินใจว่าจะทำอะไรต่อ - method ที่ override แล้วโยน
NotImplementedException/NotSupportedExceptionแทนที่จะทำงานตามสัญญาของ base type - method ที่ override แล้วกลายเป็นไม่ทำอะไรเลย (no-op) เพียงเพื่อ “ปิดปาก” compiler ให้ implement ครบตาม interface
- Subtype เพิ่ม precondition ที่เข้มกว่าเดิม (เช่น เพิ่มการเช็ก
Requiresใน method ที่ override) ทำให้ input ที่ base type เคยยอมรับได้ กลับถูกปฏิเสธใน subtype - Subtype ทำให้ postcondition อ่อนลง เช่น คืนค่าที่ผิดขอบเขตที่ base type เคยรับประกันไว้ หรือทิ้ง invariant ที่ base type คาดว่าจะคงอยู่เสมอ
- เอกสารประกอบของ subtype ต้องเขียนคำเตือนพิเศษว่า “ห้ามใช้ method นี้ในกรณี X” ทั้งที่ base type ไม่เคยมีข้อจำกัดนั้น — เป็นสัญญาณว่า contract ถูกละเมิดแบบเนียน ๆ
- unit test ของ base type ทั้งชุด (ที่เขียนโดยไม่รู้จัก subtype ใด ๆ) รันผ่าน base type แต่ล้มเหลวเมื่อรันชุดเดียวกันกับ instance ของ subtype (Liskov Substitution Test)
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Interface Segregation Principle
- Open/Closed Principle
- Dependency Inversion Principle
- SOLID
- Strategy Pattern
แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/principles/liskov-substitution-principle
- Liskov substitution principle — Wikipedia
- Cutting Edge: Code Contracts — Inheritance and the Liskov Principle, MSDN Magazine — Microsoft Learn
- C# Best Practices — Dangers of Violating SOLID Principles in C# — Microsoft Learn
- SOLID: The Liskov Substitution Principle (LSP) — Michael Whatcott