🧱 Ưu tiên Composition hơn Inheritance: Góc nhìn từ Kotlin

0 0 0

Người đăng: Kẻ hai mặt

Theo Viblo Asia

🔍 Tổng quan

Trong lập trình hướng đối tượng (OOP), có hai cách chính để tái sử dụng code và thiết lập mối quan hệ giữa các lớp: inheritance (kế thừa) và composition (thành phần). Mặc dù cả hai đều có vai trò nhất định, nguyên tắc “composition over inheritance” đã trở nên phổ biến trong thiết kế phần mềm hiện đại. Bài viết này khám phá cả hai phương pháp, những điểm mạnh và yếu của chúng, và lý do tại sao composition thường được ưu tiên, với các ví dụ thực tế trong Kotlin.

image.png

Hiểu về Inheritance (Kế thừa)

Inheritance cho phép một lớp (subclass) kế thừa thuộc tính và hành vi từ một lớp khác (superclass), thiết lập mối quan hệ “is-a”.

// Lớp cơ sở
open class Animal { open fun makeSound() { println("Some generic animal sound") } fun eat() { println("Eating...") }
} // Lớp kế thừa
class Dog : Animal() { override fun makeSound() { println("Woof!") } fun fetch() { println("Fetching...") }
} fun main() { val dog = Dog() dog.makeSound() // Outputs: Woof! dog.eat() // Outputs: Eating... dog.fetch() // Outputs: Fetching...
}

Ưu điểm của Inheritance:

✅ Tái sử dụng mã: Subclass tự động kế thừa phương thức và thuộc tính.

✅ Ghi đè phương thức: Cho phép tùy chỉnh hành vi kế thừa.

✅ Đa hình: Cho phép xử lý các đối tượng khác nhau thông qua superclass.

Nhược điểm của Inheritance:

❌ Kết nối chặt chẽ: Thay đổi trong superclass có thể ảnh hưởng đến subclass.

❌ Vấn đề lớp cơ sở dễ vỡ: Sửa đổi lớp cơ sở có thể gây ra hiệu ứng không mong muốn.

❌ Thiếu linh hoạt: Cấu trúc kế thừa cố định tại thời điểm biên dịch.

❌ Giới hạn kế thừa đơn: Nhiều ngôn ngữ (bao gồm Kotlin) chỉ hỗ trợ kế thừa đơn.

🧩 Hiểu về Composition (Thành phần)

Composition là nguyên tắc thiết kế trong đó các lớp đạt được hành vi đa hình và tái sử dụng mã bằng cách chứa các thể hiện của các lớp khác thay vì kế thừa từ chúng, thiết lập mối quan hệ “has-a”.

interface SoundBehavior { fun makeSound()
} class BarkSound : SoundBehavior { override fun makeSound() { println("Woof!") }
} class MeowSound : SoundBehavior { override fun makeSound() { println("Meow!") }
} class EatingBehavior { fun eat() { println("Eating...") }
} class Dog( private val soundBehavior: SoundBehavior, private val eatingBehavior: EatingBehavior
) { fun makeSound() { soundBehavior.makeSound() } fun eat() { eatingBehavior.eat() } fun fetch() { println("Fetching...") }
} class Cat( private val soundBehavior: SoundBehavior, private val eatingBehavior: EatingBehavior
) { fun makeSound() { soundBehavior.makeSound() } fun eat() { eatingBehavior.eat() } fun purr() { println("Purring...") }
} fun main() { val eatingBehavior = EatingBehavior() val dog = Dog(BarkSound(), eatingBehavior) val cat = Cat(MeowSound(), eatingBehavior) dog.makeSound() // Outputs: Woof! cat.makeSound() // Outputs: Meow!
}

Trong ví dụ này, thay vì kế thừa hành vi, các lớp Dog và Cat sử dụng composition bằng cách chứa các thể hiện của SoundBehaviorEatingBehavior.

Tiêu chí Inheritance Composition
Mối quan hệ "is-a" "has-a"
Tái sử dụng mã Thông qua kế thừa Thông qua thành phần
Linh hoạt Thấp (cấu trúc cố định) Cao (có thể thay đổi thành phần)
Kết nối giữa các lớp Chặt chẽ Lỏng lẻo
Đa hình
Khả năng mở rộng Hạn chế Dễ dàng thêm hoặc thay đổi hành vi

🧠 Khi nào nên sử dụng Composition?

Khi cần linh hoạt trong việc thay đổi hành vi của đối tượng tại thời điểm chạy.

Khi muốn giảm sự phụ thuộc giữa các lớp và tăng khả năng tái sử dụng mã.

Khi cần tách biệt các mối quan tâm (separation of concerns) để dễ dàng bảo trì và mở rộng.

💎 Vấn đề Diamond Problem và Tại Sao Nó Quan Trọng

Một trong các vấn đề kinh điển khi dùng kế thừa là “vấn đề kim cương (diamond problem)”, xảy ra trong trường hợp đa kế thừa:

 A / \ B C \ / D

Nếu cả B và C đều ghi đè (override) một phương thức từ A, thì D nên kế thừa phiên bản nào?

Dù Kotlin không hỗ trợ đa kế thừa lớp, nhưng lại hỗ trợ triển khai nhiều interface, nên vẫn có thể gặp tình huống tương tự:

interface A { fun doSomething() { println("A's implementation") }
} interface B : A { override fun doSomething() { println("B's implementation") }
} interface C : A { override fun doSomething() { println("C's implementation") }
} // Không thể biên dịch nếu không ghi đè rõ ràng
class D : B, C { override fun doSomething() { super<B>.doSomething() // Phải chọn gọi cái nào }
}

✅ Dùng composition để tránh hoàn toàn vấn đề này bằng cách làm rõ các mối quan hệ:

class ComponentA { fun doSomething() { println("A's implementation") }
} class ComponentB { fun doSomething() { println("B's implementation") }
} class ComponentC { fun doSomething() { println("C's implementation") }
} class D( private val componentB: ComponentB, private val componentC: ComponentC
) { fun doSomethingB() { componentB.doSomething() } fun doSomethingC() { componentC.doSomething() }
}

🧩 Ví dụ thực tế: UI Components

Hãy cùng xem ví dụ thực tế hơn với các thành phần UI:

✅ Cách tiếp cận bằng kế thừa:

open class UIComponent { open fun render() { println("Rendering component") } open fun handleClick() { println("Component clicked") }
} open class Button : UIComponent() { override fun render() { println("Rendering button") } override fun handleClick() { println("Button clicked") }
} class AnimatedButton : Button() { override fun render() { println("Rendering animated button") }
}

✅ Cách tiếp cận bằng composition:

interface Renderer { fun render()
} interface ClickHandler { fun handleClick()
} class StandardRenderer : Renderer { override fun render() { println("Standard rendering") }
} class AnimatedRenderer : Renderer { override fun render() { println("Animated rendering") }
} class StandardClickHandler : ClickHandler { override fun handleClick() { println("Standard click handling") }
} class UIComponent( private val renderer: Renderer, private val clickHandler: ClickHandler
) { fun render() { renderer.render() } fun handleClick() { clickHandler.handleClick() }
}

👉 Cách sử dụng:

fun main() { val standardButton = UIComponent(StandardRenderer(), StandardClickHandler()) val animatedButton = UIComponent(AnimatedRenderer(), StandardClickHandler()) standardButton.render() // In ra: Standard rendering animatedButton.render() // In ra: Animated rendering
}

Với composition, ta có thể dễ dàng kết hợp các hành vi mà không cần tạo ra hệ thống kế thừa phức tạp. Thậm chí, có thể thay đổi hành vi ngay tại runtime:

class DynamicButton( private var renderer: Renderer, private var clickHandler: ClickHandler
) { fun render() { renderer.render() } fun handleClick() { clickHandler.handleClick() } fun setRenderer(newRenderer: Renderer) { renderer = newRenderer } fun setClickHandler(newClickHandler: ClickHandler) { clickHandler = newClickHandler }
}

✅ Khi nào nên dùng kế thừa

Dù composition rất mạnh, inheritance vẫn có chỗ đứng riêng, ví dụ:

  • Khi có mối quan hệ "is-a" rõ ràng và ít có khả năng thay đổi
  • Khi bạn muốn tận dụng tính đa hình (polymorphism) một cách đơn giản
  • Khi class cha ổn định và không thay đổi thường xuyên
  • Trong thiết kế framework, nơi các điểm mở rộng đã được xác định rõ

▶️ Ví dụ: Trong thư viện chuẩn của Kotlin, ArrayList kế thừa AbstractList vì ArrayList thực sự là một list – mối quan hệ này rất hợp lý và không cần thay đổi.

✅ Các nguyên tắc nên áp dụng

  • Ưu tiên composition hơn kế thừa trong phần lớn trường hợp
  • Chỉ dùng kế thừa khi thực sự có mối quan hệ “is-a” rõ ràng và bền vững
  • Thiết kế theo hướng composition bằng cách tạo ra các interface và class nhỏ, tập trung
  • Xem xét ủy quyền (delegation) như giải pháp trung gian (Kotlin hỗ trợ bằng từ khóa by)
  • Tránh hệ thống kế thừa sâu — khó hiểu và khó bảo trì
  • Lập trình hướng interface, không hướng implementation để dễ dàng thay thế thành phần

✅ Tính năng delegation trong Kotlin hỗ trợ composition rất tốt:

interface SoundMaker { fun makeSound()
} class Barker : SoundMaker { override fun makeSound() = println("Woof!")
} // Sử dụng delegation với từ khóa 'by'
class Dog(soundMaker: SoundMaker) : SoundMaker by soundMaker { // Có thể thêm các hành vi riêng cho Dog fun fetch() = println("Fetching...")
} fun main() { val dog = Dog(Barker()) dog.makeSound() // In ra: Woof! dog.fetch() // In ra: Fetching...
}

🎯 Kết luận

Mặc dù inheritance có thể hữu ích trong một số trường hợp, composition thường được ưu tiên trong thiết kế phần mềm hiện đại do tính linh hoạt và khả năng mở rộng cao hơn. Bằng cách sử dụng composition, bạn có thể xây dựng các hệ thống dễ bảo trì, dễ kiểm thử và thích ứng tốt với các thay đổi trong tương lai.

Hãy nhớ rằng thiết kế tốt không phải là tuân theo các quy tắc một cách giáo điều mà là lựa chọn đúng công cụ cho công việc. Trong nhiều trường hợp, công cụ đó sẽ là composition, nhưng vẫn có những trường hợp sử dụng hợp lệ cho việc inheritance. Điều quan trọng là phải hiểu được ý nghĩa của sự lựa chọn của bạn và thiết kế code của bạn sao cho linh hoạt và dễ bảo trì nhất có thể.

Link : https://carrion.dev/en/posts/composition-over-inheritance/

Bình luận

Bài viết tương tự

- vừa được xem lúc

Học Flutter từ cơ bản đến nâng cao. Phần 1: Làm quen cô nàng Flutter

Lời mở đầu. Gần đây, Flutter nổi lên và được Google PR như một xu thế của lập trình di động vậy.

0 0 299

- vừa được xem lúc

Học Flutter từ cơ bản đến nâng cao. Phần 3: Lột trần cô nàng Flutter, BuildContext là gì?

Lời mở đầu. Màn làm quen cô nàng FLutter ở Phần 1 đã gieo rắc vào đầu chúng ta quá nhiều điều bí ẩn về nàng Flutter.

1 1 357

- vừa được xem lúc

[Android] Hiển thị Activity trên màn hình khóa - Show Activity over lock screen

Xin chào các bạn, Hôm nay là 30 tết rồi, ngồi ngắm trời chờ đón giao thừa, trong lúc rảnh rỗi mình quyết định ngồi viết bài sau 1 thời gian vắng bóng. .

0 0 114

- vừa được xem lúc

Tìm hiểu Proguard trong Android

1. Proguard là gì . Cụ thể nó giúp ứng dụng của chúng ta:. .

0 0 108

- vừa được xem lúc

Làm ứng dụng học toán đơn giản với React Native - Phần 6

Chào các bạn một năm mới an khang thịnh vượng, dồi dào sức khỏe. Lại là mình đây Đây là link app mà các bạn đang theo dõi :3 https://play.google.com/store/apps/details?id=com.

0 0 85

- vừa được xem lúc

20 Plugin hữu ích cho Android Studio

1. CodeGlance. Plugin này sẽ nhúng một minimap vào editor cùng với thanh cuộn cũng khá là lớn. Nó sẽ giúp chúng ta xem trước bộ khung của code và cho phép điều hướng đến đoạn code mà ta mong muốn một cách nhanh chóng.

0 0 322