Đa hình thường được giải thích bằng một câu: "cùng một lời gọi, các đối tượng khác nhau phản ứng khác nhau". Đúng, nhưng câu đó không giúp bạn dự đoán được kết quả trong ba tình huống mà đa hình không xảy ra — và cả ba đều là bẫy có thật.

Hôm nay ta xem cả bốn trường hợp cùng lúc, rồi mở bytecode ra xem vì sao chúng khác nhau.

Trường hợp thuận: phương thức thì đa hình

static void xuLy(ThanhToan t) {
    System.out.println("  moTa() -> " + t.moTa() + "   | ten -> " + t.ten);
}

for (ThanhToan t : List.of(new ThanhToan(), new TheTinDung(), new ViDienTu())) {
    xuLy(t);
}

Cả ba đối tượng đi qua cùng một dòng mã, tham số cùng khai kiểu ThanhToan:

  moTa() -> ThanhToan.moTa()   | ten -> thanh toán chung
  moTa() -> TheTinDung.moTa()   | ten -> thanh toán chung
  moTa() -> ViDienTu.moTa()   | ten -> thanh toán chung

Cột moTa() đổi theo đối tượng thật — đó là đa hình. Nhưng cột ten thì không đổi, dù cả hai lớp con đều khai lại trường ten với giá trị riêng.

Đây là bẫy thứ nhất, và cũng là bẫy hay gặp nhất.

Bẫy 1: trường không đa hình

ThanhToan nhinNhuCha = new TheTinDung();
TheTinDung nhinNhuCon = new TheTinDung();
  qua biến kiểu cha : ten = thanh toán chung
  qua biến kiểu con : ten = thẻ tín dụng
  moTa() thì vẫn đa hình: TheTinDung.moTa()

Cùng một đối tượng TheTinDung, nhìn qua hai biến khai kiểu khác nhau, cho ra hai giá trị ten khác nhau. Trong khi moTa() vẫn trả về bản của lớp con.

Lý do: trường trùng tên ở lớp con che trường của lớp cha chứ không ghi đè nó. Đối tượng thật sự mang cả hai trường trong bộ nhớ, và biến khai kiểu nào thì đọc trường của lớp đó.

Điều này nghe kỳ quặc cho tới khi bạn nhìn bytecode.

Bytecode: invokevirtual khác getfield

  static void xuLy(DaHinh$ThanhToan);
    Code:
       3: aload_0
       4: invokevirtual #13    // Method DaHinh$ThanhToan.moTa:()Ljava/lang/String;
       7: aload_0
       8: getfield      #19    // Field DaHinh$ThanhToan.ten:Ljava/lang/String;

Hai lệnh nằm sát nhau và đó chính là toàn bộ câu trả lời.

invokevirtual — "gọi phương thức ảo". Nó ghi tên phương thức và chữ ký, nhưng không ghi cứng địa chỉ mã cần chạy. Lúc chạy, JVM nhìn vào lớp thật của đối tượng, tra bảng phương thức ảo của lớp đó, rồi mới nhảy. Mỗi lớp có một bảng như vậy — dựng lúc nạp lớp, trong đó bản ghi đè của lớp con đã thay chỗ bản của lớp cha.

getfield — lấy trường. Ở đây chỉ mục #19 trỏ thẳng vào DaHinh$ThanhToan.ten, được chốt ngay lúc biên dịch. Không có bảng nào để tra, không có gì quyết định lúc chạy.

Nên: phương thức được chọn theo đối tượng, trường được chọn theo kiểu khai báo.

Kết luận thực dụng: đừng bao giờ khai trường trùng tên với trường của lớp cha. Không có tình huống nào việc đó có ích, và nó tạo ra một lớp lỗi mà người đọc mã gần như không thể nhìn ra. Cần giá trị khác nhau cho từng lớp con thì dùng một phương thức:

class ThanhToan {
    String ten() { return "thanh toán chung"; }
}
class TheTinDung extends ThanhToan {
    @Override String ten() { return "thẻ tín dụng"; }
}

Bẫy 2: static không đa hình

  ThanhToan.loai()  = ThanhToan.loai()
  TheTinDung.loai() = TheTinDung.loai()

Phương thức static khai lại ở lớp con cũng là che, không phải ghi đè. Nó được biên dịch thành invokestatic, và lời gọi chốt lúc biên dịch theo kiểu khai báo.

Hệ quả rất dễ nhầm:

ThanhToan t = new TheTinDung();
t.loai();     // gọi ThanhToan.loai(), KHÔNG phải TheTinDung.loai()

Gọi phương thức tĩnh qua một biến thể hiện là cú pháp hợp lệ nhưng gây hiểu lầm nặng — IDE nào cũng cảnh báo. Luôn gọi qua tên lớp: ThanhToan.loai().

Nói thêm: @Override không đặt được lên phương thức static. Trình biên dịch sẽ báo lỗi, và đó là một lời nhắc miễn phí rằng bạn đang không ghi đè cái gì cả.

Bẫy 3: private không đa hình

Phương thức private không được lớp con nhìn thấy, nên khai lại một phương thức cùng tên ở lớp con là tạo ra một phương thức hoàn toàn mới, không liên quan. Nó được biên dịch thành invokespecial — cùng loại lệnh dùng cho hàm khởi tạo và cho super.x().

Đây chính là lý do lời khuyên ở bài về hàm khởi tạo lại có hiệu lực: gọi phương thức private từ trong hàm khởi tạo là an toàn, vì không ai ghi đè được nó.

Bốn lệnh gọi phương thức

Gom lại thành bảng, vì bạn sẽ gặp bốn cái tên này suốt trong stack trace và trong log của JIT:

Lệnh Dùng cho Quyết định lúc nào
invokestatic phương thức static biên dịch
invokespecial hàm khởi tạo, private, super.x() biên dịch
invokevirtual phương thức thường của lớp chạy
invokeinterface phương thức gọi qua kiểu giao diện chạy

invokeinterface tách riêng vì tra cứu qua giao diện tốn công hơn — một lớp cài nhiều giao diện thì vị trí phương thức trong bảng không cố định như khi kế thừa lớp. JVM hiện đại tối ưu chỗ này khá tốt bằng cache nội tuyến, nên khác biệt hiệu năng hầu như không đo được trong mã thường.

Đa hình dùng để làm gì

Giá trị thật của đa hình không nằm ở chỗ "gọi một hàm ra nhiều kết quả". Nó nằm ở chỗ thêm hành vi mới mà không sửa mã cũ.

void xuLyTatCa(List<ThanhToan> danhSach) {
    for (ThanhToan t : danhSach) {
        t.thucHien();
    }
}

Hôm nay có thẻ tín dụng và ví điện tử. Tháng sau thêm chuyển khoản: viết một lớp mới, và hàm xuLyTatCa không đổi một dòng nào. So với cách viết bằng chuỗi if-else theo loại, mỗi lần thêm loại mới là phải tìm hết mọi chỗ có if đó — và luôn sót một chỗ.

Đây chính là "nguyên tắc mở–đóng" trong SOLID, và nó là một trong số ít nguyên tắc thiết kế mà lợi ích thấy được ngay chứ không phải chờ dự án lớn.

Tất nhiên có mặt trái: đọc mã khó hơn. Nhìn t.thucHien() thì không biết mã nào sẽ chạy, phải tra cả cây kế thừa. Đó là lý do sealed — thứ giới hạn danh sách lớp con và làm nó hiện rõ ngay tại chỗ khai báo — đáng dùng tới vậy. Bài về sealed sẽ nói kỹ.

instanceof và ép kiểu

Object o = new DonHang("DH-01");
if (o instanceof CoTheHuy c) System.out.println(c.huy());
  huỷ đơn DH-01

Từ Java 16, instanceof khai luôn biến đã ép kiểu — bỏ hẳn bước ép thủ công ở dòng sau. Biến c chỉ tồn tại trong nhánh đúng, nên không thể dùng nhầm.

Ép kiểu sai thì:

  ClassCastException: class DaHinh$DonHang cannot be cast to class DaHinh$ThanhToan

ClassCastException là lỗi lúc chạy — trình biên dịch để lọt vì về mặt kiểu, Object có thể là bất cứ gì. Đây là lý do nên ưu tiên instanceof có khai biến, hoặc switch với pattern matching như đã thấy ở bài về điều kiện.

Một chuỗi if (x instanceof A) ... else if (x instanceof B) ... dài dằng dặc thường là dấu hiệu đang thiếu đa hình. Thay vì hỏi "đối tượng này thuộc loại nào rồi xử lý tương ứng", hãy để chính đối tượng đó biết cách xử lý mình. Ngoại lệ: khi các lớp nằm ngoài tầm kiểm soát của bạn, hoặc khi sealed đã liệt kê hết và bạn đang dùng switch vét cạn.

Thử ba mươi giây

Lấy đoạn mã đầu bài, đổi ThanhToan nhinNhuCha = new TheTinDung(); rồi in cả nhinNhuCha.ten((TheTinDung) nhinNhuCha).ten.

Cùng một đối tượng trong bộ nhớ, hai giá trị khác nhau, chỉ vì kiểu bạn nhìn nó qua. Không có cách nào giải thích chuyện đó nếu chưa biết getfield chốt chỉ mục lúc biên dịch — và đó là lý do tôi cho rằng liếc qua bytecode thỉnh thoảng là việc đáng làm, kể cả khi bạn không bao giờ viết bytecode.

Ngày mai: lớp trừu tượng hay interface — hai công cụ giải cùng một bài toán, và tiêu chí chọn giữa chúng.