Cách viết code rõ ràng, tối ưu và dễ bảo trì

Những đoạn code tuyệt vời

Không quan trọng bạn đang ở cấp độ kiến thức nào, bạn là một lập trình viên và bạn muốn tạo ra những đoạn code tuyệt vời. Đây là những điều chúng ta sẽ nói trong bài viết này. Tôi cũng đang cố gắng thực hiện điều này mỗi ngày Nhưng chúng ta đều biết rằng, để viết ra được những đoạn code tuyệt vời không phải là dễ dàng. Vì vậy làm thế nào để chúng ta cải thiện được những đoạn code mà chúng ta viết ra mỗi ngày? Các nguyên tắc SOLID giải quyết vấn đề này, SOLID là một nhóm 5 nguyên tắc chỉ ra rằng khi chúng ta áp dụng một cách chính xác có thể giúp chúng ta viết ra những đoạn code tốt hơn

Vậy nguyên tắc SOILD là gì?

SOLID là từ viết tắt dễ nhớ được đặt bởi Uncle Bob vào đầu những năm 2000. Nó đại điện cho một nhóm 5 nguyên tắc:

  • Nguyên tắc đơn nhiệm (Single Responsibility Principle)
  • Nguyên tắc đóng mở (Open/Closed Principle)
  • Nguyên tắc thay thế Liskov (Liskov Substitution Principle)
  • Nguyên tắc phân tách giao diện (Interface Segregation Principle)
  • Nguyên tắc nghịch đảo phụ thuộc (Dependency Inversion Principle)

Tốt rồi bây giờ chúng ta sẽ cùng đi tìm hiểu từng nguyên tắc

Nguyên tắc đơn nhiệm (SRP)

Theo ý kiến của tôi, thì đây là nguyên lý dễ hiểu nhất. Những điều SRP trình bày: Mỗi lớp nên chỉ có duy nhất một trách nhiệm và trách nhiệm đấy phải hoàn toàn được bao bọc bởi lớp. Điều đó có nghĩa là gì? Về cơ bản, mọi lớp trong ứng phải có 1 duy nhất một trách nhiệm. Cách duy nhất để biết xem lớp của bạn có tuân theo nguyên tắc này không thì hãy trả lời các câu hỏi sau:

Lớp của bạn có vai trò gì?

Nếu câu trả lời có chứa từ AND, thì lớp của bạn không tuân theo nguyên tắc SRP Hãy nhìn một ví dụ đơn giản sau. Chúng ta có lớp Student đại diện cho học sinh và mỗi học sinh lại có những điểm số trong những học kỳ term khác nhau

class Student  
  attr_accessor :first_term_home_work, :first_term_test, :first_term_paper
  attr_accessor :second_term_home_work, :second_term_test, :second_term_paper

  def first_term_grade
    (first_term_home_work + first_term_test + first_term_paper) / 3
  end

  def second_term_grade
    (second_term_home_work + second_term_test + second_term_paper) / 3
  end
end  

Với cách viết như trên thì bạn đã không tuân theo nguyên tắc SRP. Lý do là lớp Student có chứa logic tính điểm trung bình cho mỗi học kỳ. Trách nhiệm của lớp Student chỉ nên chứa các thông tin/logic về học sinh, chứ không phải là của cả thông tin về điểm số. Do đó logic tính toán điểm số nên để trong class Grade chứ không phải là lớp Student.

Tái cấu trúc lại code:

class Student  
  def initialize
    @terms = [
      Grade.new(:first), 
      Grade.new(:second)
    ]
  end

  def first_term_grade
    term(:first).grade
  end

  def second_term_grade
    term(:second).grade
  end

  private
  def term reference
    @terms.find {|term| term.name == reference}
  end
end

class Grade  
  attr_reader :name, :home_work, :test, :paper

  def initialize name
    @name      = name
    @home_work = 0
    @test      = 0
    @paper     = 0
  end

  def grade
    (home_work + test + paper) / 3
  end 
end  

Bạn có thể thấy rằng, lớp Grade chứa các tính toán logic của điểm số (grade) và lớp Student chỉ lưu lại điêm số (grade) trong một collection. Như thế là đã tuân thủ theo nguyên tắc SRP bởi vì mỗi một lớp có nhiệm vụ riêng của nó.

Nguyên tắc đóng mở (OCP)

Nguyên tắc đóng mở (OCP) được định nghĩa là: Một thực thể phần mềm (class/module) phải được mở cho khi được mở rộng nhưng bị đóng khi sửa đổi. ĐIều đó có nghĩa là gì? Ở thời điểm hiện tại một lớp được yêu cầu cài đặt, trong tương lai những cài đặt này không nên được thay đổi.

Có vẻ khó hiểu nhỉ. Chúng ta hãy xem ví dụ sau:

class MyLogger  
  def initialize
    @format_string = "%s: %s\n"
  end

  def log msg
    STDOUT.write @format_string % [Time.now, msg]
  end
end 

Một class logger đơn giản đúng không? Nó có địng dạng là string và gửi thời gian hiện tại cùng tin nhắn đến STDOUT. Tuyệt vời, quá đơn giản, hãy kiểm tra nó:

irb> MyLogger.new.log "test!"
=> 2014-04-25 16:16:32 +0200: test!

Tuyệt vời, nhưng điều gì xảy ra khi sau này một người nào đó muốn thêm vào [LOG] vào đầu các tin nhắn của logger, khi đó output sẽ là:

[LOG] 2014-04-25 16:16:32 +0200: MyLogger calling!

Ví dụ, một lập trình viên không biết gì về OCP sẽ thay đổi như thế này:

class MyLogger  
  def initialize
    @format_string = "[LOG] %s: %s\n"
  end
end  

Và output của lớp MyLogger sẽ là:

irb> MyLogger.new.log "test!"
=> [LOG] 2014-04-25 16:16:32 +0200: test!

Có vẻ mọi thứ đều tốt, phải không? Đợi một giây. Hãy nghĩ về điều này, nếu đây là lớp quan trọng của ứng dụng việc thay đổi format_string sẽ phá vỡ cấu trúc lớp MyLogger. Đây thực sự là một sự vi phạm OCP và nó quá tồi! Cách tốt nhất để làm điều này là kế thừa. Hãy nhìn một ví dụ sử dụng kế thừa:

class NewCoolLogger < MyLogger  
  def initialize
    @format_string = "[LOG] %s: %s\n"
  end
end  
irb> NewCoolLogger.new.log "test!"
=> [LOG] 2014-04-25 16:16:32 +0200: test!

Tuyệt vời! Hoạt động đúng như mong ước. Còn về chức năng của MyLogger thì sao?

irb> MyLogger.new.log "test!"
=> 2014-04-25 16:16:32 +0200: test!

Chúng ta đã làm những gì, chúng ta mở rộng lớp MyLogger và tạo ra một lớp mới gọi là NewCoolLogger để mở rộng từ lớp MyLogger trước đó. Bây giờ đoạn code sẽ dựa vào chức năng của logger cũ và chức năng của logger cũ này sẽ không bị thay đổi bởi nguyên tắc mà chúng tôi giới thiệu ở trên. Logger cũ vẫn hoạt động như trước và lớp mới sẽ có chức năng mới mà người lập trình mong muốn. Ngoài ra, tôi sẽ đề cập đến object composition. Hãy xem đoạn tối ưu sau:

class MyLogger  
  def log(msg, formatter: MyLogFormatter.new)
    STDOUT.write formatter.format(msg)
  end
end

Bạn có thể nhận thấy rằng, phương thức log đã nhận một tham số tùy biến thông qua formatter. Định dạng của log string là của lớp MyLogFormatter chứ không phải của lớp MyLogger. Cách này khá hay vì MyLogger#log có thể chấp nhận những formatter từ những lớp khác nhau do đó có thể thiết lập định dạng của tin nhắn log. Ví dụ bạn có thể tạo ra ErrorLogFormatter để thêm [ERROR] vào tin nhắn log nhưng class MyLogger không được để ý đến vì tất cả những gì chúng ta cần là một string gửi đến STDOUT

Nguyên tắc thay thế Liskov (LSP)

Barbara Liskov đã định nghĩa nguyên tắc này như sau: Nếu S là con của T, thì một đối tượng kiểu T có thể được thay thế bởi đối tượng kiểu S (nghĩa là đối tượng kiểu S có thể thay thế cho đối tượng kiểu T) mà không làm thay đổi bất kỳ thuộc tính nào của chuong trình. Chân thành mà nói, tôi thấy định nghĩa này khá là khó hiểu. Do đó tôi xin lấy một ví dụ để có thể dễ hiểu hơn: Có một lớp Bird và có hai đối tượng là obj1, obj2. Trong đó, obj1 là đối tượng của lớp Duck là lớp con của lớp Bird và obj2 là đối tượng của lớp Pegion cũng là lớp con của lớp Bird. Nguyên tắc thay thế của Liskov khẳng định rằng, bạn có thể sử dụng obj1 và obj2 giống như cách bạn sử dụng đối tượng của lớp Bird. Nếu vẫn còn thấy khó hiểu, bạn hãy xem ví dụ bên dưới

class Person  
  def greet
    puts "Hey there!"
  end
end

class Student < Person  
  def years_old age
    return "I'm #{age} years old."
  end
end

person = Person.new  
student = Student.new
student.greet 
#returns "Hey there!"

LSP nói rằng, nếu bạn biết interface của Person thì bạn có thể đoán được interface của lớp Student bởi vì lớp Student là lớp con của lớp Person

Nguyên tắc phân tách giao diện (ISP)

Nguyên tắc phân tách giao diện (ISP) nêu rõ: Không một client nào bắt buộc phải phụ thuộc vào các phương thức mà nó không sử dụng
Để hiểu hơn, bạn hãy xem một ví dụ sau:

class Computer  
  def turn_on
    #turns on the computer
  end

  def type
    #type on the keyboard
  end

  def change_hard_drive
    #opens the computer body 
    #and changes the hard drive
  end
end

class Programmer  
  def use_computer
    @computer.turn_on
    @computer.type
  end
end

class Technician  
  def fix_computer
    @computer.turn_on
    @computer.type
    @computer.change_hard_drive
  end
end  

Trong ví dụ này có các lớp Computer, Programmer và Technician. Cả Programmer và Technician đều sử dụng Computer theo những cách khác nhau. Các programmer sử dụng computer để gõ nhưng các technician biết làm thế nào để thay đổi phần cứng của máy tính. Theo nguyên tắc phân tách giao diện (ISP) thì khi một lớp thực thi phụ thuộc vào một lớp khác thì không nên phụ thuộc vào các phương thức mà nó không sử dụng. Trong trường hợp này, Progammer không cần phương thức Computer#change_hard_drive vì nó không sử dụng phương thức đó. Nhưng việc thay đổi phương thức này có thể ảnh hưởng đến Programmer. Hãy tối ưu code tuân theo nguyên tắc LSP

class Computer  
  def turn_on
  end

  def type
  end
end

class ComputerInternals  
  def change_hard_drive
  end
end

class Programmer  
  def use_computer
    @computer.turn_on
    @computer.type
  end
end

class Technician  
  def fix_computer
    @computer_internals.change_hard_drive
  end
end  

Sau khi tối ưu, Technician sử dụng một đối tượng khác từ lớp ComputerInternals và tách biệt hoàn toàn với lớp Computer. Trạng thái của đối tượng Computer có thể bị ảnh hưởng bởi Programmer nhưng những thay đổi sẽ không ảnh hưởng đến Technician dưới bất kỳ hình thức nào.

Nguyên tắc nghịch đảo phụ thuộc (DIP)

Nguyên tắc nghịch đảo phụ thuộc là hình thức cụ thể của việc tách các module phần mềm. Định nghĩa có 2 phần:

  1. Các module cấp cao không nên phụ thuộc vào các module cấp thấp. Cả hai nên phụ thuộc vào sự trừu tượng .
  2. Sự trừu tượng không nên phụ thuộc vào chi tiết. Chi tiết nên phụ thuộc vào sự trừu tượng.

Để giải thích cho những khó hiểu ở trên chúng ta có ví dụ sau:

class Report  
  def initialize
    @body = "whatever"
  end

  def print
    XmlFormatter.new.generate @body
  end
end

class XmlFormatter  
  def generate body 
    #convert the body argument into XML
  end
end  

Lớp Report được sử dụng để tạo ra một báo cáo dạng XML. Khi khởi tạo chúng ta sẽ tạo ra một biến instance @body trong lớp Report. Phương thức print được sử dụng bởi lớp XmlFormatter để chuyển đổi nội dung của Report sang XML. Đơn giản là vậy. Hãy suy nghĩ một chút về lớp Report, hãy chú ý đến tên của nó. Đó là một tên chung và nó cho chúng ta biết rằng nó sẽ trả về một báo cáo nào đó nhưng không nói nhiều về định dạng sẽ trả về. Trên thực tế, trong ví dụ của chúng ta, chúng ta có thể dễ dàng đổi tên lớp của chúng ta thành XmlReport bởi chúng ta biết một cách chi tiết nó thực hiện. Hãy suy nghĩ theo hướng trừu tượng. Bây giờ lớp Report đang phụ thuộc vào lớp XmlFormatter và nó tạo ra giao diện. Lớp Report là khá chi tiết, nó không hề trừu tượng. Nó biết rằng phải có một lớp XmlFormatter để nó làm việc. Ngoài ra, tôi có một câu hỏi khác, điều gì xảy ra nếu chúng ta muốn một báo cáo dạng CSV hoặc một báo cáo dạng JSON. Khi đấy, chúng ta sẽ phải có nhiều phương thức chẳng hạn như print_xml, print_csv hoặc print_json. Điều đó có nghĩa là lớp Report rất chi tiết.

Hãy tối ưu code như sau:

class Report  
  def initialize
    @body = "whatever"
  end

  def print formatter
    formatter.generate @body
  end
end

class XmlFormatter  
  def generate body
    #convert the body argument into XML
  end
end  

Hãy nhìn phương thức print, nó cần một formatter nhưng nó chỉ quan tâm về vấn đề interface. Cụ thể hơn, nó chỉ quan tâm rằng định dạng formatter gọi đến phương thức generate nào. Làm như vậy liệu có tốt hơn, nếu chúng ta muốn một báo cáo dạng CSV thì chúng ta sẽ chỉ phải thêm vào một lớp CSVFormatter như sau:

class CSVFormatter  
  def generate body
    #convert the body argument into CSV
  end
end  

Phương thức Report#print chấp nhận một đối tượng CSVFormatter giống như một tham số để chuyển nội dung báo cáo thành một CSV string.

Kết luận

Đây là tất cả những điều tôi biết về 5 nguyên tắc SOLID. Nếu biết vận dụng thuần thục các nguyên tắc này thì tôi tin những đoạn code của bạn sẽ trông sáng sủa, gọn gàng hơn và sẽ không còn là khó khăn đối với người bao trì. Bài viết này tôi có dịch từ nguồn: http://blog.siyelo.com/solid-principles-in-ruby/ Rất mong nhận được sự đóng góp của mọi người để bài viết có thể hoàn thiện hơn


All Rights Reserved