Bạn có một đơn hàng mẫu đã cấu hình sẵn địa chỉ giao và danh sách sản phẩm, và cần tạo hàng nghìn bản sao rồi chỉnh vài trường cho từng khách. Viết lại toàn bộ bước khởi tạo thì tốn công, còn gọi clone() thì dễ dính lỗi sao chép nông khiến sửa bản sao làm hỏng bản gốc. Prototype pattern giải quyết bài toán nhân bản đối tượng mà code không phụ thuộc vào lớp cụ thể của nó. Bài này trình bày Prototype pattern trong Java từ cơ chế Cloneable của JDK, vì sao Joshua Bloch khuyên tránh nó, tới copy constructor, copy factory, prototype registry và sao chép sâu bằng serialization. Mọi ví dụ nhắm tới Java 17 (biên dịch bằng javac --release 17, chỉ dùng API có trong Java 17), đã chạy thật và output được dán nguyên văn.
Prototype pattern thuộc nhóm creational, ngay sau Builder pattern: từ Effective Java tới Lombok và record. Builder dựng đối tượng từ đầu, còn Prototype nhân bản đối tượng đã có. Nếu cần ôn cách tách việc tạo đối tượng khỏi logic, xem Factory Method và Abstract Factory; và với Singleton, một mẫu rất dễ bị phá bởi clone(), xem Singleton pattern trong Java: lazy, holder, enum và Java Memory Model.
Prototype pattern là gì và giải quyết vấn đề gì
Theo mô tả Prototype của Refactoring.Guru, đây là mẫu creational cho phép sao chép đối tượng hiện có mà code không phụ thuộc vào lớp cụ thể của đối tượng đó. Việc sao chép được giao cho chính đối tượng cần nhân bản. Vấn đề được nêu rất cụ thể: từ bên ngoài, bạn không thể sao chép một đối tượng chỉ bằng cách tạo đối tượng mới rồi chép từng field, vì có field private, và vì bạn phải biết lớp cụ thể của nó, trong khi code chỉ giữ một tham chiếu kiểu interface.
| Vai trò | Nhiệm vụ | Ví dụ trong bài |
|---|---|---|
| Prototype | Khai báo phương thức nhân bản | Mau.Prototype<T> (hoặc Cloneable) |
| Concrete Prototype | Cài đặt nhân bản, xử lý tham chiếu lồng nhau | Email, DonDeep |
| Client | Nhân bản qua interface, không biết lớp cụ thể | Main |
| Registry (tuỳ chọn) | Kho mẫu đặt tên sẵn | Mau.dangKy / Mau.lay |
Ưu điểm theo tài liệu: nhân bản mà không ghép chặt với lớp cụ thể, bớt code khởi tạo lặp, và là lựa chọn thay cho kế thừa khi cần nhiều cấu hình mẫu. Nhược điểm được nêu thẳng: nhân bản đối tượng có tham chiếu vòng rất khó. Phần khó nằm ở cách Java hiện thực hoá ý tưởng này.
Prototype pattern với clone() và Cloneable: sao chép nông theo mặc định
Java có sẵn cơ chế hiện thực Prototype pattern: Object.clone() và marker interface Cloneable. Javadoc của Object.clone() trong Java SE 17 nói rõ ba điều. Một, nếu lớp không implement Cloneable, clone() ném CloneNotSupportedException. Hai, bản cài đặt mặc định tạo đối tượng mới cùng lớp và gán từng field như phép gán, nội dung field không được clone, nên là sao chép nông. Ba, quy ước là đối tượng trả về phải lấy bằng super.clone(), và bản sao nên độc lập với bản gốc.
Ví dụ dưới đây là "tai nạn" kinh điển của Prototype pattern trong Java. DonShallow có một DiaChi (mutable) và một List; clone() chỉ gọi super.clone().
public class DonShallow implements Cloneable {
String ma; DiaChi giao; List<String> sanPham = new ArrayList<>();
@Override public DonShallow clone() {
try { return (DonShallow) super.clone(); }
catch (CloneNotSupportedException e) { throw new AssertionError(e); }
}
}
DonShallow a = new DonShallow("D1", new DiaChi("12 Lê Lợi", "Đà Nẵng"));
a.sanPham.add("Ốp lưng");
DonShallow b = a.clone();
b.ma = "D2"; // String: gán lại, không ảnh hưởng
b.giao.setDuong("99 Hai Bà Trưng"); // mutable dùng chung -> gốc đổi theo
b.sanPham.add("Sạc 20W"); // list dùng chung -> gốc đổi theo--- 1. clone() mặc định = sao chép nông ---
gốc: D1 | 99 Hai Bà Trưng, Đà Nẵng | [Ốp lưng, Sạc 20W]
sao: D2 | 99 Hai Bà Trưng, Đà Nẵng | [Ốp lưng, Sạc 20W]
cùng DiaChi? true, cùng List? trueChỉ sửa bản sao, nhưng bản gốc cũng đổi địa chỉ và có thêm "Sạc 20W". Lý do: hai đối tượng cùng trỏ vào một DiaChi và một List. ma không bị ảnh hưởng vì String bất biến, ta chỉ gán lại tham chiếu. Đây chính là điều Javadoc cảnh báo: với field mutable, phải thay tham chiếu bằng bản sao.
Prototype pattern đúng quy ước: sao chép sâu với clone()
Để Prototype pattern đúng, cách sửa là gọi super.clone() rồi sao chép sâu từng field mutable. Mỗi thành phần lồng nhau phải tự có khả năng nhân bản:
@Override public DonDeep clone() {
try {
DonDeep c = (DonDeep) super.clone();
c.giao = this.giao.clone(); // DiaChi cũng implements Cloneable
c.sanPham = new ArrayList<>(this.sanPham); // phần tử String bất biến nên đủ
return c;
} catch (CloneNotSupportedException e) { throw new AssertionError(e); }
}--- 2. clone() sâu đúng quy ước ---
gốc: D1 | 12 Lê Lợi, Đà Nẵng | [Ốp lưng]
sao: D2 | 99 Hai Bà Trưng, Đà Nẵng | [Ốp lưng, Sạc 20W]Lần này gốc không đổi. Nhưng hãy nhìn cái giá: ta phải nhớ sao chép từng field mutable, và mỗi lần thêm field mới là một lần có thể quên. Kiểu trả về hiệp biến (DonDeep thay cho Object) và nâng clone() lên public là các thủ thuật cần thiết vì bản gốc của Object là protected. Phần try/catch ném AssertionError là code thừa, vì ngoại lệ checked không bao giờ xảy ra khi lớp đã implement Cloneable.
Vì sao Effective Java khuyên tránh Cloneable
Trong Effective Java, 3rd Edition, Item 13 "Override clone judiciously" tóm lại là: cơ chế Cloneable mong manh và nên dùng copy constructor hoặc copy factory thay thế, chỉ giữ clone() cho mảng. Các lý do có thể tự kiểm chứng:
- Cloneable là marker interface không có phương thức: nó thay đổi hành vi của
Object.clone()(protected) một cách ngầm định, trái với mục đích thông thường của interface. - Không đi qua constructor:
clone()tạo đối tượng mà không gọi constructor, nên các bất biến được thiết lập trong constructor có thể bị bỏ qua. - Xung đột với field
final: bạn không thể gán lại field final trongclone()để thay bằng bản sao sâu. - Ngoại lệ checked khó chịu và ép kiểu thủ công ở khắp nơi.
- Mong manh khi kế thừa: nếu lớp cha không dùng
super.clone(), lớp con không thể làm đúng; nếuclone()gọi method có thể bị override, nó chạy trên bản sao dở dang.
Ví dụ lỗi dễ gặp nhất khi người mới viết clone(): quên khai báo implements Cloneable mà vẫn gọi super.clone().
--- 5. Quên Cloneable ---
CloneNotSupportedException: vn.sudo.prototype.KhongCloneableLỗi này chỉ lộ ra lúc chạy, vì Cloneable không có phương thức nào để trình biên dịch kiểm tra.
Thay Prototype pattern bằng copy constructor và copy factory
Giải pháp thay thế Prototype pattern kiểu clone() mà Bloch khuyên là copy constructor (nhận một đối tượng cùng lớp) hoặc copy factory (static factory tương tự). Cả hai đi qua constructor, nên field có thể final, bất biến được kiểm tra, và không cần ép kiểu hay bắt ngoại lệ.
public final class DonCopy {
private final String ma;
private final DiaChi giao;
private final List<String> sanPham;
public DonCopy(String ma, DiaChi giao, List<String> sanPham) {
this.ma = ma; this.giao = new DiaChi(giao); this.sanPham = new ArrayList<>(sanPham);
}
public DonCopy(DonCopy goc) { this(goc.ma, goc.giao, goc.sanPham); } // copy constructor
public static DonCopy of(DonCopy goc) { return new DonCopy(goc); } // copy factory
public DonCopy voiMa(String maMoi) { return new DonCopy(maMoi, giao, sanPham); } // bản sao có sửa
}--- 3. Copy constructor / copy factory ---
gốc: D1 | 12 Lê Lợi, Đà Nẵng | [Ốp lưng]
sao: D2 | 99 Hai Bà Trưng, Đà Nẵng | [Ốp lưng, Sạc 20W]Nhược điểm của copy constructor so với clone(): nó cố định theo kiểu tĩnh. Nếu client chỉ giữ tham chiếu kiểu lớp cha, copy constructor của lớp cha sẽ "cắt" đối tượng thành lớp cha, còn clone() vẫn giữ đúng kiểu thực. Đây là điểm mạnh thật sự của clone(), và là lý do cốt lõi của Prototype pattern nhân bản qua interface:
Hinh h = new Hinh.Tron("đỏ", 2.5);
Hinh qua_clone = h.clone();
Hinh qua_copy = new Hinh(h); // copy constructor theo kiểu tĩnh Hinh--- 4. Bẫy kế thừa ---
clone() -> Tron(đỏ, r=2.5) [Tron]
new Hinh(h) -> Hinh(đỏ) [Hinh]Bản sao qua copy constructor mất bán kính và mất cả kiểu Tron. Vậy khi cần nhân bản đa hình, đừng bỏ clone() một cách máy móc: hoặc tự định nghĩa phương thức nhanBan() trên interface riêng của bạn (đúng tinh thần Prototype), hoặc dùng Prototype pattern với interface tường minh như phần sau.
Prototype pattern với interface riêng và Prototype Registry
Thay vì phụ thuộc vào Cloneable, nhiều codebase áp dụng Prototype pattern bằng cách tự định nghĩa interface Prototype<T> với method nhanBan(). Lợi ích: có kiểu trả về đúng, không ngoại lệ checked, và buộc người viết lớp phải nghĩ tới sao chép sâu. Ghép với một registry (kho mẫu đặt tên sẵn như GoF mô tả) ta có một cách tạo đối tượng đã cấu hình mà không cần biết lớp cụ thể:
public interface Prototype<T> { T nhanBan(); }
public static class Email implements Prototype<Email>, Serializable {
String tieuDe; List<String> nguoiNhan = new ArrayList<>(); Map<String, String> bien = new HashMap<>();
@Override public Email nhanBan() {
Email c = new Email(tieuDe);
c.nguoiNhan.addAll(nguoiNhan);
c.bien.putAll(bien);
return c;
}
}
Mau kho = new Mau();
kho.dangKy("chao-mung", chao);
Mau.Email e1 = kho.lay("chao-mung"); e1.nguoiNhan.add("lan@example.com");
Mau.Email e2 = kho.lay("chao-mung"); e2.bien.put("ten", "Minh");--- 6. Prototype registry ---
mẫu: Chào mừng khách hàng mới [cskh@example.com] {ten={{ten}}}
e1 : Chào mừng khách hàng mới [cskh@example.com, lan@example.com] {ten={{ten}}}
e2 : Chào mừng khách hàng mới [cskh@example.com] {ten=Minh}Mẫu giữ nguyên sau hai lần nhân bản, và hai bản sao độc lập nhau. Đây là cách hợp lý để dùng Prototype pattern cho email mẫu, báo cáo mẫu, cấu hình mặc định theo khách hàng, hay đơn hàng mẫu cho môi trường kiểm thử. Lưu ý cẩn thận: registry nên trả về bản sao (lay gọi nhanBan()), không bao giờ trả chính mẫu, nếu không client có thể sửa mẫu dùng chung.
Sao chép sâu bằng serialization và các giới hạn
Với đồ thị đối tượng phức tạp, viết tay từng bước nhân bản dễ sai. Một mẹo cũ là sao chép sâu bằng tuần tự hoá: ghi đối tượng ra mảng byte rồi đọc lại.
public static <T extends Serializable> T deepCopyBangSerialization(T goc) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bos)) { out.writeObject(goc); }
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()))) {
return (T) in.readObject();
}
}--- 7. Sao chép sâu bằng serialization ---
mẫu: [cskh@example.com] | bản sao: [cskh@example.com, khac@example.com] | khác object: trueCách này tự xử lý cả tham chiếu vòng, nhưng có giá: mọi lớp trong đồ thị phải Serializable, field transient bị bỏ, chậm hơn hẳn so với copy trực tiếp (đo thô bên dưới), và nguy hiểm về bảo mật nếu đọc dữ liệu không đáng tin cậy. Chỉ dùng cho công cụ nội bộ hoặc mã kiểm thử, và tuyệt đối không giải tuần tự hoá dữ liệu từ ngoài vào. Thư viện bên thứ ba như Jackson (chuyển sang JSON rồi về) hay MapStruct cho phép sao chép sâu mà không cần Serializable, nhưng cũng có chi phí riêng.
Để có con số thay vì cảm tính, mã trong code/s6-05/src/vn/sudo/prototype/Bench.java nhân bản 100.000 email mẫu bằng hai cách, sau 20.000 vòng khởi động, trên JDK 21 (Apple M4). Đây là đo thô bằng System.nanoTime, không phải JMH nên chỉ có giá trị về bậc độ lớn:
lần 1: nhanBan() 13 ms | serialization 616 ms (100000 bản sao)
lần 2: nhanBan() 3 ms | serialization 492 ms (100000 bản sao)
lần 3: nhanBan() 2 ms | serialization 533 ms (100000 bản sao)Với một đối tượng nhỏ như vậy, serialization chậm hơn khoảng hai trăm lần trở lên. Khi áp dụng Prototype pattern ở đường nóng của hệ thống, hãy ưu tiên copy constructor hoặc phương thức nhanBan() viết tay.
Prototype pattern trong chính JDK
JDK áp dụng Prototype pattern theo cả hai cách: mảng có clone() trả đúng kiểu, các collection cũ có clone() nông, còn List.copyOf (Java 10) là copy factory kiểu hiện đại. Kết quả chạy của ví dụ Prototype pattern này:
--- 8. Mảng và collection trong JDK ---
mảng clone: [1, 2, 3] / [99, 2, 3]
ArrayList.clone là nông: gốc=ĐÃ ĐỔI, HN, cùng phần tử=true
List.copyOf: 1 phần tử (gốc 2), phần tử vẫn chia sẻ: trueHai dòng cuối là bài học quan trọng: ngay cả JDK cũng chỉ sao chép nông. ArrayList.clone() và List.copyOf tạo danh sách mới nhưng phần tử vẫn là cùng đối tượng, nên sửa DiaChi trong bản sao vẫn làm gốc đổi theo. Đừng giả định "copy" nghĩa là "độc lập hoàn toàn" khi các phần tử còn mutable. Dùng List.copyOf chỉ an toàn khi phần tử bất biến (String, record bất biến, Integer).
Khi nào không nên dùng Prototype pattern
Prototype pattern không phải lúc nào cũng đáng. Hãy cân nhắc kỹ, hoặc chọn hướng khác, khi:
- Đối tượng bất biến: Prototype pattern không cần thiết, không cần nhân bản, chia sẻ cùng một tham chiếu là đủ; cần bản sao có sửa thì dùng "with-er" hoặc
toBuilder()như trong bài Builder. - Đối tượng giữ tài nguyên ngoài: kết nối DB, socket, file handle, luồng. Nhân bản chúng thường vô nghĩa hoặc nguy hiểm.
- Đồ thị có tham chiếu vòng: Refactoring.Guru gọi đây là điểm khó; cần bảng ánh xạ đối tượng đã sao chép hoặc serialization.
- Khởi tạo vốn rẻ: nếu
newđủ nhanh, Prototype pattern chỉ làm code khó đọc. - Singleton: nếu lớp Singleton implements
Cloneablethì phá được tính duy nhất; hãy chặnclone()bằng cách ném ngoại lệ.
Lỗi thường gặp
- Gọi
clone()trong Prototype pattern rồi tưởng đã sao chép sâu: mặc định luôn là nông. Luôn kiểm tra field mutable (ví dụ trên cho thấy hậu quả). - Quên
implements Cloneable: lỗi chỉ lộ lúc chạy dưới dạngCloneNotSupportedException. - Không dùng
super.clone(): tạo bằngnewtrongclone()làm hỏng lớp con vì bản sao có kiểu sai. - Sao chép collection nhưng không sao chép phần tử mutable:
new ArrayList<>(ds)vàList.copyOfđều chỉ sao chép nông phần tử. - Registry trả về chính mẫu: client sửa mẫu dùng chung; luôn trả bản sao.
- Copy constructor làm mất kiểu lớp con: ví dụ
Hinhở trên; chọnclone()hoặcnhanBan()khi cần đa hình. - Giải tuần tự hoá dữ liệu không tin cậy: serialization chỉ dùng để sao chép đối tượng của chính bạn trong cùng tiến trình.
Checklist
- Dùng Prototype pattern khi cần nhân bản đối tượng qua interface mà không biết lớp cụ thể, hoặc khi mẫu cấu hình sẵn đắt hơn nhân bản.
- Với Prototype pattern, mặc định ưu tiên copy constructor hoặc copy factory; field có thể
final. - Nếu bắt buộc dùng
clone():implements Cloneable, gọisuper.clone(), sao chép sâu từng field mutable, trả kiểu hiệp biến. - Khi cần nhân bản đa hình, tự định nghĩa interface
Prototype<T>. - Registry luôn trả bản sao; tài liệu hoá bản sao là nông hay sâu.
- Serialization chỉ cho đồ thị phức tạp và dữ liệu tin cậy.
Môi trường chạy thử các ví dụ Prototype pattern: mã nguồn nhắm tới Java 17 (javac --release 17, không dùng API sau Java 17). Mã nguồn đầy đủ và run.sh nằm trong code/s6-05.
Bài tiếp theo
Adapter pattern: nối API cũ với giao diện mới. Sau nhóm creational, series chuyển sang nhóm structural: cách bọc một API không tương thích để dùng được trong code hiện có mà không sửa mã nguồn gốc.



