Java OO & Functional/Override và dynamic dispatch — đa hình ở runtime
4/36
Bài 4 / 36~18 phútKế thừa & Đa hìnhMiễn phí lượt xem

Override và dynamic dispatch — đa hình ở runtime

Override method vs overload, @Override annotation, dynamic dispatch dựa vào kiểu thực của object, covariant return type, và rule 'tightening' cấm thu hẹp visibility.

Bài trước bạn viết class Dog extends Animal và thấy Dog kế thừa eat() từ Animal. Nhưng nếu Dog muốn ăn khác (vd thêm log, kèm uống nước), bạn không muốn xoá eat() — bạn muốn override: giữ chữ ký giống cha, thay body.

Và đây là lúc Java làm điều tuyệt vời: kể cả bạn có reference kiểu Animal đang trỏ tới một Dog object, animal.eat() vẫn gọi phiên bản của Dog. Đó là dynamic dispatch — nền tảng của đa hình (polymorphism). Nhờ nó bạn viết code thao tác "mọi Animal" mà không cần biết loài cụ thể.

Bài này giải thích override đúng cách, phân biệt với overload, cơ chế dispatch ở bytecode, covariant return, và các rule Java quy định để override hợp lệ.

1. Analogy — ca sĩ hát lại bài hit

Bài hát "Yesterday" của Beatles được nhiều ca sĩ cover. Tên bài giống (eat() cùng signature), nhưng mỗi bản thu có phong cách riêng (body khác). Khi bạn bật playlist "Yesterday", app phát bản đúng ca sĩ được gắn vào track đó — không quan tâm bạn đang nhìn tên bài như nhau.

Đời thườngJava
Tên bài "Yesterday"Method signature
Bản gốc BeatlesMethod của class cha
Bản coverMethod override ở class con
Playlist phát đúng bản của ca sĩDynamic dispatch chọn method theo kiểu thực của object
💡 Cách nhớ

Override = giữ signature, đổi body. Dynamic dispatch = JVM chọn phiên bản theo kiểu thực của object, không phải kiểu khai báo của biến.

2. Override cơ bản

class Animal {
    public String sound() {
        return "some sound";
    }
}

class Dog extends Animal {
    @Override
    public String sound() {
        return "woof";
    }
}

class Cat extends Animal {
    @Override
    public String sound() {
        return "meow";
    }
}

Dùng:

Animal a1 = new Dog();
Animal a2 = new Cat();
System.out.println(a1.sound());   // woof
System.out.println(a2.sound());   // meow

a1a2kiểu khai báo Animal nhưng kiểu thựcDog/Cat. Method sound() chọn theo kiểu thực → in đúng woof / meow.

2.1 @Override annotation — dùng luôn

class Dog extends Animal {
    @Override
    public String sound() { return "woof"; }
}

Không bắt buộc, nhưng nên có:

  • Compiler kiểm tra bạn thật sự override method có sẵn. Viết sai tên (Sound thay sound), sai signature (int sound() thay String sound()) → compile error.
  • Không có annotation, Java sẽ coi nó là method mới của class con → không override → bug im lặng.
class Dog extends Animal {
    // gõ sai — không có @Override
    public String soound() { return "woof"; }   // method moi, khong override
}

Animal a = new Dog();
a.sound();   // goi Animal.sound() -> "some sound" — BUG
⚠️ Luôn bật warning 'missing @Override' trong IDE

IntelliJ và Eclipse có inspection "Missing @Override annotation" — bật lên, để IDE nhắc tự động. Nhiều team xem việc thiếu @Override là compile warning nghiêm trọng.

3. Override vs overload — phân biệt rõ hai loại đa hình

Một trong những nguồn nhầm lẫn lớn nhất của lập trình viên là không phân biệt được rõ ranh giới giữa Method Overriding (Ghi đè phương thức)Method Overloading (Nạp chồng phương thức). Trong Java, đây là hai hiện diện của hai kiểu đa hình hoàn toàn khác nhau:

  • Method Overriding đại diện cho Runtime Polymorphism (Đa hình lúc chạy) hay Dynamic Binding (Liên kết động).
  • Method Overloading đại diện cho Compile-time Polymorphism (Đa hình lúc biên dịch) hay Static Binding (Liên kết tĩnh).

Bảng so sánh toàn diện

Tiêu chíMethod Overriding (Ghi đè)Method Overloading (Nạp chồng)
Cơ chế Đa hìnhRuntime Polymorphism (Đa hình động)Compile-time Polymorphism (Đa hình tĩnh)
Cơ chế Liên kếtDynamic Binding (Quyết định bởi JVM khi chạy)Static Binding (Quyết định bởi Compiler khi biên dịch)
Vị trí khai báoPhải ở hai lớp có quan hệ kế thừa (Cha và Con)Có thể ở trong cùng một lớp hoặc lớp con
Chữ ký (Signature)Bắt buộc giống hệt (Tên + Tham số)Bắt buộc khác tham số (Số lượng, kiểu, hoặc thứ tự)
Kiểu trả vềPhải giống hệt hoặc là lớp con (Covariant Return)Có thể khác nhau tùy ý
Hiệu năngCó một chút chi phí vtable lookup ở RuntimeKhông có chi phí runtime, liên kết cực nhanh
Mục đíchThay đổi hoặc bổ sung hành vi của lớp chaĐịnh nghĩa nhiều cách sử dụng khác nhau cho một hành vi

Ví dụ minh họa

class Calc {
    // 1. Phương thức gốc
    public int add(int a, int b) { return a + b; }
    
    // 2. Overload: Khác tham số truyền vào (double, double)
    public double add(double a, double b) { return a + b; }
}

class SafeCalc extends Calc {
    // 3. Override: Giữ nguyên chữ ký, thay đổi logic ở lớp con
    @Override
    public int add(int a, int b) {
        return Math.addExact(a, b);   // Kiểm tra tràn số (overflow)
    }
}

Trong ví dụ trên:

  • SafeCalc.add(int, int)Override phương thức Calc.add(int, int). Khi chạy, JVM sẽ quyết định gọi hàm này nếu đối tượng thực tế là SafeCalc.
  • Calc.add(double, double)Overload của Calc.add(int, int). Ngay khi biên dịch, Compiler đã xác định được dòng code của bạn đang gọi hàm nào dựa vào kiểu dữ liệu truyền vào.

4. Dynamic dispatch — cơ chế JVM

Khi gọi a.sound() với a kiểu khai báo Animal, compiler không cố định method nào sẽ chạy. Thay vào đó, compiler sinh instruction invokevirtual — JVM runtime lookup method dựa trên kiểu thực của object qua virtual method table (vtable).

flowchart LR
    subgraph Stack
        ref["a (Animal reference)"]
    end
    subgraph Heap
        obj["Dog object<br/>[vtable ptr]"]
    end
    subgraph ClassMetadata
        vtable["Dog vtable<br/>sound() -> Dog.sound"]
    end
    ref -.-> obj
    obj -.-> vtable

Khi bytecode invokevirtual sound()V chạy:

  1. JVM theo reference a → tìm object trên heap.
  2. Từ object header đọc ra class pointer → tìm vtable của class đó.
  3. Trong vtable, chọn slot tương ứng với method sound().
  4. Gọi method ghi ở slot đó.

Kết quả: kể cả khi a khai báo kiểu Animal, nếu object thực là Dog thì JVM tra vtable của Dog — slot sound() trỏ tới Dog.sound(), nên phiên bản của Dog được gọi.

📚 Static dispatch vs dynamic dispatch
  • Static dispatch (overload, static method, private method, final method) — compiler cố định method tại compile time. Bytecode invokestatic / invokespecial.
  • Dynamic dispatch (instance method non-final) — JVM chọn runtime. Bytecode invokevirtual.

Hiểu điều này giải thích: gọi final method không có overhead vtable lookup — JIT có thể inline thẳng. Gọi non-final method có 1 tầng indirection.

5. Rule override — tightening không được, loosening được

Khi override, Java áp rule về:

5.1 Visibility — không thu hẹp

class Animal {
    public void eat() { }
}

class Dog extends Animal {
    protected void eat() { }   // COMPILE ERROR — thu hep tu public xuong protected
}

Class con không được thu hẹp visibility. public cha → public con. Có thể nới rộng (protectedpublic).

Lý do: Liskov — caller có Animal a; a.eat() phải gọi được. Nếu con thu hẹp thành protected, caller bị refuse → phá substitutability.

5.2 Checked exception — không mở rộng

class A {
    public void run() throws IOException { }
}

class B extends A {
    public void run() throws IOException, SQLException { }   // COMPILE ERROR
    public void run() { }                                     // OK — narrow xuong
    public void run() throws FileNotFoundException { }        // OK — subtype cua IOException
}

Con chỉ được ném subset exception của cha. Ngăn caller bị exception mới không lường trước.

5.3 Return type — covariant (Java 5+)

class Animal {
    public Animal clone() { return new Animal(); }
}

class Dog extends Animal {
    @Override
    public Dog clone() { return new Dog(); }   // OK — Dog la subtype cua Animal
}

Return type con có thể là subtype của return type cha. Gọi là covariant return.

Caller viết Animal a = dog.clone() vẫn work; nhưng caller biết rõ Dog dog có thể lấy trực tiếp Dog d = dog.clone() không cần cast.

5.4 final, static, private — không override được

class A {
    public final void f() { }
    public static void g() { }
    private void h() { }
}

class B extends A {
    public void f() { }        // COMPILE ERROR — cannot override final
    public static void g() { }  // KHONG override — chi shadow (bai sau)
    private void h() { }        // KHONG override — private khong ke thua
}
  • final cấm override — dùng để khoá behaviour quan trọng.
  • static method không có dynamic dispatch — chúng thuộc class, không phải object. Viết cùng tên ở con chỉ là shadow.
  • private không kế thừa nên không có gì để override.

6. Gọi phiên bản cha trong override — super.method()

class Animal {
    public String describe() { return "Animal"; }
}

class Dog extends Animal {
    @Override
    public String describe() {
        return super.describe() + " (Dog)";   // goi Animal.describe
    }
}

System.out.println(new Dog().describe());   // "Animal (Dog)"

super.describe() bypass dispatch, gọi chính xác phiên bản của cha. Pattern này gặp khi con muốn bổ sung logic thay vì thay hoàn toàn.

7. Bẫy Constructor Override Trap — hiểm họa khi khởi tạo

Một trong những cạm bẫy tinh vi nhất liên quan đến đa hình là sự kết hợp giữa Constructor và Dynamic Dispatch. Khi bạn gọi một phương thức có thể override bên trong constructor của lớp cha, chương trình sẽ hoạt động theo cách cực kỳ khó lường.

⚠️ Cạm bẫy Constructor Override Trap

Hãy xem đoạn code sau và dự đoán xem new Dog() sẽ in ra gì:


class Animal {
  public Animal() {
      sound();   // Gọi method instance trong constructor của cha!
  }
  
  public void sound() { 
      System.out.println("animal sound"); 
  }
}

class Dog extends Animal {
  private String name = "Rex";

  @Override
  public void sound() {
      System.out.println("Dog says woof, name = " + name);
  }
}

Nhiều lập trình viên nghĩ rằng kết quả sẽ là Dog says woof, name = Rex. Nhưng kết quả thực tế lại là: Dog says woof, name = null!

Giải thích cơ chế (Tại sao name = null?):

  1. Khi chạy new Dog(), constructor của lớp con (Dog()) sẽ ngầm gọi super() trước tiên để chạy constructor của lớp cha (Animal()).
  2. Constructor của Animal() bắt đầu chạy và gọi sound().
  3. Vì đối tượng thực tế đang được tạo là Dog, cơ chế Dynamic Dispatch lập tức kích hoạt vtable của Dog và chuyển hướng cuộc gọi đến phương thức @Override public void sound() của lớp Dog.
  4. Tuy nhiên, lúc này constructor của lớp Dog vẫn chưa hề chạy! Điều đó có nghĩa là tiến trình khởi tạo biến thành viên private String name = "Rex" của lớp con chưa diễn ra. Giá trị của name lúc này vẫn là mặc định: null.

Quy tắc an toàn tuyệt đối:

Không bao giờ gọi phương thức non-final (có thể override) bên trong constructor. Nếu muốn gọi phương thức phụ trợ trong constructor, hãy đánh dấu phương thức đó là private hoặc final để chặn đứng cơ chế Dynamic Dispatch, bảo vệ trạng thái an toàn của đối tượng.

8. Pitfall tổng hợp

Nhầm 1: Quên @Override → sai signature → method mới thay vì override.

class Dog extends Animal {
    public void Sound() { ... }   // 'S' viet hoa — method moi
}

✅ Luôn @Override — compiler bắt tự động.

Nhầm 2: Thu hẹp visibility khi override.

class Dog extends Animal {
    protected String sound() { return "woof"; }   // cha la public
}

✅ Giữ public hoặc nới rộng, không thu hẹp.

Nhầm 3: Override static.

class A { public static void f() { ... } }
class B extends A { public static void f() { ... } }   // khong override — shadow

static method không tham gia dispatch. Nếu muốn polymorphism, dùng instance method.

Nhầm 4: Gọi method override trong constructor.

abstract class Animal {
    public Animal() { init(); }   // init() la abstract hoac override-able
    protected abstract void init();
}

✅ Không. Khởi tạo hẳn trong constructor của mỗi class cụ thể, hoặc dùng factory method.

Nhầm 5: Cast rồi gọi để "override" — thực ra là dispatch bình thường.

Animal a = new Dog();
((Dog) a).sound();   // cast khong can thiet — a.sound() da dispatch dung

✅ Chỉ cast khi cần gọi method chỉ có ở con (không có ở cha).

9. 📚 Deep Dive Oracle

📚 Deep Dive Oracle (optional)

Spec / reference chính thức:

Ghi chú: JVMS §5.4.5 mô tả chính xác method resolution — JVM bắt đầu từ class thực, đi lên cây kế thừa đến khi tìm thấy method có signature match. Hiểu quy trình này giải thích sao override "cứ chạy" mà không cần ai register. JIT sau đó tối ưu bằng inline cachingspeculative inlining — gọi method polymorphic nhưng trong thực tế hầu như toàn 1 kiểu concrete thì JIT inline thẳng.

10. Tóm tắt

  • Override = class con viết lại method của cha với cùng signature, body khác.
  • @Override annotation — luôn dùng; compiler bắt lỗi nếu bạn gõ sai signature.
  • Overload khác override: overload là cùng class, khác signature; override là khác class, cùng signature.
  • Dynamic dispatch — JVM chọn method theo kiểu thực của object, không phải kiểu khai báo của biến. Cơ chế qua vtable + invokevirtual.
  • Rule override:
    • Không thu hẹp visibility (public → public, protected → public OK).
    • Không ném checked exception rộng hơn cha.
    • Return type có thể là subtype (covariant) — Java 5+.
  • final method cấm override; static không tham gia dispatch; private không kế thừa.
  • super.method() gọi phiên bản cha — pattern "làm thêm, vẫn gọi cha".
  • Không gọi method non-final trong constructor — dispatch chọn method con khi con chưa init xong.

11. Tự kiểm tra

Tự kiểm tra
Q1
Đoạn sau in gì?
class Shape {
    public String name() { return "Shape"; }
}
class Circle extends Shape {
    @Override
    public String name() { return "Circle"; }
}

Shape s = new Circle();
Circle c = new Circle();
Shape s2 = new Shape();
System.out.println(s.name() + " / " + c.name() + " / " + s2.name());

In Circle / Circle / Shape.

Cả 3 call đều dispatch theo kiểu thực:

  • s khai báo Shape nhưng kiểu thực CircleCircle.name()"Circle".
  • c khai báo Circle, kiểu thực Circle"Circle".
  • s2 khai báo Shape, kiểu thực ShapeShape.name()"Shape".

Kiểu khai báo (Shape s vs Circle c) chỉ ảnh hưởng: (1) compile-time check — method nào gọi được, (2) overload resolution. Không ảnh hưởng instance method dispatch.

Q2
Vì sao đoạn sau in "name=null"name đã khai báo private String name = "Rex";?
class Animal {
    public Animal() { greet(); }
    public void greet() { System.out.println("hi"); }
}
class Dog extends Animal {
    private String name = "Rex";
    @Override
    public void greet() { System.out.println("name=" + name); }
}

new Dog();

Thứ tự khởi tạo object Dog:

  1. JVM cấp memory, zero-out mọi field → name = null.
  2. Constructor Dog() chạy. Ngầm gọi super()Animal().
  3. Animal() gọi greet(). Dynamic dispatch chọn method theo kiểu thực DogDog.greet().
  4. Dog.greet() đọc name — nhưng name = "Rex" chưa được gán (bước này nằm sau khi super() return). name vẫn là null.
  5. Animal() return. Giờ Java mới chạy field initializer của Dog: name = "Rex".
  6. Body Dog() (không có gì) chạy.

Bài học: không gọi method non-final trong constructor. Nếu bạn kế thừa class framework có constructor gọi method của bạn override, bạn sẽ gặp bug này — rất khó debug vì khác với mental model "object đã init xong khi constructor chạy".

Q3
Đoạn sau compile không?
class A {
    public void run() throws java.io.IOException { }
}
class B extends A {
    @Override
    public void run() throws java.sql.SQLException { }
}

Không compile. JLS §8.4.8.3 yêu cầu override không ném checked exception rộng hơn cha. SQLException không phải subtype của IOException — nó là một exception mới hoàn toàn. Caller có A a; try { a.run(); } catch (IOException e) { ... } sẽ không catch được SQLException khi a thực ra là B.

Fix (chọn 1):

  • Không ném exception: public void run() { ... }.
  • Ném subset: throws java.io.FileNotFoundException (subtype của IOException).
  • Ném cùng kiểu: throws IOException.
  • Ném unchecked exception (subclass RuntimeException) — rule này không áp dụng cho unchecked.
Q4
Covariant return — đoạn sau OK hay lỗi?
class Shape {
    public Shape clone() { return new Shape(); }
}
class Circle extends Shape {
    @Override
    public Circle clone() { return new Circle(); }
}

OK. Từ Java 5+, override được phép return subtype của return type cha — gọi là covariant return. Circle là subtype của Shape → hợp lệ.

Lợi ích: caller Circle c = new Circle(); Circle c2 = c.clone(); không cần cast. Viết chung Shape s = someCircle; Shape cloned = s.clone(); cũng vẫn work — chỉ là bạn mất loại kiểu ở compile time.

Nếu đảo lại — cha return Circle, con return Shapecompile errorShape không phải subtype của Circle, caller kỳ vọng Circle sẽ bị nhận về Shape rộng hơn.

Q5
Java dispatch static method thế nào?
class A {
    public static void greet() { System.out.println("A"); }
}
class B extends A {
    public static void greet() { System.out.println("B"); }
}

A a = new B();
a.greet();

In A.

static method không tham gia dynamic dispatch. Compiler resolve method tại compile time dựa vào kiểu khai báo của reference (A a) — chọn A.greet(). Bytecode là invokestatic, không invokevirtual.

Khi B khai public static void greet(), nó không override — nó hide (ẩn) method của cha. Gọi qua B.greet() hoặc a thực tế là B nhưng khai báo B b, lúc đó mới chọn B.greet().

Bài học:

  • static không có polymorphism — đừng thiết kế như thể có.
  • Tránh gọi static qua instance reference (a.greet()). Gọi qua class (A.greet(), B.greet()) cho rõ.
  • Muốn polymorphism cho "hành vi của class" → dùng instance method + factory, hoặc enum với method override.

Bài tiếp theo: Abstract class — class không tạo được nhưng định hình subtype

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên