+3

Nguyên tắc SOLID trong lập trình Java

Giới thiệu

Nguyên tắc SOLID được Robert C. Martin giới thiệu vào năm 2000, SOLID là viết tắt của 5 nguyên tắc, các nguyên tắc này được sử dụng trong lập trình hướng đối tượng(OOP) giúp hệ thống được tổ chức linh hoạt, dễ hiểu, dễ kiểm tra khi có vấn đề.

Nội dung

Nguyên tắc SOLID gồm:

  • Single Responsibility
  • Open/Closed
  • Liskov Substitution
  • Interface Segregation
  • Dependency Inversion

1. Single Responsibility

"There should never be more than one reason for a class to change." In other words, every class should have only one responsibility.

public class A {
    public void doSomeThing1() {
        //code
    }
    public void doSomeThing2() {
        //code
    }
}

Ví dụ đơn giản trên mình vừa đưa ra, Class A đã vi phạm nguyên tắc đầu tiên đó là thực hiện cùng lúc 2 nhiệm vụ. Đây là ví dụ đơn giản nhưng trong các dự án lớn sẽ khó quản lí, sử lí khi gặp lỗi.

Giải pháp

Để phù hợp với nguyên tắc, Class A nên được tách thành hai Class thực hiện từng nhiệm vụ

public class A1 {
     public void doSomeThing1() {
        //code
    }
}
public class A2 {
     public void doSomeThing2() {
        //code
    }
}

Như vậy, bất kì khi nào một hoạt động nào đó gặp lỗi hay cần sửa đổi, thêm chức năng, chúng ta sẽ giải quyết vấn đề dễ dàng hơn.

Open/Closed

"Software entities ... should be open for extension, but closed for modification."

public class Calculator {
    public double result(MathOperations mathOperation) {
        double calculationResults;
        if (mathOperation.type("Addition")) {
            // Calculate addition
        } else if (mathOperation.type("Subtraction")) {
            // Calculate subtraction
        }
        return calculationResults;
}

Phương thức result thực hiện tính toán và trả về kết quả theo từng phép toán MathOperations. Vấn đề là khi muốn tính toán một phép tính khác chúng ta phải sửa lại code trong class. Để tối ưu chúng ta nên tạo một Interface Calculator và các class tính toán kế thừa từ Interface đó.

public interface Calculator {
    double result();
}
public class Add implements Calculator {
   @Override
    public double result() {
         // Calculate addition
    }
}
public class Subtract implements Calculator {
    @Override
    public double result() {
         // Calculate subtraction
    }
}

Với cách làm trên chương trình trong tương lai sẽ "mở để mở rộng" khi cần thêm phương thức mới và "đóng để sửa đổi" khi sửa đổi một phương thức nào đó mà không làm ảnh hưởng đến chương trình.

Liskov Substitution

"Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it."

Các khái niệm mình lấy trên Wikipedia , theo mình hiểu nguyên tắc nói rằng ví dụ class A khi tham chiếu đến class B (base class) thì A cũng có thể thay thế cho B thực hiện các hoạt động mà B đang làm

public abstract class A {
    //Xử lí dữ liệu truyền vào và trả về một Object
    abstract Object task(Data data);
}
// Tuỳ vào mỗi class, logic code trong method task khác nhau.
public class B extends A {
    @Override
    public Object task(Data data) {

    }
}

public class C extends A {
    @Override
    public Object task(Data data) {
        //code
        return object;
    }
}

Ví dụ trên mình tạo một abstract class A có phương thức task, các class B,C khi kế thừa phải ghi đè phương thức task, nhưng trong class B có các thuộc tính không thể sử dụng để xử lý Data trong phương thức task vì vậy mình đã để trống nó, còn class C có thể xử lý Data để trả về Object.

Vấn đề sẽ xảy ra khi có một class X muốn sử dụng class A để lấy Ọbject sau khi xử lý Data. Class A chỉ là trừu tượng(abstract) các class B,C là các class cụ thể của A nên sẽ được dùng để phục vụ anh X

  • Khi X dùng C, không có vấn đề vì C có thể thay thế A xử lý Data.
  • Khi X dùng B, do thuộc tính của B không thể dùng trong phương thức task để xử lý dữ liệu cho nên B không trả về object, X dùng B sẽ bị lỗi. Điều này đã vi phạm nguyên tắc do B không thể thay thế A làm nhiệm vụ

Để phù hợp với nguyên tắc class B nên được kế thừa sang một abstract khác

public abstract class A {
    abstract Object task(Data data);
}
public abstract class D {
    abstract void doSomething();
}
public class C extends A {
    @Override
    public Object task(Data data) {
        //code
        return object;
    }
}
public class B extends D {
    @Override
    public void doSomething() {
        // code
    }

Tổng quát, nguyên tắc này khá trừu tượng theo mình thấy nguyên tắc này bị vi phạm khi chúng ta kế thừa một class đến một class khác, nhìn bề ngoài nó có thể là một class con, có thể kế thừa nhưng có thể nó không thay thế được class cha thực hiện các nhiệm vụ.

Interface Segregation

"Clients should not be forced to depend upon interfaces that they do not use."

Theo nguyên tắc này chúng ta nên thiết kế chương trình với các interface nhỏ, các class kế thừa một interface chỉ sử dụng các method cần thiết của interface đó.

public interface ShapeCalculator {
    double calculatorArea(); //Tính diện tích
    double calculatorVolume(); //Tính thể tích
    double calculatorSurroundingArea(); //Tính diện tích xung quanh
    ...
}
public class Square implements ShapeCalculator {
    @Override
    public double calculatorArea() {
        //code
        return area;
    }
    @Override
    public double calculatorVolume() {

    }
    ...
}

Nếu "ShapeCalculator" có rất nhiều method, class "Square" sẽ bị phình to do chỉ cần sử một method để tính diện tích các method còn lại không được dùng đến. Để khắc phục lỗi này ta sẽ tạo ra các interface khác nhau chứa các method phục vụ từng mục đích như ICalculatorArea, ICalculatorVolume, ICalculatorSurroundingArea, ... Khi cần các class có thể implement các interface đó.

Dependency Inversion

"High-level modules should not import anything from low-level modules. Both should depend on abstractions."
"Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions."

public class ReadFile {
    private PdfFile pdfFile;
    private WordFile wordFile;
    ... //Có thể có nhiều dạng file khác

    public ReadFile(PdfFile file) {
        this.pdfFile = file;
    }
     public ReadFile(WorldFile file) {
        this.wordFile = file;
    }
    //Với mỗi loại file chương trình phải có các method theo từng loại để đọc nội dung file
    public String pdfFileContent() {
        return pdfFile.getData();
    }
    public String wordFileContent() {
        return wordFile.getData();
    }
}

public class PdfFile {
    private String data;
    public PdfFile(String input) {
         this.data = input;
    }
    public String getData() {
        return this.data;
    }
}

public class WordFile {
    private String data;
    public WordFile(String input) {
         this.data = input;
    }
    public String getData() {
        return this.data;
    }
}

Chương trình trên class ReadFile với level cao hơn đang phải phụ thuộc vào các class PdfFile, WordFile ở level thấp hơn và khi chương trình muốn đọc một loại file khác, ta phải sửa code, thêm method và làm chương trình trở nên phức tạp. Để phù hợp các bạn tạo một interface để các class chi tiết(details) theo nguyên tắc giao tiếp với nhau qua đó.

public interface FileContent {
    public String export();
}

public class ReadFile {
    private FileContent fileContent;

    public ReadFile(FileContent file) {
        this.FileContent = file;
    }

    public String content() {
        return fileContent.export();
    }
}

public class PdfFile implements FileContent {
     private String data;

    public PdfFile(String input) {
         this.data = input;
    }
    @Override
    public String export() {
        return this.data;
    }
}

public class WordFile implements FileContent {
     private String data;

    public WordFile(String input) {
         this.data = input;
    }
    @Override
    public String export() {
        return this.data;
    }
}

public class Test {
    public static void main() {
        FileContext file = new PdfFile();
        ReadFile readFile = new ReadFile(file);
        System.out.print(readFile.content());
    }
}

PdfFileWordFile bây giờ có thể giao tiếp với ReadFile thông qua interface FileContent, đồng thời bây giờ ReadFile chỉ phụ thuộc vào interface không phải các class cụ thể như trước.

Tổng kết

SOLID là một nguyên tắc quan trọng, rất cần thiết trong lập trình hướng đối tượng, hi vọng bài viết của mình giải đáp được một số thắc mắc của các bạn.


All rights reserved

Bình luận

Đăng nhập để bình luận
Avatar

Cái Dependency Inversion cuối khá giống với DJ, IoC trong spring boot nhỉ , đảo ngược sự phụ thuộc, thay vì bắt buộc khởi tạo Class và phụ thuộc vào class trong hàm contractor, thì no sẽ tim các Interface vào , các Interface đó được thực thi bởi các class service.

Avatar
@refacore
thg 6 28, 2024 2:12 SA

Dependency Injection là Dependency Injection. Nó che giấu đi cách các concrete class được khởi tạo, chỉ việc gọi tên class, nên làm cho mình cảm tưởng đó là bỏ đi sự phụ thuộc. Dependency Injection hoàn toàn có thể đăng ký 1 concrete class và injection nó trong các class khác. Các thư viện DI thì chỉ nhận là DI chứ không nhận là IoC vì bản chất là khác nhau. DI giống như một thể hiện rõ ràng hơn của Dependency Inversion (hay IoC), giúp lập trình viên cảm nhận và hiểu nguyên lý của Dependency Inversion.

Sự phụ thuộc mà D.Injection và D.Inversion đề cập đến là khác nhau.

  • D.Injection giải quyết sự phức tạp của constructor và lifetime của một class, hay một phụ thuộc trong class. Một class có thể phụ thuộc vào nhiều class khác, dẫn đến constructor của nó trở nên phức tạp, độ phức tạp còn tăng lên thêm khi nó là một phụ thuộc trong một class khác. Để giải quyết constructor thì ngày trước dùng công cụ thường gọi là ServiceLocator chứ không phải D.Injection (thường là đăng ký factory cho từng class cho ServiceLocator). Ngày nay D.Injection giải quyết constructor tự động, và vẫn cung cấp khả năng đăng ký factory nếu cần. Ngoài ra, D.Injection quản lý cả lifetime của một dependency, tức nó không quản lý 1 instance mà quản lý một chuỗi dependency, khởi tạo và giải phóng cùng nhau (theo lifetime).

  • D.Inversion là khái niệm về thiết kế phần mềm. Khi chưa có D.Injection, ng ta vẫn thiết kế dùng D.Inversion để giúp mã nguồn mềm dẻo, dễ thích nghi. D.Inversion khuyến khích việc trừu tượng hóa vấn đề trước khi implement nó. Lý do thì đơn giản: các bản phác họa trừu tượng hóa thường thể hiện sự tương tác giữa các lớp trừu tượng chứ không có sự phụ thuộc - như các interface: chỉ thể hiện hành vi. Các phụ thuộc chỉ có khi implement các lớp trừu tượng, tức là chỉ có trong các concrete class. Khi các concrete class thay vì phụ thuộc vào các concrete class mà phụ thuộc vào các lớp trừu tượng thì rõ ràng nó loại bỏ được rất nhiều sự phụ thuộc phát sinh của các concrete class mà nếu nó phụ thuộc vào.

D.Injection là công cụ. Còn D.Inversion là nguyên lý thiết kế.

Avatar

@refacore Cảm ơn bạn đã chia sẻ

Avatar
@refacore
thg 6 28, 2024 2:29 SA

Phần giải thích Single Responsibility có thể gây nhầm lẫn thành mỗi class chỉ nên có một public method, có 2 public method trở lên thì phải tách ra.

Cần phải phần biệt giữa trách nhiệm và hành vi. Nếu nhầm lẫn thì Java đã sai ngay từ đầu khi cho đa thừa kế.

Một class có trách nhiệm duy nhất nhưng nó có thể có nhiều hành vi. Cùng xem một ví dụ của mẫu Facade:

public class AtmFacade 
{
  public WithdrawalFunction WithdrawalFunction { get; set; }

  public DepositFunction DepositFunction { get; set; }

  public BalanceFunction BalanceFunction { get; set; }
}

Có thể thấy AtmFacade có trách nhiệm duy nhất là gom các chức năng của cây ATM lại để dễ sử dụng (giảm sự phức tạp). Nếu coi các chức năng con trong đó là trách nhiệm của nó thì thiết kế này sai mất. Các chức năng con là các hành vi mà cây ATM này cung cấp.

Nhưng các chức năng con này cũng thể hiện trách nhiệm duy nhất như cách giải thích của bài viết:

public class WithdrawalFunction 
{
  public void Withdraw(decimal amount);
}

Single Responsibility là nguyên lý đơn giản nhưng rất linh động và thể hiện nhiều nhất sự khác biệt trong tư duy thiết kế cũng như trình độ của các kĩ sư. Không có công thức cho Single Responsibility. Có lẽ chỉ có một tiêu chí đánh giá xem mã nguồn có đơn nhiệm không: Khi không còn gì để nói về nó nữa.

Avatar
@refacore
thg 6 28, 2024 2:56 SA

Phần giải thích Open/Close cũng nhầm lẫn giữa trách nhiệm và hành vi. Addition, Subtraction thì đều là hành vi của Calculator. Addition, Subtraction nên thừa kế từ interface Operator chứ không phải Calculator. Thiết kế cho Calculator sẽ giống thế này:

calculator

Avatar
@H003g
thg 6 28, 2024 3:44 SA

Bài của e chán quá, a xem mấy cái còn lại có vấn đề gì không, a sửa giúp e với nhé

Avatar
@refacore
thg 6 28, 2024 4:03 SA

@H003g nghĩ ra, nói ra là bước đầu tiên để hiểu ra. a vẫn upvote bài viết 😃

Avatar
@H003g
thg 6 28, 2024 6:49 CH

@refacore ♥️

Avatar
@refacore
thg 6 28, 2024 3:51 SA

Liskov thì có 2 ví dụ kinh điển:

  1. Con vịt trời và con vịt cao su không thể thay thế cho nhau dù cùng là vịt. Phương thức bay của giống vịt sẽ gây lỗi nếu là gọi từ instance con vịt cao su.
  2. Shape. Nếu trừu tượng hóa các đa giác theo số cạnh của nó, các hành vi của nó sẽ không hoạt động giống nhau với mỗi mỗi hình khác nhau. Tam giác có cách tính diện tích khác. Hình vuông có cách tính diện tích khác tam giác (khác số cạnh), và khác hình chữ nhật (dù cùng số cạnh). Cho nên các hình chỉ đơn giản là thừa kế IShape với phương thức computeSurface mà thôi.

Thực ra ví dụ trên vẫn trừu tượng. Hãy xét một bài toán về e-commerce, cách implement chương trình khuyến mãi hay giảm giá cho sản phẩm là coupon (voucher) và giảm giá - discount. Liệu chúng ta có thể tạo một parent chung cho cả hai phương thức là ISaleProgram được không?

public interface ISaleProgram {}

public class Voucher : ISaleProgram {}

public class Discount : ISaleProgram {}

public class Order 
{
  private readonly IEnumerable<ISaleProgram> salePrograms;

  public number Pay()
  {
    var totalCost = items.Sum(i => i.Price);

    var afterTax = totalCost * 1.1; // tax 10%

     var matchedSalePrograms = allSalePrograms.Where(p => p.IsMatched(this));

     var totalDiscountAmount = matchedSaleProgames.Sum(p => p.Discount(this));

     return afterTax - totalDiscountAmount;
  }
}

Tổng quan thì là hợp lý, nhưng có sự khác biệt đối với 2 phương thức trên:

  • Voucher: trừ thẳng tiền mặt, tính sau thuế. Thuế tính theo giá gốc.
  • Discount: trừ giá trước thuế, thuế tính trên giá mới.

Như vậy Voucher được tính sau khi tính tổng hóa đơn rồi trừ đi. Discount được tính trong khi tính tổng hóa đơn. Sự khác biệt này sẽ làm chương trình tính giá bị lỗi nếu chúng ta coi Voucher và Discount là cùng họ. Hai cách tính là khác nhau và tham gia vào hai bước khác nhau của quá trình tính giá phải trả.

Avatar
@refacore
thg 6 28, 2024 4:01 SA

Interface segregation: cách giải thích dễ nhầm tưởng mỗi method tách 1 interface.

ShapeCalculator là một lớp đặc thù và việc nó có 3 phương thức tính chu vi, diện tích, thể tích là hoàn toàn hợp lý, không có gì sai cả. Vậy nếu có interface IShapeCalculator thì nó nên có cả 3 phương thức.

Nếu 3 phương thức trên thuộc về Shape thì lại khác. Chu vi và diện tích thì của hình 2D, diện tích và thể tích lại của hình 3D, việc gom cả 3 hành vi vào 1 interface là không thể nên cần tách ra để giữ được sự mềm dẻo.

public class Circle: IHasArea, IHasPerimeter
{
  public number GetArea();
  public number GetPerimeter();
}

public class Cube: IHasArea, IHasVolume
{
  public number GetArea();
  public number GetVolume();
}
Avatar
+3
Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí