Command Palette

Search for a command to run...

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

Inheritance cho phép một class lấy lại toàn bộ field và method của một class khác rồi bổ sung thêm. Đây là tính năng mà hầu hết tài liệu dạy đầu tiên, và cũng là tính năng dễ bị dùng sai nhất so với bài toán đang có trong tay.

Vì vậy bài này làm hai việc. Nó dạy extends, super, protectedfinal một cách đầy đủ, xuống tới đúng thứ tự mà các constructor chạy. Và nó nói thật về cái giá phải trả, vì một cây class trông hợp lý trong ngày đầu tiên là thứ khó gỡ nhất sau sáu tháng.

Chuỗi các class box nối lên nhau bằng extends tới java.lang.Object

Mọi dòng output và mọi message lỗi bên dưới đều lấy từ việc compile và chạy code thật trên OpenJDK 21.0.6. Chỗ nào compiler ra tay, message javac thật được trích nguyên văn chứ không diễn giải lại.

Kế thừa là gì, và bài test "is a"

class B extends A nói hai điều cùng lúc. Nó nói một object B chứa mọi thứ mà một object A chứa, và nó nói B là một A — có thể dùng ở bất cứ chỗ nào đang cần một A.

Vế thứ hai là vế hay bị bỏ qua, và nó mới là vế quyết định inheritance có đúng hay không. Bài test là một câu: bạn có nói được "mọi B đều là một A" và câu đó đúng với bài toán thực tế, chứ không chỉ tiện cho code của bạn?

Quan hệCâu đó có đúng không?Công cụ đúng
SavingsAccount / Accountmọi savings account đều là một accountinheritance
Manager / Employeemọi manager đều là một employeeinheritance
Car / Enginexe không phải là engine, nó một enginecomposition
Invoice / Bufferhóa đơn không phải là buffer, nó dùng buffercomposition
Stack / ArrayListstack không phải là list — list cho chèn vào giữacomposition

Ba dòng cuối đều là cùng một lỗi khoác áo khác nhau: với tay tới extends chỉ vì class kia đã có sẵn code mình muốn dùng. Đó là tái sử dụng code, không phải phân loại, và composition làm được điều đó mà không kèm tác dụng phụ. Cuối bài có hẳn một mục cho chuyện này, kèm cả hai phiên bản của cùng một chương trình.

Hãy đọc phần còn lại như tài liệu hướng dẫn cho một công cụ sắc. Học cho kỹ, rồi dùng cho tiết kiệm.

extends: subclass nhận được những gì

Subclass khai báo extends và kế thừa field cùng method của superclass, trong giới hạn của access modifier. Nó thêm được thành phần riêng, và gọi được các thành phần kế thừa như thể chính nó khai báo ra.

class Account {
    String owner;
    double balance;

    Account(String owner, double balance) {
        this.owner = owner;
        this.balance = balance;
    }

    void deposit(double amount) {
        balance += amount;
    }

    String describe() {
        return owner + " has " + balance;
    }
}

class SavingsAccount extends Account {
    double rate;

    SavingsAccount(String owner, double balance, double rate) {
        super(owner, balance);
        this.rate = rate;
    }

    void addInterest() {
        deposit(balance * rate);
    }
}

public class InheritanceBasics {
    public static void main(String[] args) {
        SavingsAccount s = new SavingsAccount("Mai", 1000.0, 0.05);
        s.deposit(500.0);
        s.addInterest();
        System.out.println(s.describe());
        System.out.println("owner field  = " + s.owner);
        System.out.println("rate field   = " + s.rate);
    }
}
Mai has 1575.0
owner field  = Mai
rate field   = 0.05

SavingsAccount không hề khai báo owner, balance, deposit hay describe, mà dùng cả bốn. addInterest gọi deposit không cần tiền tố nào, và đọc balance cũng vậy — thành phần kế thừa là thành phần bình thường của subclass.

Cái gì đi qua được ranh giới và cái gì không:

Thành phầnCó kế thừa không?
Field và method public, protected
Thành phần package-privatechỉ khi hai class cùng package
Thành phần privatekhông — chúng tồn tại trong object, nhưng subclass không gọi tên được
Field và method staticcó, và gọi được qua tên subclass
Constructorkhông bao giờ
Initializer blockkhông kế thừa; mỗi class chạy block của chính nó

Dòng private đáng nhìn lại lần nữa vì nó hay gây nhầm. Một field private vẫn được cấp chỗ trong mọi instance của subclass — object mang nó theo — nhưng không dòng code nào của subclass đọc hay ghi được. Đường vào duy nhất là một accessor public hoặc protected mà superclass chủ động cung cấp.

Constructor không được kế thừa

Đây là quy tắc sinh ra lỗi đầu tiên khó hiểu nhất, vì message chỉ vào một dòng mà bạn không nghĩ là nó đang làm gì cả.

class Animal {
    String name;

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

class Dog extends Animal {
}

public class CtorNotInherited {
    public static void main(String[] args) {
        Dog d = new Dog("Rex");
        System.out.println(d.name);
    }
}
CtorNotInherited.java:9: error: constructor Animal in class Animal cannot be applied to given types;
class Dog extends Animal {
^
  required: String
  found:    no arguments
  reason: actual and formal argument lists differ in length
CtorNotInherited.java:14: error: constructor Dog in class Dog cannot be applied to given types;
        Dog d = new Dog("Rex");
                ^
  required: no arguments
  found:    String
  reason: actual and formal argument lists differ in length

Hai lỗi từ một class không có constructor nào nhìn thấy được. Lỗi thứ hai trả lời thẳng: Dog không có constructor nhận String, vì Animal(String) không đi kèm theo inheritance. Thay vào đó Dog nhận constructor mặc định không tham số do compiler sinh ra — đó chính là ý nghĩa của required: no arguments.

Lỗi thứ nhất là hệ quả của cùng một sự thật. Dog() được sinh ra sẽ bắt đầu bằng super() ngầm định, Animal lại không có constructor không tham số, nên lời gọi đó hỏng. Một constructor thiếu, hai lỗi, và không lỗi nào nằm ở dòng do bạn viết.

Mọi class đều kế thừa từ java.lang.Object

Một class không có mệnh đề extends không hề nằm ở đỉnh của thứ gì. Nó extends java.lang.Object một cách ngầm định, nên mọi object trong một chương trình Java đều chung một gốc.

class Vehicle {
    int wheels = 4;
}

class Car extends Vehicle {
    String model = "Civic";
}

class SportsCar extends Car {
    int topSpeed = 250;
}

public class ObjectRoot {
    public static void main(String[] args) {
        SportsCar sc = new SportsCar();

        Class<?> c = sc.getClass();
        while (c != null) {
            System.out.println(c.getName());
            c = c.getSuperclass();
        }
        System.out.println("after Object: " + Object.class.getSuperclass());

        System.out.println();
        System.out.println("toString  -> " + sc.toString());
        System.out.println("hashCode  -> " + Integer.toHexString(sc.hashCode()));
        System.out.println("equals    -> " + sc.equals(sc) + " / " + sc.equals(new SportsCar()));
        System.out.println("getClass  -> " + sc.getClass().getSimpleName());
    }
}
SportsCar
Car
Vehicle
java.lang.Object
after Object: null

getSuperclass() đi lên từng nấc một và trả về null ở trên cùng, đó là bằng chứng cụ thể rằng Object là điểm kết thúc của chuỗi, và rằng Vehicle — vốn không khai báo extends — vẫn nằm dưới nó.

Chính inheritance đó là lý do bốn method này có mặt trên mọi reference bạn từng cầm:

toString  -> SportsCar@6d6f6e28
hashCode  -> 6d6f6e28
equals    -> true / false
getClass  -> SportsCar

Đuôi hex là identity hash và khác nhau ở mỗi lần chạy; phần còn lại thì ổn định.

MethodHành vi mặc định kế thừa từ Object
toString()tên class, @, và identity hash dạng hex — đúng như trên
equals(Object)so sánh identity, nên a.equals(b) chính là a == b
hashCode()một identity hash, không đổi suốt vòng đời của object
getClass()class lúc runtime; nó là final nên không gì sửa được thông tin nó trả về

Object còn góp thêm clone(), notify(), notifyAll() và các overload của wait(). Việc equals trả về false cho hai object SportsCar được tạo riêng là hành vi identity mặc định chứ không phải bug — cho nó ý nghĩa theo giá trị là một việc bạn phải chủ động làm.

Chuỗi class kèm phần mỗi tầng thêm vào, kết quả getSuperclass thật, và lỗi chứng minh constructor không được kế thừa

Java chỉ cho phép kế thừa đơn với class

Một class extends đúng một class. Liệt kê hai cái không phải lỗi ngữ nghĩa để compiler giải thích tử tế — cú pháp đơn giản là kết thúc ngay sau tên class đầu tiên.

class Engine {
    void start() { System.out.println("engine start"); }
}

class Radio {
    void play() { System.out.println("radio play"); }
}

class Car extends Engine, Radio {
}
SingleInheritance.java:9: error: '{' expected
class Car extends Engine, Radio {
                        ^

Dấu mũi tên chỉ vào dấu phẩy. javac đọc xong extends Engine và chờ thân class mở ra.

Giới hạn này tồn tại để tránh diamond problem: nếu Car kế thừa một reset() có sẵn thân từ hai superclass, sẽ không có cách nào hợp lý để quyết định thân nào chạy. Câu trả lời của Java là mỗi class có một cha về mặt cài đặt và bao nhiêu interface tùy ý — đó mới là chỗ chứa đa kế thừa về type, và là chủ đề của bài 30.

Ví dụ trên cũng là một ca trượt bài test "is a" rất đẹp. Xe không phải engine và không phải radio; nó có mỗi thứ một cái. Hai field giải quyết xong, không cần tính năng ngôn ngữ nào.

super(...) xâu chuỗi constructor thế nào

Đây là chỗ người mới hay tắc, nên cần nói cho chính xác. Mọi constructor đều bắt đầu bằng việc chạy một constructor của superclass. Nếu statement đầu tiên không phải super(...) hay this(...) viết rõ ràng, compiler chèn super() vào giúp bạn.

Chuyện chèn này không phải nói cho vui. Compile một subclass hoàn toàn không có constructor rồi dịch ngược ra:

class Parent {
    Parent() {
        System.out.println("Parent()");
    }
}

class Child extends Parent {
    int x = 7;
}
class Child extends Parent {
  int x;

  Child();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method Parent."<init>":()V
       4: aload_0
       5: bipush        7
       7: putfield      #7                  // Field x:I
      10: return
}

Child() được sinh ra gọi Parent."<init>" trước khi làm bất cứ việc gì khác, rồi mới gán x. Field initializer chạy sau khi constructor của superclass trả về — thứ tự đó rất quan trọng, và ví dụ tiếp theo cho thấy rõ.

Hãy trace toàn bộ. ConstructorChain.trace in ra một counter tăng dần, nên các số trong output là thứ tự thực thi thật chứ không phải lời khẳng định suông:

class Base {
    String baseTag = ConstructorChain.trace("Base field initializer");

    Base(String from) {
        ConstructorChain.trace("Base body (arg = " + from + ")");
    }
}

class Middle extends Base {
    String middleTag = ConstructorChain.trace("Middle field initializer");

    Middle() {
        super(ConstructorChain.trace("argument of super(...) in Middle"));
        ConstructorChain.trace("Middle body");
    }
}

class Leaf extends Middle {
    String leafTag = ConstructorChain.trace("Leaf field initializer");

    Leaf() {
        ConstructorChain.trace("Leaf body");
    }
}

public class ConstructorChain {
    static int step = 0;

    static String trace(String msg) {
        System.out.println((++step) + ". " + msg);
        return msg;
    }

    public static void main(String[] args) {
        System.out.println("new Leaf()");
        new Leaf();
    }
}
new Leaf()
1. argument of super(...) in Middle
2. Base field initializer
3. Base body (arg = argument of super(...) in Middle)
4. Middle field initializer
5. Middle body
6. Leaf field initializer
7. Leaf body

Đọc kết quả đó cùng với source là thấy ngay hình dạng. new Leaf() bước vào Leaf(), và Leaf() gọi ngay super() ngầm định. Điều đó đưa vào Middle(), nơi biểu thức làm argument cho super(...) được tính — bước 1, thứ duy nhất xảy ra trên đường đi lên — rồi gọi Base(String). Base gọi Object(). Đến lúc này mới có thứ gì đó chạy trong một body: field initializer của Base, rồi body của Base, rồi tới Middle, rồi tới Leaf.

Nghĩa là lời gọi đi lên còn công việc chạy xuống. Với mỗi class, thứ tự là: constructor của superclass, rồi field initializer và instance initializer block của class đó, rồi mới tới thân constructor của nó.

new Leaf đi lên chuỗi super tới Object rồi các body chạy ngược xuống, đánh số theo output thật

Hệ quả thực tế: lúc constructor của superclass đang chạy, chưa field nào của subclass được khởi tạo. Chúng vẫn đang giữ giá trị mặc định.

Khi superclass không có constructor không tham số

Viết một constructor trong subclass, quên super(...), và cái super() vô hình mà compiler chèn vào sẽ không có gì để gọi.

class Shape {
    String name;

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

class Circle extends Shape {
    double radius;

    Circle(double radius) {
        this.radius = radius;
    }
}
MissingSuper.java:12: error: constructor Shape in class Shape cannot be applied to given types;
    Circle(double radius) {
                          ^
  required: String
  found:    no arguments
  reason: actual and formal argument lists differ in length

found: no arguments là dấu hiệu nhận biết. Không chỗ nào trong Circle gọi Shape với không tham số, nhưng compiler thì có. Cách sửa là tự gọi lấy: super("circle") hoặc Circle(String name, double radius) { super(name); ... }.

Chú ý sự lệch pha mà điều này tạo ra. Cho một class một constructor có tham số sẽ xóa mất constructor không tham số mặc định, và mọi subclass lập tức phải cung cấp super(...). Một superclass giữ lại constructor không tham số thì dễ kế thừa hơn — đó là một quyết định thiết kế, không phải chuyện tình cờ.

super(...) phải là statement đầu tiên

Lời gọi này phải là statement đầu tiên của constructor. Không phải statement có ý nghĩa đầu tiên — mà là statement đầu tiên.

class Square extends Shape {
    double side;

    Square(double side) {
        this.side = side;
        super("square");
    }
}
SuperNotFirst.java:14: error: call to super must be first statement in constructor
        super("square");
             ^

Cùng quy tắc đó áp cho this(...), thứ chuyển tiếp sang một constructor khác của cùng class. Một constructor được bắt đầu bằng super(...) hoặc this(...), không bao giờ cả hai:

class Rect extends Shape {
    double w, h;

    Rect(double w, double h) {
        super("rect");
        this.w = w;
        this.h = h;
    }

    Rect(double side) {
        super("square");
        this(side, side);
    }
}
ThisAndSuper.java:20: error: call to this must be first statement in constructor
        this(side, side);
            ^

Mà thiết kế đúng vốn cũng là vậy: Rect(double) nên gọi this(side, side) và để constructor kia thực hiện lời gọi super(...) duy nhất. Đúng một constructor của superclass chạy cho mỗi object, bất kể có bao nhiêu bước this(...) xếp trước nó.

Quy tắc "statement đầu tiên" là thứ JDK 21 áp dụng, và đó là quy tắc cần thuộc. Java 25 chốt JEP 513, Flexible Constructor Bodies, cho phép đặt statement trước super(...) miễn là chúng không đọc hay ghi vào chính instance đang được dựng. Toàn bộ ví dụ trong bài này compile trên OpenJDK 21.0.6, nơi quy tắc là tuyệt đối.

Dùng super.method() và super.field để chạm tới superclass

Bên trong subclass, super. gọi tên phiên bản của superclass. Đó là cách mở rộng hành vi thay vì thay thế nó:

class Report {
    String title;

    Report(String title) {
        this.title = title;
    }

    String render() {
        return "== " + title + " ==";
    }
}

class SalesReport extends Report {
    double total;

    SalesReport(double total) {
        super("Sales");
        this.total = total;
    }

    String render() {
        return super.render() + "\n   total: " + total;
    }
}
== Sales ==
   total: 1250.5

SalesReport.render dùng lại output của superclass rồi nối thêm vào. Bỏ super. đi thì method tự gọi chính nó:

class SalesReport extends Report {
    String render() {
        return render() + " + total";
    }
}
Exception in thread "main" java.lang.StackOverflowError
	at SalesReport.render(ForgotSuper.java:9)
	at SalesReport.render(ForgotSuper.java:9)
	at SalesReport.render(ForgotSuper.java:9)
	at SalesReport.render(ForgotSuper.java:9)
	at SalesReport.render(ForgotSuper.java:9)

Một stack trace lặp đi lặp lại đúng một dòng là chữ ký của chính lỗi này. super. không phải trang trí; nó là thứ khiến lời gọi đi tới một method khác.

super.tenField hoạt động y hệt và là cách duy nhất đọc được một field của superclass đã bị field trùng tên của subclass che đi — chủ đề của mục ngay sau đây. Thứ super. không làm được là nhảy cóc: không có super.super.render(), vì một class chỉ được phép dựa vào hợp đồng của cha trực tiếp.

Việc định nghĩa lại một method mà superclass đã khai báo gọi là overriding, và toàn bộ ngữ nghĩa của nó — thân nào thực sự chạy, @Override, và chuyện gì xảy ra khi gọi qua một reference kiểu superclass — thuộc về bài 28. Ở đây nó chỉ là giàn giáo để cho thấy super. chạm tới cái gì.

Field hiding không phải là overriding

Đây là cái bẫy của bài này, và đáng để đi chậm lại. Khi subclass khai báo một field trùng tên với field của superclass, nó không thay thế field kia. Cả hai field cùng tồn tại trên object, và cái nào được đọc là do type khai báo của reference quyết định, ngay lúc compile.

import java.lang.reflect.Field;

class Config {
    String source = "base";
    int timeout = 30;

    String fromBase() {
        return "inside Config: source=" + source + " timeout=" + timeout;
    }
}

class DevConfig extends Config {
    String source = "dev";

    String fromSub() {
        return "inside DevConfig: source=" + source + " super.source=" + super.source
                + " timeout=" + timeout;
    }
}

public class FieldHiding {
    static void readAsBase(Config c) {
        System.out.println("through a Config reference: source=" + c.source);
    }

    public static void main(String[] args) throws Exception {
        DevConfig dev = new DevConfig();
        Config asBase = dev;

        System.out.println("same object? " + (dev == asBase));
        System.out.println("dev.source     = " + dev.source);
        System.out.println("asBase.source  = " + asBase.source);
        readAsBase(dev);
        System.out.println(dev.fromBase());
        System.out.println(dev.fromSub());

        System.out.println();
        System.out.println("slots on one object:");
        for (Class<?> c = dev.getClass(); c != Object.class; c = c.getSuperclass()) {
            for (Field f : c.getDeclaredFields()) {
                f.setAccessible(true);
                System.out.println("  " + c.getSimpleName() + "." + f.getName()
                        + " = " + f.get(dev));
            }
        }
    }
}
same object? true
dev.source     = dev
asBase.source  = base
through a Config reference: source=base
inside Config: source=base timeout=30
inside DevConfig: source=dev super.source=base timeout=30

slots on one object:
  DevConfig.source = dev
  Config.source = base
  Config.timeout = 30

devasBase là cùng một object — dev == asBase in ra true — mà đọc source qua chúng lại ra hai chuỗi khác nhau. Không có gì trong object thay đổi; chỉ có type của cái tên dùng để chạm tới nó là khác.

Phần dump bằng reflection ở cuối cho thấy lý do: object thật sự mang cả DevConfig.source lẫn Config.source cạnh nhau. Hiding không ghi đè lên một slot, nó thêm vào một slot mới. timeout chỉ xuất hiện một lần vì chỉ Config khai báo nó, nên hai reference đều đồng ý về nó.

Một object chứa cả các slot field của Config lẫn slot của DevConfig, hai reference chọn hai slot source khác nhau

Lời gọi method thì hoàn toàn không như vậy, và chính sự tương phản đó là toàn bộ nội dung bài 28. Còn bây giờ, quy tắc cần nhớ rất đơn giản:

⚠️ Đừng bao giờ đặt cho field của subclass cùng tên với field của superclass. Không có trường hợp nào mà hành vi hai slot là thứ bạn muốn, và bug sinh ra đọc lên nghe như "giá trị tôi gán không phải giá trị tôi nhận".

Tai nạn này thường xảy ra trong constructor. Viết String source; trong subclass vì nghĩ field đó "phải có ở đây" là âm thầm tạo ra slot thứ hai, constructor điền vào bản copy của subclass, còn mọi method kế thừa từ superclass vẫn đọc bản copy rỗng của superclass — và in ra null.

protected: subclass ở package khác chạm được tới đâu

protected tồn tại vì đúng một mục đích: mở một thành phần ra cho subclass mà không mở ra cho cả thế giới. Nó chỉ thực sự có ý nghĩa khi subclass nằm ở package khác, nên ví dụ cần một cây hai package thật:

src/com/example/base/Employee.java
src/com/example/hr/Manager.java
package com.example.base;

public class Employee {
    private String id = "E-001";
    protected String name;
    protected double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    protected void raise(double pct) {
        salary += salary * pct;
    }

    public String toString() {
        return name + " (" + id + ") earns " + salary;
    }
}
package com.example.hr;

import com.example.base.Employee;

public class Manager extends Employee {
    private int reports;

    public Manager(String name, double salary, int reports) {
        super(name, salary);
        this.reports = reports;
    }

    public void annualReview() {
        raise(0.10);
        salary += reports * 500;
        System.out.println("reviewed " + name + ", salary now " + salary);
    }
}
reviewed Linh, salary now 68000.0
Linh (E-001) earns 68000.0

Manager nằm trong com.example.hr và đọc salary, ghi salary, gọi raise — đều protected, đều xuyên qua ranh giới package. Nhưng nó không chạm được vào id:

src/com/example/hr/Intern.java:11: error: id has private access in Employee
        System.out.println(id);
                           ^

Vậy mà id rõ ràng có mặt: toString in ra E-001 cho một Manager. Field đó vừa được kế thừa vào object vừa bị giấu khỏi code của subclass cùng lúc.

Bỏ extends đi thì quyền truy cập biến mất theo. Một class thường trong cùng package lạ đó không nhận được gì:

package com.example.hr;

import com.example.base.Employee;

public class Auditor {
    public void audit(Employee e) {
        System.out.println(e.salary);
    }
}
src/com/example/hr/Auditor.java:7: error: salary has protected access in Employee
        System.out.println(e.salary);
                            ^

Còn một quy tắc nữa, và nó làm gần như tất cả mọi người bất ngờ. Subclass ở package khác chỉ chạm được tới thành phần protected qua một reference đúng type của chính nó, không phải qua reference kiểu superclass:

package com.example.hr;

import com.example.base.Employee;

public class Payroll extends Employee {
    public Payroll(String name, double salary) {
        super(name, salary);
    }

    public void compare(Employee other) {
        System.out.println(this.salary);
        System.out.println(other.salary);
    }
}
src/com/example/hr/Payroll.java:12: error: salary has protected access in Employee
        System.out.println(other.salary);
                                ^

this.salary compile được. other.salary thì không, mà other lại là Employee — chính class đã khai báo field đó. Quy tắc trao cho subclass quyền vào state của chính nó đã kế thừa, chứ không phải state của một instance bất kỳ của class cha.

ModifierCùng classCùng packageSubclass, package khácMọi nơi
privatekhôngkhôngkhông
package-private (không modifier)khôngkhông
protectedcó, qua reference đúng type của nókhông
public

Để ý cột thứ hai: protected bao gồm cả quyền theo package. Bên trong com.example.base, một class không liên quan đọc salary mà chẳng cần inheritance nào — same package, no inheritance, protected read: 42000.0. protected luôn rộng hơn package-private, không bao giờ hẹp hơn.

Mỗi thành phần protected là một lời hứa với mọi subclass trong tương lai, và lời hứa đó khó rút lại hơn hẳn một thành phần private. Hãy ưu tiên private kèm một accessor protected khi subclass cần giá trị chứ không cần chính field.

final: chặn kế thừa và chặn override

Bài 25 giới thiệu final như một modifier chặn việc gán lại. Trên class và method nó làm việc khác: nó chặn việc mở rộng.

Một class final không thể làm superclass:

final class Money {
    final double amount;

    Money(double amount) {
        this.amount = amount;
    }
}

class Discount extends Money {
    Discount(double amount) {
        super(amount);
    }
}
FinalClass.java:9: error: cannot inherit from final Money
class Discount extends Money {
                       ^

Vẫn message đó là thứ bạn nhận được từ class MyString extends String:

ExtendString.java:1: error: cannot inherit from final String
public class ExtendString extends String {
                                  ^

String là final có chủ đích. Tính immutable, string pool và bộ nhớ đệm hashCode của nó chỉ an toàn khi không subclass nào xen vào được, và ngôn ngữ bảo đảm điều đó bằng cách từ chối luôn extends.

Một method final không thể bị subclass định nghĩa lại:

class Session {
    private final String token = "abc123";

    public final String token() {
        return token;
    }
}

class FakeSession extends Session {
    public String token() {
        return "anything";
    }
}
FinalMethod.java:10: error: token() in FakeSession cannot override token() in Session
    public String token() {
                  ^
  overridden method is final

Đó cũng là cơ chế đằng sau Object.getClass() — nó là final, nên không class nào nói dối được về type runtime của chính mình.

Bạn viếtBạn đang nói
final class Xhành vi của class này là cố định; extends nó là lỗi compile
final void m()subclass thì được, nhưng thân method này không thương lượng
private void m()subclass không nhìn thấy, nên trên thực tế cũng không override được

Đánh dấu một class là final không phải phòng thủ quá đà; đó là quyết định giữ lại quyền tự do sửa class đó về sau. Mà đó chính là vấn đề của mục tiếp theo.

Vấn đề fragile base class

Đây là cái giá phải trả, ở dạng nhỏ nhất mà vẫn thấy được. Mailer gửi mail; subclass CountingMailer đếm số mail đã gửi.

class Mailer {
    void send(String to) {
        System.out.println("  -> " + to);
    }

    void sendAll(String[] recipients) {
        for (String r : recipients) {
            System.out.println("  -> " + r);
        }
    }
}

class CountingMailer extends Mailer {
    int sent = 0;

    void send(String to) {
        sent++;
        super.send(to);
    }

    void sendAll(String[] recipients) {
        sent += recipients.length;
        super.sendAll(recipients);
    }
}

Một lần send và một lô hai địa chỉ cho ra đáp án đúng:

  -> a@example.com
  -> b@example.com
  -> c@example.com
sent = 3

Rồi có người dọn dẹp Mailer. Dòng print bị lặp trong sendAll được đổi thành lời gọi send. API public không đụng tới, không signature nào thay đổi, và không subclass nào bị sửa:

    void sendAll(String[] recipients) {
        for (String r : recipients) {
            send(r);
        }
    }
  -> a@example.com
  -> b@example.com
  -> c@example.com
sent = 5

Mail y hệt, mà counter thì sai. CountingMailer.sendAll cộng 2, rồi Mailer.sendAll gọi send hai lần và cộng thêm 2 nữa. Cả lô bị đếm hai lần.

Đọc riêng từng class thì không class nào có lỗi. Subclass đã dựa vào một sự thật mà superclass chưa từng hứa — rằng sendAll không đi qua send — và một lần refactor bên trong superclass đã âm thầm đổi đáp án. Đó là vấn đề fragile base class: subclass bị gắn chặt vào phần cài đặt của superclass chứ không chỉ vào API của nó, và không có cú pháp nào để tác giả superclass tuyên bố lời gọi nội bộ nào là một phần của hợp đồng.

Ngoài đời còn tệ hơn, khi superclass nằm trong một thư viện bạn nâng cấp chứ không phải một file bạn đọc được trong cùng commit. Chính JDK cũng có đúng hình dạng này. Viết cùng một subclass đếm hai lần, một lần trên HashSet và một lần trên ArrayList:

class CountingSet<E> extends HashSet<E> {
    int added = 0;

    public boolean add(E e) {
        added++;
        return super.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        added += c.size();
        return super.addAll(c);
    }
}

CountingList extends ArrayList với thân giống hệt từng dòng. Một lần add cộng thêm một lô ba phần tử đi vào cả hai:

HashSet   subclass: size=4 added=7
ArrayList subclass: size=4 added=4

Đằng nào cũng bốn phần tử, mà subclass của HashSet đếm ra bảy. HashSet không hề cài đặt addAll — reflection báo nó declared in java.util.AbstractCollection, và bản ở đó lặp và gọi add, nên add của subclass chạy thêm ba lần nữa. ArrayList tự khai báo addAll của mình và không bao giờ gọi add.

Cùng một đoạn code subclass, cùng một JDK, hai đáp án khác nhau, và không có gì trong API công bố của hai class đó cho bạn biết mình đang nhận cái nào. Đó là lý do "ưu tiên composition hơn inheritance" là lời khuyên tiêu chuẩn chứ không phải sở thích về phong cách.

Composition thay cho inheritance

Composition nghĩa là giữ object kia trong một field rồi gọi nó, thay vì trở thành nó. Bộ đếm mail viết theo cách kia, với Mailer đã refactor giữ nguyên:

class CountingMailerBox {
    private final Mailer inner = new Mailer();
    int sent = 0;

    void send(String to) {
        sent++;
        inner.send(to);
    }

    void sendAll(String[] recipients) {
        for (String r : recipients) {
            send(r);
        }
    }
}
inheritance -> sent = 5
composition -> sent = 3

Cùng một chương trình, cùng một Mailer, và bản composition thì đúng. Nó không bao giờ gọi inner.sendAll, nên Mailer cài đặt việc gửi theo lô bên trong ra sao cũng không liên quan tới nó. Nó chỉ phụ thuộc vào hai method nó thực sự gọi, không gì khác.

Tác dụng thứ hai là quyền kiểm soát API. Kế thừa kéo mọi thành phần public của superclass vào class của bạn, dù chúng có hợp lý ở đó hay không:

class Buffer {
    private StringBuilder sb = new StringBuilder();

    void write(String s) { sb.append(s); }
    void clear() { sb.setLength(0); }
    String content() { return sb.toString(); }
}

class InvoiceInherits extends Buffer {
    void addLine(String item, double price) {
        write(item + "  " + price + "\n");
    }
}

class InvoiceComposes {
    private final Buffer buf = new Buffer();

    void addLine(String item, double price) {
        buf.write(item + "  " + price + "\n");
    }

    String render() { return buf.content(); }
}

clear() chưa bao giờ được thiết kế để nằm trong một hóa đơn, nhưng InvoiceInherits vẫn công bố nó ra, và người gọi có thể xóa sạch một hóa đơn đang dựng dở:

inherited invoice:
Tea  3.0
composed invoice:
Coffee  4.5
Tea  3.0

Bản composition thì không có method đó để mà dùng sai:

NoClear.java:5: error: cannot find symbol
        b.clear();
         ^
  symbol:   method clear()
  location: variable b of type InvoiceComposes

Một so sánh sòng phẳng, vì inheritance không phải lúc nào cũng là bên thua:

InheritanceComposition
Quan hệ diễn đạtis ahas a
Gắn chặt vàophần cài đặt của superclass, gồm cả lời gọi nội bộchỉ những method bạn gọi
Bề mặt APImọi thành phần public và protected đi kèm theođúng những gì bạn chọn công bố
Superclass thay đổicó thể âm thầm đổi hành vi, như trênkhông chạm tới bạn được
Khả năng thay thếsubclass truyền được vào mọi chỗ đang cần superclasskhông có gì miễn phí
Chi phí ủy quyềnkhông phải viết gìmỗi thao tác công bố lại là một method chuyển tiếp

Dòng cuối mới là cái giá thật. Composition bắt bạn viết method chuyển tiếp, và với một API rộng thì đúng là mệt. Inheritance là lựa chọn đúng khi câu "is a" thực sự đúng bạn cần khả năng thay thế — một Manager truyền được vào mọi method nhận Employee chính là toàn bộ mục đích. Hãy chọn composition khi bạn chỉ muốn phần code.

Những lỗi thường gặp

Tưởng constructor được kế thừa. new Dog("Rex") sau class Dog extends Animal mà chỉ có Animal(String) sẽ cho constructor Dog in class Dog cannot be applied to given types, vì Dog chỉ có constructor mặc định không tham số. Hãy khai báo constructor bạn cần rồi chuyển tiếp bằng super(name).

Quên super(...) khi superclass không có constructor không tham số. Message gọi tên superclass và nói found: no arguments, đọc lên rất lạ trên một constructor của subclass đang có tham số hẳn hoi. Nó đang mô tả cái super() mà compiler chèn vào.

Đặt super(...) ở chỗ khác ngoài vị trí đầu tiên. call to super must be first statement in constructor. Nếu cần tính một giá trị cho nó, hãy tính ngay trong biểu thức argument hoặc trong một helper private static — những thứ đó được tính trước khi constructor của superclass chạy, đúng như bước 1 trong bản trace.

Khai báo lại field của superclass trong subclass. Không lỗi, không cảnh báo, hai slot, và dev.source với asBase.source in ra hai chuỗi khác nhau cho một object. Nếu muốn subclass đổi giá trị, hãy gán vào field đã kế thừa thay vì khai báo một field mới.

Dùng extends chỉ vì superclass có sẵn code mình muốn. Cái Invoice extends Buffer đã kế thừa clear() rồi công bố nó cho mọi người gọi. Nếu câu "hóa đơn là một buffer" sai, hãy giữ một Buffer trong field.

Với tay tới protected theo phản xạ. Mỗi thành phần protected là một phần hợp đồng với mọi subclass tương lai, và nới rộng về sau thì dễ còn thu hẹp lại là một thay đổi phá vỡ tương thích. Hãy bắt đầu bằng private.

FAQ

Subclass trong Java có kế thừa constructor không?

Không. Constructor là thứ duy nhất extends không bao giờ mang qua, vì constructor gắn liền với đúng class mà nó mang tên. Subclass hoặc tự khai báo constructor của mình, hoặc nhận constructor mặc định không tham số do compiler sinh. Đó là lý do class Dog extends Animal { } với Animal(String) ở superclass sẽ hỏng ở new Dog("Rex") kèm constructor Dog in class Dog cannot be applied to given types.

super() làm gì nếu tôi không viết nó?

Compiler chèn super() làm statement đầu tiên của mọi constructor không mở đầu bằng super(...) hay this(...) viết rõ. Đó là một lời gọi thật — dịch ngược một subclass không có constructor sẽ thấy invokespecial Method Parent."<init>":()V là chỉ thị đầu tiên. Nếu superclass không có constructor không tham số, lời gọi được chèn đó không compile được, và lỗi hiện lên ngay ở dòng signature constructor của bạn.

Vì sao javac báo "constructor X in class X cannot be applied to given types"?

Có hai tình huống sinh ra nó. Nếu class được gọi tên là superclass và message ghi found: no arguments, thì cái super() ngầm định đã hỏng — hãy thêm super(...) với đúng argument. Nếu class được gọi tên chính là class bạn đang khởi tạo và ghi required: no arguments, thì bạn đã gọi một constructor class đó không có, thường vì tưởng nó kế thừa được.

Một class Java có extends được nhiều class không?

Không. class Car extends Engine, Radio thậm chí không parse nổi — javac báo '{' expected với dấu mũi tên chỉ vào dấu phẩy. Một class có đúng một superclass, mặc định là java.lang.Object. Đa kế thừa về type đến từ interface, thứ mà một class implement bao nhiêu cũng được, và bài 30 sẽ nói về nó.

Field hiding khác method overriding chỗ nào?

Field của subclass trùng tên với field của superclass sẽ che field kia: cả hai slot cùng nằm trên object, và slot nào được đọc là do type khai báo của reference quyết định lúc compile. Cùng một object in ra dev qua reference DevConfigbase qua reference Config. Method thì phân giải theo class thật của object, nên hai thứ trông giống nhau trong source mà hành xử khác hẳn — bài 28 nói đủ về nửa còn lại đó.

Khi nào nên dùng composition thay cho inheritance?

Bất cứ khi nào câu "is a" sai, và nhiều lúc kể cả khi nó đúng. Dùng inheritance khi subclass thực sự phải dùng được ở mọi chỗ superclass dùng được, và khi bạn kiểm soát cả hai class. Dùng composition khi bạn chỉ muốn tái sử dụng code — nó không kéo cả API của superclass vào của bạn, và không thể bị một lần refactor bên trong class bạn đang dùng lại làm hỏng, đúng như bộ đếm nhảy từ 3 lên 5 đã cho thấy.

Kết luận

Inheritance là hai cơ chế nấp dưới một keyword. extends chép một tập thành phần vào class của bạn, và nó tuyên bố một quan hệ "is a" cho phép object của bạn đứng thay chỗ superclass. Cái thứ nhất dễ với tay tới, còn cái thứ hai mới là thứ khiến nó đúng.

Những chi tiết quyết định code của bạn chạy đúng hay không đều nằm trong output của bài này. Constructor không đi qua ranh giới, nên new Dog("Rex") hỏng trên một class bạn chưa từng viết constructor cho nó. super(...) luôn chạy trước, và chuỗi đi lên tới Object trước khi có một body nào thực thi — bước 1 tới bước 7 trong bản trace, đúng thứ tự đó. Một field khai báo lại tạo ra slot thứ hai và một phép đọc trên cùng object trả về hai giá trị khác nhau. Và một superclass lặng lẽ refactor sendAll thành gọi send có thể biến một subclass đang đúng thành sai mà không file nào trông có vẻ hỏng.

Bài tiếp theo trong series: polymorphism — overriding và overloading. Method overriding là chỗ inheritance thôi nói về tái sử dụng và bắt đầu nói về thay thế: thân nào chạy khi bạn gọi method qua một reference kiểu superclass, @Override mang lại gì, và vì sao overloading — thứ được compiler chọn theo type tĩnh — là một cơ chế hoàn toàn khác chỉ trông na ná.

Bài viết liên quan

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

Tính đa hình trong Java trên OpenJDK 21: quy tắc method overriding, @Override, dynamic dispatch chứng minh bằng javap, overriding và overloading, field hiding, static hiding, upcasting và downcasting, cùng cái bẫy trong constructor.

[Java Basics] Phương thức trong Java: khai báo và gọi method

Cách khai báo và gọi method trong Java: các thành phần của một declaration, static method so với instance method và lỗi non-static method cannot be referenced from a static context, return statement, call stack và StackOverflowError, cách đọc stack trace, Javadoc, cùng những lỗi compiler thật mà người mới hay gặp.

[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.