Abstract Class và Interface trong Java
Trong lập trình hướng đối tượng, Abstract class và Interface là hai công cụ quan trọng dùng để xây dựng kiến trúc phần mềm theo hướng mở rộng và tái sử dụng. Tuy nhiên, nhiều người mới học thường dễ bị nhầm lẫn giữa hai khái niệm này. Bài viết này sẽ giúp bạn hiểu rõ bản chất, điểm giống và khác nhau giữa chúng, từ đó biết khi nào nên dùng cái nào.
A. Abtract Class
-
Abstract class (lớp trừu tượng) là một lớp không thể tạo đối tượng trực tiếp (tức khởi tảo thông qua từ khóa new), được dùng làm lớp cơ sở cho các lớp con
-
Các lớp con kế thừa (extends) phải triển khai các phương thức abtract (phương thức trừu tượng)
-
Nó có thể chứa:
- Các phương thức trừu tượng (chưa có nội dung, chỉ khai báo),
- Các phương thức bình thường (có nội dung cụ thể),
- Các thuộc tính (biến).
Ví dụ
public abstract class Animal {
// Phương thức trừu tượng (không có thân)
public abstract void makeSound();
// Phương thức bình thường
public void sleep() {
System.out.println("Sleeping...");
}
}
Lớp con kế thừa phải override (ghi đè) tất cả các phương thức abstract, trừ khi lớp con cũng là abstract. Tức là:
-
Nếu lớp cha có phương thức abstract (không có thân), thì lớp con bắt buộc phải triển khai (override) phương thức đó để định nghĩa rõ hành vi.
-
Nhưng nếu lớp con đó cũng được khai báo là abstract class, thì nó không cần phải override ngay các phương thức abstract đó, vì lớp con abstract có thể vẫn để lại cho lớp con tiếp theo xử lý.
public class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof Woof!");
}
}
1. Một số đặc điểm
1. Abstract Class không thể tạo đối tượng trực tiếp
Bạn không thể viết:
Animal a = new Animal("Buddy"); // Lỗi biên dịch!
vì Animal là abstract, nó chưa hoàn chỉnh, không thể khởi tạo đối tượng trực tiếp.
2. Abstract Class có thể có constructor
Mặc dù không tạo đối tượng trực tiếp từ abstract class, bạn vẫn có thể khai báo constructor trong abstract class.
Constructor này sẽ được gọi khi một đối tượng của lớp con (non-abstract) được tạo ra.
abstract class Animal {
String name;
public Animal(String name) {
this.name = name;
System.out.println("Constructor của Animal được gọi, name = " + name);
}
abstract void makeSound();
}
class Dog extends Animal {
public Dog(String name) {
super(name); // Gọi constructor của lớp cha (Animal)
System.out.println("Constructor của Dog được gọi");
}
@Override
void makeSound() {
System.out.println(name + " says Woof!");
}
}
public class Test {
public static void main(String[] args) {
Dog dog = new Dog("Buddy"); // Tạo đối tượng Dog
dog.makeSound();
}
}
3. Một lớp có thể chỉ định là abstract mà không có phương thức abstract nào
Ví dụ:
abstract class A {
public void hello() {
System.out.println("Hello from A");
}
}
- Lớp A ở trên là hợp lệ. Không có phương thức abstract nhưng vẫn là abstract class
- Lí do cho việc khai báo abstract mà không có phương thức abstract thường là:
- Lớp đó được thiết kế để không cho phép tạo trực tiếp, dù đã có đầy đủ các phương thức. Tức là nó chỉ dùng làm lớp cha để kế thừa.
- Ép buộc thiết kế hướng kế thừa (template, strategy, factory pattern...)
- Làm rõ ý đồ lập trình: "lớp này là nền tảng, không phải để dùng trực tiếp"
4. Abstract class giúp thiết kế theo hướng OOP, tạo ra khuôn mẫu bắt buộc các lớp con phải triển khai.
-
Khi bạn khai báo một hoặc nhiều phương thức là abstract, bạn đang nói: 👉 “Mọi lớp kế thừa từ lớp này phải tự định nghĩa hành vi cụ thể cho những phương thức đó.”
-
Điều này giúp đảm bảo tính nhất quán và kiểm soát thiết kế trong các hệ thống lớn.
abstract class Animal {
// Phương thức bắt buộc lớp con phải định nghĩa
abstract void makeSound();
// Hành vi chung có thể dùng lại
void breathe() {
System.out.println("Breathing...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Woof!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Meow!");
}
}
-
Lớp Animal là khuôn mẫu: mọi "con vật" đều phải biết kêu (makeSound()), nhưng cụ thể kêu thế nào là do từng loài quyết định.
-
breathe() là hành vi chung → lớp cha định nghĩa sẵn → lớp con không cần viết lại.
Kết luận ngắn gọn:
Abstract class giống như “bản thiết kế khung” của một nhóm đối tượng — nó nói “bạn phải làm gì”, nhưng không nói “làm như thế nào”.
B. Interface
Trong Java, interface là một kiểu dữ liệu đặc biệt dùng để khai báo một tập hợp các phương thức trừu tượng (abstract methods) mà một lớp (class) có thể cam kết thực hiện.
Ví dụ:
public interface Animal {
void makeSound(); // phương thức trừu tượng
}
Một class muốn triển khai (implement) một interface phải override tất cả các phương thức bên trong interface đó.
public class Dog implements Animal {
@Override
public void makeSound() {
System.out.println("Woof!");
}
}
1. Một số đặc điểm
-
Các phương thức trong interface mặc định là public abstract (kể cả nếu không ghi rõ).
-
Các biến trong interface mặc định là public static final (hằng số).
-
Một lớp có thể implement nhiều interface → hỗ trợ đa kế thừa kiểu interface.
interface Flyable {
void fly();
}
interface Swimable {
void swim();
}
class Duck implements Flyable, Swimable {
public void fly() { System.out.println("Duck flies"); }
public void swim() { System.out.println("Duck swims"); }
}
2. Những thay đổi của interface trong các phiên bản Java mới
Trong quá trình phát triển của ngôn ngữ lập trình Java, interface từng được xem như một công cụ đơn thuần để khai báo hành vi trừu tượng, buộc các lớp con phải triển khai.
🟢Java 7 trở về trước:
-
Interface chỉ chứa các phương thức trừu tượng (abstract).
-
Không thể có logic thực thi bên trong.
-
Các method đều là public abstract.
👉 Điều này tạo ra giới hạn lớn: mỗi khi muốn thay đổi interface (thêm method mới), tất cả class implement đều phải sửa theo.
Tuy nhiên, kể từ phiên bản Java 8 trở đi, khái niệm về interface đã trải qua nhiều thay đổi quan trọng nhằm đáp ứng nhu cầu ngày càng đa dạng trong lập trình hiện đại. Những thay đổi này không chỉ giúp nâng cao tính linh hoạt của ngôn ngữ, mà còn đảm bảo khả năng mở rộng và duy trì hệ thống phần mềm một cách hiệu quả hơn
1️⃣Phương thức mặc định (default) (có thân hàm trong interface)
- Giúp cung cấp logic mặc định, không bắt buộc class triển khai phải override.
- Cho phép thêm chức năng mới vào interface mà không làm hỏng các class cũ đang implements interface đó.
public interface PaymentMethod {
void pay(double amount); // phương thức chính (bắt buộc override)
// default method
default void printReceipt(double amount) {
System.out.println("Đã thanh toán số tiền: " + amount);
System.out.println("Hẹn gặp lại quý khách!");
}
}
⚠️ Lưu ý về xung đột default method Nếu một class implements nhiều interface mà các interface đó có cùng tên default method, bạn phải override trong class.
interface A {
default void hello() { System.out.println("Hello from A"); }
}
interface B {
default void hello() { System.out.println("Hello from B"); }
}
class C implements A, B {
@Override
public void hello() {
// Bắt buộc ghi đè để giải quyết xung đột
A.super.hello(); // hoặc B.super.hello();
}
}
2️⃣Phương thức tĩnh (static):
- Trong Java, từ Java 8 trở đi, bạn có thể khai báo phương thức static trong interface
- Phương thức static trong interface chỉ được gọi qua chính tên của interface
public interface PaymentUtils {
static void printSupportHotline() {
System.out.println("Gọi 1800-9999 để được hỗ trợ thanh toán.");
}
}
Gọi method static từ interface
public class Main {
public static void main(String[] args) {
PaymentUtils.printSupportHotline(); // ✅ Gọi đúng
}
}
Phương thức static trong interface thường
- Dùng để cung cấp tiện ích chung (utility) liên quan đến logic của interface.
- Tăng tính đóng gói logic liên quan, tránh viết rải rác vào class tiện ích (utility class) như Utils.java.
3️⃣Private methods trong interface (Java 9)
- Cho phép viết private method dùng trong nội bộ default và static, giúp tái sử dụng logic và làm code gọn gàng.
- Không thể gọi từ bên ngoài, chỉ dùng nội bộ trong interface
interface PaymentMethod {
default void validate() {
log("Validating...");
}
private void log(String message) {
System.out.println("LOG: " + message);
}
}
C. Bản chất cốt lõi - Sự khác biệt về Triết lý thiết kế (bắt buộc phải đọc)
Thay vì phân biệt bằng cách học thuộc lòng kiểu "Interface đa kế thừa, Abstract Class đơn kế thừa", chúng ta hãy nhìn vào mục đích mà những người tạo ra Java đã gửi gắm vào chúng. Mình tin chỉ cần hiểu được điều này một lần và mãi mãi, bạn sẽ không bao giờ còn phải học vẹt từng khái niệm liên quan đến chúng mà sẽ thực sự biết khi nào cần lôi thứ gì ra để sử dụng chỉ ngay sau khi đọc đầu bài. Hãy cùng mổ xẻ nó nhé
-
🧬 Abstract Class (Định nghĩa "Bản thể" - Khái niệm IS-A): Nó đại diện cho một thực thể chưa hoàn thiện. Bạn dùng Abstract Class khi muốn nhóm các class có chung một "dòng máu" hoặc bản chất. Ví dụ: Chó và Mèo đều là một (IS-A) Động Vật. Abstract Class Động Vật cho phép chúng chia sẻ cả trạng thái (như biến
tuoi,canNang) và hành vi cốt lõi (như hàm an(), ngu()). -
📜 Interface (Định nghĩa "Năng lực" - Khái niệm CAN-DO / Hợp đồng): Interface hoàn toàn không quan tâm bạn là ai, sinh ra từ đâu. Nó chỉ là một bản hợp đồng quy định những gì bạn có thể làm (CAN-DO).
- Ví dụ: Một con
Chim(động vật) và một chiếcMáy Bay(máy móc) có bản chất hoàn toàn khác nhau, nhưng chúng đều có năng lựcBay. Lúc này,Baychính là một Interface. Nó giúp hai hệ thống không liên quan có thể có chung một hành vi.
- Ví dụ: Một con
Câu hỏi cho bạn: Giả sử chúng ta đang xây dựng phần mềm cho một công ty. Chúng ta có hai class là NhanVienToanThoiGian (Full-time) và NhanVienBanThoiGian (Part-time). Cả hai đều có các thông tin như ten, maNhanVien, và đều cần và đều cần một hành vi là tinhLuong().
Theo bạn, để gom nhóm hai class này, chúng ta nên thiết kế một Abstract Class hay một Interface tên là NhanVien, và tại sao bạn lại chọn như vậy?
Chúng ta có thế nhân thấy ngay được NhanVienToanThoiGian (Full-time) và NhanVienBanThoiGian (Part-time) thì đều là một (IS-A) nhân viên và có chung hành vi cốt lõi là hàm tinhLuong() -> nên chắc chắn câu trả lời là Abstract Class. Cả hai class con đều chia sẻ cùng một bản chất là Nhân viên và dùng chung các thuộc tính trạng thái như ten hay maNhanVien.
Tuy nhiên, hãy nhìn sâu hơn một chút vào hàm tinhLuong(). Dù cả hai đều cần hành vi này, nhưng cách tính lương của nhân viên toàn thời gian (nhận lương cứng) và bán thời gian (nhận lương theo giờ) lại hoàn toàn khác nhau.
Vì vậy, trong Abstract Class NhanVien, chúng ta sẽ giữ chung các biến trạng thái, nhưng hàm tinhLuong() sẽ được khai báo là một abstract method (phương thức trừu tượng, không có phần thân code). Lớp cha nói rằng "Đã là nhân viên thì phải tính được lương", nhưng cụ thể tính thế nào thì từng lớp con phải tự định nghĩa (implement). Đây chính là sức mạnh tái sử dụng trạng thái và ép buộc hành vi của Abstract Class.
Bây giờ, chúng ta hãy hoàn thiện mảnh ghép còn lại của Cấp độ 1: Interface (Hợp đồng CAN-DO).
Giả sử công ty yêu cầu hệ thống phần mềm phải có tính năng xuất dữ liệu ra file PDF. Cả class NhanVien và một class hoàn toàn không liên quan là BaoCaoTaiChinh (Báo cáo tài chính) đều cần có khả năng chạy
Dựa vào triết lý thiết kế mà chúng ta đã trao đổi, bạn sẽ làm cách nào để liên kết hai thực thể hoàn toàn khác biệt này lại với nhau để chúng có chung một "năng lực" xuất PDF?
➡️Câu trả lời chắc chắn là interface rồi bên trong sẽ là hàm xuatPDF(), hai class kia sẽ implement lại và Override hàm xuatPDF() để tự định nghĩa cách xuất dữ liệu của riêng mình. Cấu trúc này giúp hai class hoàn toàn không chung "huyết thống" vẫn có thể ký chung một hợp đồng năng lực.
D. Sự tiến hóa làm lu mờ ranh giới (Java 8 -> Java 17)
Những gì chúng ta vừa bàn là quy tắc của Java từ phiên bản 7 trở về trước. Interface khi đó rất "thuần khiết", hoàn toàn rỗng, chỉ chứa khai báo hàm mà không có code thực thi. Nhưng sự cứng nhắc này đã gây ra một bài toán cực kỳ hóc búa cho các nhà thiết kế ngôn ngữ mang tên: Tiến hóa API (API Evolution).
Bạn hãy đặt mình vào tình huống lịch sử này nhé: Interface Collection (tập hợp) của Java đã tồn tại nhiều năm và được hàng triệu dự án trên toàn cầu implement. Đến phiên bản Java 8, đội ngũ phát triển muốn bổ sung thêm một hàm tên là stream() vào Collection để hỗ trợ xử lý dữ liệu hiện đại hơn.
Nếu họ cứ thế ném thêm một abstract method stream() vào Interface Collection theo cách truyền thống, điều tồi tệ gì sẽ xảy ra với tất cả các class của người dùng đang implement Collection khi họ cập nhật lên Java 8?
Và theo bạn, để giải quyết vấn đề này mà không làm hỏng các hệ thống cũ (backward compatibility), Java 8 đã phải phá lệ, cho phép Interface sử dụng từ khóa mới nào để chứa code thực thi bên trong nó?
Đó chính là từ khóa default 🌟
Nhờ có từ khóa này, Interface mang trong mình một sức mạnh mới: cung cấp một hành vi mặc định. Khi Java thêm hàm default Stream<E> stream() vào Collection, hàng triệu class đang implement Collection trên toàn thế giới vẫn an toàn. Nếu các class đó không tự định nghĩa lại (override) hàm stream(), chúng sẽ tự động sử dụng đoạn code mặc định có sẵn trong Interface. Chức năng này giữ cho hệ thống tương thích ngược (backward compatibility) một cách hoàn hảo.
Tuy nhiên, việc cho phép Interface chứa code thực thi đã mở ra "chiếc hộp Pandora", dẫn đến một rắc rối kinh điển trong thiết kế hướng đối tượng: Đa kế thừa hành vi (Multiple Inheritance of Behavior) hay còn gọi là Diamond Problem.
Hãy tưởng tượng một kịch bản thế này:
Chúng ta có Interface MayQuet (Scanner) và Interface MayIn (Printer). Cả hai Interface này vô tình đều có chung một hàm default void hoatDong() nhưng chứa đoạn code thực thi hoàn toàn khác nhau.
Bây giờ, bạn tạo ra một class MayPhotocopy implement cả hai Interface trên.
Theo bạn, trình biên dịch (compiler) của Java sẽ phản ứng thế nào trong tình huống này, và lập trình viên bắt buộc phải làm gì bên trong class MayPhotocopy để giải quyết sự xung đột đó?
Nếu bạn chưa có lời giải thì cũng không sao cả, đây là một "cú lừa" kinh điển trong Java. Khi gặp tình huống hai Interface có cùng một hàm default, trình biên dịch (compiler) của Java sẽ lập tức báo lỗi Compile-time Error (Lỗi khi biên dịch). Tại sao ư? Vì Java được thiết kế với tiêu chí an toàn đặt lên hàng đầu. Khi MayPhotocopy nhận được hai hàm hoatDong() khác nhau, nó bị bối rối (đây chính là Diamond Problem). Trình biên dịch thà báo lỗi để bạn can thiệp, còn hơn là tự ý chọn bừa một hàm và gây ra lỗi sai logic nghiêm trọng khi ứng dụng đang chạy.
Để giải quyết, Java ép lập trình viên bắt buộc phải Override (ghi đè) hàm hoatDong() bên trong class MayPhotocopy. Tại đây, bạn nắm quyền quyết định:
Để giải quyết, Java ép lập trình viên bắt buộc phải Override (ghi đè) hàm hoatDong() bên trong class MayPhotocopy. Tại đây, bạn nắm quyền quyết định:
- Tự viết một đoạn code thực thi hoàn toàn mới cho máy photocopy.
- Chỉ định rõ ràng muốn dùng lại code mặc định của ai bằng một cú pháp đặc biệt:
MayQuet.super.hoatDong();hoặcMayIn.super.hoatDong();.
Vậy là chúng ta đã giải quyết xong bài toán tiến hóa API của Java 8. Đến Java 9, chúng ta có thêm private method trong Interface để dọn dẹp code nội bộ. Nhưng bước ngoặt thực sự về kiểm soát kiến trúc lại nằm ở Java 15/17 với tính năng Sealed Classes/Interfaces (Niêm phong kế thừa) 🔒.
E. Sealed Classes/Interfaces (Niêm phong kế thừa) 🔒
Trước đây, một Interface sinh ra là mở toang cửa: bất cứ ai cũng có thể implement nó. Nhưng giả sử bạn thiết kế một module quản lý thanh toán với Interface TrangThaiThanhToan. Bạn biết chắc chắn nghiệp vụ chỉ có đúng 3 trạng thái: ThanhCong, ThatBai, và DangXuLy. Bạn không hề muốn các lập trình viên khác trong team tự ý tạo thêm class HuyGiaoDich hay TreoGiaoDich implement Interface này, vì nó sẽ làm vỡ luồng xử lý cốt lõi.
Với từ khóa sealed, bạn có quyền "niêm phong" Interface lại và chỉ định đích danh những class nào được phép implement nó.
Theo bạn, việc giới hạn chặt chẽ quyền kế thừa và đóng gói ranh giới hệ thống như vậy mang lại lợi ích thiết thực nào khi chúng ta làm việc trong một dự án lớn, có nhiều team cùng tham gia viết code?
Nếu TrangThaiThanhToan là một Interface mở thông thường, một lập trình viên ở team khác có thể tự ý tạo class TrangThaiTreo implement nó và đẩy vào luồng dữ liệu chung. Lúc này, các hàm xử lý cốt lõi của bạn (vốn chỉ được thiết kế để xử lý 3 trạng thái: Thành công, Thất bại, Đang xử lý) sẽ không biết làm gì với TrangThaiTreo này, dẫn đến sập hệ thống hoặc sai lệch dữ liệu tài chính.
Khi bạn dùng từ khóa sealed, bạn tạo ra một ranh giới miền (Domain Boundary) vững chắc.
- Bạn ép tất cả các team khác phải tuân thủ đúng những kịch bản nghiệp vụ mà kiến trúc sư đã định ra.
- Đặc biệt, khi kết hợp sealed interfaces với cấu trúc switch trong các phiên bản Java mới, trình biên dịch có khả năng kiểm tra tính vét cạn (exhaustiveness). Nó sẽ báo lỗi ngay lúc viết code nếu một lập trình viên quên viết logic xử lý cho một trong các class con đã được cấp phép! 🧠
Dưới đây là đoạn code minh họa cho cơ chế sealed interface và các lỗi trình biên dịch sẽ bắt được 🔒.
// 1. Niêm phong interface và chỉ định đích danh 3 class được phép implement
public sealed interface TrangThaiThanhToan permits ThanhCong, ThatBai, DangXuLy {
String layThongBao();
}
// 2. Các class hợp lệ (bắt buộc phải khai báo là final, sealed, hoặc non-sealed)
public final class ThanhCong implements TrangThaiThanhToan {
public String layThongBao() { return "Giao dịch thành công"; }
}
public final class ThatBai implements TrangThaiThanhToan {
public String layThongBao() { return "Giao dịch thất bại"; }
}
public final class DangXuLy implements TrangThaiThanhToan {
public String layThongBao() { return "Hệ thống đang xử lý"; }
}
// ❌ LỖI 1: Vi phạm ranh giới hệ thống
// Trình biên dịch báo lỗi: class is not allowed to extend sealed class
public final class TrangThaiTreo implements TrangThaiThanhToan {
public String layThongBao() { return "Giao dịch bị treo"; }
}
Và đây là sức mạnh kiểm tra tính vét cạn (exhaustiveness) khi kết hợp với cấu trúc switch (từ phiên bản Java 21+):
public void xuLyGiaoDich(TrangThaiThanhToan trangThai) {
// ❌ LỖI 2: Quên xử lý logic
// Trình biên dịch báo lỗi: the switch expression does not cover all possible input values
String hanhDong = switch (trangThai) {
case ThanhCong t -> "Tiến hành giao hàng";
case ThatBai f -> "Hoàn tiền cho khách";
// Lỗi xảy ra vì lập trình viên quên viết case cho "DangXuLy".
// Với sealed interface, bạn không cần dùng nhánh "default",
// trình biên dịch tự biết chính xác có bao nhiêu trường hợp có thể xảy ra.
};
}
Nhờ cơ chế này, bạn sẽ phát hiện lỗi thiếu logic ngay lúc gõ code, thay vì để ứng dụng chạy rồi sập giữa chừng.
F. Tư duy ứng dụng trong Kiến trúc hệ thống
Vừa rồi chúng ta đã cùng đi qua sự lột xác của Interface qua các phiên bản để làm mờ đi ranh giới so với Abtract Class. Bây giờ, chúng ta dùng Abstract Class và Interface để định hình bộ khung của dự án. Một trong những kỹ thuật kinh điển nhất của Abstract Class là Template Method Pattern (Mẫu thiết kế Khuôn mẫu).
Hãy tưởng tượng bạn có một quy trình xuất file báo cáo. Bất kể là báo cáo gì, quy trình luôn phải chạy theo đúng 3 bước cố định:
layDuLieu()(Mở kết nối và query từ Database).dinhDangDuLieu()(Định dạng thành PDF, Excel, hoặc CSV).dongKetNoi()(Đóng kết nối Database để tránh tràn bộ nhớ).
Trong 3 bước này, bước 1 và bước 3 luôn dùng chung một đoạn code cho mọi loại báo cáo. Chỉ có bước 2 là thay đổi tùy thuộc vào định dạng file mà người dùng muốn xuất.
Dựa vào đặc tính của Abstract Class (vừa có thể chứa code thực thi, vừa có thể chứa phương thức rỗng), bạn sẽ thiết kế một class BaoCaoBase như thế nào để class con (ví dụ: BaoCaoPDF) chỉ cần viết logic cho bước 2 mà hệ thống vẫn đảm bảo chạy đúng quy trình 3 bước?
Lớp BaoCaoBase sẽ chứa sẵn code thực thi cho layDuLieu() và dongKetNoi(), đồng thời khai báo một phương thức trừu tượng abstract void dinhDangDuLieu().
Vì layDuLieu() đã được lớp cha (Base) lo liệu trọn gói, nên các lớp con (như BaoCaoPDF) sẽ chỉ cần tập trung tự định nghĩa cách định dạng dữ liệu (override hàm dinhDangDuLieu()). Việc phân chia này giúp code không bị lặp lại.
Nhưng để thiết kế này thực sự trở thành một "khuôn mẫu" (Template) vững chắc, chúng ta còn thiếu một mảnh ghép cốt lõi. Trong Abstract Class BaoCaoBase, chúng ta cần viết một hàm tổng để gọi 3 bước này theo đúng thứ tự nghiêm ngặt. Giả sử chúng ta gọi nó là xuatBaoCao():
public void xuatBaoCao() {
layDuLieu(); // Bước 1
dinhDangDuLieu(); // Bước 2 (Lớp con sẽ chạy logic của riêng nó ở đây)
dongKetNoi(); // Bước 3
}
Nếu chỉ viết như thế này, một lỗ hổng kiến trúc sẽ xuất hiện. Một lập trình viên ở team khác có thể tạo class BaoCaoLoi kế thừa BaoCaoBase, và vô tình (hoặc cố ý) ghi đè (override) luôn cả hàm xuatBaoCao() này. Họ có thể tự ý đảo lộn thứ tự thành: Đóng kết nối Lấy dữ liệu, làm sập toàn bộ ứng dụng.
Đó chính là từ khóa final 🔒.
Khi bạn khai báo public final void xuatBaoCao(), bạn đang thiết lập một "đạo luật" bất di bất dịch cho kiến trúc của mình. Lớp con (như BaoCaoPDF hay BaoCaoExcel) bắt buộc phải tuân theo luồng 3 bước đã định sẵn, chúng chỉ có quyền (và có nghĩa vụ) điền logic chi tiết vào bước dinhDangDuLieu() thông qua việc override.
Đây chính là tinh hoa của Template Method Pattern. Nó bảo vệ tính toàn vẹn của quy trình nghiệp vụ lõi, đồng thời vẫn giữ được sự linh hoạt cần thiết cho các chi tiết cụ thể.
Chúng ta hãy cùng xem đoạn code hoàn chỉnh để chuyển hóa lý thuyết về Template Method Pattern thành thực tế nhé.
Để đảm bảo tính bảo mật và rành mạch của kiến trúc, chúng ta sẽ kết hợp thêm các Access Modifier (private, protected, public) vào cấu trúc này.
Lớp khung xương (Abstract Class)
Đây là lớp định nghĩa quy trình cốt lõi. Chú ý cách chúng ta sử dụng từ khóa final và các mức độ truy cập khác nhau.
public abstract class BaoCaoBase {
// 🔒 Template Method: Đánh dấu final để chốt chặt quy trình 3 bước
public final void xuatBaoCao() {
System.out.println("--- Bắt đầu quy trình xuất báo cáo ---");
layDuLieu();
dinhDangDuLieu(); // Bước linh hoạt do lớp con quyết định
dongKetNoi();
System.out.println("--- Hoàn tất quy trình ---\n");
}
// Bước 1: Dùng chung. Đặt private vì không muốn ai gọi nó đứng độc lập ngoài quy trình.
private void layDuLieu() {
System.out.println("1. Đang kết nối và lấy dữ liệu từ Database...");
}
// Bước 2: Bước trừu tượng. Đặt protected để chỉ cho phép các lớp con nhìn thấy và ghi đè.
protected abstract void dinhDangDuLieu();
// Bước 3: Dùng chung. Đặt private để giấu kín logic dọn dẹp hệ thống.
private void dongKetNoi() {
System.out.println("3. Đang đóng kết nối Database để giải phóng bộ nhớ.");
}
}
Các lớp triển khai (Concrete Classes)
Các lớp con bây giờ cực kỳ gọn nhẹ. Chúng không cần quan tâm đến Database hay luồng chạy, chỉ tập trung đúng vào nghiệp vụ định dạng của mình.
public class BaoCaoPDF extends BaoCaoBase {
@Override
protected void dinhDangDuLieu() {
System.out.println("2. [PDF] Đang vẽ biểu đồ và chuyển đổi thành định dạng PDF 📄");
}
}
public class BaoCaoExcel extends BaoCaoBase {
@Override
protected void dinhDangDuLieu() {
System.out.println("2. [Excel] Đang xuất dữ liệu ra bảng tính và tô màu tiêu đề 📊");
}
}
Cách hệ thống vận hành
Khi một hệ thống khác (Client) gọi để xuất báo cáo, họ chỉ cần tương tác với hàm xuatBaoCao().
public class HeThongChinh {
public static void main(String[] args) {
// Khởi tạo báo cáo định dạng PDF
BaoCaoBase baoCaoThang = new BaoCaoPDF();
baoCaoThang.xuatBaoCao(); // Luồng chạy sẽ tự động khóa chặt theo 3 bước
// Khởi tạo báo cáo định dạng Excel
BaoCaoBase baoCaoNam = new BaoCaoExcel();
baoCaoNam.xuatBaoCao();
}
}
Kết quả khi chạy:
--- Bắt đầu quy trình xuất báo cáo ---
Đang kết nối và lấy dữ liệu từ Database...
[PDF] Đang vẽ biểu đồ và chuyển đổi thành định dạng PDF 📄
Đang đóng kết nối Database để giải phóng bộ nhớ.
--- Hoàn tất quy trình ---
Thông qua đoạn code này, bạn có thể thấy Abstract Class không chỉ là nơi chia sẻ code, mà còn là một công cụ để kiểm soát luồng thực thi (Inversion of Control).
Bây giờ, giả sử có một yêu cầu mới: Một số báo cáo sau khi xuất xong cần được tự động gửi qua Email, nhưng một số khác thì lại không cần.
Dựa trên cấu trúc Template Method này, theo bạn chúng ta có thể thêm một hàm rỗng tên là guiEmail() vào BaoCaoBase và đặt nó ở đâu trong hàm xuatBaoCao() để các lớp con có quyền tùy chọn: ghi đè (nếu muốn gửi) hoặc cứ ngó lơ nó đi (nếu không muốn gửi)?
Câu trả lời đó chính là chúng ta sẽ tạo thêm một phương thức rỗng guiEmail() trong BaoCaoBase và đặt xuống cuối xuatBaoCao(). Đây cũng là một kỹ thuật nâng cao rất hay trong Template Method Pattern, được gọi là Hook (Phương thức móc nối) 🪝.
Đúng như phân tích, chúng ta sẽ đặt hàm guiEmail() vào cuối quy trình xuatBaoCao(). Điểm mấu chốt để các lớp con "có quyền tùy chọn" (muốn thì dùng, không thì ngó lơ) là chúng ta không khai báo hàm này là abstract.
Thay vào đó, chúng ta sẽ viết cho nó một thân hàm rỗng (hoặc một hành vi mặc định, ví dụ như không làm gì cả) ngay trong lớp BaoCaoBase. Vì nó không phải là hàm trừu tượng, Java sẽ không ép các lớp con phải ghi đè nó. Những lớp nào cần gửi email thì mới chủ động gọi @Override để định nghĩa lại.
Bây giờ mình với các bạn hãy cùng xem kỹ thuật Hook (móc nối) 🪝 này giúp thiết kế của chúng ta trở nên mềm dẻo như thế nào trong code nhé.
Đầu tiên, chúng ta cập nhật lại lớp BaoCaoBase, thêm hàm guiEmail() vào cuối luồng xử lý:
public abstract class BaoCaoBase {
// 🔒 Template Method: Vẫn chốt chặt luồng chạy bằng từ khóa final
public final void xuatBaoCao() {
System.out.println("--- Bắt đầu quy trình xuất báo cáo ---");
layDuLieu();
dinhDangDuLieu();
dongKetNoi();
guiEmail(); // Hook được móc vào cuối quy trình
System.out.println("--- Hoàn tất quy trình ---\n");
}
private void layDuLieu() {
System.out.println("1. Đang kết nối và lấy dữ liệu...");
}
protected abstract void dinhDangDuLieu();
private void dongKetNoi() {
System.out.println("3. Đang đóng kết nối Database.");
}
// 🪝 ĐÂY LÀ HOOK: Không có từ khóa abstract.
// Mặc định là không làm gì cả (thân hàm rỗng).
protected void guiEmail() {
// Trống. Lớp con thích thì ghi đè, không thì thôi.
}
}
Bây giờ, hãy xem sự khác biệt khi tạo các lớp con:
// 1. Lớp BaoCaoExcel KHÔNG cần gửi email
// Nó cứ làm ngơ hàm guiEmail(), Java không hề báo lỗi vì hàm đó không phải là abstract.
public class BaoCaoExcel extends BaoCaoBase {
@Override
protected void dinhDangDuLieu() {
System.out.println("2. [Excel] Đang xuất dữ liệu ra bảng tính 📊");
}
}
// 2. Lớp BaoCaoThongKe CẦN gửi email
// Nó chủ động @Override lại cái hook để định nghĩa hành vi riêng.
public class BaoCaoThongKe extends BaoCaoBase {
@Override
protected void dinhDangDuLieu() {
System.out.println("2. [Thống Kê] Đang phân tích và tổng hợp số liệu 📈");
}
@Override
protected void guiEmail() {
System.out.println("4. [Email] Đã đính kèm file và gửi tự động cho Giám đốc 📧");
}
}
Nhờ kỹ thuật này, bộ khung của chúng ta vừa an toàn (không ai phá được quy trình 3 bước cốt lõi) lại vừa linh hoạt cho những tình huống phát sinh thêm ở các lớp con.
Tổng kết so sánh
Được rồi, đi qua phần lí thuyết khô khan, bây giờ chúng ta hãy cùng nhau giải đáp một câu hỏi có thể coi là kinh điển trong các buổi phỏng vấn về Java đó chính là "Bạn hãy phân biệt Abtract, Interface và khi nào nên sử dụng cái nào". Câu hỏi tưởng chừng như rất đơn giản này nhưng mình tin nó đã làm không ít anh em phải bối rối và khó thở, nếu hỏi riêng "Abtract là gì?", "Interface là gì?", các bạn có thể tự tin như một cái máy, tuy nhiên khi gộp lại, thì không khác gì một chiếc đồng hồ hết pin. Nhưng hôm nay, một lần và mãi mãi hãy là người chủ động biến trở ngại thành thứ vũ khí sắc bén cùa bản thân, hãy cùng mình giải đáp nó một cách đơn giản và dễ hiểu nhất nhé.
Chúng ta sẽ đặt các khái niệm này vào một bài toán thiết kế hệ thống cụ thể để xem chúng thể hiện sức mạnh như thế nào nhé.
Hãy tưởng tượng chúng ta đang xây dựng một tựa game nhập vai (RPG). Trong game này, chúng ta có các nhân vật: Chiến binh (Warrior), Pháp sư (Mage), và Quái vật (Monster).
Tất cả các thực thể này đều có một số điểm chung:
Thuộc tính cơ bản: Tọa độ vị trí (X, Y) trên bản đồ và Lượng máu (HP).
Hành vi chung: Hành động
nhanSatThuong()(bị trừ HP khi trúng đòn). Logic tính toán sự sụt giảm máu này hoàn toàn giống nhau cho mọi nhân vật.Hành vi đặc thù: Hành động
tanCong(). Chiến binh chém bằng kiếm, Pháp sư bắn cầu lửa, còn Quái vật thì cắn. Mỗi nhân vật có một cách thức tấn công hoàn toàn khác biệt.
Nếu chúng ta cần tạo một khuôn mẫu gốc (base) mang tên ThucTheGame để cả Chiến binh, Pháp sư và Quái vật cùng dựa vào đó mà phát triển lên:
Theo bạn, đối với ThucTheGame, chúng ta nên sử dụng Abstract Class hay Interface để thiết kế? Tại sao bạn lại nghiêng về phương án đó?
Để trả lời được câu hỏi này, hãy cùng mình bóc tách từng chi tiết một của bài toán nhé.
Hãy tập trung vào điểm chung đầu tiên của ThucTheGame: Thuộc tính cơ bản gồm Tọa độ (X, Y) 📍 và Lượng máu (HP) 🩸.
Trong quá trình chơi game, tọa độ của nhân vật sẽ thay đổi khi di chuyển, và lượng máu sẽ thay đổi liên tục khi chiến đấu hoặc hồi phục.
Trong Java, có một quy tắc cốt lõi về việc lưu trữ dữ liệu (trạng thái):
-
Interface: Chỉ cho phép khai báo các thuộc tính dưới dạng hằng số (public static final). Nghĩa là một khi đã gán giá trị thì không bao giờ thay đổi được nữa.
-
Abstract Class: Cho phép khai báo các biến thông thường (instance variables). Các biến này có thể thay đổi giá trị tự do cho từng đối tượng (mỗi nhân vật có mức HP riêng và thay đổi độc lập).
Dựa vào tính chất này của Tọa độ và Lượng máu, để xây dựng khuôn mẫu ThucTheGame, bạn nghĩ chúng ta bắt buộc phải chọn Abstract Class hay Interface?
Có ngay câu trả lời rồi đúng không, chắc chắn là Abtract Class rồi
| Tiêu chí | Abstract class | Interface |
|---|---|---|
| Khai báo | abstract class | interface |
| Hỗ trợ kế thừa (inheritance) | Chỉ được kế thừa 1 abstract class (single inheritance) | Có thể implements nhiều interface |
| Hỗ trợ đa kế thừa | ⛔️ Không | ⛔️ Không (đạt được mục đích đa kế thừa thông qua implement lại nhiều interface) |
| Có thể chứa phương thức abstract? | ✔️ Có | ✔️ Có |
| Có thể chứa phương thức có thân hàm? | ✔️ Có (normal method) | ✔️ Có default, static (Java 8+) |
| Có thể chứa private method? | ✔️ Có | ✔️ Có (từ Java 9+) |
| Có thể chứa biến (field)? | ✔️ Có (instance + static + final) | ✔️ public static final (hằng số) |
| Constructor | ✔️ Có thể có constructor | ⛔️ Không thể có constructor |
| Access modifier | Cho phép: public, protected, private, | Chỉ cho phép: public, private (Java 9+), default, static |
Trong lập trình Java, cả abstract class và interface đều là những công cụ quan trọng để thiết kế kiến trúc hệ thống một cách linh hoạt và có tính mở rộng cao. Tuy có điểm tương đồng – đều cho phép định nghĩa các phương thức trừu tượng – nhưng mỗi loại phục vụ những mục đích khác nhau:
"Dấu hiệu bắt buộc phải dùng Interface là khi bạn cần cung cấp một hành vi chung cho các đối tượng thuộc những họ (gia phả) hoàn toàn khác nhau, hoặc khi một lớp cần đảm nhận nhiều vai trò độc lập cùng một lúc."
Vượt qua rào cản đơn kế thừa: Java chỉ cho phép một lớp có duy nhất một lớp cha (Abstract Class). Việc dùng Abstract Class ép đối tượng vào một bản chất duy nhất suốt đời (IS-A). Ví dụ, class XeHoi đã kế thừa PhuongTien thì không thể kế thừa thêm class TaiSan được nữa.
Lắp ráp năng lực linh hoạt: Interface phá vỡ giới hạn đó. Bằng hợp đồng "CAN-DO", Interface cho phép bạn lắp ráp thêm vô số khả năng (như MuaBaoHiem, DongThue) cho XeHoi mà không làm thay đổi bản chất gốc của nó, đồng thời kết nối nó với các hệ thống không hề có quan hệ huyết thống.
Java ngày càng linh hoạt với nhiều cải tiến từ Java 8 trở đi, nên ranh giới giữa hai khái niệm này có thể mờ dần. Tuy nhiên, việc lựa chọn đúng công cụ vẫn là chìa khóa để xây dựng hệ thống mạnh mẽ, mở rộng tốt và dễ bảo trì.
Chọn đúng giữa interface và abstract class – không chỉ là lập trình, mà là thiết kế.
All rights reserved