/ OOD

[365 วันแห่งโปรแกรม #day45] SOLID (ตอนที่ 2 Open/closed)

วันที่สี่สิบห้าของ ‪#‎365วันแห่งโปรแกรม วันนี้เราจะคุยกันเรื่อง Open/closed ซึ่วเป็นเรื่องที่สองของ SOLID


Open/closed principle

Open/closed principle หรือ OCP เป็นแนวทางปฏิบัติข้อที่สองของ SOLID ซึ่งบอกไว้ว่า software entities ทุกประเภทควรเปิดให้มีการเพิ่มเติม (extension) แต่ปิดไม่ให้แก้ของเก่า (modification)

"software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification"

งงใช่ไหมครับ ผมก็งงเหมือนกัน >< เรื่องก็มีอยู่ว่าถ้าเรายอมให้แก้ไขโค้ดเดิมได้ หลังจากแก้ไขเสร็จก็ต้องมาทำโค้ดรีวิว ต้องมาเทสกันอีกรอบ ตองมาตรวจสอบคุณภาพอย่างนู้นอย่างนี้ เพื่อให้มั่นใจได้ว่าทุกโมดูลที่เรียกใช้สิ่งที่เราแก้เนี่ยทำงานได้ถูกต้องเหมือนเดิมนะ ดังนั้นเพื่อจะได้ไม่ต้องทำสิ่งที่กล่าวมาข้างต้น ก็ปิดไม่ให้แก้ไขไปซะเลยก็จบ 5555

OCP เนี่ยเวลาทำเราก็ใช้หลักการของ inheritance นั่นแหละ คือไม่แก้ไขที่คลาสเดิม แต่ไปสร้างคลาสใหม่แล้วแก้เอา เพิ่มเติมเอา ซึ่งมี 2 แนวทางในการใช้ OCP ดังนี้

  • Meyer's open/closed principle เมเยอร์บอกว่าโค้ดเดิมที่เราสร้างขึ้นเนี่ยนะเราจะไม่แก้มันเลยเว้นแต่ว่ามันมีบั๊คจริงๆ แต่ถ้าอย่างเพิ่มเติมอะไรลงไปก็สร้างคลาสใหม่ไปเลย แล้ว inherit จากของเดิมเอา เมเยอร์มีความเห็นว่าควร inherit จาก implementation inheritance ไปเลย เพราะจะได้ reuse โค้ดเดิมด้วย ไม่ต้องทำใหม่ทั้งหมด

  • Polymorphic open/closed principle เป็นอีกแนวทางหนึ่งของ OCP ซึ่งขัดแย้งกับแนวทางต้นตำหรับของเมเยอร์เลย โดยอันนี้จะบอกว่าคลาสของเรานั้นต้อง implement มาจาก interface และหากเราต้องการแก้ไขหรือเพิ่มเติมอะไร ก็สร้างคลาสใหม่แล้ว implement จาก interface เดิมนั่นแหละ ไม่ต้องไปสนใจ implementation เดิม ซึ่งแนวทางนี้ได้รับการสนับสนุนจาก Robert C. Martin หรือ Uncle Bob ผู้เขียน Agile Manifesto

จะเห็นว่าไม่ว่าวิธีไหนก็บอกไว้ว่าอย่าไปยุ่งกับของเดิม เพราะเดี๋ยวมันจะมีปัญหา >< แต่แน่นอนว่าผมสนับสนุนแนวทางที่สอง (จริงๆ นะ) เดี๋ยวเรามาลองดูกันดีกว่าว่า OCP เอาไปใช้ได้ยังไง

เอา OCP ไปใช้ยังไง

ก่อนอื่นเลยเราต้องออกแบบตามหลักของ SRP ก่อน ให้สร้างเป็น Interface ไว้ด้วยนะ แล้วหลังจากนั้นเราก็ imeplement ให้เรียบร้อยซะ ตรวจสอบว่ามันทำงานได้ถูกต้องแน่นอน รีวิวโค้ด หรืออะไรก็ว่าไป แล้วก็เอาไปใช้ หลังจากนั้นเราจะไม่กลับมาแก้ implementation ตัวนี้อีกแล้ว ถ้าวันใดวันหนึ่งเกิดมีการเปลี่ยน requirement หรือต้องการเพิ่มอะไร ก็สร้างคลาสใหม่เลย implement interface เดิมใหม่ หรือบางทีอาจจะต้องสร้าง interface ใหม่ที่ extends มาจาก interface เดิมก่อนสร้างคลาสใหม่ก็แล้วแต่ครับแค่นั้นเลย

ลองลงมือทำ

จริงๆ แล้วเราก็ทำเรื่องนี้ไปแล้วไปตัวอย่างเมื่อวานไงครับ จำได้ไหมครับส่วน Document Persistence เราสร้างเป็น interface ไว้ แล้วเราก็ implement สำหรับเซฟลงไฟล์ อัพโหลดไปยัง server และถ้าเราต้องการเพิ่มการบันทึกไปที่อื่นอีก เราก็แค่ implement ใหม่ แค่นั้นเลยครับ

สำหรับโจทย์ใหม่ของวันนี้คือ สร้างโปรแกรมคำนวณพื้นที่ของรูปร่างต่างๆ ซึ่งในช่วงแรกต้องการแค่รูปสี่เหลี่ยนก่อน สิ่งที่เราต้องทำคือ จำกัดความว่ารูปร่างคืออะไร เขียนเป็น abstraction ได้อย่างไร

interface Shape{
    public double getArea();
}

เพราะรูปร่างมีคุณลักษณะและส่วนประกอบที่ต่างกัน เราจึงไม่ลงรายละเอียดเกี่ยวกับส่วนประกอบของรูปร่าง ดังนั้นเราจึงใส่แค่ getArea() ซึ่งเป็น requirement ของโปรแกรมเรา

ต่อมาจะเป็นการ implement รูปสี่เหลี่ยม

class Rectangle implements Shape{
    private double width;
    private double height;
    public void setSize(width, height){...}
    public double getArea(){...}
}

แล้วถ้าเราอยากได้วงกลมล่ะ ก็แค่ implement เพิ่มอีกอันแค่นั้นเอง

class Circle implements Shape{
    private double radius;
    public void setSize(radius){...}
    public double getArea(){...}
}

เห็นไหมครับว่าเรื่องนี้ง่ายมาก

แล้วสมมติว่าถ้าเราต้องการเอาคลาสเหล่านี้ไปใช้กับระบบ graphic editor ล่ะ ทำได้ไหม? เราก็ต้องพิจารณาก่อนครับว่าใน graphic editor มีอะไรที่เราต้องการนอกเหนือจากของเดิมบ้าง

  • ต้องวาดได้

  • ต้องกำหนดตำแหน่งที่จะวาดได้

ข้อสองนี่เหมือนไม่เป็นประเด็น เพราะเราไม่ควรเก็บตำแหน่งที่จะวาดใน object เดิมอยู่แล้ว ควรให้โมดูลของ graphic editor เก็บให้แทนแล้วค่อยส่งมาตอนวาด ส่วนในข้อแรกเราก็ต้องตัดสินใจดูครับ ว่าจะสร้างคลาสใหม่ (ตามหลัก OCP) หรือจะใส่ draw() ลงในคลาสเดิมเลย

ถ้าสร้างคลาสใหม่สำหรับใช้วาดก็จะได้ประมาณนี้ครับ

interface DrawableShape{
    public void draw(renderer, x, y);
}

class DrawableRectangle implements DrawableShape{
    private Rectangle rectangle;
    ...
    public void draw(renderer, x, y){...}
}

คือสร้าง interface ใหม่อีกตัวสำหรับวาด และสร้างคลาสวาดสี่เหลี่ยนขึ้นมาโดยใช้คลาสสี่เหลี่ยมเดิมเป็นตัวเก็บข้อมูล หรือไม่ก็อาจจะเป็น

interface ShapePainter<T extends Shape>{
    public void draw(T, renderer, x, y);
}

class RectangleShapePainter implements ShapePainter<Rectangle> {
    public void draw(rectangle, renderer, x, y){...}
}

เป็นการสร้างเครื่องมือสำหรับวาดขึ้นมาแล้วเวลาจะวาดก็ inject เข้าไปทั้ง shape และพารามิเตอร์อื่นๆ

จะเห็นว่าด้วยวิธีสร้างคลาสใหม่ทั้ง 2 แบบนี้ เป็นการทำตาม SRP แต่ break หลักการของ OOP ในเรื่องของ information hiding/encapsulation

ถ้าไส่ draw() ลงไปในคลาสเดิมเลยจะได้แบบนี้

interface Shape{
    public double getArea();
}

interface DrawableShape{
     public void draw(renderer, x, y);
}

class Rectangle implements Shape, DrawableShape{
    private double width;
    private double height;
    public void setSize(width, height){...}
    public double getArea(){...}
    public void draw(renderer, x, y){...}
}

จากโค้ดข้างต้นนี้ไม่ผิดหลัก information hiding ครับ แต่ขัดกับหลักการของ SRP อีกทั้งทำให้โมดูลที่ไม่ต้องการความสามารถเกี่ยวกับการวาดก็ต้องเอาไปทั้งก้อนด้วย ตรงนี้ก็ต้องเลือกเอาครับว่าแบบไหนที่ make sense กว่ากัน หรือแบบไหนทำให้โปรแกรมเรามีประสิทธิภาพกว่ากัน

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