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

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 ฝั่งผู้เรียกที่เขียนโดยอิงตามสัญญาของ Rectangle
static 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 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)