Command Palette

Search for a command to run...

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

Mọi hình đều có diện tích. Nhưng không có hình nào chỉ là một hình. Câu đó gói trọn ý nghĩa của abstract: Shape đủ thật để khai báo variable, truyền vào method và bỏ vào một list, nhưng lại quá tổng quát để tạo ra một cái.

Java cho bạn hai công cụ cho việc đó. abstract đặt trên class nói rằng "type này tồn tại, nhưng new nó là compile error". abstract đặt trên method nói rằng "mọi subclass concrete phải cung cấp body này, và compiler sẽ kiểm tra". Bài này đi hết cả hai, những cái bẫy quanh chúng, và pattern mà abstract class thật sự sinh ra để phục vụ. Mọi dòng output và mọi error message bên dưới đều lấy từ lần compile và chạy thật trên OpenJDK 21.0.6.

Lệnh new bị chặn trước một base class nét đứt, bên dưới là hai subclass concrete

Hai từ, mỗi từ một quy tắc: abstract trên class chặn new, còn abstract trên method không cho subclass im lặng bỏ qua.

Tính trừu tượng trong Java nghĩa là gì

Trừu tượng ở đây không phải một chữ mơ hồ trong thiết kế. Nó là một lời khẳng định cụ thể, kiểm tra được, mà bạn nói với compiler: khái niệm này có những operation này, và tôi sẽ không nói chúng chạy ra sao.

Lấy một chương trình vẽ hình. Circle, rectangle và triangle đều có diện tích và chu vi, và đoạn code cộng tổng diện tích không quan tâm cái nào là cái nào. Vậy Shape đáng được đặt tên như một type. Nhưng công thức cho "diện tích của một hình" thì không tồn tại — chỉ có công thức cho diện tích của một hình cụ thể. Nên Shapearea() trong API của nó và không có area() trong phần implementation.

Đó chính xác là hình dạng của một abstract class:

  • nó đặt tên cho khái niệm và các operation của khái niệm đó, để code khác viết dựa trên nó được;
  • nó giữ những gì các subclass thật sự có chung — state, constructor, method có code thật;
  • nó để lại những operation khác nhau ở từng subclass dưới dạng khai báo không có body.

Giá trị nằm ở chỗ compiler bây giờ ép được hợp đồng đó. Một subclass quên area() sẽ không compile được, và không đoạn nào trong chương trình lỡ tay tạo ra một Shape trơ trọi rồi nhận về số 0 vô nghĩa.

abstract đặt trên class

Đặt abstract trước class thì đúng một thứ thay đổi: class đó không tạo object được nữa.

Abstract class chứa field, constructor và method có body thật, cùng hai khai báo không body, và hai subclass điền nốt

abstract class Shape {
    private final String name;
    private final String color;

    protected Shape(String name, String color) {
        this.name = name;
        this.color = color;
    }

    public String getName() { return name; }
    public String getColor() { return color; }

    public abstract double area();
    public abstract double perimeter();

    public String describe() {
        return String.format("%s (%s): area=%.2f perimeter=%.2f",
                             name, color, area(), perimeter());
    }
}

class Circle extends Shape {
    private final double r;

    Circle(String color, double r) {
        super("Circle", color);
        this.r = r;
    }

    @Override public double area() { return Math.PI * r * r; }
    @Override public double perimeter() { return 2 * Math.PI * r; }
}

class Rectangle extends Shape {
    private final double w, h;

    Rectangle(String color, double w, double h) {
        super("Rectangle", color);
        this.w = w;
        this.h = h;
    }

    @Override public double area() { return w * h; }
    @Override public double perimeter() { return 2 * (w + h); }
}

public class ShapeDemo {
    public static void main(String[] args) {
        Shape[] shapes = { new Circle("red", 3), new Rectangle("blue", 3, 4) };
        for (Shape s : shapes) {
            System.out.println(s.describe());
        }
    }
}
Circle (red): area=28.27 perimeter=18.85
Rectangle (blue): area=12.00 perimeter=14.00

Để ý xem phần lớn class đó là Java bình thường tới mức nào. Hai field private final, một constructor gán chúng, ba method có body thật — trong đó describe() gọi area() mà không hề biết area() làm gì. Chỉ đúng hai dòng là khác thường, và cả hai đều kết thúc bằng dấu chấm phẩy thay vì một body.

Thử tạo object của chính base type và javac từ chối:

public class NewAbstract {
    public static void main(String[] args) {
        Shape s = new Shape();
        System.out.println(s.area());
    }
}
NewAbstract.java:7: error: Shape is abstract; cannot be instantiated
        Shape s = new Shape();
                  ^
1 error

Đây là compile error chứ không phải runtime error. Chương trình chưa từng chạy. Reflection cũng không lách được — Shape.class.getDeclaredConstructor().newInstance() ném java.lang.InstantiationException lúc runtime, tức là cùng một quy tắc nhưng do JVM ép thay vì javac.

Vì sao abstract class vẫn có constructor

Một constructor trên class mà bạn không bao giờ tạo object được trông có vẻ vô nghĩa, và đây là chỗ người ta hiểu sai abstract nhiều nhất. Constructor đó không dành cho new Shape(...). Nó dành cho super("Circle", color).

Constructor không được kế thừa. Khi Circle cần khởi tạo hai field namecolor nằm trong Shape, nó không gán trực tiếp được — hai field đó private với Shape — nên nó gọi cái constructor gán được. protected là mức access quy ước cho đúng lý do này: subclass với tới được, còn ngoài ra thì không ai với tới được một cách có ích. public sẽ ngụ ý rằng caller gọi thẳng được, mà không caller nào gọi được cả.

Cùng một lập luận giải thích phần còn lại của class. Một abstract class chứa được instance field, static field, static method, initializer block, nested class, method final, bản toString() override và thậm chí một main chạy được bằng java YourAbstractClass. abstract lấy đi đúng một khả năng — tạo object — và thêm vào đúng một khả năng — được phép khai báo method không có body.

abstract đặt trên method

Một abstract method là một khai báo không có body. Nó kết thúc ở dấu chấm phẩy:

public abstract double area();

Không có { }, và thêm vào là một error riêng:

BodyAbstract.java:2: error: abstract methods cannot have a body
    public abstract double area() { return 0; }
                           ^
1 error

Khai báo một cái như vậy kéo theo hệ quả lan tới mọi subclass: class nào extends Shape mà bản thân không phải abstract thì phải cung cấp body cho mọi abstract method nó thừa hưởng. Quên một cái thì chính subclass là chỗ không compile được, chứ không phải base class:

abstract class Shape {
    public abstract double area();
    public abstract double perimeter();
}

class Square extends Shape {
    private final double side;
    Square(double side) { this.side = side; }

    @Override public double area() { return side * side; }
}
Missing.java:6: error: Square is not abstract and does not override abstract method perimeter() in Shape
class Square extends Shape {
^
1 error

Hãy đọc message đó theo đúng nghĩa đen, vì nó gọi tên cả hai lối thoát: Square is not abstractdoes not override perimeter(). Sửa vế nào cũng hết error.

Cùng message đó xuất hiện ở dạng kỳ lạ nhất khi bạn đặt một abstract method vào một class mà bạn quên đánh dấu abstract:

NotAbstractClass.java:1: error: NotAbstractClass is not abstract and does not override abstract method area() in NotAbstractClass
public class NotAbstractClass {
       ^
1 error

Class không override method của chính nó. Cách diễn đạt hơi lạ, nhưng quy tắc bên dưới thì thống nhất: một class concrete không được phép còn method chưa có implementation, dù chúng đến từ đâu.

Hai cách thoát khỏi error đó

Implement nó. Subclass cung cấp body, trở thành concrete, và tạo object được.

Hoặc khai báo luôn subclass là abstract. Một subclass được phép implement vài abstract method và để phần còn lại cho subclass của nó. Đây là cách tổ chức một hierarchy có tầng trung gian mang phần code dùng chung thật sự:

abstract class Shape {
    public abstract double area();
    public abstract double perimeter();
}

// Way 1: implement every abstract method — the class becomes concrete.
class Square extends Shape {
    private final double side;
    Square(double side) { this.side = side; }

    @Override public double area() { return side * side; }
    @Override public double perimeter() { return 4 * side; }
}

// Way 2: implement some, stay abstract — the rest is the next subclass's job.
abstract class Ellipse extends Shape {
    protected final double a, b;
    protected Ellipse(double a, double b) { this.a = a; this.b = b; }

    @Override public double area() { return Math.PI * a * b; }
    // perimeter() is still abstract here, and that is legal
}

class Oval extends Ellipse {
    Oval(double a, double b) { super(a, b); }

    // Ramanujan's approximation
    @Override public double perimeter() {
        double h = Math.pow(a - b, 2) / Math.pow(a + b, 2);
        return Math.PI * (a + b) * (1 + 3 * h / (10 + Math.sqrt(4 - 3 * h)));
    }
}

public class WaysOut {
    public static void main(String[] args) {
        Shape sq = new Square(3);
        Shape ov = new Oval(4, 2);
        System.out.printf("Square area=%.2f perimeter=%.2f%n", sq.area(), sq.perimeter());
        System.out.printf("Oval   area=%.2f perimeter=%.2f%n", ov.area(), ov.perimeter());
    }
}
Square area=9.00 perimeter=12.00
Oval   area=25.13 perimeter=19.38

Ellipse implement area() và để ngỏ perimeter(), nên nó vẫn abstract và new Ellipse(4, 2) sẽ dính đúng error cannot be instantiated. Oval bịt nốt chỗ trống cuối cùng và trở nên dùng được.

Ba cách để có một object thật

new Shape() không bao giờ compile được, nên mọi object tồn tại lúc runtime đều là một subclass. Chuyện đó chỉ có đúng ba dạng.

new Shape() thất bại, đặt cạnh subclass concrete, subclass vẫn abstract và anonymous subclass

Hai dạng đầu chính là hai ví dụ ở trên: một subclass concrete như Square, và một subclass implement dở dang như Ellipse được Oval hoàn thiện. Dạng thứ ba được viết thẳng tại chỗ dùng.

Anonymous subclass

new Shape() { ... } không phải là "tạo object của một abstract class". Nó là một khai báo class được viết ở chỗ đáng lẽ là một biểu thức: javac compile cặp ngoặc đó thành một subclass thật, không tên, rồi tạo object của subclass ấy. Bạn xem tận mắt được bằng cách in tên class ra:

abstract class Shape {
    public abstract double area();
    public String tag() { return getClass().getName() + " -> " + area(); }
}

public class AnonDemo {
    public static void main(String[] args) {
        Shape unit = new Shape() {
            @Override public double area() { return 1.0; }
        };
        Shape half = new Shape() {
            @Override public double area() { return 0.5; }
        };
        System.out.println(unit.tag());
        System.out.println(half.tag());
        System.out.println("superclass of unit: " + unit.getClass().getSuperclass().getName());
        System.out.println("is anonymous: " + unit.getClass().isAnonymousClass());
    }
}
AnonDemo$1 -> 1.0
AnonDemo$2 -> 0.5
superclass of unit: Shape
is anonymous: true

Compiler đã sinh ra hai file class thật nằm cạnh các file kia:

AnonDemo$1.class
AnonDemo$2.class
AnonDemo.class
Shape.class

Tên gồm class bao ngoài, một dấu đô la, rồi một số đếm — anonymous class đầu tiên trong file là $1, cái thứ hai là $2. Đó chính là nguồn gốc của mấy dòng Outer$1 khó hiểu trong stack trace.

Có hai tính chất đáng nhớ. Một anonymous subclass truyền được argument cho constructor của abstract class, vì nó là subclass như mọi subclass khác. Và nó đọc được local variable effectively final của method bao ngoài:

abstract class Task {
    protected final String name;
    protected Task(String name) { this.name = name; }
    public abstract int run(int input);
    public String toString() { return name + " (" + getClass().getName() + ")"; }
}

public class AnonState {
    public static void main(String[] args) {
        int factor = 3;                       // effectively final, captured
        Task triple = new Task("triple") {    // anonymous subclass CAN call super(...)
            @Override public int run(int input) { return input * factor; }
        };
        Task negate = new Task("negate") {
            @Override public int run(int input) { return -input; }
        };
        for (Task t : new Task[] { triple, negate }) {
            System.out.println(t + " -> " + t.run(7));
        }
    }
}
triple (AnonState$1) -> 21
negate (AnonState$2) -> -7

Anonymous subclass là lựa chọn đúng cho một implementation dùng đúng một lần ở đúng một chỗ. Hãy đặt tên cho nó ngay khi nó được dùng lần thứ hai, hoặc ngay khi body dài quá vài dòng.

Những modifier mà abstract method không dùng được

Có ba modifier không hợp lệ trên một abstract method, và javac báo cả ba trong một lần:

public abstract class AllThree {
    private abstract double a();
    static abstract double b();
    final abstract double c();
}
AllThree.java:2: error: illegal combination of modifiers: abstract and private
    private abstract double a();
                            ^
AllThree.java:3: error: illegal combination of modifiers: abstract and static
    static abstract double b();
                           ^
AllThree.java:4: error: illegal combination of modifiers: abstract and final
    final abstract double c();
                          ^
3 errors

Mỗi cái có một lý do đáng viết ra một câu:

Tổ hợpVì sao không chạy được
abstract privateMethod private không nhìn thấy được từ subclass, nên không subclass nào cung cấp nổi cái body mà khai báo kia đòi hỏi.
abstract staticMethod static được giải quyết theo class đứng trước lời gọi chứ không override theo runtime type của object, nên không có cơ chế dispatch nào chạm tới được body trong subclass.
abstract finalfinal nghĩa là "không được override", abstract nghĩa là "bắt buộc phải override". Hai cái ngược nhau hoàn toàn.

Va chạm y hệt xảy ra ở cấp class. abstract final class bị từ chối vì đúng lý do đó — sẽ không có gì extends được nó, nên sẽ không có gì implement nổi nó:

AbstractFinalClass.java:1: error: illegal combination of modifiers: abstract and final
public abstract final class AbstractFinalClass {
                      ^
1 error

Có hai quy tắc liên quan rất dễ vấp. Bạn không với tới một abstract method qua super được — ở đó không có gì để gọi:

SuperAbstract.java:7: error: abstract method area() in Shape cannot be accessed directly
        return super.area() * 2;
                    ^
1 error

Và bạn không thu hẹp mức access lại khi implement nó được. Một method public abstract phải được implement là public:

WeakerAccess.java:6: error: area() in Square cannot override area() in Shape
    @Override protected double area() { return 1; }
                               ^
  attempting to assign weaker access privileges; was public
1 error

protected abstract là mức trung gian hữu ích khi hook là một điểm mở rộng nội bộ chứ không thuộc public API — subclass implement được, còn caller thì không gọi được.

Abstract class không có abstract method nào có được không?

Được, và nó compile không một dòng cảnh báo. Một abstract class có đủ body cho mọi method là cách hợp lệ để nói đừng tạo object trực tiếp từ class này:

abstract class HttpStatus {
    public static final int OK = 200;
    public static final int NOT_FOUND = 404;

    public static boolean isSuccess(int code) {
        return code >= 200 && code < 300;
    }
}

public class NoAbstractMethods {
    public static void main(String[] args) {
        System.out.println(HttpStatus.isSuccess(HttpStatus.OK));
        System.out.println(HttpStatus.isSuccess(HttpStatus.NOT_FOUND));
    }
}
true
false

HttpStatus là một túi hằng số và vài static helper. Một object của nó chẳng mang ý nghĩa gì, và abstract biến sự thật đó thành thứ ép được thay vì một dòng comment. Cách thay thế — một constructor private duy nhất — cũng chặn việc tạo object và chặn luôn việc kế thừa; chọn cách đó khi bạn còn muốn đóng hẳn class lại. Cả hai đều không thay được abstract khi class thật sự là base cho những class khác.

Chiều ngược lại cũng đúng: một abstract class extends được một class concrete. abstract class ValidatedWidget extends Widget là hợp lệ, và nó thêm một method chưa implement vào một type trước đó không có method nào như vậy.

Trộn abstract method và concrete method

Code dùng chung cộng với những điểm mở rộng bắt buộc mới là lý do thật sự để chọn abstract class thay vì thứ gì đó đơn giản hơn. Base class gánh mọi thứ các subclass thật sự có chung, và gọi tên vài thứ chúng không chia sẻ được:

abstract class Account {
    private static int opened = 0;          // static state
    protected final String id;              // instance field
    private double balance;                 // mutable instance state

    protected Account(String id, double opening) {   // constructor, for super(...)
        this.id = id;
        this.balance = opening;
        opened++;
    }

    public static int openedCount() { return opened; }   // static method

    public double balance() { return balance; }          // concrete method

    public final void applyMonthEnd() {                  // concrete, uses the hook
        balance += balance * monthlyRate();
        balance -= fee();
    }

    protected abstract double monthlyRate();             // abstract hook
    protected abstract double fee();                     // abstract hook

    @Override public String toString() {
        return String.format("%s[%s] %.2f", getClass().getSimpleName(), id, balance);
    }
}

class Savings extends Account {
    Savings(String id, double opening) { super(id, opening); }
    @Override protected double monthlyRate() { return 0.004; }
    @Override protected double fee() { return 0; }
}

class Checking extends Account {
    Checking(String id, double opening) { super(id, opening); }
    @Override protected double monthlyRate() { return 0; }
    @Override protected double fee() { return 2.5; }
}

public class AccountDemo {
    public static void main(String[] args) {
        Account[] accounts = { new Savings("S-1", 1000), new Checking("C-1", 1000) };
        System.out.println("opened = " + Account.openedCount());
        for (Account a : accounts) {
            a.applyMonthEnd();
            System.out.println(a);
        }
    }
}
opened = 2
Savings[S-1] 1004.00
Checking[C-1] 997.50

balance là state private thay đổi được nằm trên một abstract class, opened là bộ đếm static do chính nó duy trì, và applyMonthEnd() là phần tính toán chỉ viết một lần. Không subclass nào lặp lại chỗ nào cả. Mỗi cái cung cấp hai con số — một rate và một fee — hết. Mọi SavingsChecking dùng chung đúng một đoạn code xử lý tiền, nên một bug sửa trong applyMonthEnd() là sửa cho tất cả.

Template method pattern

applyMonthEnd() ở trên đã là một ví dụ của chính cái pattern mà abstract class sinh ra để phục vụ. Base class định nghĩa một method final chốt cứng thuật toán — các bước và thứ tự của chúng — rồi gọi abstract method cho những phần thay đổi. Subclass điền vào từng bước và không đụng được vào trình tự.

Method export() final chạy bốn bước, ba bước là abstract hook được hai subclass điền khác nhau

Đây là dạng đầy đủ của nó: một report exporter mà base class giữ vòng lặp còn subclass giữ phần định dạng.

import java.util.List;

abstract class ReportExporter {
    private final String title;
    private final String[] columns;
    private int rowsWritten = 0;

    protected ReportExporter(String title, String[] columns) {
        this.title = title;
        this.columns = columns;
    }

    protected String title() { return title; }
    protected String[] columns() { return columns; }
    public int rowsWritten() { return rowsWritten; }

    /** The algorithm. Fixed here, once, for every subclass. */
    public final String export(List<String[]> rows) {
        StringBuilder out = new StringBuilder();
        out.append(header());
        for (String[] row : rows) {
            String[] clean = new String[row.length];
            for (int i = 0; i < row.length; i++) clean[i] = escape(row[i]);
            out.append(formatRow(clean));
            rowsWritten++;
        }
        out.append(footer());
        return out.toString();
    }

    /** Shared implementation: every exporter needs it, none of them differ. */
    protected String escape(String cell) {
        return cell == null ? "" : cell.trim();
    }

    /* The extension points. */
    protected abstract String header();
    protected abstract String formatRow(String[] cells);
    protected abstract String footer();
}

Hai subclass, mỗi cái chỉ cung cấp đúng ba hook:

class CsvExporter extends ReportExporter {
    CsvExporter(String title, String[] columns) { super(title, columns); }

    @Override protected String header() {
        return String.join(",", columns()) + "\n";
    }

    @Override protected String formatRow(String[] cells) {
        return String.join(",", cells) + "\n";
    }

    @Override protected String footer() { return ""; }
}

class MarkdownExporter extends ReportExporter {
    MarkdownExporter(String title, String[] columns) { super(title, columns); }

    @Override protected String header() {
        return "## " + title() + "\n\n"
             + "| " + String.join(" | ", columns()) + " |\n"
             + "|" + "---|".repeat(columns().length) + "\n";
    }

    @Override protected String formatRow(String[] cells) {
        return "| " + String.join(" | ", cells) + " |\n";
    }

    @Override protected String footer() {
        return "\n" + rowsWritten() + " rows\n";
    }
}

public class ExportDemo {
    public static void main(String[] args) {
        List<String[]> rows = List.of(
            new String[] {"  Ada ", "Engineer"},
            new String[] {"Linus", "  Maintainer"});
        String[] cols = {"Name", "Role"};

        for (ReportExporter e : new ReportExporter[] {
                 new CsvExporter("Team", cols),
                 new MarkdownExporter("Team", cols) }) {
            System.out.println("--- " + e.getClass().getSimpleName() + " ---");
            System.out.print(e.export(rows));
            System.out.println("rowsWritten = " + e.rowsWritten());
            System.out.println();
        }
    }
}
--- CsvExporter ---
Name,Role
Ada,Engineer
Linus,Maintainer
rowsWritten = 2

--- MarkdownExporter ---
## Team

| Name | Role |
|---|---|
| Ada | Engineer |
| Linus | Maintainer |

2 rows
rowsWritten = 2

Hai output khác nhau hoàn toàn từ một thuật toán. Để ý phần input chưa cắt khoảng trắng: " Ada "" Maintainer" đều ra sạch ở cả hai định dạng, vì escape() chạy trong base class và không subclass nào phải nhớ tới nó. Đó là phần lời — những bước mà mọi exporter tuyệt đối không được làm sai chỉ viết một lần, ở một chỗ, và không bỏ qua được.

Vì sao template method phải là final

export()final một cách có chủ ý. Không có nó, một subclass override được cả method và âm thầm thay luôn thuật toán — đúng cái hỏng mà pattern này sinh ra để ngăn. final biến chuyện đó từ một bug im lặng thành một compile error:

abstract class Pipeline {
    public final String run() { return step(); }
    protected abstract String step();
}

class Sneaky extends Pipeline {
    @Override protected String step() { return "step"; }

    @Override public String run() { return "I skipped the algorithm"; }
}
OverrideFinal.java:9: error: run() in Sneaky cannot override run() in Pipeline
    @Override public String run() { return "I skipped the algorithm"; }
                            ^
  overridden method is final
1 error

Sự phân vai đáng được nói thẳng ra, vì chính nó làm pattern này dễ đọc: final nghĩa là phần này của tôi, và đã chốt; abstract nghĩa là phần này của bạn, và bạn bắt buộc phải cung cấp; một method thường có body nghĩa là đây là mặc định, override nếu bạn có lý do. Nhóm cuối chính là "hook" trong cách mô tả kinh điển của pattern — một method rỗng hoặc gần như rỗng mà base class gọi ở chỗ mà phần lớn subclass chẳng làm gì.

Polymorphism thông qua một abstract type

Vì mọi abstract method chắc chắn có implementation trong mọi subclass concrete, abstract type an toàn để dùng làm kiểu phần tử của một array hay một List. Một vòng lặp, một lời gọi, và mỗi object chạy body của riêng nó:

import java.util.List;

abstract class Shape {
    public abstract double area();
    public abstract String name();
}

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

class Rect extends Shape {
    private final double w, h;
    Rect(double w, double h) { this.w = w; this.h = h; }
    @Override public double area() { return w * h; }
    @Override public String name() { return "Rect"; }
}

class Triangle extends Shape {
    private final double b, h;
    Triangle(double b, double h) { this.b = b; this.h = h; }
    @Override public double area() { return b * h / 2; }
    @Override public String name() { return "Triangle"; }
}

public class PolyDemo {
    public static void main(String[] args) {
        List<Shape> shapes = List.of(new Circle(1), new Rect(3, 4), new Triangle(6, 5));

        double total = 0;
        for (Shape s : shapes) {
            System.out.printf("%-8s %8.3f%n", s.name(), s.area());
            total += s.area();
        }
        System.out.printf("%-8s %8.3f%n", "TOTAL", total);

        Shape biggest = shapes.stream().max((a, b) -> Double.compare(a.area(), b.area())).get();
        System.out.println("biggest = " + biggest.name());
    }
}
Circle      3.142
Rect       12.000
Triangle   15.000
TOTAL      30.142
biggest = Triangle

Cơ chế chọn đúng area() ở mỗi vòng lặp là dynamic dispatch, đã nói ở bài trước — không có gì thay đổi chỉ vì kiểu khai báo là abstract. Thứ thay đổi là mức bảo đảm. Với một base class concrete, bạn đang hy vọng mọi subclass đã override area(); với abstract class thì compiler đã kiểm tra xong rồi. Ở đây không có instanceof, không có switch trên một cờ đánh dấu type, và thêm hình thứ tư cũng không phải sửa một chữ nào trong vòng lặp này.

Constructor trong một hierarchy có abstract class

Constructor của một abstract base chạy y hệt constructor của mọi superclass khác: chạy trước, trước phần thân constructor của subclass, ngay sau khi new cấp phát object.

abstract class Shape {
    protected final String name;

    protected Shape(String name) {
        System.out.println("2. Shape(String) body, name = " + name);
        this.name = name;
    }
}

class Circle extends Shape {
    private final double r;

    Circle(double r) {
        super("Circle");
        System.out.println("3. Circle(double) body, r = " + r);
        this.r = r;
    }
}

public class CtorOrder {
    public static void main(String[] args) {
        System.out.println("1. before new Circle(3)");
        new Circle(3);
        System.out.println("4. after new Circle(3)");
    }
}
1. before new Circle(3)
2. Shape(String) body, name = Circle
3. Circle(double) body, r = 3.0
4. after new Circle(3)

Chuỗi gọi này chính là chuỗi mà bài về inheritance đã mô tả; việc class là abstract không đổi gì trong đó. Cái mới là cái bẫy mà thứ tự này mở ra.

Cái bẫy: gọi abstract method từ constructor

Quy tắc từ bài trước — đừng gọi method override được từ trong constructor — ở đây là nặng nhất, vì abstract method không cho bạn đường lui. Không có phần thân nào ở base để rơi về, nên lời gọi luôn rơi vào bản override của subclass, và rơi vào đó trước khi thân constructor của subclass kịp chạy.

abstract class Shape {
    protected Shape() {
        System.out.println("in Shape(): area() = " + area());
    }
    public abstract double area();
}

class Square extends Shape {
    private final double side;

    Square(double side) {
        this.side = side;
        System.out.println("in Square(): area() = " + area());
    }

    @Override public double area() { return side * side; }
}
in Shape(): area() = 0.0
in Square(): area() = 16.0

sidefinal và đã được gán 4, vậy mà area() vẫn trả về 0.0 — vì Square.area() chạy trước this.side = side. Không có gì cảnh báo bạn cả. Một method thường ở base ít ra còn để lại được một giá trị mặc định dùng tạm; abstract method thì không, nên constructor của abstract class là nơi cuối cùng nên gọi nó.

Chọn abstract class hay concrete base class?

Thứ thay thế cho abstract là một class bình thường với các method override được và một giá trị mặc định trông có vẻ vô hại. Nó compile được, và nó hỏng trong im lặng:

class Shape {                       // concrete base, not abstract
    public double area() { return 0; }   // a "default" nobody meant to use
}

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

class Hexagon extends Shape {       // author forgot area() entirely
    private final double side;
    Hexagon(double side) { this.side = side; }
}

public class ConcreteBase {
    public static void main(String[] args) {
        Shape[] shapes = { new Circle(1), new Hexagon(1), new Shape() };
        double total = 0;
        for (Shape s : shapes) {
            System.out.printf("%-8s %.4f%n", s.getClass().getSimpleName(), s.area());
            total += s.area();
        }
        System.out.printf("total    %.4f%n", total);
    }
}
Circle   3.1416
Hexagon  0.0000
Shape    0.0000
total    3.1416

Không error, không warning, và một tổng sai đúng bằng phần diện tích của hình lục giác. Cho Shape thành abstract là biến cả hai lỗi đó thành compile error: Hexagon không build được, còn new Shape() bị từ chối thẳng.

ChọnKhi nào
abstract classCó ít nhất một operation không có giá trị mặc định hợp lý, và một object của riêng base sẽ vô nghĩa.
Base class concreteBase tự nó đã là một object hoàn chỉnh, dùng được, và override chỉ là phần tinh chỉnh tuỳ chọn.
abstract mà không có abstract methodMọi operation đều có implementation thật, nhưng tạo object của base vẫn chẳng mang ý nghĩa gì.
Class finalClass đã hoàn chỉnh và kế thừa chỉ tạo thêm cách làm hỏng nó.

Phép thử quyết định: viết ra xem new Base() sẽ mang nghĩa gì. Nếu bạn không viết hết được câu đó, class ấy là abstract.

Abstract class khác interface ở đâu

Bản ngắn, vì bài sau mới là bài sở hữu phần so sánh đầy đủ. Ba khác biệt gánh gần hết. Một class extends được đúng một class nhưng implement được bao nhiêu interface cũng được — class Square extends Shape, Printable thậm chí không parse nổi, javac báo error: '{' expected ngay tại dấu phẩy. Một abstract class giữ được instance state thay đổi được và một constructor, chính là thứ cho phép Account giữ balanceReportExporter giữ bộ đếm rowsWritten; interface không có cả hai, và field của nó ngầm là hằng số public static final. Và abstract class giữ được thành phần ở mức protected hay package-private, trong khi mọi thứ interface khai báo đều ngầm là public. Xấp xỉ đầu tiên: abstract class chia sẻ implementation xuống một nhánh dòng dõi, còn interface khai báo một khả năng mà nhiều type không họ hàng đều nhận được.

Lỗi thường gặp và error message tương ứng

Sai ở đâuChuyện gì xảy raMessage
new trên một abstract classcompile errorShape is abstract; cannot be instantiated
Subclass concrete thiếu một abstract methodcompile errorSquare is not abstract and does not override abstract method perimeter() in Shape
Abstract method trong một class không phải abstractcompile errorNotAbstractClass is not abstract and does not override abstract method area() in NotAbstractClass
Abstract method có bodycompile errorabstract methods cannot have a body
Method private abstractcompile errorillegal combination of modifiers: abstract and private
Method static abstractcompile errorillegal combination of modifiers: abstract and static
Method final abstractcompile errorillegal combination of modifiers: abstract and final
abstract final classcompile errorillegal combination of modifiers: abstract and final
super.area() khi area() là abstractcompile errorabstract method area() in Shape cannot be accessed directly
Implement hook public thành protectedcompile errorattempting to assign weaker access privileges; was public
Override một template method finalcompile errorrun() in Sneaky cannot override run() in Pipeline
Constructor của base gọi abstract methodcompile được, đọc phải state chưa khởi tạokhông có
Base concrete với mặc định vô nghĩa thay vì abstractcompile được, âm thầm ra kết quả saikhông có

Hai dòng cuối không sinh ra bất kỳ cảnh báo nào, và đó là lý do bài này dành nhiều thời gian nhất cho chúng.

FAQ

Có tạo object của abstract class trong Java được không?

Không. new Shape() bị từ chối ngay lúc compile với Shape is abstract; cannot be instantiated, và JVM ép đúng quy tắc đó với bất cứ thứ gì lọt qua compiler — Shape.class.getDeclaredConstructor().newInstance() ném java.lang.InstantiationException. Thứ trông giống như đang tạo object của abstract class, tức new Shape() { ... }, thật ra là một subclass không tên được khai báo rồi mới được tạo object; gọi getClass().getName() trên kết quả sẽ in ra kiểu như AnonDemo$1.

Abstract class trong Java có constructor không?

Có, và phần lớn abstract class hữu ích đều có. Nó không bao giờ được gọi bởi new trên chính abstract class đó; nó được mọi constructor của subclass gọi qua super(...), tường minh hay ngầm định, và nó chạy trước phần thân constructor của subclass. protected là mức access quy ước, vì subclass là caller hợp lệ duy nhất. Điều duy nhất không nên làm bên trong nó là gọi một abstract method — bản override trong subclass sẽ chạy trên những field còn nguyên giá trị mặc định.

Abstract class không có abstract method nào có hợp lệ không?

Có, và nó compile sạch. Đó là cách nói "class này sinh ra để được extends hoặc dùng ở mức static, không bao giờ để tạo object", và đó là thứ ép được chứ không như một dòng comment. Một constructor private cũng chặn được việc tạo object đồng thời chặn luôn kế thừa, nên chọn cách đó khi bạn muốn đóng hẳn class lại.

Vì sao abstract method không được private, static hay final?

Cả ba đều mâu thuẫn với ý nghĩa của abstract. private giấu method khỏi subclass, nên không gì implement được nó. Method static không dispatch theo runtime type của object, nên không có cơ chế nào chạm tới body trong subclass. final cấm override, mà đó lại là thứ abstract bắt buộc. javac báo cả ba là illegal combination of modifiers.

Khi nào nên dùng abstract class thay cho base class bình thường?

Khi có ít nhất một operation không có giá trị mặc định hợp lý và một object của riêng base sẽ vô nghĩa. Base class concrete buộc bạn phải bịa ra một mặc định — thường là return 0 hoặc một method rỗng — và subclass nào quên override sẽ cho kết quả sai mà không có error nào. Cho base thành abstract là biến cả hai sai lầm đó thành compile error. Còn nếu mọi operation đều thật sự có mặc định đúng và object của base tự nó đã dùng được, class concrete là lựa chọn đơn giản hơn.

Abstract class và interface khác nhau thế nào?

Abstract class giữ được constructor, instance state thay đổi được và thành phần không public, và một class chỉ extends được một cái. Interface khai báo một khả năng mà bao nhiêu type không họ hàng cũng implement được, nhưng không có instance state và không có constructor. Nói gọn: dùng abstract class để chia sẻ implementation trong một dòng họ, dùng interface để mô tả một type làm được gì. Bài tiếp theo đi hết phần so sánh này một cách chi tiết.

Kết luận

abstract trên một class lấy đi một khả năng và thêm vào một khả năng. Nó lấy đi new, nên base type chỉ còn dùng để khai báo, truyền đi và lưu trữ; và nó cho phép method không có body, nên compiler ép được mọi subclass concrete cung cấp đúng những phần thật sự khác nhau. Mọi thứ còn lại của class không đổi — field, constructor để super(...) gọi tới, thành phần static và method có code thật đều nguyên vẹn, và đó là lý do "abstract nghĩa là rỗng" là cách hiểu sai đáng bỏ đi đầu tiên.

Pattern biện minh cho sự tồn tại của cả cơ chế này là template method: một method final trong base chốt thứ tự các bước, cộng với các abstract hook để subclass điền vào. CsvExporterMarkdownExporter cho ra output khác hẳn nhau mà không cái nào chạm vào vòng lặp, phần escape hay bộ đếm row. Quanh đó là những quy tắc đáng thuộc — abstract method không được private, static hay final, một subclass hoặc implement hết hoặc tự khai báo là abstract, và constructor của base tuyệt đối không gọi abstract method.

Bài tiếp theo trong series: interface, và so sánh với abstract classimplements, vì sao một class có nhiều interface nhưng chỉ một superclass, method defaultstatic trên interface, cùng bảng so sánh đầy đủ với mọi thứ ở trên.

Bài viết liên quan

[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 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] Interface trong Java và so sánh với abstract class

Interface trong Java trên OpenJDK 21: implements khác extends thế nào, member ngầm định public static final, một class implement nhiều interface, default, static và private method, diamond problem và Interface.super, luật class-wins, functional interface, cùng bảng so sánh chi tiết với abstract class.

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