Java OO & Functional
3/38
Bài 3 / 38~12 phútKế thừa & Đa hìnhLộ trình · chặng 03/21Miễn phí… lượt xem

`extends` và `super` — kế thừa cơ bản

Cơ chế extends, constructor chain với super(...), đơn kế thừa trong Java, Object là gốc và thứ tự khởi tạo bộ nhớ từ cha xuống con.

TL;DR: Khi một class con khai báo extends class cha, nó thừa hưởng toàn bộ state và behavior hợp lệ của cha. Điểm mấu chốt nằm ở vòng đời khởi tạo: constructor class con luôn phải kích hoạt constructor của cha qua super(...) ở dòng đầu tiên. Java ép buộc một class chỉ có một cha trực tiếp và mọi class đều quy tụ về gốc Object. Nắm vững thứ tự nạp constructor giúp bạn tránh được bẫy kinh điển: rò rỉ phương thức override khi trạng thái con chưa hoàn tất.

Ở bài trước, bạn đã trừu tượng hoá các ấn phẩm cho mượn thành mẫu chung LibraryItem. Bây giờ, ta hiện thực hoá mối quan hệ đó bằng cú pháp kế thừa trong Java.

Bạn đã biết cách gom các đặc điểm chung của sách, DVD hay tài liệu số về LibraryItem. Thay vì sao chép các trường id, title qua từng class riêng lẻ, Java cung cấp từ khoá extends để biểu diễn mối quan hệ phụ thuộc này.

Cơ chế kế thừa giúp tái sử dụng mã nguồn, nhưng cũng là công cụ dễ tạo ra thiết kế giòn gãy nhất nếu áp dụng sai bản chất. Bài này làm rõ quy tắc vận hành của extends, cách super phối hợp giữa các tầng và bức tranh thứ tự khởi tạo trong bộ nhớ.

1. Analogy — Hồ sơ gốc và phụ lục chuyên biệt

Hình dung hệ thống lưu trữ hồ sơ tài sản của một thư viện:

  1. Hồ sơ gốc (LibraryItem): Mọi tài sản nhập về đều phải có mã định danh, tiêu đề và năm nhập kho. Thủ thư làm sẵn một biểu mẫu tiêu chuẩn.
  2. Phụ lục chuyên biệt (Book): Khi nhập một cuốn sách in, thủ thư lấy biểu mẫu gốc kẹp thêm trang bổ sung ghi số trang và tác giả.
Nghiệp vụ hồ sơKhái niệm Java
Biểu mẫu tiêu chuẩnClass cha (LibraryItem)
Hồ sơ sách kẹp thêm phụ lụcClass con (Book extends LibraryItem)
Thông tin chung trên mẫu gốcField và method của class cha (id, title)
Thông tin bổ sung của sáchField và method của class con (author, pageCount)
💡 Quy tắc kiểm tra quan hệ

Quan hệ class B extends A chỉ hợp lệ khi câu phát biểu "B là một loại A" đúng về mặt bản chất nghiệp vụ. Ví dụ, Book là một loại LibraryItem. Nếu bạn muốn viết LibraryCard extends Book, câu nói trở nên vô nghĩa; khi đó cần dùng quan hệ thành phần (composition) thay vì kế thừa.

Thử ngẫmTrong dự án bạn đang làm, có class nào đang kế thừa class khác chỉ để lấy ké 1-2 hàm tiện ích mà không thực sự là một dạng con của nó?

2. Cú pháp extends và cơ chế đơn kế thừa hoạt động ra sao?

Để kế thừa một class, bạn đặt từ khoá extends phía sau tên class con:

Java
public class LibraryItem {
    protected String id;
    protected String title;

    public LibraryItem(String id, String title) {
        this.id = id;
        this.title = title;
    }

    public void displaySummary() {
        System.out.println("[" + id + "] " + title);
    }
}

public class Book extends LibraryItem {
    private String author;

    public Book(String id, String title, String author) {
        super(id, title);
        this.author = author;
    }

    public void printDetails() {
        displaySummary();
        System.out.println("Author: " + author);
    }
}

Khi sử dụng:

Java
Book book = new Book("B-101", "Clean Code", "Robert C. Martin");
book.printDetails();

Class Book kế thừa hai trường dữ liệu cùng phương thức displaySummary() từ LibraryItem, đồng thời khai báo thêm trường dữ liệu author và phương thức chuyên biệt của mình.

2.1 Vì sao Java chỉ cho phép đơn kế thừa class?

Java không cho phép một class kế thừa trực tiếp từ nhiều class cha:

Java
class Book extends LibraryItem, DigitalAsset { } // Compile error: multiple inheritance

Quy định này loại bỏ bài toán đa kế thừa (diamond problem): khi hai class cha cùng định nghĩa một hàm trùng tên, class con sẽ rơi vào trạng thái nhập nhằng không biết chọn nhánh nào. Để bù đắp nhu cầu tích hợp nhiều hành vi, Java sử dụng hệ thống interface.

3. Vì sao constructor con phải gọi super(...)?

Khi tạo mới một thực thể Book, trạng thái của LibraryItem (gồm id và title) phải được khởi tạo trước. Bạn thực hiện điều này bằng cách gọi super(...):

Java
public Book(String id, String title, String author) {
    super(id, title); // Must be the first statement in child constructor
    this.author = author;
}

Nếu bạn không gọi super(...) tường minh, trình biên dịch Java sẽ tự động chèn lời gọi không tham số super(). Khi đó, nếu class cha không có constructor không tham số, mã nguồn sẽ báo lỗi biên dịch ngay lập tức:

Java
class LibraryItem {
    public LibraryItem(String id) { ... } // No default no-arg constructor
}

class Book extends LibraryItem {
    public Book(String author) {
        // Compiler inserts super(), but LibraryItem lacks no-arg -> Compile error!
    }
}

3.1 Vì sao super(...) phải nằm ở dòng đầu tiên?

Quy tắc này đảm bảo nền móng của đối tượng được xây dựng hoàn tất trước khi tầng con bắt đầu thao tác. Nếu cho phép chạy code khác trước super(...), class con có thể vô tình đọc phải những trường dữ liệu của cha chưa kịp khởi tạo giá trị.

Từ Java 22 trở đi (chính thức hoá tại JEP 513 ở Java 25), Java nới lỏng cơ chế này bằng Flexible Constructor Bodies: bạn được phép kiểm tra tham số đầu vào trước lời gọi super(...), miễn là không truy cập biến tham chiếu this.

4. Thứ tự thực thi constructor trong bộ nhớ diễn ra thế nào?

Khi một câu lệnh new thực thi, JVM tiến hành theo hai pha rõ rệt:

  1. Pha 1 (Leo lên): Đi theo các lời gọi super() từ con lên cha cho tới tận gốc java.lang.Object.
  2. Pha 2 (Chạy thân constructor): Thực thi thân hàm lần lượt từ gốc Object trở ngược xuống class con.

Hai cột: pha một leo từ new Warrior lên tới Object bằng super, pha hai chạy thân constructor ngược từ Object xuống Warrior

Hãy quan sát đoạn code kiểm chứng:

Java
class LibraryItem {
    public LibraryItem() {
        System.out.println("1. Init LibraryItem");
    }
}

class PrintedMedia extends LibraryItem {
    public PrintedMedia() {
        System.out.println("2. Init PrintedMedia");
    }
}

class Book extends PrintedMedia {
    public Book() {
        System.out.println("3. Init Book");
    }
}

Kết quả in ra màn hình luôn theo thứ tự:

Plain Text
1. Init LibraryItem
2. Init PrintedMedia
3. Init Book
⚠️ Bẫy rò rỉ phương thức trong constructor

Tuyệt đối không gọi các method có thể bị ghi đè (non-final, non-private) ngay bên trong constructor của class cha.

Java
class BaseItem {
    public BaseItem() {
        validate(); // Dangerous: method can be overridden by subclass
    }
    public void validate() { }
}

class PremiumBook extends BaseItem {
    private String secretCode;

    public PremiumBook() {
        super();
        this.secretCode = "SEC-99";
    }

    @Override
    public void validate() {
        System.out.println(secretCode.length()); // Throws NullPointerException!
    }
}

Khi chạy new PremiumBook(), constructor của BaseItem gọi validate(). Do cơ chế đa hình động, phiên bản validate() của PremiumBook được kích hoạt. Tuy nhiên lúc này thân constructor con chưa chạy tới, biến secretCode vẫn mang giá trị mặc định null, gây ra lỗi dừng chương trình ngoài ý muốn.

5. Vì sao Object là gốc của mọi class?

Mọi class trong Java nếu không khai báo extends tường minh đều ngầm kế thừa từ java.lang.Object. Đây là lý do mọi đối tượng bạn tạo ra đều tự động sở hữu các phương thức nền tảng như toString(), equals(), hashCode(), và getClass().

Java
Book book = new Book("B-1", "Refactoring", "Martin Fowler");
System.out.println(book.getClass().getSuperclass().getName());

Cây kế thừa trong Java luôn hội tụ về một gốc duy nhất, giúp bảo đảm tính đồng nhất khi truyền tham số hoặc lưu trữ đối tượng trong các cấu trúc dữ liệu tổng quát.

6. Phạm vi truy cập và từ khoá final giới hạn kế thừa thế nào?

Class con kế thừa các thành phần từ class cha tuỳ theo mức độ kiểm soát truy cập:

  • protected: Cho phép các class con truy cập trực tiếp các trường và phương thức của cha, bất kể class con nằm cùng hay khác package.
  • private: Thuộc quyền sở hữu tuyệt đối của class cha; class con không thể nhìn thấy hay can thiệp trực tiếp. Nếu muốn chia sẻ dữ liệu, hãy cung cấp getter dạng protected.
  • super.method(): Cho phép class con gọi lại hành vi ban đầu của class cha khi đang thực hiện override, hỗ trợ mở rộng thêm logic thay vì viết lại toàn bộ.

Khi một class đã hoàn thiện thiết kế và không mong muốn bất kỳ ai mở rộng, hãy gắn từ khoá final:

Java
public final class ImmutableBarcode {
    private final String code;
    public ImmutableBarcode(String code) { this.code = code; }
}

Các class then chốt của Java như String, Integer đều là final để bảo toàn tính bất biến và ngăn ngừa các lỗi bảo mật.

7. Các lỗi sai thường gặp khi kế thừa là gì?

❌ Lỗi 1: Quên gọi super(...) khi class cha không có constructor mặc định.
✅ Khai báo tường minh lời gọi super(...) kèm đầy đủ tham số tương ứng trong constructor của class con.

❌ Lỗi 2: Gọi hàm this hoặc truy cập biến instance trước super(...).
✅ Mọi thao tác với biến instance chỉ được bắt đầu sau khi dòng lệnh super(...) hoàn thành nhiệm vụ.

❌ Lỗi 3: Nhầm lẫn giữa quan hệ sở hữu (has-a) và quan hệ kế thừa (is-a).
✅ Tránh viết class Shelf extends Book. Giá sách chỉ chứa sách (Shelf chứa List<Book>), hãy ưu tiên đóng gói thay vì kế thừa bừa bãi.

8. Tóm tắt

  • Từ khoá extends biểu diễn quan hệ cha-con; class con nhận lại các phương thức và trường dữ liệu hợp lệ từ cha.
  • Java áp dụng quy tắc đơn kế thừa để tránh xung đột mã nguồn.
  • Constructor con luôn phải kích hoạt constructor cha qua super(...) ở đầu hàm; quá trình nạp dữ liệu chạy ngược từ Object xuống con.
  • Không bao giờ gọi hàm có thể bị override từ bên trong constructor của class cha.
  • Áp dụng final cho các class cần khoá lại cấu trúc và bảo vệ trạng thái bất biến.

9. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Vì sao constructor của class con bắt buộc phải gọi constructor của class cha trước tiên?
  2. Q2
    Khi nào trình biên dịch báo lỗi nếu bạn không viết lời gọi super(...) tường minh?
  3. Q3
    Thứ tự thực thi constructor khi tạo mới một đối tượng diễn ra như thế nào?
  4. Q4
    Tại sao việc gọi phương thức có thể override bên trong constructor của cha lại dẫn tới lỗi NullPointerException?
  5. Q5
    Mục đích chính của việc đánh dấu từ khoá final lên một class là gì?

Bài tiếp theo: Override và dynamic dispatch — method "cùng tên" chọn phiên bản ở runtime

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

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 3/21 · OOP: class, kế thừa, đa hình, abstraction — bài 10/16 của chặng

Ôn phỏng vấn

Bài này trả lời được các câu phỏng vấn sau — tự trả lời thử trước khi mở đáp án.

Thảo luận & góp ý

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

Bài tiếp theo

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