+2

Thiết kế hướng đối tượng trong Ruby (Phần 1)

Trong thiết kế hướng đối tượng, nguyên lý SOLID bao gồm:

  1. The Single Responsibility Principle
  2. The Open Closed Principle
  3. The Liskov Substitution Principle
  4. The Interface Sergregation Principle
  5. 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: gear.png

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 đó.

wheel.png

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

Viblo
Let's register a Viblo Account to get more interesting posts.