/ OOD

[365 วันแห่งโปรแกรม #day46] SOLID (ตอนที่ 3 Liskov substitution)

วันที่สี่สิบหกของ ‪#‎365วันแห่งโปรแกรม และแล้วเรื่องราวของ SOLID ก็ดำเนินมาถึงตอนที่ 3 แล้วครับ ในชื่อเรื่องว่า Liskov substitution


Liskov substitution principle

Liskov substitution principle หรือ LSP เป็น principle ข้อที่สามของ SOLID ข้อนี้กล่าวไว้ว่า กำหนดให้ S เป็น subtype ของ T และโปรแกรม P มีการใช้งาน object ของคลาส T แล้ว object ของคลาส T ใดๆ ในโปรแกรม P จะต้องสามารถแทนที่ด้วย object ของคลาส S ได้ และโปรแกรมทำงานถูกต้องโดยไม่ต้องแก้อะไรเพิ่มเติม

ดูเหมือนง่ายใช่ไหมครับ เพราะใน OOP ก็บอกชัดเจนอยู่แล้วว่า object ของคลาสลูก ก็ถือเป็น object ของคลาสแม่ได้ด้วย แต่จริงๆ แล้วมันไม่ได้ง่ายขนาดนั้นครับ บ่อยคร้างที่เราทำ implementation inheritance แล้วมีการ override property ของ base class ซึ่งนั่นแหละที่จะทำให้ object ของคลาสใหม่เรา เอาไปใช้แทน object ของคลาสเดิมไม่ได้ 100%

ตัวอย่างที่ง่ายที่สุดน่าจะเป็นเรื่องการสร้าง Stack โดย derive จาก List เมื่อเราสร้าง Stack จาก List แล้ว อะไรจะเกิดขึ้นบ้าง?

  • เราสามรถ insert ข้อมูลลงไปยังตำแหน่งใดๆ ของ Stack ก็ได้ แก้โดย throw exception ซะ ถ้ามีการ insert แบบระบุตำแหน่ง

  • เราสามรถอ่านข้อมูล ณ ตำแหน่งใดๆ ใน Stack ก็ได้ แก้โดยใช้วิธีเดียวกัน

แต่เมื่อเราบอกว่าแก้โดยโยน exception แล้ว ผลที่ตามมาคืออะไร?

  • เมื่อเราสร้างตัวแปร List โดยให้ชี้ไปยัง object ของ Stack แล้วก็ทำการ insert ข้อมูลแบบระบุตำแหน่ง เราก็จะได้ exception

  • เมื่อเราสร้างตัวแปร List โดยให้ชี้ไปยัง object ของ Stack แล้วก็ทำการอ่านข้อมูลแบบระบุตำแหน่ง เราก็จะได้ exception

เคสนี้อาจจะยอมรับได้หรือไม่ได้ก็ได้ครับ ถ้าถามว่าเคสที่กล่าวมาเนี่ยมันเป็นไปตาม LSP หรือไม่ ก็ต้องไปดูที่โปรแกรม P (ในเรื่องนี้ต้องอ้างถึงโปรแกรมที่เรียกใช้งานมันตลอด) ว่าโปรแกรม P ในส่วนที่มีการใช้ List นั้นมีการดัก exception นี้เอาไว้หรือเปล่า ถ้ามีแปลว่าถูกหลัก LSP ครับ แต่ถ้าไม่มีก็แปลว่ากำลังแหกกฎ LSP อยู่ (เพราะโปรแกรมทำงานไม่ได้) ที่กล่าวมานี้เป็นแค่การวินิฉัยเบื้องต้นเท่านั้นนะครับ ยังมีรายละเอียดที่ต้องพิจารณาดังนี้ครับ

Method ที่ถูก override ต้องมี signature ดังนี้จึงจะเป็นไปตามหลัก LSP

  • method arguments แต่ละตัวต้องเป็น type เดิม หรือเป็น Contravariance ของ type เดิม เช่น ถ้าตอนแรก method ของคลาสแม่รับพารามิเตอร์เป็น Circle แล้ว method เดียวกันนี้ของคลาสลูกจะต้องรับพารามิเตอร์เป็น Circle หรืออะไรก็ได้ที่เป็นคลาสแม่ของ Circle

  • return type จะต้องเป็น type เดิม หรือเป็น Covariance ของ type เดิม เช่น ถ้าตอนแรก method ของคลาสแม่ คืนค่าเป็น string แล้ว method เดียวกันนี้ของคลาสลูกจะต้องคืนค่าเป็น string หรืออะไรก็ได้ที่ derived มาจาก string

  • จะต้องไม่มีการโยน exception ใหม่ (exception อื่น นอกเหนือจากที่คลาสแม่โยนอยู่แล้ว) ยกเว้นกรณี exception ใหม่นั้น เป็นคลาสที่ derived ไปจาก exception เดิมที่คลาสแม่โยนอยู่แล้ว

ที่กล่าวมานี้เป็นแค่เรื่องผิวๆ ของความเป็น LSP ครับ ยังต้องมีการพิจารณาเนื้อหาใน method อีก ดังนี้

Method ที่ถูก override ต้องมี behavior ดังนี้จึงจะเป็นไปตามหลัก LSP

  • Preconditions จะต้องไม่ถูกกำหนดเพิ่ม subtype กล่าวคือเงื่อนไขที่จะต้องเช็คก่อนการทำงานจะต้องเหมือนของคลาสแม่ เช่น method สำหรับปิดการใช้งานไฟล์อาจจะมีการเช็คว่าไฟล์นี้ถูกเปิดอยู่หรือเปล่า ถ้าเปิดถึงจะทำ ถ้าคลาสลูกจะ override method นี้ก็ต้องคงเงื่อนไขนี้ไว้และไม่ใส่เงื่อนไขอื่นเพิ่ม ถ้าอยากจะเพิ่ม ให้ไปพิจารณาแล้วใส่ที่คลาสแม่ (ในคลาสแม่เงื่อนไขนี้จะจะคืน true ตลอด)

  • Postconditions เดิมจะต้องไม่ถูกทำลายโดย subtype กล่าวคือผลลัพธ์ที่คาดหวังของ method เดิม จะต้องอยู่คงเดิม ห้ามเปลี่ยนแปลง

  • Invariants จะต้องถูกเก็บรักษาไว้ กล่าวคือเงื่อนไขระหว่างการประมวลผล เช่น loop condition จะต้องเป๋นไปตามเดิมห้ามแก้ไข

  • History constraint หมายถึง method ใหม่จะต้องไม่มีการแก้ไข state ของ object (state ที่ define ในคลาสแม่) นอกเหนือจากที่ method เดิมระบุไว้

เอิ่มม ถ้าจำเยอะซะขนาดนี้ สรุปสั้นๆ ว่าห้ามแก้อะไรเลยดีกว่ามั้ย แต่ให้เพิ่มได้ และต้องได้ผลลัพธ์สอดคล้องกับของเดิม ก็ยังยากอยู่ดีนั่นแหละ ><

ตัวอย่างของการแหกกฎ LSP

ผมพูดไม่ออกเลยหลังจากที่ได้ศึกษา LSP อย่างจริงๆ จังๆ ดังนั้นขอยกตัวอย่างที่เบสิคที่สุดละกันครับ ตัวอย่างที่จะกล่าวถึงนี้คือถ้าเราสร้างคลาส Square จาก Rectangle จะถือว่าขัดหลัก LSP มั้ย

จากคลาส Rectangle เมื่อวันก่อนนะครับมาแก้นิดหน่อยให้กำหนด width และ height ได้อย่างอิสระ

class Rectangle implements Shape{
    private double width;
    private double height;
    public void setWidth(width){
        this.width = width;
    }
    public void setHeight(height){
        this.height= height;
    }
    public void setSize(width, height){
        this.width = width;
        this.height= height;
    }
    public double getArea(){
        return this.width * this.height;
    }
}

เราต้องการสร้างคลาส Square จาก Rectangle นี้

class Square extends Rectangle{
    public void setWidth(width){
        this.width = width;
        this.height = width;
    }
    public void setHeight(height){
        this.width = height;
        this.height = height;
    }
    public void setSize(width, height){
        throw new NotImplementedException();
    }
}

จากตัวอย่างนี้มีการแหกกฎ LSP อยู่ 3 method เลยครับ สองอันแรกคือ setWidth และ setHeight ผิดเพราะว่า Postconditions ผิด เนื่องจากการกำหนดความกว้างและสูงไม่เป็นอิสระต่อกัน ซึ่งก็ส่งผลต่อตัว unit test ด้วย เช่นถ้าเราใส่ width = 5 height = 4 ก็ย่อมคาดหวังว่าจะได้ area = 20 แต่กลับได้ 16 (เพราะ set height ทีหลัง) จึงถือว่าผิด ต่อมา setSize นี่ผิด เพราะมีการโยน exception ใหม่ที่ method เดิมไม่เคยโยน แต่เรื่องนี้อาจจะอนุโลมได้ถ้าโปรแกรม P นั้นออกแบบมารองรับ

เรื่องก็เป็นเช่นนี้แล...

#‎day46 #365วันแห่งโปรแกรม ‪#‎โครงการ365วันแห่ง‬...