Ngày 12 tháng 7 năm 2024 - Lập trình máy tính Trong lập trình hướng đối tượng, có một nguyên tắc thiết kế kinh điển: “Hợp thành tốt hơn kế thừa”, nghĩa là nên sử dụng hợp thành nhiều hơn và hạn chế sử dụng kế thừa. Vậy kế thừa là gì? Hợp thành là gì? Tại sao không khuyến khích sử dụng kế thừa? Hợp thành có những lợi thế nào? Làm thế nào để quyết định khi nào nên dùng hợp thành và khi nào nên dùng kế thừa? Bài viết này sẽ phân tích lý do tại sao hợp thành lại tốt hơn kế thừa thông qua các câu hỏi trên.
1. Kế thừa là gì? Hợp thành là gì?
Kế thừa (Inheritance) và hợp thành (Composition) là hai cơ chế khác nhau trong lập trình hướng đối tượng (Object-Oriented Programming) để tái sử dụng mã nguồn.
Kế thừa là quá trình mà một lớp (gọi là lớp con hoặc lớp dẫn xuất) có thể kế thừa các thuộc tính (dữ liệu) và phương thức (hành vi) từ một lớp khác (gọi là lớp cha hoặc lớp cơ sở). Lớp con có thể tái sử dụng mã của lớp cha và thêm mới các chức năng hoặc sửa đổi các chức năng hiện có dựa trên nền tảng đó. Kế thừa giúp tổ chức và cấu trúc hóa mã thông qua việc tạo ra một hệ thống phân cấp giữa các lớp (còn được gọi là chuỗi kế thừa). Ví dụ: Nếu có một lớp Động_vật
, thì các lớp Chó
và Mèo
có thể kế thừa từ lớp Động_vật
để sở hữu các thuộc tính và phương thức của nó, đồng thời cũng có thể thêm các thuộc tính và phương thức riêng biệt dành cho Chó
và Mèo
.
Hợp thành, mặt khác, là quá trình mà một lớp sử dụng một đối tượng của lớp khác làm biến thành viên để tái sử dụng chức năng mà lớp đó cung cấp. Nói cách khác, hợp thành cho phép một lớp sử dụng đối tượng của một lớp khác để thực hiện chức năng thay vì thông qua cấu trúc phân cấp kế thừa hành vi. Ví dụ: Một lớp Ô_tô
có thể chứa một đối tượng của lớp Động_cơ
như một phần của nó, nhờ vậy Ô_tô
có thể sử dụng các chức năng mà Động_cơ
cung cấp.
Tóm lại: Kế thừa nhấn mạnh vào mối quan hệ “là một” (is-a), tức là lớp con là phiên bản đặc thù của lớp cha; trong khi đó, hợp thành nhấn mạnh vào mối quan hệ “có một” (has-a), tức là một lớp bao gồm một phần của lớp khác.
2. Tại sao không khuyến khích sử dụng kế thừa?
Bởi vì kế thừa phá vỡ tính đóng gói (Encapsulation), nghĩa là kế thừa tạo ra một mối liên kết chặt chẽ giữa lớp con và lớp cha, nơi mà việc triển khai của lớp con có thể phụ thuộc vào chi tiết triển khai của lớp cha. Khi lớp cha thay đổi, lớp con có thể cần phải điều chỉnh tương ứng, điều này làm tăng độ giòn và chi phí bảo trì của mã nguồn.
Ngoài ra, nếu cấu trúc kế thừa trở nên quá sâu, mã nguồn sẽ trở nên phức tạp, dễ mắc lỗi và khó hiểu.
Chúng ta có thể xem xét một ví dụ từ cuốn sách Effective Java: Giả sử chúng ta muốn tạo một lớp không chỉ có đầy đủ chức năng của HashSet
mà còn có khả năng truy vấn tổng số phần tử đã được thêm vào kể từ khi HashSet
được tạo ra.
Dưới đây là đoạn mã sử dụng kế thừa HashSet
để triển khai chức năng của lớp InstrumentHashSet
:
// src/test/java/InstrumentHashSet.java
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
public class InstrumentHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentHashSet() {
super();
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentHashSet<String> set = new InstrumentHashSet<>();
set.addAll(List.of("a", "b", "c"));
System.out.println(set.getAddCount()); // Kết quả là 6
}
}
Như bạn thấy, lớp InstrumentHashSet
khai báo một biến addCount
để theo dõi tổng số phần tử mới được thêm vào, và cung cấp một phương thức getAddCount()
để người dùng có thể lấy giá trị này. Ngoài ra, vì HashSet
có hai phương thức có thể thêm phần tử, chúng ta phải ghi đè cả hai phương thức này trong lớp con.
Tuy nhiên, khi thử nghiệm bằng cách khởi tạo một đối tượng InstrumentHashSet
và sử dụng phương thức addAll()
để thêm một tập hợp có ba phần tử, chúng ta nhận thấy kết quả không đúng với kỳ vọng (kỳ vọng là 3, nhưng kết quả lại là 6).
Lý do là vì phương thức addAll()
của lớp cha HashSet
hoạt động bằng cách lặp lại và gọi phương thức add()
để thêm từng phần tử.
// java.util.AbstractCollection
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
Vì phương thức add()
đã bị ghi đè bởi lớp con InstrumentHashSet
, khi thực thi, phương thức add()
của lớp con sẽ được gọi (điều này được gọi là đa hình), dẫn đến việc addCount
bị đếm lại nhiều lần.
Do đó, việc sử dụng kế thừa đòi hỏi sự thận trọng cao, cần phải hiểu rõ chi tiết triển khai bên trong của các phương thức mà bạn ghi đè trước khi thực hiện.
3. Những lợi ích của hợp thành là gì?
So với việc kế thừa một lớp đã tồn tại, việc sử dụng hợp thành giúp loại bỏ sự phụ thuộc vào chi tiết triển khai của lớp đó.
Dưới đây là cách triển khai lại lớp InstrumentHashSet
bằng cách sử dụng hợp thành và chuyển tiếp (forwarding), sau đó phân tích những lợi ích mà cách tiếp cận này mang lại.
Trước tiên, chúng ta tạo một lớp chuyển tiếp tái sử dụng ForwardingSet
và triển khai giao diện Set
:
// src/test/java/ForwardingSet.java
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;
public class ForwardingSet<E> implements Set<E> {
private final Set<E> set;
public ForwardingSet(Set<E> set) {
this.set = set;
}
@Override
public int size() {
return set.size();
}
@Override
public boolean isEmpty() {
return set.isEmpty();
}
@Override
public boolean contains(Object o) {
return set.contains(o);
}
@Override
public Iterator<E> iterator() {
return set.iterator();
}
@Override
public Object[] toArray() {
return set.toArray();
}
@Override
public <T> T[] toArray(T[] a) {
return set.toArray(a);
}
@Override
public boolean add(E e) {
return set.add(e);
}
@Override
public boolean remove(Object o) {
return set.remove(o);
}
@Override
public boolean containsAll(Collection<?> c) {
return set.containsAll(c);
}
@Override
public boolean addAll(Collection<? extends E> c) {
return set.addAll(c);
}
@Override
public boolean retainAll(Collection<?> c) {
return set.retainAll(c);
}
@Override
public boolean removeAll(Collection<?> c) {
return set.removeAll(c);
}
@Override
public void clear() {
set.clear();
}
}
Như bạn có thể thấy, lớp này không tự triển khai bất kỳ phương thức nào mà chỉ giữ một biến Set<E> set
và gọi các phương thức của nó để triển khai tất cả các phương thức được định nghĩa trong giao diện Set
.
Tiếp theo, chúng ta tạo một lớp bọc InstrumentSet
để cung cấp chức năng mà chúng ta cần:
// src/test/java/InstrumentSet.java
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class InstrumentSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentSet(Set<E> set) {
super(set);
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() { [win 789 club](https://www.whbmj.com)
return addCount;
}
public static void main(String[] args) {
InstrumentSet<String> set = new InstrumentSet<>(new HashSet<>());
set.addAll(List.of("a", "b", "c"));
System.out.println(set.getAddCount()); // Kết quả là 3
}
}
Như bạn thấy, lớp InstrumentSet
kế thừa từ ForwardingSet
và ghi đè các phương thức add()
và addAll()
.
Nhờ cách tiếp cận này, chúng ta không cần quan tâm đến chi tiết triển khai nội bộ của Set
. Ngay cả khi chi tiết triển khai nội bộ thay đổi, chức năng của chúng ta vẫn không bị ảnh hưởng.
4. Làm thế nào để quyết định khi nào nên dùng hợp thành và khi nào nên dùng kế thừa?
Một quy tắc chung để quyết định khi nào nên dùng hợp thành và khi nào nên dùng kế thừa là: Trong trường hợp hai lớp thực sự có mối quan hệ “là một” (ví dụ: Mèo
là một Động_vật
), thì nên sử dụng kế thừa; ngược lại, nên sử dụng hợp thành.
Việc thiết kế một lớp để sử dụng cho kế thừa là một công việc “khá khó khăn”: Bạn phải giải thích chi tiết trong tài liệu về cách sử dụng các lớp có thể ghi đè và đảm bảo rằng mô hình được phơi bày không bị phá vỡ trong suốt vòng đời của lớp đó.
win 911 5. Tổng kết
Bài viết này đã giải thích lý do tại sao “hợp thành tốt hơn kế thừa” thông qua các câu hỏi liên quan đến hợp thành và kế thừa. Toàn bộ mã ví dụ đã được đưa lên GitHub, mời bạn theo dõi hoặc Fork.