Command Palette

Search for a command to run...

[Java Basics] Tính đa hình trong Java: overriding và overloading

Tính đa hình là khả năng một call expression duy nhất chạy ra những đoạn code khác nhau, tuỳ theo object nào đang nằm ở đầu kia của reference. Nó là lý do một vòng lặp duy nhất xử lý được Dog, CatCow mà không nhắc tên class nào, và là phần thưởng cho toàn bộ những gì inheritance đã dựng lên.

Toàn bộ cơ chế nằm gọn trong một câu: static type quyết định cái gì compile được, dynamic type quyết định cái gì chạy. Mọi phần bên dưới chỉ là câu đó áp dụng cho method, field, thành phần static và cast. Mọi dòng output và mọi thông báo lỗi ở đây đều được tạo ra bằng cách compile và chạy code thật trên OpenJDK 21.0.6.

Một call expression, ba method body, ba dòng output khác nhau

Mỗi lời gọi có hai câu hỏi được trả lời, ở hai thời điểm khác nhau, bởi hai type khác nhau — tách được hai câu hỏi đó ra thì không còn gì trong bài này là bất ngờ.

Static type quyết định cái gì compile được, dynamic type quyết định cái gì chạy

Mỗi reference variable trong Java mang hai type. Static type là type viết trong phần khai báo; đó là toàn bộ những gì compiler nhìn thấy về variable. Dynamic type là class của object mà variable thực sự trỏ tới khi chương trình chạy, và nói chung compiler không thể biết trước.

class Animal {
    String speak() {
        return "some generic noise";
    }
}

class Dog extends Animal {
    @Override
    String speak() {
        return "Woof";
    }

    String fetch() {
        return "returns the ball";
    }
}

public class StaticVsDynamic {
    public static void main(String[] args) {
        Animal a = new Dog();

        System.out.println("declared (static) type : Animal");
        System.out.println("actual (dynamic) type  : " + a.getClass().getSimpleName());
        System.out.println("a.speak()              -> " + a.speak());
    }
}
declared (static) type : Animal
actual (dynamic) type  : Dog
a.speak()              -> Woof

Một variable, hai type, hai câu hỏi tách biệt:

Câu hỏiTrả lời lúc nàoDựa vào
Thành phần này có tồn tại không, lời gọi có hợp lệ về type không?compile timestatic type
Method body nào thực sự chạy?run timedynamic type

Dòng thứ nhất giải thích vì sao việc object là một Dog không giúp được gì ở đây:

public class StaticTypeLimits {
    public static void main(String[] args) {
        Animal a = new Dog();
        System.out.println(a.fetch());
    }
}
StaticTypeLimits.java:13: error: cannot find symbol
        System.out.println(a.fetch());
                            ^
  symbol:   method fetch()
  location: variable a of type Animal
1 error

Dog có khai báo fetch(), và object đúng là một Dog. javac vẫn từ chối, và thông báo lỗi nói rõ nó đã tra type nào: location: variable a of type Animal. Dòng thứ hai của bảng giải thích vì sao a.speak() in ra Woof thay vì phiên bản của Animal. Hai dòng đó giải thích mọi kết quả trong phần còn lại của bài.

Method overriding: cùng tên, cùng parameter list, trong subclass

Override là một method trong subclass thay thế method thừa kế từ superclass. Hai khai báo phải trùng tên và trùng danh sách có thứ tự của các parameter type; bốn quy tắc nữa ràng buộc tất cả những phần còn lại.

Signature phải khớp chính xác

javac ghép một override với method ở superclass dựa trên tên và danh sách có thứ tự của các parameter type. Không có gì khác tham gia — tên parameter không, final trên parameter không, return type cũng không. Đổi một parameter type là bạn đã khai báo một method thứ hai tình cờ nằm trong subclass, và đó là nội dung của phần tiếp theo.

Override thay thế body, nhưng body bị thay thế vẫn còn với tới được qua super:

class Animal {
    String speak() { return "some generic noise"; }
}

class Dog extends Animal {
    @Override
    String speak() { return super.speak() + ", then Woof"; }
}

public class SuperCall {
    public static void main(String[] args) {
        Animal a = new Dog();
        System.out.println("a.speak() -> " + a.speak());
    }
}
a.speak() -> some generic noise, then Woof

super.speak() là dạng gọi instance duy nhất không được dispatch động: nó compile thành invokespecial Animal.speak chứ không phải invokevirtual, nên nó chỉ đích danh một implementation cụ thể và không thể quay ngược lại Dog.speak() gây đệ quy.

Return type có thể covariant

Override có thể trả về đúng type cũ, hoặc bất kỳ subtype nào của nó. Đó là covariant return, có từ Java 5, và nó giúp phía gọi đang giữ type của subclass bỏ được một lần cast.

class Animal {
    Animal reproduce() { return new Animal(); }
    public String toString() { return getClass().getSimpleName(); }
}

class Dog extends Animal {
    @Override
    Dog reproduce() { return new Dog(); }   // covariant: Dog is an Animal
}

public class Covariant {
    public static void main(String[] args) {
        Animal a = new Dog();
        Dog d = new Dog();

        Animal child1 = a.reproduce();   // static type Animal
        Dog    child2 = d.reproduce();   // static type Dog, no cast needed

        System.out.println("a.reproduce() -> " + child1);
        System.out.println("d.reproduce() -> " + child2);
    }
}
a.reproduce() -> Dog
d.reproduce() -> Dog

Cả hai lời gọi đều chạy Dog.reproduce() và đều tạo ra một Dog. Chỉ static type của biểu thức là khác nhau, và đó là lý do child2 khai báo được là Dog còn child1 thì không. Một return type không phải subtype sẽ bị từ chối, và khi có @Override thì compiler báo hai lần — một lần vì type không tương thích, một lần vì lời hứa của annotation đã hỏng:

class Dog extends Animal {
    @Override
    int speak() { return 42; }
}
BadReturn.java:7: error: speak() in Dog cannot override speak() in Animal
    int speak() { return 42; }
        ^
  return type int is not compatible with String
BadReturn.java:6: error: method does not override or implement a method from a supertype
    @Override
    ^
2 errors

Access level không được thu hẹp

Override được phép mở rộng access — từ protected lên public là hợp lệ — nhưng không bao giờ được thu hẹp. Nếu được phép, chỉ cần upcast lên superclass là phía gọi với tới được method mà tác giả subclass đang cố giấu.

class Animal {
    public String speak() { return "some generic noise"; }
}

class Dog extends Animal {
    @Override
    protected String speak() { return "Woof"; }
}
NarrowAccess.java:7: error: speak() in Dog cannot override speak() in Animal
    protected String speak() { return "Woof"; }
                     ^
  attempting to assign weaker access privileges; was public
1 error

Không được ném checked exception rộng hơn

Override được phép ném ít checked exception hơn, hoặc subclass của những exception đã khai báo, hoặc không ném gì cả. Nó không được thêm một checked exception mà phía gọi chưa từng được yêu cầu xử lý.

import java.io.IOException;

class Animal {
    void feed() throws IllegalStateException { }
}

class Dog extends Animal {
    @Override
    void feed() throws IOException { }
}
WiderException.java:9: error: feed() in Dog cannot override feed() in Animal
    void feed() throws IOException { }
         ^
  overridden method does not throw IOException
1 error

Unchecked exception thì nằm ngoài quy tắc này hoàn toàn — RuntimeException không cần khai báo nên cũng không có gì ràng buộc. Cả ba nới lỏng hợp lệ có thể đứng chung một chỗ:

import java.io.FileNotFoundException;
import java.io.IOException;

class Animal {
    protected Animal reproduce() throws IOException { return new Animal(); }
    @Override public String toString() { return getClass().getSimpleName(); }
}

class Dog extends Animal {
    @Override
    public Dog reproduce() throws FileNotFoundException {   // wider access, covariant return,
        return new Dog();                                   // narrower checked exception
    }
}

class Cat extends Animal {
    @Override
    public Cat reproduce() {                                // no checked exception at all
        throw new IllegalStateException("unchecked is always allowed");
    }
}
new Dog().reproduce() -> Dog
Cat.reproduce() threw IllegalStateException: unchecked is always allowed

Bốn quy tắc gói trong một bảng:

Phần khai báoOverride được phép làm gì
Tên và các parameter typephải khớp chính xác — khác đi là một method mới
Return typeđúng type cũ, hoặc subtype của nó (covariant)
Access modifierbằng, hoặc rộng hơn; không bao giờ hẹp hơn
Checked exceptionbằng, ít hơn, subtype của chúng, hoặc không có

@Override biến một lỗi âm thầm thành compile error

@Override không bắt buộc. Nó không sinh ra code và không đổi hành vi. Việc nó làm là yêu cầu compiler kiểm chứng rằng method thực sự override một cái gì đó, và điều đó biến bug phổ biến nhất ở khu vực này từ một bất ngờ lúc chạy thành một lỗi lúc build.

Không có nó, một lỗi gõ nhầm và một parameter type sai đều compile sạch sẽ:

class Animal {
    String speak()             { return "some generic noise"; }
    String greet(String name)  { return "generic hello, " + name; }
}

class Dog extends Animal {
    String Speak()             { return "Woof"; }          // typo: capital S
    String greet(Object name)  { return "Woof, " + name; } // wrong parameter type
}

public class SilentOverload {
    public static void main(String[] args) {
        Animal a = new Dog();
        System.out.println("a.speak()               -> " + a.speak());
        System.out.println("a.greet(\"Rex\")          -> " + a.greet("Rex"));

        Dog d = new Dog();
        System.out.println("d.greet(\"Rex\")          -> " + d.greet("Rex"));
        System.out.println("d.greet((Object) \"Rex\") -> " + d.greet((Object) "Rex"));
    }
}
a.speak()               -> some generic noise
a.greet("Rex")          -> generic hello, Rex
d.greet("Rex")          -> generic hello, Rex
d.greet((Object) "Rex") -> Woof, Rex

Không có gì hỏng, và cũng không có gì tác giả muốn xảy ra. Speak() chỉ là một method không liên quan mà chẳng ai gọi. greet(Object) còn tệ hơn: nó là một overload hợp lệ của greet(String) thừa kế được, nên d.greet("Rex") vẫn chọn phiên bản của superclass — argument kiểu String khớp chính xác với greet(String) — và body trong subclass chỉ chạy khi static type của argument được nới lên Object. Hai method cùng tên bây giờ mang hai ý nghĩa khác nhau trên cùng một object.

Thêm annotation vào là cả hai lỗi trở thành lỗi build:

class Dog extends Animal {
    @Override String Speak()            { return "Woof"; }
    @Override String greet(Object name) { return "Woof, " + name; }
}
OverrideCatches.java:7: error: method does not override or implement a method from a supertype
    @Override String Speak()            { return "Woof"; }
    ^
OverrideCatches.java:8: error: method does not override or implement a method from a supertype
    @Override String greet(Object name) { return "Woof, " + name; }
    ^
2 errors

⚠️ Hãy đặt @Override lên mọi method bạn định viết như một override. Đó là biện pháp bảo vệ máy móc duy nhất chống lại việc subclass âm thầm ngừng override khi ai đó đổi một parameter type ở superclass.

Dynamic dispatch: một call site, ba method body

Nửa compile time được kiểm tra theo static type và sinh ra một invokevirtual; nửa run time chọn body theo dynamic type

Dynamic dispatch là cơ chế nằm sau dòng thứ hai của bảng ở đầu bài: khi một reference có static type là superclass đang giữ object của subclass, lời gọi sẽ chạy method của subclass. Cách chứng minh là đặt ba subclass khác nhau vào một array toàn slot mang type của superclass rồi gọi tất cả trong một vòng lặp duy nhất.

class Animal {
    private final String name;

    Animal(String name) { this.name = name; }

    String name()  { return name; }
    String speak() { return "some generic noise"; }
}

class Dog extends Animal {
    Dog(String name) { super(name); }
    @Override String speak() { return "Woof"; }
}

class Cat extends Animal {
    Cat(String name) { super(name); }
    @Override String speak() { return "Meow"; }
}

class Cow extends Animal {
    Cow(String name) { super(name); }
    @Override String speak() { return "Moo"; }
}

public class Dispatch {
    public static void main(String[] args) {
        Animal[] pen = { new Dog("Rex"), new Cat("Mia"), new Cow("Bella") };

        for (Animal a : pen) {                       // static type: Animal
            System.out.println(a.name()
                    + "  dynamic type = " + a.getClass().getSimpleName()
                    + "  a.speak() -> " + a.speak());
        }
    }
}
Rex  dynamic type = Dog  a.speak() -> Woof
Mia  dynamic type = Cat  a.speak() -> Meow
Bella  dynamic type = Cow  a.speak() -> Moo

Trong source chỉ có đúng một a.speak(), và biến a được khai báo là Animal ở mọi vòng lặp. Ba body khác nhau đã chạy. Vòng lặp không có if, không có kiểm tra type, không có cast, và không nhắc tên ba subclass nào — thứ duy nhất thay đổi giữa các vòng là object mà reference đang trỏ tới.

Đây chính là lý do người ta sắp xếp class thành hierarchy. Code viết theo Animal vẫn chạy đúng với những subclass chưa tồn tại lúc nó được viết.

Bytecode nói gì

Khẳng định "đích được chọn lúc run time" là thứ kiểm chứng được chứ không phải chuyện phải tin. Hãy compile một method có parameter khai báo là Animal rồi disassemble nó.

class Animal {
    String speak() { return "some generic noise"; }
}

class Dog extends Animal {
    @Override String speak() { return "Woof"; }
}

public class DispatchBytecode {
    static String announce(Animal a) {   // parameter static type: Animal
        return a.speak();
    }

    public static void main(String[] args) {
        System.out.println(announce(new Dog()));
        System.out.println(announce(new Animal()));
    }
}
Woof
some generic noise

javap -c DispatchBytecode.class in ra các instruction của announce:

  static java.lang.String announce(Animal);
    Code:
       0: aload_0
       1: invokevirtual #7                  // Method Animal.speak:()Ljava/lang/String;
       4: areturn

Ba sự thật hiện ra ở đó, mỗi cái xác nhận một phần của mô hình:

  • Chỉ có một instruction cho lời gọi, invokevirtual, không phải một nhánh rẽ hay một bảng lựa chọn. Compiler không sinh ra logic quyết định nào cả.
  • Tham chiếu constant pool của nó ghi Animal.speak, tức static type của parameter. Dog không xuất hiện ở bất kỳ đâu trong method này, dù Dog.speak() mới là thứ đã chạy ở lời gọi đầu tiên.
  • Vì cùng một instruction đó tạo ra Woof rồi some generic noise, đích không thể được chốt lúc compile. Đặc tả của invokevirtual là tra method trên class của object đang nằm trên stack, và JVM làm việc tra cứu đó ở mỗi lần thực thi.

Lần tra cứu cuối cùng đó chính là ý nghĩa của chữ "virtual", và là lý do một reference kiểu superclass với tới được body của subclass.

Overriding và overloading khác nhau ở đâu

Overriding giải quyết lúc run time theo dynamic type, còn overloading giải quyết lúc compile time theo static type, trên cùng một object

Hai từ này mô tả hai cơ chế không liên quan gì nhau, chỉ tình cờ cùng chia sẻ ý tưởng một cái tên phục vụ nhiều body.

OverridingOverloading
Signaturecùng tên, cùng parameter listcùng tên, khác parameter list
Các method nằm ở đâusuperclass và subclassở đâu cũng được, thường là một class
Có cần inheritance khôngkhông
Giải quyết lúcrun timecompile time
Dựa vàodynamic type của receiverstatic type của các argument
Return typephải bằng hoặc covariantkhông được xét tới
Có kiểm chứng được không@Overridekhông có gì tương đương

Các quy tắc chọn giữa nhiều overload — bốn phase, widening trước boxing, cái cụ thể nhất thắng — đã được trình bày đầy đủ trong bài về method parameter, return value và overloading, và không có quy tắc nào thay đổi khi có inheritance. Ở đây chỉ cần đúng sự thật nền tảng bên dưới chúng: overload do javac chọn dựa trên type khai báo của các argument, và lựa chọn đó được ghi cứng vào class file.

Một object, hai câu trả lời

Đặt cả hai cơ chế lên cùng một object thì khác biệt không còn là lý thuyết nữa.

class Animal {
    String speak() { return "some generic noise"; }
}

class Dog extends Animal {
    @Override String speak() { return "Woof"; }
}

public class OverrideVsOverload {
    // OVERLOADS: two methods, different parameter lists, one class
    static String feed(Animal a) { return "feed(Animal) ran"; }
    static String feed(Dog d)    { return "feed(Dog) ran"; }

    public static void main(String[] args) {
        Dog d = new Dog();
        Animal a = d;                       // ONE object, two static types

        System.out.println("a == d      -> " + (a == d));
        System.out.println("a.speak()   -> " + a.speak());   // override
        System.out.println("d.speak()   -> " + d.speak());   // override
        System.out.println("feed(a)     -> " + feed(a));     // overload
        System.out.println("feed(d)     -> " + feed(d));     // overload
    }
}
a == d      -> true
a.speak()   -> Woof
d.speak()   -> Woof
feed(a)     -> feed(Animal) ran
feed(d)     -> feed(Dog) ran

a == dtrue, nghĩa là trên heap chỉ có đúng một object. Bảo nó nói qua biến nào cũng được, Dog.speak() chạy cả hai lần, vì override đi theo object. Truyền chính object đó vào feed qua biến nào thì hai method khác nhau chạy, vì overload đi theo khai báo. Reference a là nửa gây bất ngờ: a.speak() với tới body của Dog trong khi feed(a) với tới overload của Animal, trên cùng một object, trong cùng một khối lệnh.

Quy tắc rút ra: muốn hành vi riêng của subclass thì override; còn nếu đã overload trên cả superclass lẫn subclass, hãy chắc chắn mọi call site khai báo đúng type mà bạn định dùng.

Cùng một bug trong code thật: equals(Dog)

Dạng đắt giá nhất của lỗi này là viết equals với parameter type sai. Object.equals nhận một Object, nên phiên bản nhận thứ gì hẹp hơn chỉ là một overload, và JDK không bao giờ gọi tới nó.

import java.util.List;

class Point {
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    // OVERLOAD of Object.equals(Object), not an override
    public boolean equals(Point p) {
        return p != null && x == p.x && y == p.y;
    }
}

public class EqualsTrap {
    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);
        Object o = b;

        System.out.println("a.equals(b)              -> " + a.equals(b));
        System.out.println("a.equals(o)              -> " + a.equals(o));
        System.out.println("List.of(a).contains(b)   -> " + List.of(a).contains(b));
    }
}
a.equals(b)              -> true
a.equals(o)              -> false
List.of(a).contains(b)   -> false

Class này compile không một cảnh báo và chạy đúng trong đúng cái test mà tác giả tự tay viết. Mọi collection trong JDK đều giữ phần tử dưới dạng Object, gọi equals(Object), và nhận về phép so sánh identity thừa kế từ Object. Chỉ cần @Override trên method đó là build đã hỏng ngay lập tức.

Field không có tính đa hình

Truy cập field được giải quyết hoàn toàn theo static type. Một field ở subclass trùng tên với field ở superclass không thay thế field kia — cả hai cùng tồn tại trên một object, và type khai báo của biểu thức quyết định cái nào được đọc.

class Animal {
    String kind = "animal";
    String speak() { return "some generic noise"; }
    String kindFromAnimal() { return kind; }
}

class Dog extends Animal {
    String kind = "dog";                 // HIDES Animal.kind, does not replace it
    @Override String speak() { return "Woof"; }
    String kindFromDog() { return kind; }
}

public class FieldHiding {
    public static void main(String[] args) {
        Dog d = new Dog();
        Animal a = d;                    // one object, two static types

        System.out.println("a == d              -> " + (a == d));
        System.out.println("a.speak()           -> " + a.speak());
        System.out.println("d.speak()           -> " + d.speak());
        System.out.println("a.kind              -> " + a.kind);
        System.out.println("d.kind              -> " + d.kind);
        System.out.println("((Animal) d).kind   -> " + ((Animal) d).kind);
        System.out.println("d.kindFromAnimal()  -> " + d.kindFromAnimal());
        System.out.println("d.kindFromDog()     -> " + d.kindFromDog());
    }
}
a == d              -> true
a.speak()           -> Woof
d.speak()           -> Woof
a.kind              -> animal
d.kind              -> dog
((Animal) d).kind   -> animal
d.kindFromAnimal()  -> animal
d.kindFromDog()     -> dog

Hãy đọc dòng thứ tư và thứ năm đối chiếu với dòng thứ hai và thứ ba. Một object; lời gọi method cho ra Woof qua cả hai biến, còn field cho ra hai chuỗi khác nhau cũng qua đúng hai biến đó. Bản thân chuyện field hiding — việc subclass được phép khai báo lại tên field thừa kế, và khi đó hai ô nhớ cùng tồn tại trên một object — đã được nói trong bài về inheritance. Ở đây điều quan trọng là quy tắc giải quyết: một lần cast đổi được câu trả lời cho field và không đổi được câu trả lời cho method, còn d.kindFromAnimal() trả về animal vì code bên trong Animal được compile theo Animal.kind.

Bytecode cho thấy sự bất đối xứng đó rất rõ. Hai method dưới đây chỉ khác nhau ở type khai báo của parameter, và cùng được gọi với một Dog. category() là method static, đúng chủ đề của phần tiếp theo.

class Animal {
    String kind = "animal";
    String speak() { return "some generic noise"; }
    static String category() { return "Animal.category()"; }
}

class Dog extends Animal {
    String kind = "dog";
    @Override String speak() { return "Woof"; }
    static String category() { return "Dog.category()"; }
}

public class FieldBytecode {
    static String viaAnimal(Animal a) { return a.kind + " " + a.speak() + " " + a.category(); }
    static String viaDog(Dog d)       { return d.kind + " " + d.speak() + " " + d.category(); }

    public static void main(String[] args) {
        Dog d = new Dog();
        System.out.println("viaAnimal(d) -> " + viaAnimal(d));
        System.out.println("viaDog(d)    -> " + viaDog(d));
    }
}
viaAnimal(d) -> animal Woof Animal.category()
viaDog(d)    -> dog Woof Dog.category()
  static java.lang.String viaAnimal(Animal);
       1: getfield      #7                  // Field Animal.kind:Ljava/lang/String;
       5: invokevirtual #13                 // Method Animal.speak:()Ljava/lang/String;
      10: invokestatic  #17                 // Method Animal.category:()Ljava/lang/String;

  static java.lang.String viaDog(Dog);
       1: getfield      #24                 // Field Dog.kind:Ljava/lang/String;
       5: invokevirtual #27                 // Method Dog.speak:()Ljava/lang/String;
      10: invokestatic  #28                 // Method Dog.category:()Ljava/lang/String;

getfieldinvokestatic ghi tên thành phần khác nhau ở mỗi phiên bản, và đúng thành phần đó chạy. invokevirtual cũng ghi tên khác nhau, nhưng JVM bỏ qua cái tên đó khi chọn body, nên speak() in ra Woof ở cả hai dòng. Hide một field gần như luôn là sai lầm: hãy đổi tên field trong subclass.

Static method bị hide chứ không bị override

Method static thuộc về class chứ không thuộc về object, nên không có gì để dispatch. Khai báo một method như vậy trong subclass với cùng signature sẽ hide phiên bản của superclass, và mọi lời gọi đều được giải quyết từ một type đã biết lúc compile.

class Animal {
    static String category() { return "Animal.category()"; }
    String        speak()    { return "some generic noise"; }
}

class Dog extends Animal {
    static String category() { return "Dog.category()"; }   // HIDES, does not override
    @Override String speak() { return "Woof"; }
}

public class StaticHiding {
    public static void main(String[] args) {
        Animal a = new Dog();

        System.out.println("Animal.category()  -> " + Animal.category());
        System.out.println("Dog.category()     -> " + Dog.category());
        System.out.println("a.category()       -> " + a.category());
        System.out.println("a.speak()          -> " + a.speak());
    }
}
Animal.category()  -> Animal.category()
Dog.category()     -> Dog.category()
a.category()       -> Animal.category()
a.speak()          -> Woof

Dòng thứ ba mới đáng nhìn kỹ. a trỏ tới một Dog, vậy mà a.category() chạy Animal.category(). Dòng thứ tư, trên cùng object đó trong cùng lần chạy đó, chạy Dog.speak(). Gọi method static qua một reference là hợp lệ nhưng gây hiểu nhầm, và javac nói thẳng điều đó với -Xlint:static:

StaticHiding.java:17: warning: [static] static method should be qualified by type name, Animal, instead of by an expression
        System.out.println("a.category()       -> " + a.category());
                                                       ^
1 warning

Cảnh báo còn nêu luôn type mà nó đã dùng. Viết Animal.category() hoặc Dog.category() là hết mơ hồ. @Override cũng không cứu được, vì làm gì có override nào để đánh dấu:

OverrideStatic.java:6: error: static methods cannot be annotated with @Override
    @Override
    ^
1 error

Upcasting, downcasting và guard bằng instanceof

Upcast là ngầm định và luôn an toàn, downcast là cast tường minh được kiểm tra lúc run time, cùng với guard bằng instanceof pattern

Upcasting là gán một reference của subclass vào biến kiểu superclass. Nó không cần toán tử cast, không cần kiểm tra lúc chạy, và không bao giờ hỏng được, vì mọi Dog đều là Animal ngay từ cách nó được tạo ra. Downcasting đi theo chiều ngược lại, và là một lời khẳng định về object mà JVM sẽ kiểm chứng ngay khi cast thực thi.

class Animal {
    @Override public String toString() { return getClass().getSimpleName(); }
    String speak() { return "some generic noise"; }
}

class Dog extends Animal {
    @Override String speak() { return "Woof"; }
    String fetch() { return "returns the ball"; }
}

class Cat extends Animal {
    @Override String speak() { return "Meow"; }
}

public class Casting {
    public static void main(String[] args) {
        Animal a = new Dog();                 // upcast: implicit, always safe
        System.out.println("upcast    Animal a = new Dog()   -> a.speak() = " + a.speak());

        Dog d = (Dog) a;                      // downcast: explicit, checked at run time
        System.out.println("downcast  (Dog) a                -> d.fetch() = " + d.fetch());

        Animal c = new Cat();
        System.out.println("c instanceof Dog                 -> " + (c instanceof Dog));

        for (Animal x : new Animal[] { new Dog(), new Cat() }) {
            if (x instanceof Dog dog) {       // Java 16+: binds dog only when the test passes
                System.out.println("pattern   " + x + " -> " + dog.fetch());
            } else {
                System.out.println("pattern   " + x + " -> no fetch, skipped");
            }
        }

        Dog boom = (Dog) c;                   // c really is a Cat
        System.out.println("never reached: " + boom);
    }
}
upcast    Animal a = new Dog()   -> a.speak() = Woof
downcast  (Dog) a                -> d.fetch() = returns the ball
c instanceof Dog                 -> false
pattern   Dog -> returns the ball
pattern   Cat -> no fetch, skipped
Exception in thread "main" java.lang.ClassCastException: class Cat cannot be cast to class Dog (Cat and Dog are in unnamed module of loader 'app')
	at Casting.main(Casting.java:34)

Downcast thành công không phải là một phép chuyển đổi. Object không thay đổi gì cả; cast chỉ nới rộng những gì compiler cho phép bạn hỏi, và đó là cách d.fetch() trở nên hợp lệ. Trường hợp thất bại cho thấy cast thật sự là gì: một lần kiểm tra type lúc chạy, ném ClassCastException khi lời khẳng định sai.

instanceof chính là guard. Từ Java 16 nó có dạng pattern vừa kiểm tra vừa bind trong một bước, nhờ vậy cast biến mất khỏi source:

if (x instanceof Dog dog) {
    System.out.println(dog.fetch());
}

dog chỉ nằm trong scope ở nhánh mà phép kiểm tra thành công, và như vậy tốt hơn hẳn việc khai báo một biến rồi cast vào — không còn cách nào dùng biến đó ở nhánh chưa được kiểm tra type. instanceof cũng trả về false cho null, nên guard này không cần kiểm tra null riêng. Cast giữa hai type không hề có quan hệ subtyping thì thậm chí không phải chuyện của run time; compiler từ chối luôn:

BadCast.java:6: error: incompatible types: Animal cannot be converted to String
        String s = (String) a;
                            ^
1 error

getClass() hay instanceof?

instanceof hỏi object có gán được vào type đó không, nên nó trả về true cho cả subclass; còn getClass() trả về đúng class thật lúc chạy, nên a.getClass() == Dog.classtrue với một Dogfalse với mọi subclass của Dog. Hầu như lúc nào cũng nên dùng instanceof, vì code chạy được với một type thì cũng nên chạy được với các subtype của nó — đó chính là lý do tồn tại của hierarchy. Chỉ dùng getClass() khi thật sự cần danh tính chính xác, trường hợp kinh điển là equals, nơi chấp nhận subclass ở một phía mà không chấp nhận ở phía kia sẽ phá vỡ tính đối xứng.

Đa hình như một công cụ thiết kế

Giá trị thực tế của tất cả những điều trên là một vòng lặp không biết nó đang lặp qua cái gì, và không cần sửa khi có type mới xuất hiện.

class Shape {
    double area() { return 0.0; }
    String describe() {
        return String.format("%-10s area = %6.2f", getClass().getSimpleName(), area());
    }
}

class Circle extends Shape {
    private final double r;
    Circle(double r) { this.r = r; }
    @Override double area() { return Math.PI * r * r; }
}

class Rectangle extends Shape {
    private final double w, h;
    Rectangle(double w, double h) { this.w = w; this.h = h; }
    @Override double area() { return w * h; }
}

public class Shapes {
    static double totalArea(Shape[] shapes) {
        double total = 0;
        for (Shape s : shapes) {
            total += s.area();          // ONE call site, every subclass
        }
        return total;
    }

    public static void main(String[] args) {
        Shape[] shapes = { new Circle(2), new Rectangle(3, 4) };
        for (Shape s : shapes) {
            System.out.println(s.describe());
        }
        System.out.printf("%-10s total = %6.2f%n", "", totalArea(shapes));
    }
}
Circle     area =  12.57
Rectangle  area =  12.00
           total =  24.57

describe() chỉ viết một lần trong Shape và gọi area(), cái được override. totalArea chứa đúng một s.area(). Cả hai đều không nhắc tên Circle hay Rectangle. Bây giờ thêm một type:

class Triangle extends Shape {
    private final double base, height;
    Triangle(double base, double height) { this.base = base; this.height = height; }
    @Override double area() { return base * height / 2; }
}

Thêm new Triangle(6, 5) vào array rồi compile lại. totalArea, describe() và vòng lặp trong main không bị đụng tới:

Circle     area =  12.57
Rectangle  area =  12.00
Triangle   area =  15.00
           total =  39.57

Hãy so với phương án còn lại — một chuỗi if (s instanceof Circle) ... else if (s instanceof Rectangle) ... — thứ phải đi tìm và sửa ở mọi nơi từng viết, cho mọi type mới, mãi mãi. Một chuỗi instanceof dài trên một hierarchy thường là dấu hiệu của một override còn thiếu.

Có một điểm yếu lộ ra ngay trong code trên: Shape.area() trả về 0.0, một giá trị vô nghĩa với mọi hình thật và là thứ mà tác giả subclass có thể quên thay thế mà không ai biết. Java có một cấu trúc xoá bỏ cả hai vấn đề đó bằng cách khai báo method không có body và từ chối compile subclass nào không cài đặt nó. Đó là bài tiếp theo.

Đừng gọi method có thể override từ constructor

Quá trình tạo object chạy constructor của superclass trước, trước mọi field initializer và mọi thân constructor của subclass. Nhưng dynamic dispatch thì đã hoạt động đầy đủ. Nếu constructor của superclass gọi một method có thể override, override của subclass sẽ chạy trên một object mới xây được một nửa.

import java.util.ArrayList;
import java.util.List;

class Animal {
    Animal() {
        System.out.println("Animal() starts");
        System.out.println("  describe() -> " + describe());   // overridable call
        System.out.println("Animal() ends");
    }

    String describe() { return "an animal"; }
}

class Dog extends Animal {
    private final String name;
    private int legs = 4;
    private final List<String> tricks = new ArrayList<>();

    Dog(String name) {
        // super() runs here, implicitly, BEFORE anything below
        this.name = name;
        System.out.println("Dog(String) ends");
    }

    @Override
    String describe() {
        return "a dog named " + name + " with " + legs + " legs, tricks = " + tricks;
    }
}

public class ConstructorTrap {
    public static void main(String[] args) {
        Dog d = new Dog("Rex");
        System.out.println();
        System.out.println("after construction: " + d.describe());
    }
}
Animal() starts
  describe() -> a dog named null with 0 legs, tricks = null
Animal() ends
Dog(String) ends

after construction: a dog named Rex with 4 legs, tricks = []

Cùng một method, trên cùng một object, lần đầu trả về thứ vô nghĩa rồi lần sau mới trả về sự thật. Trong lúc constructor của superclass chạy, namenull, legs0tricksnull, vì bộ nhớ của object đã được xoá về 0 và chưa initializer nào của Dog chạy cả — kể cả = 4= new ArrayList<>(). javap -c trên Dog cho thấy lý do: lời gọi super() là thứ đầu tiên trong constructor, và mọi lệnh ghi field đều được compile vào sau nó (các instruction sau offset 22 là phần println).

  Dog(java.lang.String);
    Code:
       0: aload_0
       1: invokespecial #1                  // Method Animal."<init>":()V
       4: aload_0
       5: iconst_4
       6: putfield      #7                  // Field legs:I
       9: aload_0
      10: new           #13                 // class java/util/ArrayList
      13: dup
      14: invokespecial #15                 // Method java/util/ArrayList."<init>":()V
      17: putfield      #16                 // Field tricks:Ljava/util/List;
      20: aload_0
      21: aload_1
      22: putfield      #20                 // Field name:Ljava/lang/String;

Chú ý rằng final không bảo vệ được gì: nametricks đều final và đều bị nhìn thấy khi còn rỗng.

Khi override đụng vào một trong những field đó thay vì chỉ in ra, kết quả là một NullPointerException ném ra từ constructor:

class Animal {
    Animal() { System.out.println("trick count: " + trickCount()); }
    int trickCount() { return 0; }
}

class Dog extends Animal {
    private final List<String> tricks = new ArrayList<>();
    Dog() { tricks.add("sit"); }
    @Override int trickCount() { return tricks.size(); }
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.List.size()" because "this.tricks" is null
	at Dog.trickCount(ConstructorNPE.java:12)
	at Animal.<init>(ConstructorNPE.java:5)
	at Dog.<init>(ConstructorNPE.java:11)
	at ConstructorNPE.main(ConstructorNPE.java:17)

Stack trace là bằng chứng: Dog.trickCount được gọi từ Animal.<init>, và Animal.<init> được gọi từ Dog.<init>. Một method của subclass đã chạy trước thân constructor của chính subclass đó.

Cách sửa là một quy tắc chứ không phải mẹo vặt: constructor chỉ được gọi những method không thể bị overrideprivate, static, hoặc final. Nếu subclass thật sự cần đóng góp vào quá trình khởi tạo, hãy nhận giá trị qua parameter của constructor và truyền lên bằng super(...), hoặc làm phần việc đó trong một factory method sau khi object đã dựng xong hoàn toàn.

Những lỗi thường gặp và thông báo lỗi thật

Có thêm hai kiểu khai báo từ chối bị override. Method final là cánh cửa đóng lại có chủ đích, và bài về inheritance đã giải thích lý do; thông báo ở đây là overridden method is final.

Method private thì không hề được thừa kế, nên một method cùng tên trong subclass đơn giản là một method mới — và không có @Override thì không có thông báo nào cả:

class Animal {
    private String secret() { return "animal secret"; }
    String reveal()         { return secret(); }
}

class Dog extends Animal {
    private String secret() { return "dog secret"; }   // a brand new method, not an override
}
a.reveal() -> animal secret

Danh sách đầy đủ:

LỗiChuyện gì xảy raThông báo
Đổi một parameter type trong overridecompile được, thành overload, body của superclass chạykhông có nếu thiếu @Override
Gõ sai tên methodcompile được, method mới không bao giờ được gọikhông có nếu thiếu @Override
@Override trên method không override cái gìcompile errormethod does not override or implement a method from a supertype
Thu hẹp access trong overridecompile errorattempting to assign weaker access privileges; was public
Return type không phải subtypecompile errorreturn type int is not compatible with String
Thêm checked exception rộng hơncompile erroroverridden method does not throw IOException
Override một method finalcompile erroroverridden method is final
@Override trên method staticcompile errorstatic methods cannot be annotated with @Override
Khai báo lại method private trong subclasscompile được, không có override nào xảy rakhông có
Downcast sang sai classexception lúc chạyClassCastException: class Cat cannot be cast to class Dog
Cast giữa hai type không liên quancompile errorincompatible types: Animal cannot be converted to String
Tưởng field bị hide có tính đa hìnhcompile được, đọc sai giá trịkhông có
Tưởng static bị hide có tính đa hìnhcompile được, chạy sai methodchỉ có warning -Xlint:static
Viết equals(MyType) thay vì equals(Object)compile được, collection hoạt động saikhông có nếu thiếu @Override
Gọi method có thể override từ constructorcompile được, field bị nhìn thấy là null hoặc 0không có, hoặc một NullPointerException

Nửa nguy hiểm của bảng đó là những dòng có cột thông báo ghi "không có". Mỗi dòng như vậy đều là một trường hợp static type âm thầm trả lời câu hỏi mà bạn tưởng dynamic type sẽ trả lời, và mọi dòng liên quan tới method đều bị @Override bắt được.

FAQ

Overriding và overloading trong Java khác nhau thế nào?

Overriding thay thế một method của superclass bằng method có cùng signature trong subclass, và JVM chọn body lúc run time dựa trên class thật của object. Overloading khai báo nhiều method cùng tên với khác parameter list, và javac chọn một cái lúc compile dựa trên type khai báo của các argument. Overriding cần inheritance; overloading thì không. @Override kiểm chứng được cái thứ nhất và không có gì tương đương cho cái thứ hai.

Vì sao truyền subclass vào mà method chạy lại sai?

Vì method đó là overload chứ không phải override. feed(Animal)feed(Dog) là hai method khác nhau, và compiler chọn giữa chúng bằng type khai báo của biểu thức argument, không phải bằng class của object. Gán một Dog vào biến Animal rồi gọi feed(a) sẽ chọn feed(Animal)a đang trỏ tới một Dog. Muốn để object quyết định thì hành vi phải nằm trong một method được override trên hierarchy, chứ không nằm trong một bộ overload bên ngoài nó.

Có override được static method trong Java không?

Không. Subclass được phép khai báo một method static cùng signature, việc đó hide phiên bản của superclass, và cả hai đều được giải quyết từ một type biết trước lúc compile — nên một reference kiểu superclass sẽ với tới phiên bản của superclass ngay cả khi nó trỏ tới instance của subclass. Đặt @Override lên method static là compile error: static methods cannot be annotated with @Override. Hãy luôn gọi thành phần static qua tên class.

Vì sao field của tôi lại ra giá trị của superclass?

Vì field được giải quyết theo static type chứ không theo dynamic type. Nếu subclass khai báo một field trùng tên với field ở superclass, cả hai ô nhớ cùng tồn tại trên object, và ((Animal) d).kind đọc ô của Animal còn d.kind đọc ô của Dog. Code bên trong Animal cũng đọc ô của Animal, bất kể object hoá ra là gì. Không có cách nào làm cho field có tính đa hình; hãy bọc nó bằng một getter có thể override, hoặc đừng dùng lại tên đó.

@Override có bắt buộc trong Java không?

Không, và nó cũng không sinh ra bytecode nào. Nhưng vẫn nên viết nó trên mọi override, vì nó bắt compiler kiểm chứng lời khẳng định của bạn. Thiếu nó, một lần đổi tên hoặc đổi parameter type sẽ biến một override đang chạy tốt thành một overload âm thầm, còn chương trình thì vẫn compile và vẫn chạy với hành vi của superclass. Cũng chính annotation đó bắt được lỗi viết equals(MyType) thay cho equals(Object).

Vì sao constructor không nên gọi method có thể bị override?

Vì constructor của superclass chạy trước mọi field initializer của subclass, trong khi dispatch thì đã động rồi. Override của subclass do đó thực thi trên một object mà các field vẫn còn giá trị mặc định, kể cả field finalnull với reference, 0 với số — dẫn tới kết quả sai hoặc một NullPointerException ném ra từ bên trong constructor. Trong lúc khởi tạo chỉ nên gọi method private, static hoặc final.

Kết luận

Một câu duy nhất gánh cả chủ đề này: static type quyết định cái gì compile được, dynamic type quyết định cái gì chạy. Method là thành phần duy nhất đi theo object — một override với signature khớp, return type bằng hoặc covariant, access không bị thu hẹp và không có checked exception rộng hơn, tất cả đi qua một invokevirtual duy nhất mà JVM tra đích ở mỗi lần thực thi. Field, method static và việc chọn overload đều đi theo khai báo, và đó là lý do một field bị hide, một static bị hide và một overload chọn theo parameter kiểu superclass đều cho câu trả lời mâu thuẫn với lời gọi method nằm ngay bên cạnh.

Các quy tắc thực hành thì ngắn. Viết @Override trên mọi override. Đừng bao giờ hide một field hay một method static. Upcast thoải mái, downcast thì đặt sau một instanceof pattern, và ưu tiên một method được override hơn là một chuỗi kiểm tra type. Đừng gọi method có thể override từ constructor.

Điểm yếu duy nhất còn lại là Shape.area() trả về 0.0 — một body tồn tại chỉ vì ngôn ngữ đòi phải có, và là thứ subclass có thể quên thay thế. Bài tiếp theo trong series: abstract class và abstract method — khai báo method không có body, buộc mọi subclass phải cài đặt, và khác biệt giữa một class có thể extend với một class có thể tạo instance.

Bài viết liên quan

[Java Basics] Tính kế thừa trong Java: extends và super

extends hoạt động thế nào, subclass kế thừa gì và không kế thừa gì, vì sao constructor không bao giờ được kế thừa, super(...) xâu chuỗi constructor lên tới java.lang.Object rồi chạy ngược xuống ra sao, field hiding khác overriding chỗ nào, protected qua package, final class, vấn đề fragile base class, và khi nào composition là lựa chọn đúng.

[Java Basics] Vòng lặp trong Java: for, while và do-while

Vòng lặp trong Java giải thích bằng cách chạy thật: thứ tự thực thi chính xác của header for, while so với do-while, enhanced for và lý do nó không ghi ngược vào array, lỗi off-by-one với length, và ba cách tạo ra vòng lặp vô hạn.

[Java Basics] Tính đóng gói trong Java: access modifier, getter và setter

Tính đóng gói (encapsulation) trong Java qua code chạy thật: bốn access modifier demo trên hai package thật, quy tắc protected mà phần lớn tài liệu nói sai, getter làm lộ mutable field, defensive copy, immutable class và record, cùng mọi error của javac chạy thật trên JDK 21.

[Java Basics] Tính trừu tượng trong Java: abstract class và abstract method

Abstract class và abstract method trong Java: vì sao không tạo được object của Shape, abstract class chứa được những gì, template method pattern, anonymous subclass, thứ tự constructor, cùng mọi compile error chạy thật trên JDK 21.