Tìm hiểu thiết kế hướng đối tượng trong Rails Phần 5

Tìm hiểu thiết kế hướng đối tượng trong Ruby on Rails (Phần V)_ Giảm bớt chi phí với duck typing

I. Giới thiệu duck type

Mục tiêu của thiết kế hướng đối tượng là giảm bớt cost hay effort phải bỏ ra đối với các thay đổi của hệ thống. Như ta đã biết các thông điệp là trọng tâm của thiết kế, tuy nhiên có một nhân tố khác cũng hỗ trợ trong quá trình giảm bớt effort đó là các public interface _ các giao diện công khai.

Đó được cũng có thể biết tới với tên gọi là duck typing. Duck type là các public interface không được gắn với một lớp cụ thể nào, cung cấp khả năng xử lý mềm dẻo với các sự phụ thuộc trong hệ thống.

Một đối tượng được thể hiện thông qua các hành vi của nó hay qua các public interface, tuy nhiên, nó không chỉ đơn thuần thể hiện chỉ một interface mà ứng với mỗi một kiểu thông điệp gửi đến thì nó sẽ đáp ứng với một interface phù hợp. Đó chính là thực tế của thế giới hiện thực, người dùng đối tượng sẽ không cần quan tâm, không cần biết về lớp của nó. Các lớp chỉ đơn thuần là nơi để đối tượng thực hiện một public interface nào đó.

II. Vấn đề đặt ra

Ta có ví dụ sau với phương thức Trip's prepare thực hiện những sự chuẩn bị cho chuyến đi:

class Trip
  attr_reader :bicycles, :customers, :vehicle

  với tham số truyền vào có thể là một lớp bất kỳ
  def prepare(mechanic)
    mechanic.prepare_bicycles(bicycles)
  end
end

class Mechanic
  def prepare_bicycles(bicycles)
    bicycles.each{|bicycle| prepare_bicycle(bicycle)}
  end

  def prepare_bicycle(bicycle).
  end
end

Phương thức prepare tuy không trực tiếp phụ thuộc vào lớp Mechanic nhưng nó yêu cầu được nhận một đối tượng mà có thể thực hiện thông điệp prepare_bicycles. Sự phụ thuộc này rất cơ bản và rất dễ bỏ qua.

Giả sử có vấn đề phát sinh và yêu cầu bị thay đổi, để chuẩn bị cho chuyến đi thì cần phải có hướng dẫn viên _ trip coordinator, và một lái xe _ driver. Dựa theo mô hình chúng ta đang thực hiện thì ta sẽ tạo ra lớp mới là TripCoordinatorDriver và cung cấp các hành vi mà chúng có thể đáp ứng.

class TripCoordinator
  def buy_food(customers)end
end

class Driver
  def gas_up(vehicle)
    ...
  end

  def fill_water_tank(vehicle)
    ...
  end
end

Khi đó, phương thức prepare của lớp Trip bắt buộc phải thay đổi cho phù hợp với từng tham số đối tượng truyền vào.

Ta có thay đổi tương ứng:

class Trip
  attr_reader :bicycles, :customers, :vehicle

  def prepare(preparers)
    preparers.each do |preparer|
      case preparer
      when Mechanic
        preparer.prepare_bicycles(bicycles)
      when TripCoordinator
        preparer.buy_food(customers)
      when Driver
        preparer.gas_up(vehicle)
        preparer.fill_water_tank(vehicle)
    end
  end
end

Vâng, mối hiểm họa đã dần hiện ra. Ban đầu, ta chỉ suy nghĩ là preparer chỉ yêu cầu một đối tượng của Mechanic. Tuy nhiên, ta dễ dàng nhận ra tham số của prepare có thể là bất kỳ lớp nào, nhưng bạn vẫn luôn suy nghĩ đó chỉ là Mechanic. Bởi vì chỉ có Mechanic mới hiểu thông điệp prepare_bicycle. Điều đó vẫn hoạt động tốt nhưng khi có một vài thay đổi, có thêm vài đối tượng của các lớp khác truyền vào, thì một vấn đề đã phát sinh _ không phải đối tượng nào cũng hiểu thông điệp prepare_bicycle.

Từ đó có thể thấy nếu thiết kế bị giới hạn bởi lớp và khi ta phát hiện các đối tượng không mong muốn và không hiểu được thông điệp bạn gửi đến, bạn sẽ tìm kiếm các thông điệp mà có thể đáp lại. Trong trường hợp này với lớp TripCoordinator và Driver, public interface của chúng chứa buy_food, gas_up và fill_water_tank là các phản hồi mà perpare mong muốn.

Khi đó, cách xử lý dễ nhất cho vấn đề này là sử dụng case when để phản hồi các thông điệp tương ứng với đối tượng của các lớp khác nhau. Điều đó đã dẫn tới một sự phụ thuộc quá lớn. Nếu có thêm một yêu cầu nào đó, thêm một lớp khác chẳng hạn thì đó lại là thêm một xử lý when.

Với cách xử lý này, ta phụ thuộc quá nhiều vào lớp, ta phải biết chi tiết thông tin về các phản hồi mà từng lớp có được. Khi đó, bất kỳ một sự thay đổi nào đó cũng cần phải xem xét sự ảnh hưởng đến hàm preparer này.

- Giải pháp đưa ra _ tìm kiếm các duck type

Trước hết, ta phải nhìn nhận rõ một điều, hàm Trip's preparer được dùng cho một mục đích gì? đó là muốn chuẩn bị cho chuyến du lịch. Do đó, các tham số truyền vào cũng cần phải đảm bảo mục đích duy nhất đó, nghĩa là mọi tham số cùng một lý do và không liên quan đến một lớp cụ thể nào đó. Đó là các Preparers, các đối tượng chuẩn bị mà có thể thực hiện prepare_trip.

Preparers không tồn tại cố định mà là một đối tượng trừu tượng, cung cấp duck type hay các public interface, cụ thể ở đây là prepare_trip.

Ta có cách sử lý mới:

class Trip
  attr_reader :bicycles, :customers, :vehicle

  def prepare(preparers)
    preparers.each do |preparer|
      preparer.prepare_trip(self)
    end
  end
end

# khi mọi preparer là một Duck, thực hiện trả về prepare_trip
class Mechanic
  def prepare_trip(trip)
    trip.bicycles.each do |bicycle|
      prepare_bicycle(bicycle)}
    end
  end
end

class TripCoordinator
  def prepare_trip(trip)
    buy_food(trip.customers)
  end
end

class Driver
  def prepare_trip(trip)
    vehicle = trip.vehicle
    gas_up(vehicle)
    fill_water_tank(vehicle)
  end

Hàm prepare bây giờ cho phép thêm các Preparer mới mà không cần phải thay đổi gì cả, chỉ cần tạo ra các Preparers mới theo yêu cầu.

Với cách xử lý ban đầu, hàm prepare phụ thuộc vào một lớp cụ thể. Còn với thay đổi hiện giờ, thì prepare phụ thuộc vào một duck type.

Và một điều dễ nhận ra từ ví dụ trên, sự cố định giúp ta dễ hiểu nhưng lại khó khăn trong việc mở rộng. Với duck type, thay thế bằng sự trừu tượng hơn thì lại đặt ra vấn đề về tìm hiểu nhưng mở ra khả năng tùy biến dễ dàng.

Từ đó ta thấy chị phí dành cho sự cụ thể và sự trừu tượng trong thiết kế hướng đối tượng là khá đối lập nhau. Code cụ thể thì dễ dàng trong đọc hiểu nhưng khó khăn trong mở rộng. Trừu tượng ban đầu hiểu rõ là một vấn đề nhưng về sau sẽ dễ dàng trong việc mở rộng code. Với việc dùng duck type, sẽ chuyển code từ cụ thể sang trừu tượng hơn, làm cho code dễ dàng để tùy biến, mở rộng.

Trong Rails có một kỹ thuật vận dụng nguyên tắc duck type và được sử dụng rộng rãi, đó là polymorphism.

  • Polymorphism cung cấp khả năng nhiều đối tượng khác nhau phản hồi thông điệp giống nhau. Người gửi thông điệp không cần quan tâm đến lớp của người nhân, người nhận sẽ đáp lại dựa vào hành vi của họ

Cách nhận biết Duck

Dựa vào code xử lý có:

  • case when

  • kind_of? và is_a?

  • responds_to?

Khi bắt gặp các đoạn code trên, thì sử dụng duck type là một giải pháp hiệu quả.

III. Kết luận

Các thông điệp là trung tâm của ứng dụng hướng đối tượng và chúng truyền qua lại giữa các đối tượng nhờ các public interfaces. Duck type tách rời các public interfaces khỏi các lớp cụ thể, tạo ra các kiểu trừu tượng và định nghĩa công việc của chúng là gì.

Phụ thuộc vào sự trừu tượng sẽ giảm bớt các rủi ro và tăng tính mềm dẻo, làm cho ứng dụng giảm bớt chi phí bảo trì và dễ dàng thay đổi khi có yêu cầu.

Tài liệu tham khảo:

Pratical Object-oriented design in ruby