Bỏ qua điều hướng
EN
Tất cả bài viết

Java cơ bản #7: kế thừa, đa hình, lớp trừu tượng, interface và sealed

Ba trụ cột còn lại của OOP: kế thừa với extends và super, ghi đè @Override, đa hình (một lời gọi, nhiều hành vi), lớp trừu tượng, interface và default method, instanceof với pattern matching, sealed + switch pattern, và nguyên tắc ưu tiên kết hợp (composition) hơn kế thừa.

13 phút đọc

Ôn tập bài nàyThẻ ghi nhớ & câu hỏi ôn tập6 thẻ ghi nhớ · 6 câu trắc nghiệm
Mục lục

Bài 6 đã có đóng gói. Ba trụ cột còn lại của lập trình hướng đối tượng là kế thừa, đa hình và trừu tượng. Chúng trả lời câu hỏi: làm sao viết code xử lý nhiều loại đối tượng — thẻ tín dụng, ví điện tử, chuyển khoản — mà không phải viết if (loai == ...) khắp nơi, và thêm loại mới mà không sửa code cũ?

Bài này đi từ kế thừa cổ điển tới cách Java hiện đại làm: interface, sealed và switch với pattern.

Kế thừa (extends)

Lớp con kế thừa thuộc tính và phương thức của lớp cha, rồi thêm hoặc thay đổi phần riêng:

KeThua.java
class NhanVien {
    protected final String hoTen;        // protected: lớp con truy cập được
    protected final long luongCoBan;
 
    NhanVien(String hoTen, long luongCoBan) {
        this.hoTen = hoTen;
        this.luongCoBan = luongCoBan;
    }
 
    long tinhLuong() {
        return luongCoBan;
    }
 
    String moTa() {
        return hoTen + " — lương " + String.format("%,d", tinhLuong());
    }
}
 
class QuanLy extends NhanVien {
    private final long phuCap;
 
    QuanLy(String hoTen, long luongCoBan, long phuCap) {
        super(hoTen, luongCoBan);        // gọi constructor lớp cha — bắt buộc khi cha không có constructor rỗng
        this.phuCap = phuCap;
    }
 
    @Override
    long tinhLuong() {                   // ghi đè: thay hành vi của cha
        return super.tinhLuong() + phuCap;   // super.xxx(): gọi bản của lớp cha
    }
}
 
class NhanVienKinhDoanh extends NhanVien {
    private final long doanhSo;
 
    NhanVienKinhDoanh(String hoTen, long luongCoBan, long doanhSo) {
        super(hoTen, luongCoBan);
        this.doanhSo = doanhSo;
    }
 
    @Override
    long tinhLuong() {
        return luongCoBan + doanhSo * 5 / 100;   // hoa hồng 5%
    }
}
 
void main() {
    NhanVien[] ds = {
        new NhanVien("An", 12_000_000),
        new QuanLy("Bình", 20_000_000, 5_000_000),
        new NhanVienKinhDoanh("Chi", 10_000_000, 200_000_000),
    };
    long tongQuy = 0;
    for (NhanVien nv : ds) {
        IO.println(nv.moTa());           // mỗi đối tượng tự gọi đúng tinhLuong() của nó
        tongQuy += nv.tinhLuong();
    }
    IO.println("Tổng quỹ lương: " + String.format("%,d", tongQuy));
}

Những điểm cần nhớ:

  • Java chỉ cho kế thừa một lớp (extends một lớp duy nhất).
  • Mọi lớp đều ngầm kế thừa Object — vì vậy đối tượng nào cũng có toString(), equals(), hashCode() (bài 8).
  • @Override không bắt buộc nhưng luôn nên viết: gõ sai tên hay sai tham số, trình biên dịch báo lỗi ngay thay vì âm thầm tạo phương thức mới.
  • Phương thức/lớp đánh dấu final không ghi đè/kế thừa được.

Đa hình (polymorphism)

Nhìn lại vòng lặp: biến nv có kiểu NhanVien, nhưng nv.tinhLuong() gọi bản của đối tượng thật (QuanLy, NhanVienKinhDoanh…). Quyết định này diễn ra lúc chạy (dynamic dispatch). Đó là đa hình: một lời gọi, nhiều hành vi.

Lợi ích: thêm loại nhân viên mới (ThucTapSinh) chỉ cần thêm một lớp; vòng lặp tính lương không phải sửa.

DaHinh.java
class DongVat {
    String keu() { return "..."; }
}
class Cho extends DongVat {
    @Override String keu() { return "Gâu gâu"; }
    void giuNha() { IO.println("Chó đang giữ nhà"); }
}
class Meo extends DongVat {
    @Override String keu() { return "Meo meo"; }
}
 
void main() {
    DongVat d = new Cho();       // biến kiểu cha, đối tượng kiểu con — hợp lệ (upcasting)
    IO.println(d.keu());         // Gâu gâu — theo đối tượng thật
    // d.giuNha();               → lỗi biên dịch: kiểu khai báo DongVat không có giuNha
 
    if (d instanceof Cho c) {    // kiểm tra kiểu + ép kiểu trong một bước (pattern matching, Java 16+)
        c.giuNha();
    }
}

Lưu ý: trình biên dịch chỉ cho gọi những gì kiểu khai báo có; còn bản nào được chạy thì do kiểu đối tượng thật quyết định. Phải dùng instanceof để "xuống" kiểu con thường là dấu hiệu thiết kế chưa tốt — hành vi đó có lẽ nên là một phương thức được ghi đè.

Lớp trừu tượng (abstract)

NhanVien chung chung hay DongVat chung chung có nên tạo được đối tượng không? Thường là không. Lớp trừu tượng không tạo đối tượng được và có thể chứa phương thức trừu tượng — không có thân, lớp con bắt buộc cài đặt:

TruuTuong.java
abstract class Hinh {
    private final String ten;
    Hinh(String ten) { this.ten = ten; }
 
    abstract double dienTich();          // lớp con phải cài đặt
    abstract double chuVi();
 
    String moTa() {                      // phương thức thường dùng phương thức trừu tượng
        return "%s: diện tích %.2f, chu vi %.2f".formatted(ten, dienTich(), chuVi());
    }
}
 
class HinhTron extends Hinh {
    private final double r;
    HinhTron(double r) { super("Hình tròn"); this.r = r; }
    @Override double dienTich() { return Math.PI * r * r; }
    @Override double chuVi() { return 2 * Math.PI * r; }
}
 
class HinhChuNhat extends Hinh {
    private final double dai, rong;
    HinhChuNhat(double dai, double rong) { super("Hình chữ nhật"); this.dai = dai; this.rong = rong; }
    @Override double dienTich() { return dai * rong; }
    @Override double chuVi() { return 2 * (dai + rong); }
}
 
void main() {
    Hinh[] cacHinh = { new HinhTron(2), new HinhChuNhat(3, 4) };
    for (Hinh h : cacHinh) IO.println(h.moTa());
    // new Hinh("x") → lỗi biên dịch: Hinh is abstract; cannot be instantiated
}

Interface

Interface mô tả một khả năng — "làm được gì" — mà không quan tâm "là gì". Một lớp kế thừa được một lớp, nhưng cài đặt được nhiều interface:

GiaoDien.java
interface ThanhToan {
    boolean thanhToan(long soTien);              // ngầm public abstract
 
    default String bienLai(long soTien) {         // default method: có sẵn thân, lớp cài đặt dùng luôn hoặc ghi đè
        return "Đã thanh toán " + String.format("%,d", soTien) + "đ qua " + ten();
    }
 
    String ten();
}
 
interface HoanTien {
    void hoanTien(long soTien);
}
 
class TheTinDung implements ThanhToan, HoanTien {
    private long hanMuc;
    TheTinDung(long hanMuc) { this.hanMuc = hanMuc; }
 
    @Override public boolean thanhToan(long soTien) {
        if (soTien > hanMuc) return false;
        hanMuc -= soTien;
        return true;
    }
    @Override public void hoanTien(long soTien) { hanMuc += soTien; }
    @Override public String ten() { return "thẻ tín dụng"; }
}
 
class ViDienTu implements ThanhToan {
    private long soDu;
    ViDienTu(long soDu) { this.soDu = soDu; }
 
    @Override public boolean thanhToan(long soTien) {
        if (soTien > soDu) return false;
        soDu -= soTien;
        return true;
    }
    @Override public String ten() { return "ví điện tử"; }
}
 
// Phương thức chỉ phụ thuộc vào interface, không biết cũng không cần biết lớp cụ thể
void datHang(ThanhToan pt, long tongTien) {
    IO.println(pt.thanhToan(tongTien) ? pt.bienLai(tongTien) : "Thanh toán thất bại bằng " + pt.ten());
}
 
void main() {
    datHang(new TheTinDung(5_000_000), 1_200_000);
    datHang(new ViDienTu(500_000), 1_200_000);
    datHang(new ViDienTu(2_000_000), 1_200_000);
}

Lập trình theo interface (datHang(ThanhToan pt, ...)) là nguyên tắc nền của các framework Java như Spring: code nghiệp vụ không gắn chặt vào cài đặt cụ thể, thay được bằng bản giả khi kiểm thử.

Lớp trừu tượng hay interface?

Lớp trừu tượngInterface
Quan hệ"là một" (HinhTron là Hinh)"làm được" (TheTinDung có khả năng HoanTien)
Số lượngKế thừa mộtCài đặt nhiều
Trạng thái (thuộc tính)CóKhông (chỉ hằng số)
ConstructorCóKhông
Mặc định nên chọnKhi các lớp con chia sẻ dữ liệu/code đáng kểPhần lớn trường hợp

Interface hàm (functional interface)

Interface có đúng một phương thức trừu tượng có thể cài đặt bằng lambda — cú pháp gọn bạn sẽ học kỹ ở bài 11:

LambdaNho.java
interface PhepToan {
    int tinh(int a, int b);
}
 
void main() {
    PhepToan cong = (a, b) -> a + b;
    PhepToan luyThua = (a, b) -> (int) Math.pow(a, b);
    IO.println(cong.tinh(3, 4) + " " + luyThua.tinh(2, 10));
}

sealed và switch với pattern (Java 17–21)

Đôi khi ta muốn giới hạn các lớp con: một phương thức thanh toán chỉ có thể là thẻ, ví hoặc tiền mặt, không ai được thêm loại khác. sealed làm điều đó, và kết hợp với record (bài 8) + switch pattern, trình biên dịch kiểm tra giúp ta đã xử lý đủ mọi trường hợp:

SealedSwitch.java
sealed interface HinhThuc permits The, Vi, TienMat {}
record The(String soThe, boolean quocTe) implements HinhThuc {}
record Vi(String nhaCungCap) implements HinhThuc {}
record TienMat() implements HinhThuc {}
 
long phi(HinhThuc ht, long soTien) {
    return switch (ht) {                                     // không cần default: trình biên dịch biết chỉ có 3 loại
        case The t when t.quocTe() -> soTien * 3 / 100;     // điều kiện phụ với when
        case The t -> soTien / 100;
        case Vi v -> v.nhaCungCap().equals("MoMo") ? 0 : 2_000;
        case TienMat tm -> 0;
    };
}
 
void main() {
    HinhThuc[] ds = { new The("4111", true), new The("9704", false), new Vi("MoMo"), new TienMat() };
    for (HinhThuc ht : ds) IO.println(ht + " → phí " + phi(ht, 1_000_000));
}

Thêm loại ChuyenKhoan vào permits mà quên sửa switch → lỗi biên dịch chỉ đúng chỗ cần sửa. Đây là cách Java hiện đại mô hình hoá dữ liệu có số dạng cố định (trạng thái đơn hàng, kết quả xử lý, cây cú pháp…).

Ưu tiên kết hợp hơn kế thừa

Kế thừa tạo liên kết rất chặt: lớp con phụ thuộc chi tiết cài đặt của lớp cha. Một quy tắc nổi tiếng (Effective Java, mục 18): ưu tiên kết hợp (composition) hơn kế thừa.

KetHop.java
interface ThongBao {
    void gui(String noiDung);
}
class Email implements ThongBao {
    public void gui(String noiDung) { IO.println("[Email] " + noiDung); }
}
class Sms implements ThongBao {
    public void gui(String noiDung) { IO.println("[SMS] " + noiDung); }
}
 
// DonHangService CÓ MỘT ThongBao, thay vì KẾ THỪA từ EmailService
class DonHangService {
    private final ThongBao thongBao;
    DonHangService(ThongBao thongBao) { this.thongBao = thongBao; }   // "tiêm" phụ thuộc qua constructor
 
    void xacNhan(int maDon) {
        // ... lưu đơn hàng ...
        thongBao.gui("Đơn #" + maDon + " đã được xác nhận");
    }
}
 
void main() {
    new DonHangService(new Email()).xacNhan(101);
    new DonHangService(new Sms()).xacNhan(102);     // đổi cách gửi không cần sửa DonHangService
}

Dùng kế thừa khi quan hệ "là một" thật sự và ổn định; còn lại, gom đối tượng khác làm thuộc tính và gọi qua interface. Kỹ thuật truyền phụ thuộc qua constructor ở trên chính là dependency injection mà Spring tự động hoá.

Bài tập

  1. Thêm lớp ThucTapSinh (lương cố định 5 triệu, không phụ thuộc lương cơ bản) vào ví dụ nhân viên mà không sửa vòng lặp tính lương.
  2. Thêm HinhTamGiac (ba cạnh, kiểm tra bất đẳng thức tam giác trong constructor, diện tích theo công thức Heron) vào ví dụ Hinh.
  3. Viết interface CoTheSapXep với phương thức int soSanh(Object khac), cho SanPham cài đặt theo giá; viết phương thức sắp xếp mảng bất kỳ đối tượng CoTheSapXep.
  4. Mô hình hoá trạng thái đơn hàng bằng sealed interface (ChoXacNhan, DangGiao(maVanDon), DaGiao(ngay), DaHuy(lyDo)) và viết String moTa(TrangThai t) bằng switch pattern.

Tổng kết

  • extends kế thừa một lớp; super(...) gọi constructor cha, super.x() gọi bản của cha; luôn viết @Override.
  • Đa hình: kiểu khai báo quyết định gọi được gì, kiểu đối tượng thật quyết định chạy bản nào.
  • Lớp abstract không tạo đối tượng được, buộc lớp con cài đặt phương thức trừu tượng.
  • Interface mô tả khả năng; một lớp cài đặt nhiều interface; lập trình theo interface để code linh hoạt và dễ kiểm thử.
  • sealed + record + switch pattern: tập kiểu đóng, trình biên dịch kiểm tra đủ trường hợp.
  • Ưu tiên kết hợp và truyền phụ thuộc qua constructor hơn kế thừa sâu.
  • Bài tiếp theo: record, enum và ba phương thức quan trọng của Object.

Nguồn tham khảo: dev.java — "Inheritance", "Interfaces", "Pattern Matching"; JEP 409: Sealed Classes; JEP 441: Pattern Matching for switch; Bloch — Effective Java, mục 18–20 (composition, interface hơn lớp trừu tượng); Java Programming MOOC — University of Helsinki, phần 9.

Đang tải câu hỏi luyện tập…

Bài này thế nào?

Bài viết có hữu ích với bạn?

Đánh giá giúp mình biết nên viết thêm về chủ đề nào.

Đang tải đánh giá…
Chọn số sao từ 1 đến 5

Bình luận

Đăng nhập để bình luận không cần nhập tên và được duyệt nhanh hơn.

0/2000
Đang tải bình luận…

Đọc tiếp

Bài số № 51

Java cơ bản #8: record, enum và equals, hashCode, toString

Ba phương thức mọi đối tượng đều có — toString, equals, hashCode — vì sao phải ghi đè chúng cùng nhau và cái bẫy khi quên; record (Java 16+) viết lớp dữ liệu bất biến trong một dòng; enum cho tập giá trị cố định, có thuộc tính, phương thức và dùng trong switch.

15 phút đọc

Bài số № 49

Java cơ bản #6: lớp và đối tượng — constructor, this, đóng gói, static

Bước vào lập trình hướng đối tượng: lớp là bản thiết kế, đối tượng là thực thể; thuộc tính và phương thức, constructor, từ khoá this, đóng gói với private + getter/setter có kiểm tra, static khác gì instance, null và NullPointerException, package và tổ chức file.

Bài số № 55

Java cơ bản #12: dự án cuối — ứng dụng quản lý chi tiêu với Maven và JUnit

Gom toàn bộ kiến thức vào một dự án thật: cấu trúc Maven và pom.xml, chia lớp model–kho dữ liệu–service–giao diện, lưu file CSV, thống kê bằng Stream, cảnh báo ngân sách, viết kiểm thử JUnit 5, đóng gói file jar chạy được — và lộ trình học tiếp sau Java cơ bản.

19 phút đọc