Thiết kế hướng đối tượng trong Ruby (Phần 1)
Bài đăng này đã không được cập nhật trong 3 năm
Trong thiết kế hướng đối tượng, nguyên lý SOLID bao gồm:
- The Single Responsibility Principle
- The Open Closed Principle
- The Liskov Substitution Principle
- The Interface Sergregation Principle
- The Dependency Inversion Principle
Trong khuôn khổ bài viết này chúng ta sẽ tìm hiểu nguyên lý đầu tiên - SRP. SRP đề cập đến việc một lớp (phương thức) chỉ nên có một nhiệm vụ duy nhất. Nếu chúng ta gom nhiều chức năng cho một lớp thì khi thay đổi một chức năng nào đó, thì toàn bộ lớp đó phải thay đổi theo. Và khi có nhiều thay đổi, điều đó cũng có nghĩa là sẽ phát sinh ra nhiều vấn đề khác như lỗi, buộc ta phải test lại hết toàn bộ lớp đó.
Tạo một Class chỉ với một nhiệm vụ duy nhất
Một lớp chỉ nên thực hiện số lượng nhiệm vụ ít nhất có thể. Tốt nhât nó chỉ nên có một nhiệm vụ duy nhất.
Trong phần này chúng ta sẽ học cách làm sao để tạo một lớp với một nhiệm vụ duy nhất thông qua một ví dụ cụ thể - chiếc xe đạp.
Ví dụ về xe đạp và bánh răng
Xe đạp là một phương tiện giúp chúng ta thông qua bộ động cơ (gear) chuyển hóa từ sức người sang dạng cơ học. Khi đạp xe, chúng ta có thể chọn giữa gear nhỏ (dễ khi đạp nhưng lại đi chậm) hoặc gear lớn (khó đạp hơn nhưng lại đi nhanh hơn).
Bộ động cơ xe đạp hoạt động bằng cách xem xét được khoảng cách xe đạp di chuyển khi chúng ta kết thúc một vòng đạp. Cụ thể hơn, động cơ sẽ điều khiển xem bánh xe được quay bao nhiêu vòng với mỗi vòng đạp. Trong trường hợp động cơ nhỏ bạn cần đạp nhiều vòng để bánh xe có thể quay được một vòng và ngược lại đối với động cơ lớn. Ví dụ như trong hình:
Việc chúng ta nói (động cơ) lớn hay nhỏ không thực sự chính xác. Để có thể so sánh được sự khác biệt giữa các loại động cơ. Chúng ta sẽ sử dụng tỉ lệ giữa số lượng răng cưa. Tỉ lệ đó có thể được tính như sau:
chainring = 52
cog = 11
ratio = chainring / cog.to_f
puts ratio # -> 4.72727272727273
chainring = 30
cog = 27
ratio = chainring / cog.to_f
puts ratio # -> 1.11111111111111
Nếu một động cơ được tổng hợp bởi 52 răng của chainring và 11 răng cog sẽ có tỉ lệ 4,73. Tức là mỗi vòng đạp xe thì bánh sẽ quay khoảng 5 vòng. Tương tự, trường hợp còn lại bánh chỉ quay khoảng 1 vòng.
Để đi sâu hơn về ví dụ trên, chúng ta có thể khái quát hóa lên và xây dựng một lớp tên là Gear.
class Gear
attr_reader :chainring, :cog
def initialize(chainring, cog)
@chainring = chainring
@cog = cog
end
def ratio
chainring / cog.to_f
end
end
puts Gear.new(52, 11).ratio # -> 4.72727272727273
puts Gear.new(30, 27).ratio # -> 1.11111111111111
Gear là một lớp con của lớp Object, và vì thế nó được kế thừa nhiều phương thức khác.
Trong trường hợp chúng ta có hai chiếc xe đạp với cùng loại động cơ nhưng khác nhau về kích thước bánh xe. Chúng ta sẽ tìm cách để đánh giá hiệu quả của hai chiếc bánh xe đó.
Chúng ta sẽ dùng thuật ngữ gear inches
để so sánh sự khác biệt với cả động cơ và kích thước bánh xe, nó được xác định theo công thức:
gear inches = wheel diameter * gear ratio
trong đó: wheel diameter = rim diameter + twice tire diameter
Giờ lớp Gear sẽ được bổ sung như sau:
class Gear
attr_reader :chainring, :cog, :rim, :tire
def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
@rim = rim
@tire = tire
end
def ratio
chainring / cog.to_f
end
def gear_inches
# tire goes around rim twice for diameter
ratio * (rim + (tire * 2))
end
end
puts Gear.new(52, 11, 26, 1.5).gear_inches
# -> 137.090909090909
puts Gear.new(52, 11, 24, 1.25).gear_inches
# -> 125.272727272727
Cấu trúc thô sơ ban đầu của lớp Gear đã được hoàn thiện. Tuy nhiên, đó có phải là cách tốt nhất để tổ chức code. Câu trả lời là: còn tùy vào hoàn cảnh. Nếu như chúng ta cần một ứng dụng đơn giản, không thay đổi thì như vậy là đủ. Còn trong trường hợp cần mở rộng ứng dụng, sẽ có nhiều class khác được phát triển dựa trên lớp Gear, thì đoạn code cần linh hoạt hơn.
Tại sao cần Single Responsibility Matters
Những ứng dụng dễ thay đổi bao gồm nhiều lớp lớp sẽ dễ dàng tái sử dụng. Một ứng dụng có thể dễ dàng thay đổi giống như một chiếc hộp được xây dựng bởi nhiều khối nhỏ. Bạn có thể lựa chọn từng phần bạn muốn và lắp ráp chúng theo những hình dạng đặc biệt.
Một lớp có nhiều hơn một nhiệm vụ thì sẽ khó tái sử dụng. Nhiều nhiệm vụ khác nhau có khả năng gây lộn xộn trong lớp. Nếu như bạn muốn tái sử dụng một vài (không phải tất cả) các hành vi của nó, thì bạn không thể chỉ lấy những phần bạn cần.
Nếu như các nhiệm vụ được gắn liền với nhau khiến bạn không thể chỉ sử dụng những gì bạn cần, bạn có thể lặp lại đoạn code cần thiết. Nhưng là một ý tưởng tồi tệ. Code trùng lặp sẽ tăng thêm công việc bảo trì và phát sinh thêm nhiều lỗi. Nếu một lớp được cấu trúc sao cho bạn có thể truy cập chỉ những hành vi bạn cần dùng, bạn có thể sử dụng toàn bộ lớp đó.
Bạn có thể viết code sao cho dễ dàng thay đổi ngay cả khi bạn không biết điều gì sẽ thay đổi. Bởi vì thay đổi là không thể tránh khỏi, viết code sao cho dễ thay đổi sẽ mang lại lợi ích lớn trong tương lai. Tiếp theo chúng ta sẽ tìm hiểu một vài kỹ thuật nổi tiếng mà bạn có thể áp dụng.
Depend on Behavior, Not Data (Phụ thuộc vào hành vi, không phải dữ liệu)
Các hành vi được thể hiện trong những phương thức và được gọi bằng cách truyền thông điệp. Khi tạo ra những lớp chỉ với một nhiệm vụ duy nhất, mỗi hành vi chỉ nên được xác định ở một vị trí. Cụm từ "Don't Repeat Yourself" (DRY) dùng để chỉ tư tưởng đó. Khi đó bất kỳ sự thay đổi nào của một hành vi đề được thay đổi chỉ ở một chỗ.
Ngoài hành vi, các đối tượng thường bao gồm dữ liệu. Dữ liệu có thể được truy cập theo một trong hai cách: tham chiếu trực tiếp tới thể hiện của biến hoặc có thể đóng gói thể hiện của biến trong một phương thức truy cập.
Hide Instance Variables - Che giấu các thể hiện của các biến
Luôn luôn đóng gói các thể hiện của biến trong các phương thức truy cập thay vì tham chiếu trực tiếp tới biến, giống như phương thức ratio sau:
class Gear
def initialize(chainring, cog)
@chainring= chainring
@cog = cog
end
def ratio
@chainring/@cog.to_f # <-- road to ruin
end
end
Che giấu các biến, thậm chí từ lớp định nghĩa chúng, bằng cách đóng gói chúng trong các phương thức. Ruby cung cấp attr_reader
như một cách đơn giản để tạo ra các phương pháp đóng gói:
class Gear
attr_reader :chainring,:cog
def initialize(chainring, cog)
@chainring= chainring
@cog = cog
end
def ratio
@chainring/@cog.to_f # <-- road to ruin
end
end
Sử dụng attr_reader
để tạo các phương thức đóng gói đơn giản cho các biến. Đây là một cách thể hiện cho biến cog.
# default implementation via attr_reader
def cog
@cog
end
Enforce Single Responsibility Everywhere - Cố gắng áp dụng nguyên lý SRP ở mọi hoàn cảnh
Ý tưởng về thiết kế các lớp với một nhiệm vụ duy nhất có thể hữu ích trong rất nhiều hoàn cảnh khác nhau.
Áp dụng SRP đối với các phương thức
Các phương thức, cũng giống như các lớp, chỉ nên có một nhiệm vụ duy nhất. Điều đó giúp chúng dễ dàng được sửa đổi và tái sử dụng.
Xem xét ví dụ về phương thức diameters
sau:
def diameters
wheels.collect {|wheel|
wheel.rim + (wheel.tire * 2)}
end
Phương thức trên có hai nhiệm vụ: lặp qua tất cả các wheels và tính đường kính của từng bánh xe (wheel).
Chúng ta sẽ làm đơng giản đoạn code trên bằng cách tách nó ra thành hai phương thức, mỗi phương thức với một nhiệm vụ duy nhất.
# first - iterate over the array
def diameters
wheels.collect {|wheel| diameter(wheel)}
end
# second - calculate diameter of ONE wheel
def diameter wheel
wheel.rim + (wheel.tire * 2))
end
Việc tách vòng lặp và hành động trên mỗi phần tử như trên là một cách khá phổ biến. Tuy nhiên trong nhiều trường hợp khác, vấn đề lại không rõ ràng như vậy.
Xem lại phương thức gear_inches trong lớp Gear
def gear_inches
# tire goes around rim twice for diameter
ratio * (rim + (tire * 2))
end
Tương tự theo cách trên, chúng ta có thể tránh các vấn đề sau này bởi việc thiết kế lại phương thức chỉ với một nhiệm vụ duy nhất:
def gear_inches
ratio * diameter
end
def diameter
rim + (tire * 2)
end
Phương thức gear_inches
sẽ gửi thông điệp để nhận giá trị diameter
. Chú ý rằng việc tái cấu trúc code không làm thay đổi cách tính diameter
, nó chỉ tách biệt hai hành động để có thể dễ thay đổi sau này.
Việc thực hiện tái cấu trúc code như trên rất đơn giản, nhưng lợi ích nó mang lại về lâu dài rất lớn. Những phương thức chỉ có một nhiệm vụ duy nhất có những lợi ích sau:
- Tránh được những comment trong code.
- Khuyến khích việc tái sử dụng lại code
- Dễ dàng di chuyển phương thức sang các lớp khác
Phân chia từng nhiệm vụ trong một lớp
Khi ứng dụng được phát triển lên, lớp Gear chúng ta đã đề cập trong phần trước sẽ có khả năng cần phải bổ sung nhiều nghiệp vụ tương tự như nghiệp vụ về bánh xe (wheel). Nếu được cho phép, bạn có thể tách chúng ra thành một lớp mới - Wheel. Nhưng giả sử bạn không chọn cách đó. Ruby cho phép bạn di chuyển nghiệp vụ vụ tính đường kính bánh xe mà không cần phải tạo thêm lớp mới. Ví dụ sau sẽ tạo thêm một cấu trúc Wheel, trong đó tạo thêm phương thức tính đường kính bánh xe:
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, rim, tire)
@chainring = chainring
@cog = cog
@wheel = Wheel.new(rim, tire)
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
Wheel = Struct.new(:rim, :tire) do
def diameter
rim + (tire * 2)
end
end
end
Nếu như bạn có một lớp lộn xộn với quá nhiều nhiệm vụ, hãy tách biệt các nhiệm vụ đó ra nhiều lớp khác nhau. Tập trung vào một lớp chính với những nhiệm vụ chính của nó. Nếu như có nhiều nhiệm vụ khác không thể loại bỏ, hãy tách biệt chúng ra giống cách chúng ta đã đưa phương thức tính đường kính bánh xe vào cấu trúc Wheel như trên.
Giờ hãy giả sử rằng bạn có một chiếc máy tính được gắn trên chiếc xe đạp để tính toán tốc độ của xe. Như vậy chiếc máy tính cần được cấu hình với bánh xe để có thể làm được công việc đó. Lúc này bạn có thể tạo thêm một phương thức mới để tính toán chu vi của bánh xe. Sự thay đổi này thực ra không đáng kể. Sự thay đổi thực sự là ứng dụng của bạn cần có thêm một lớp mới - Wheel để có thể thực hiện các nhiệm vụ độc lập hoàn toàn với lớp Gear trước đó. Để làm việc này, bạn chỉ cần tách cấu trúc Wheel thành một lớp Wheel mới rồi thêm phương thức tính chu vi - circumference:
class Gear
attr_reader :chainring, :cog, :wheel
def initialize(chainring, cog, wheel=nil)
@chainring = chainring
@cog = cog
@wheel = wheel
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
end
class Wheel
attr_reader :rim, :tire
def initialize(rim, tire)
@rim = rim
@tire = tire
end
def diameter
rim + (tire * 2)
end
def circumference
diameter * Math::PI
end
end
@wheel = Wheel.new(26, 1.5)
puts @wheel.circumference
# -> 91.106186954104
puts Gear.new(52, 11, @wheel).gear_inches
# -> 137.090909090909
puts Gear.new(52, 11).ratio
# -> 4.72727272727273
Cả hai lớp Gear và Wheel đều chỉ có một nhiệm vụ duy nhất. Code của bạn hiện tại chưa thực sự hoàn hảo nhưng nó đã khá tốt để có thể đáp ứng nhu cầu mở rộng về sau.
Tóm lại, bước khởi đầu để có thể thay đổi và bảo trì một phần mềm hướng đối tượng bắt đầu với những lớp được thiết kế chỉ với một nhiệm vụ duy nhất. Mỗi lớp thực hiện một nhiệm vụ riêng biệt so với những lớp còn lại trong ứng dụng. Sự phân tách này cho phép thay đổi mã nguồn mà không gây ra hậu quả và có thể tái sử dụng mà không bị trùng lặp mã nguồn.
Cám ơn bạn đã theo dõi bài viết này. Chúng ta sẽ cùng tìm hiểu các nguyên lý thiết kế hướng đối tượng khác trong các phần tiếp theo. Tài liệu tham khảo: Practical Object-Oriented Design in Ruby
All rights reserved