Nguyên lý S.O.L.I.D trong lập trình hướng đối tượng

Nguyên lý S.O.L.I.D được sinh ra để giúp chúng ta trong quá trình lập trình hướng đối tượng có thể tạo ra được những ứng dụng tốt hơn. Cụ thể là những nguyên tắc này khuyến khích chúng ta tạo được những phần mềm dễ maintai hơn, code dễ hiểu hơn. Và đồng thời "mềm dẻo" hơn. Dẫn tới khi ứng dụng "phình to" , chúng ta sẽ giảm thiểu được độ phức tạp. Đội ngũ phát triển sẽ tốn ít thời gian hơn.

Có quá nhiều lợi ích để chúng ta tuân thủ theo S.O.L.I.D phải không nào? Tất nhiên nguyên lý vẫn là nguyên lý. Bạn hoàn toàn có thể không thể tuân thủ. Hứng đâu thì viết code ở đó. Nhưng nên nhớ là đây là trích rút ra từ kinh nghiệm của rất nhiều thế hệ developer đi trước. Còn bây giờ chúng ta cùng đào sâu tìm hiểu về những nguyên tắc này:

  1. Single Responsibility
  2. Open/Closed
  3. Liskov Substitution
  4. Interface Segregation
  5. Dependency Inversion

S.O.L.I.D là thực chất là ghép của 5 chữ cái đầu của các nguyên lý. Đọc vậy cho dễ nhớ. Mà thực chất khi bạn có thể áp dụng thành thục những nguyên lý trên bạn cũng có thể trở thành Solid developer hay chính là "Lập trình viên cứng". Bây giờ chúng ta cùng mổ sẻ từng nguyên lý một.

1. Single Responsibility principle

Nội dung của nguyên lý:

Mỗi class chỉ nên chịu trách nhiệm về một nhiệm vụ cụ thể nào đó mà thôi. Không có nhiều hơn một lý do để chỉnh sửa một class.

Ví dụ thực tế: Ta có class Coder

Coder.java

public class Coder {

    private String name;

    public void code(){
        System.out.println("Đang code");
    }
    String allLettersToUpperCase(String s) { ... }
    String findSubTextAndDelete(String s) { ... }
}

Tuy ở class Coder trên thì 2 phương thức ở allLettersToUpperCase và findSubTextAndDelete đều hoạt động bình thường. Nhưng về mặt logic các bạn có thể tự hỏi là nó có đúng là trách nhiệm của class trên không? Và dường như hai phương thức - 2 chức năng đó không thuộc về class Coder.

Áp dụng nguyên lý Single Responsibility - đơn nhiệm, ta có thể refactor code lại như sau:

Coder.java

public class Coder {

    private String name;

    public void code(){
        System.out.println("Đang code");
    }
}

TextUtils.java

public class TextUtils {
   public static String allLettersToUpperCase(String s) { ... }
   public static String findSubTextAndDelete(String s) { ... }
}

Sau khi refactor xong thì code của chúng ta đã dễ hiểu, dành mạch, rõ ràng hơn rất nhiều. Tất nhiên là cũng dễ maintain hơn nữa.

2. Open/closed principle

Đây là một trong những nguyên lý rất quan trọng trong quá trình phát triển phần mềm. Nguyên lý mà chúng ta cần luôn luôn ghi nhớ và áp dụng thường xuyên. Nội dung nguyên lý:

Chúng ta có thể thoải mái mở rộng class nhưng không được chỉnh sửa nội dung bên trong nó.

Nguyên lý này nghĩa là: Mỗi khi chúng ta thiết kế một class nào đó thì chúng ta cũng viết làm sao cho sau này, mỗi khi một developer muốn thay đổi luồng trong ứng dụng. Họ chỉ cần thừa kế class ta đã viết hoặc override một hàm nào đó.

Nếu chúng ta không thiết kế class đủ tốt. Mà do đó những developer khác khi muốn thay đổi luồng của ứng dụng. Họ bắt buộc phải sửa class của chúng ta. Dẫn đến logic của toàn bộ ứng dụng cũng thay đổi theo. Điều này rất gây hại cho quá trình phát triển ứng dụng.

Đây là một ví dụ tuân thủ nguyên lý OCP của chúng ta:

Khi tôi muốn viết một class tính toán diên tích của các hình thay vì viết một class cụ thể để thực hiện điều này tôi sẽ tạo một abstract class chung. Sau đó cho các class là các hình cụ thể implement nó:

Shape.java

public abstract class Shape
{
    public abstract double Area();
}
public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public override double Area()
    {
        return Width*Height;
    }
}

public class Circle : Shape
{
    public double Radius { get; set; }
    public override double Area()
    {
        return Radius*Radius*Math.PI;
    }
}

Sau này có một developer X nào đó muốn tính thêm diện tích hình nào đó thì cũng làm tương tự như với class Rectangle và Circle. Cũng chỉ cần thừa kế class Shape và override phương thức Area.

3. Liskov Substitution Principle

Nội dung nguyên lý này như sau:

Trong một chương trình, các object của class con có thể thay thế class cha mà không làm thay đổi tính đúng đắn của chương trình.

Giả sử chúng ta có 3 class; Vit ( base class ) và VitPin (class con thừa kế từ class Vit), VitBau (class con thừa kế từ class Vit)

Vit.java

public abstract class Vit {
    public void keu(){
        // kêu quạc quạc
    }
}
VitPin.java

public class VitPin extends Vit {
    private  Boolean Pin;
    @Override
    public void keu() {
        if (Pin){
            super.keu();
        }
        else {
            try {
                throw new Exception("Không có Pin không kêu được!!!");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public Boolean getPin() {
        return Pin;
    }

    public void setPin(Boolean pin) {
        Pin = pin;
    }
}
VitBau.java

public class VitBau extends Vit {
    @Override
    public void keu() {
        super.keu();
    }
}

Bây giờ ta thử chạy đoạn code sau:

        Vit vitBau = new VitBau();
        vitBau.keu(); // chạy bình thường
        Vit vitPin = new VitPin();
        // Chạy phương thức bên dưới gây ra Exception vì lúc này chúng ta chưa set Pin
        // Pin = false
        vitPin.keu(); 

Như bậy clas VitPin đã vi phạm nguyên lý của chúng ta vì VitPin là class con không thể thay thế hoàn toàn class cha - Vit.

4. Interface Segregation Principle

Nguyên lý này rất dễ hiều. Nội dung nguyên lý phát biểu như sau:

Thay vì dùng 1 interface lớn, ta nên tách thành nhiều interface nhỏ, với nhiều mục đích cụ thể

Bạn tưởng tượng chúng ta có một chiếc sạc điện thoại đa năng. Một đầu là một ổ cắm điện. Đầu kia là 3, 4 chiếc dây để phù hợp với từng dòng này khác nhau: Type - C, Micro Usb, Sạc chân to, sạc chân nhỏ.... Thoạt nhìn thì chúng ta có vẻ rất tiện và gọn. Nhưng trong quá trình sử dụng thực tế thì cực kì vướng víu. Tôi chỉ cần một đầu dây Type - C để sạc điện thoại của mình. Trong khi đó lại có một mớ dây sạc loại điện thoại khác không cần dùng đến. Việc thiết kế interface cũng như vậy. Nên tuân thủ nguyên lý trên để tránh tình trạng "sạc điện thoại đa năng".

Ví dụ thực tế:

Giả sử ta có interface cho các vận động viên như sau:

IVanDongVien.java

public interface IVanDongVien {
    void nhayCao();
    void nhayXa();
    void boi();
}

Vận động viên bơi lội implement:

VdvBoiLoi.java

public class VdvBoiLoi implements IVanDongVien {
    @Override
    public void nhayCao() {
        // vận động viên bơi lội không thể nhảy cao
        // không implement
    }

    @Override
    public void nhayXa() {
       // vận động viên bơi lội không thể nhảy xa
        // không implement
    }

    @Override
    public void boi() {
        System.out.println("Đang bơi");
    }
}

Vận động viên thi nhảy implement:

public class VdvNhay implements IVanDongVien {
    @Override
    public void nhayCao() {
        System.out.println("Nhay cao");
    }

    @Override
    public void nhayXa() {
        System.out.println("Nhay xa");
    }

    @Override
    public void boi() {
        // Vận động viên thi nhảy không bơi
        // Không implement
    }
}

Như bạn đã thấy VdvBoiLoi không implement phương thức nhayCao(), nhayXa() , VdvNhay thì không implement phương thức boi(). Để tránh tình trạng trên ta nên chia nhỏ interface IVanDongVien của chúng ta thành:

IVdvBoi.java

public interface IVdvBoi {
    void boi();
}

IVdvNhay.java

public interface IVdvNhay {
    void nhayCao();
    void nhayXa();
}

Khi implement lại các interface ta được:

VdvBoiLoi.java

public class VdvBoiLoi implements IVdvBoi {
    @Override
    public void boi() {
        System.out.println("Đang bơi");
    }
}

VdvNhay.java
public class VdvNhay implements IVdvNhay {
    @Override
    public void nhayCao() {
        System.out.println("Nhay cao");
    }

    @Override
    public void nhayXa() {
        System.out.println("Nhay xa");
    }
}

Vậy là chúng ta tránh được tình trạng có phương thức mà không được implement sau khi chia nhỏ interface.

5. Dependency inversion principle

Nội dung nguyên lý của chúng ta như sau:

  1. Các module cấp cao không nên phụ thuộc vào các modules cấp thấp. Cả 2 nên phụ thuộc vào abstraction.

  2. Interface (abstraction) không nên phụ thuộc vào chi tiết, mà ngược lại. ( Các class giao tiếp với nhau thông qua interface, không phải thông qua implementation.)

Đối với nguyên lý này cách đơn giản nhất là trong ví dụ thực tế. Chúng ta đều biết 2 loại đèn: đèn tròn và đèn huỳnh quang. Chúng cùng có đuôi tròn, do đó ta có thể thay thế đèn tròn bằng đèn huỳnh quanh cho nhau 1 cách dễ dàng. Ở đây, interface chính là đuôi tròn, implementation là bóng đèn tròn và bóng đèn huỳnh quang. Ta có thể swap dễ dàng giữa 2 loại bóng vì ổ điện chỉ quan tâm tới interface (đuôi tròn), không quan tâm tới implementation.

Khi trong quá trình làm ứng dụng thực tế, khi áp dụng Dependency Inverse, ta chỉ cần quan tâm tới interface. Để gửi một đoạn message chắng hạn ta chỉ cần quan tâm đến hàm sendMessage() của interface ISendMessage. Sau này khi cần thay đổi ta cũng cần thay đổi implement của interface trên mà thôi. Ta có thể swap qua lại giữa: SendEmailMessage, SendSmsMessage cùng implement interface ISendMessage.

Ví dụ:

ISendMessage.java

// đây chính là đuôi đèn
public interface ISendMessage {
    void sendMessage();
}
SendEmailMessage.java

public class SendEmailMessage implements ISendMessage {
    private String message;

    public SendEmailMessage(String message){
        this.message = message;
    }
    @Override
    public void sendMessage() {
        System.out.println(message + " được gửi qua Email");
    }
}
SendSmsMessage.java

public class SendSmsMessage implements ISendMessage {

    private String message;

    public SendSmsMessage(String message){
        this.message = message;
    }
    @Override
    public void sendMessage() {
        System.out.println(message + " được gửi qua Sms");
    }
}

User.java

public class User {

    public void sendMessage(ISendMessage iSendMessage){
        iSendMessage.sendMessage();
    }
}

Chúng ta cùng sử dụng:

      
        User user = new User();
        user.sendMessage(new SendEmailMessage("hello"));
        // in ra:  hello được gửi qua Email
        user.sendMessage(new SendSmsMessage("hello"));
        // in ra: hello được gửi qua Sms

Điểm thuận lợi lớn nhất của nguyên lý này:

Khi thay đổi yêu cầu thì ta cũng dễ dàng chuyển đổi mà không ảnh hưởng tới code cũ. Như trên là ta có thể chuyển tùy ý giữa các class: SendEmailMessage và SendSmsMessage. Hoặc thậm chí nếu phát sinh thêm yêu cầu mới là gửi message qua Facebook. Thì ta cũng chỉ cần tạo thêm class SendFacebookMessage, sau đó implement ISendMessage interface.

Vừa rồi là trình bày của mình về S.O.L.I.D nguyên lý trong lập trình hướng đối tượng. Nội dung cũng khá dài. Các bạn cố gắng áp dụng vào code. Vì một tương lai Solid Developer. Chúc các bạn thành công.

Thanks for reading.

Bài viết có tham khảo ở nhiều nguồn khác nhau:

https://nhungdongcodevui.com/category/tro-thanh-developer/series-solid/ https://springframework.guru/principles-of-object-oriented-design/ https://toidicodedao.com/2015/03/24/solid-la-gi-ap-dung-cac-nguyen-ly-solid-de-tro-thanh-lap-trinh-vien-code-cung/ https://www.javacodegeeks.com