Đừng lạm dụng kế thừa trong Ruby
Bài đăng này đã không được cập nhật trong 3 năm
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
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
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
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ẽ có động cơ điện (engine).
Không phải là xe tải, nhưng nó có thân xe tải (body).
Từ đó, ta có thể phân ra thành 2 phần: engine và body.
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, Include và Composition.
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