+6

Đừng lạm dụng kế thừa trong Ruby

I. Đặt vấn đề

Xin chào các bác (bow).

Bài viết hôm nay mình sẽ trình bày một vấn đề:

Con lãnh đạo làm lãnh đạo là hạnh phúc của dân tộc 👏

Việc con em lãnh đạo được giao trọng trách quản lý là điều hạnh phúc của dân tộc, không có gì phải nghi ngại.


Ngoài đời là vậy (yaoming)

Nhưng trong lập trình, việc sử dụng kế thừa (Inheritance) thì có vấn đề gì sao?

Mình sẽ mình họa bằng một ví dụ sau đây.

Giả sử khách hàng yêu cầu bạn tạo ra một traffic simulator app - muốn nó có thể mô phỏng một số loại xe.

Nếu bạn sử dụng ngôn ngữ hướng đối tượng như Ruby, có thể bạn sẽ hướng tới xây dựng một model class kiểu như thế này:

Vehicle class Vehicle class

Xong task và bàn giao lại với khách.

Nhưng giờ họ muốn chia làm 2 loại xe là ô tô (cars) và xe tải (trucks).

2 lớp này sẽ có chung một số behavior, và bạn không muốn code bị trùng lặp

-> Ta sử dụng kế thừa là giải quyết ez luôn - is-a relation

Vehicle với inheritance Vehicle với inheritance

Khách hàng ok, nhưng một lần nữa họ lại bảo:

Nếu ô tô và xe tải này có thể có các loại động cơ khác nhau nữa thì tốt quá!

Giả dụ như là động cơ chạy xăng và điện.

Một lần nữa, kế thừa giải quyết trong 1 nốt nhạc:

Vehicle với nhiều inheritance Vehicle với nhiều inheritance

Có thể thấy với kế thừa, ta có thể dễ dàng giải quyết những yêu cầu kiểu như vậy.

Nhưng nếu họ muốn phân mảnh nhiều nữa thì sao, ví dụ: xe tư nhân, xe công, xe cứ hỏa, ...

Cây kế thừa của ta sẽ ngày càng lớn và phức tạp đến mức khó kiểm soát.

Thay vì giảm lượng code bị lặp, cuối cùng vẫn sẽ xuất hiện những đoạn logic giống nhưng ở nhiều chỗ khác nhau.

Trường hợp này xảy ra không chỉ trong ví dụ trên, mình đã gặp nó rất nhiều lần trong project, đặc biệt là khi phát triển tính năng mới hoặc cố thêm behavior trong class kế thừa.

Để có cái nhìn trực quan hơn, ta hãy nhìn vào đoạn code sau:

class Vehicle
  def run
    refill
    load
  end
end

class Car < Vehicle
  def load
    # load passengers
  end
end

class Truck < Vehicle
  def load
    # load cargo
  end
end

class PetrolCar < Car
  def refill
    # refill with fuel
  end
end

class ElectricCar < Car
  def refill
    # refill with electricity
  end
end

class PetrolTruck < Truck
  def refill
    # refill with fuel (code duplication!)
  end
end

class ElectricTruck < Truck
  def refill
    # refill with electricity (code duplication!)
  end
end

Vậy có thể xử lý việc này không, có chứ (dance2)

II. Giải pháp

Sử dụng include (mixins)

Đây thường là ý tưởng đầu tiên xuất hiện trong đầu Ruby developer khi họ nhận ra rằng - kế thừa không phải là giải pháp hay.

Đơn giản là ta nhóm các methods lại thành các module rồi include vào trong class nó cần thiết.

Ta có thể dễ dàng điều tra những logic bên trong và tránh việc lặp code.

Các methods trên có thể tách ra các module như sau:

module Vehicle
  def run
    refill
    load
  end
end

module Truck
  def load
    # load cargo
  end
end

module Car
  def load
    # load passengers
  end
end

module ElectricEngine
  def refill
    # refill with electricity
  end
end

module PetrolEngine
  def refill
    # refill with petrol
  end
end

Include vào class chính:

class PetrolCar
  include Vehicle
  include Car
  include PetrolEngine
end

class ElectricCar
  include Vehicle
  include Car
  include ElectricEngine
end

class PetrolTruck
  include Vehicle
  include Truck
  include PetrolEngine
end

class ElectricTruck
  include Vehicle
  include Truck
  include ElectricEngine
end

Trông hợp lý hơn hẳn: không bị lặp code, cũng như ta có thể thoải mái add thêm module nếu khách hàng yêu cầu.

Nhưng vẫn còn một số vấn đề tồn đọng.

Khi nhìn vào class này, bạn sẽ không chắc được những hành vi nào đã include sẽ được sử dụng.

Mặc dù code vẫn chạy đúng, nhưng sẽ khó để ta theo dõi được flow các methods chạy từ đâu đến đâu.

Nếu chẳng may 2 modules có methods trùng tên thì bạn sẽ gặp rắc rối - 1 module sẽ lẳng lặng mà sử dụng methods từ module khác.

Tương tự như vậy, methods trong module sẽ phá đám methods bạn implement trực tiếp trong class.

Include không sida và chắc chắn có nhiều case sử dụng nó là hợp lý.

Nhưng ở quản điểm của tôi, nó sẽ hoạt động tốt khi bạn muốn định nghĩa một meta behavior của class như kiểu logging, authorization hay validation.

Ưu điểm lớn nhất của nó trong giải pháp này là giữ code được sạch và ngắn gọn.

Nó vẫn sẽ hoạt động tốt miễn là bạn implement chính xác và không phá bất kỳ logic nào khác khi thêm vào.

Nhưng hãy nhớ là, nó chỉ là một trong những cách để implement đa kế thừa trong Ruby mà thôi.

Composition

Composition (tạm dịch là tổng hợp) là một kỹ thuật đã xuất hiện từ khá lâu rồi.

Vậy dùng Composition là dư lào?

Thay vì share những behavior giống nhau giữa các class, bạn nên xác định những phần khác nhau giữa chúng, đặt tên, tách ra class riêng và tổng hợp vào trong object cuối cùng sẽ sử dụng.

Nếu gọi kế thừa là kiểu quan hệ is-a (là một), thì composition là kiểu quan hệ has-a (có một).

Xe (vehicle) không phải là xe điện nữa, nhưng nó sẽ động cơ điện (engine).

Không phải là xe tải, nhưng nó thân xe tải (body).

Từ đó, ta có thể phân ra thành 2 phần: enginebody.

Cấu trúc app giờ sẽ nhìn như thế này. Chúng ta phải implement phần engine, body vào bên trong Vehicle class.

Về mặt code sẽ như thế nào? Bắt đầu với main class:

class Vehicle
  def initialize(engine:, body:)
    @engine = engine
    @body = body
  end

  def run
    @engine.refill
    @body.load
  end
end

Giờ ta có implement các phần tách riêng, cấy nó vào trong Vehicle object

class ElectricEngine
  def refill
    # refill with electricity
  end
end

class PetrolEngine
  def refill
    # refill with petrol
  end
end

class TruckBody
  def load
    # load cargo
  end
end

class CarBody
  def load
    # load passengers
  end
end

Cuối cùng đưa nó vào sử dụng sẽ như sau:

petrol_car = Vehicle.new(engine: PetrolEngine.new, body: CarBody.new)
electric_car = Vehicle.new(engine: ElectricEngine.new, body: CarBody.new)
petrol_truck = Vehicle.new(engine: PetrolEngine.new, body: TruckBody.new)
electric_truck = Vehicle.new(engine: ElectricEngine.new, body: TruckBody.new)

Phương pháp này có nhiều ưu điểm hơn.

Cách mà class Vehicle sử dụng những methods thêm bên ngoài hoàn toàn trực quan, rõ ràng.

Ta sẽ không gặp phải vấn đề conflict tên hàm.

Mỗi class thực hiện chỉ một công việc (đáp ứng cho rule single responsibility principle).

Nhớ đó mà bạn có thể test và viết test dễ hơn nhiều.

Ngoài ra nó có sự gắn kết cao (giữ cùng một logic với nhau) và đồng thời giữ các cho class không bị phụ thuộc vào nhau. Ta có thể thoải mái thay đổi code trong engine hoặc body miễn là interface cũng chúng vấn giữ nguyên là được.

Vậy composition có nhược điểm nào không? Tất nhiên là có.

Sử dụng compostion có xu hướng làm code mình dài hơn, đặc biết là lúc cấy tất cả các thành phần vào object cuối cùng.

Với tôi, khó nhất trong compostion chính là việc phải thay đổi mindset để phân tích và implement được theo cách đó.

Túm cái váy lại

Tôi đã trình bày cho các bạn 3 kiểu cấu trúc code khác nhau: sử dụng Inheritance, IncludeComposition.

Kế thừa là sự lựa chọn đầu tiên của nhiều lập trình viên, nhưng cẩn thận đừng lạm dụng nó. Nó sẽ làm code phức tạp hơn và khó để maintain.

Include (Mixins) là giải pháp thông minh hơn, tuy nhiên về bản chất, nó vẫn chỉ là kiểu đa kế thừa ngầm, mà dần dần có thể làm tăng sự phức tạp của code.

Composition dài dòng nhất, nhưng đồng thời nó giúp code của ta clear, dễ test và dễ bảo trì nhất.

Bạn phải biết rằng, lập trình hướng đối tượng chỉ là những quy ước của một số lập trình viên đưa ra để giúp các lập trình viên khác giải quyết vấn đề của họ. Nên đừng chỉ trói buộc trong những rules đó.

Hãy chọn giải pháp phù hợp nhất cho tình huống của bạn.

Designing object-oriented software is hard, and designing reusable object-oriented software is even harder. - Gang of Four

Nguồn


All rights reserved

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í