Command Palette

Search for a command to run...

[Advanced Java] 4 tính chất OOP dưới góc nhìn nâng cao: thiết kế object sai ở đâu

Bạn đã học xong phần cơ bản: khai báo class, giấu field sau getter, extend một base class, override method, implement interface. Advanced Java bắt đầu từ chỗ đó. Cú pháp không còn là phần khó nữa. Phần khó là một thiết kế có thể đúng Java hoàn toàn mà vẫn là thiết kế sai, và javac sẽ không bao giờ nhắc bạn.

Bài đầu tiên này quay lại 4 tính chất OOP, không phải để định nghĩa lại mà để hỏi mỗi tính chất phải trả giá gì khi được áp dụng một cách máy móc. Mọi phần đều cùng một cấu trúc: một class trông có vẻ đã encapsulation, hoặc một subclass trông đúng là subclass, rồi tới chương trình cho thấy nó không phải như vậy.

4 trụ OOP, một trụ bị nứt, dưới dòng chữ: vẫn compile, vẫn sai thiết kế

Mọi chương trình, mọi khối output và mọi thông báo của compiler bên dưới đều được tạo ra bằng cách compile và chạy thật trên OpenJDK 21.0.6. Không có dòng nào viết theo trí nhớ.

Series nâng cao này giả định bạn đã có gì

Series này giả định bạn nắm toàn bộ phần cơ bản: class và object, constructor, staticfinal, array và collection, exception, cùng 4 tính chất OOP ở mức "keyword này làm gì". Không phần nào trong đó được dạy lại ở đây.

Thứ thay đổi là câu hỏi. Người mới hỏi extends cho tôi những gì. Series này hỏi extends lấy đi của tôi những gì, và nó chặn tôi thay đổi điều gì sáu tháng sau. Hai câu hỏi cho hai câu trả lời khác nhau, và câu thứ hai quyết định một codebase có hấp thụ nổi yêu cầu mới hay phải viết lại vòng quanh nó.

Mỗi tính chất, cơ chế bạn vẫn viết, và lỗi mà cơ chế đó không ngăn được

Bốn dòng trong hình chính là bốn phần tiếp theo, theo đúng thứ tự. Mỗi phần đều bắt đầu từ code compile được hôm nay, chạy được hôm nay, và pass mọi test mà tác giả của nó đã viết.

Encapsulation được bảo đảm bằng behaviour, không phải bằng accessor

Invariant là một mệnh đề về object phải đúng trước và sau mỗi lời gọi public. "Số lượng tồn kho không bao giờ âm" là một invariant. Object hoặc bảo đảm được nó, hoặc không, và bản thân từ khóa private trên field không quyết định điều gì cả.

Đây là một kho hàng có field private, mọi truy cập đều đi qua method. Nó vẫn không bảo vệ được gì:

import java.util.HashMap;
import java.util.Map;

public class AskInventory {
    private final Map<String, Integer> stock = new HashMap<>();

    public AskInventory() {
        stock.put("SKU-1", 3);
    }

    public Map<String, Integer> getStock() {   // the invariant leaves the object here
        return stock;
    }

    public static void main(String[] args) {
        AskInventory inv = new AskInventory();

        // call site 1: remembers the rule
        if (inv.getStock().get("SKU-1") >= 2) {
            inv.getStock().put("SKU-1", inv.getStock().get("SKU-1") - 2);
        }
        System.out.println("after call site 1: " + inv.getStock());

        // call site 2: forgets it
        inv.getStock().put("SKU-1", inv.getStock().get("SKU-1") - 5);
        System.out.println("after call site 2: " + inv.getStock());
    }
}
after call site 1: {SKU-1=1}
after call site 2: {SKU-1=-4}

Tồn kho đã âm và không một method nào của AskInventory chạy để gây ra chuyện đó. Rule "không bán nhiều hơn số đang có" không nằm trong class; nó nằm ở call site 1, do một developer còn nhớ rule viết ra. Call site 2 cũng là rule đó, do một developer đã quên viết ra, và object không có chỗ nào để phát hiện.

Đó mới là phép thử thật cho encapsulation: không phải "field có private không" mà là "caller có thể đưa object vào trạng thái không hợp lệ mà không đi qua behaviour của tôi hay không". Đẩy rule vào bên trong thì câu hỏi tự trả lời:

import java.util.HashMap;
import java.util.Map;

final class Inventory {
    private final Map<String, Integer> stock = new HashMap<>();

    Inventory() {
        stock.put("SKU-1", 3);
    }

    /** The rule lives here, once. Callers cannot skip it. */
    boolean reserve(String sku, int qty) {
        if (qty <= 0) {
            throw new IllegalArgumentException("qty must be positive: " + qty);
        }
        int have = stock.getOrDefault(sku, 0);
        if (have < qty) {
            return false;
        }
        stock.put(sku, have - qty);
        return true;
    }

    int available(String sku) {
        return stock.getOrDefault(sku, 0);
    }
}

public class TellInventory {
    public static void main(String[] args) {
        Inventory inv = new Inventory();

        System.out.println("call site 1: " + inv.reserve("SKU-1", 2) + "  left " + inv.available("SKU-1"));
        System.out.println("call site 2: " + inv.reserve("SKU-1", 5) + "  left " + inv.available("SKU-1"));
        try {
            inv.reserve("SKU-1", 0);
        } catch (IllegalArgumentException e) {
            System.out.println("call site 3: " + e.getMessage());
        }
    }
}
call site 1: true  left 1
call site 2: false  left 1
call site 3: qty must be positive: 0

reserve không phải setter gắn thêm validate. Nó là một operation đọc và ghi state cùng lúc, nên không còn khoảng trống giữa bước kiểm tra và bước thực hiện để caller chen vào. available vẫn trả ra một con số, nhưng con số là bản sao của một giá trị — đưa nó ra ngoài không cho đi thứ gì.

Khác biệt ở đây không phải chuyện phong cách. Class thứ hai có đúng một chỗ phải sửa khi rule thay đổi, còn class thứ nhất có bao nhiêu caller thì có bấy nhiêu chỗ.

Unmodifiable view không phải là snapshot

Cách sửa quen thuộc cho một getter bị rò rỉ là Collections.unmodifiableList. Nó chặn caller ghi, tức là giải quyết được một nửa vấn đề, và nửa còn lại thường xuyên bị hiểu nhầm:

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

final class Cart {
    private final List<String> items = new ArrayList<>();

    void add(String item) { items.add(item); }

    List<String> view()     { return Collections.unmodifiableList(items); }  // wrapper
    List<String> snapshot() { return List.copyOf(items); }                   // copy
}

public class LiveView {
    public static void main(String[] args) {
        Cart cart = new Cart();
        cart.add("book");
        cart.add("pen");

        List<String> view = cart.view();
        List<String> snap = cart.snapshot();
        System.out.println("view " + view + "   snapshot " + snap);

        cart.add("mug");                       // the object mutates itself
        System.out.println("view " + view + "   snapshot " + snap);

        try {
            for (String s : view) {            // reading the "read-only" list
                if (s.equals("book")) cart.add("lamp");
            }
        } catch (Exception e) {
            System.out.println("iterating the view threw " + e.getClass().getName());
        }
        System.out.println("view is a wrapper? " + (view.getClass().getSimpleName()));
        System.out.println("snapshot type      " + (snap.getClass().getSimpleName()));
    }
}
view [book, pen]   snapshot [book, pen]
view [book, pen, mug]   snapshot [book, pen]
iterating the view threw java.util.ConcurrentModificationException
view is a wrapper? UnmodifiableRandomAccessList
snapshot type      List12

Wrapper là read-only với caller và hoàn toàn "sống" với chủ sở hữu. Một caller giữ lại nó và tin rằng mình đang cầm một list ổn định thực ra đang cầm một cửa sổ nhìn vào object vẫn đang thay đổi — và duyệt cửa sổ đó trong lúc chủ sở hữu append sẽ ném ConcurrentModificationException, tức là stack trace của caller cho một thao tác ghi của người khác.

⚠️ Collections.unmodifiableList(items) bảo vệ field khỏi caller. Nó không bảo vệ caller khỏi field. Chỉ bản copy mới làm được việc đó.

Getter trả về gìPhản ánh thay đổi sau đó bên trongCaller sửa được khôngCấp phát mỗi lần gọi
items, chính field đókhông có gì
Collections.unmodifiableList(items)khôngmột wrapper object
List.copyOf(items)khôngkhôngmột list cộng size() lần copy reference
không trả list nào, chỉ trả câu trả lờikhông áp dụngkhôngkhông có gì

Dòng cuối cùng chính là thứ phần này muốn thuyết phục bạn. contains, total, isEmptycount đều trả về giá trị, mà một giá trị thì không thể bị alias.

Một defensive copy thực sự tốn gì

"Copy khi trả ra" là lời khuyên đúng và có giá của nó, và cái giá đó không đo bằng đồng hồ bấm giây — nó là số object được tạo và số reference được chuyển, con số này giống nhau trên mọi máy. Gắn bộ đếm vào là ra kết quả chính xác:

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

final class Catalog {
    static long listsAllocated = 0;
    static long referencesCopied = 0;

    private final List<Integer> prices = new ArrayList<>();

    Catalog(int n) {
        for (int i = 1; i <= n; i++) prices.add(i);
    }

    /** accessor style: every caller gets its own defensive copy */
    List<Integer> getPrices() {
        listsAllocated++;
        referencesCopied += prices.size();   // new ArrayList<>(c) copies c.size() references
        return new ArrayList<>(prices);
    }

    /** behaviour style: the question is answered where the data lives */
    long totalAbove(int floor) {
        long sum = 0;
        for (int p : prices) if (p > floor) sum += p;
        return sum;
    }
}

public class CopyCost {
    public static void main(String[] args) {
        int size = 500, queries = 1000;
        Catalog c = new Catalog(size);

        long viaAccessor = 0;
        for (int q = 0; q < queries; q++) {
            for (int p : c.getPrices()) if (p > 400) viaAccessor += p;
        }
        System.out.println("accessor   total=" + viaAccessor
                + "  lists allocated=" + Catalog.listsAllocated
                + "  references copied=" + Catalog.referencesCopied);

        long viaBehaviour = 0;
        for (int q = 0; q < queries; q++) viaBehaviour += c.totalAbove(400);
        System.out.println("behaviour  total=" + viaBehaviour
                + "  lists allocated=0  references copied=0");
    }
}
accessor   total=45050000  lists allocated=1000  references copied=500000
behaviour  total=45050000  lists allocated=0  references copied=0

Cùng một kết quả, và đường đi qua accessor đã tạo một nghìn list và chuyển nửa triệu reference để có được nó. Chi phí ở đây mang tính cấu trúc: một lần cấp phát và size() lần copy reference cho mỗi lời gọi, mọi lời gọi, bất kể caller có đọc quá một phần tử hay không.

Không điều nào ở trên nói defensive copy là sai. Nó nói defensive copy là một quyết định. Getter làm rò rỉ field là bug về tính đúng đắn; getter copy là đúng và tuyến tính theo kích thước collection; method trả lời đúng câu hỏi của caller là đúng và không cấp phát gì. Hãy cân nhắc phương án thứ ba trước khi tranh luận về hai phương án đầu.

Inheritance: khi extends tạo ra một class sai

Một subclass thêm guard vào. Đó là lý do phổ biến nhất để viết extends: lấy một class đang chạy tốt rồi làm nó chặt hơn. BoundedBuffer chỉ cho phép tối đa ba dòng:

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

class Buffer {
    private final List<String> lines = new ArrayList<>();

    void append(String line)          { lines.add(line); }
    void insertAt(int i, String line) { lines.add(i, line); }
    int size()                        { return lines.size(); }
    String render()                   { return String.join(" | ", lines); }
}

class BoundedBuffer extends Buffer {
    private final int capacity;

    BoundedBuffer(int capacity) { this.capacity = capacity; }

    @Override
    void append(String line) {
        if (size() == capacity) {
            throw new IllegalStateException("buffer full at " + capacity);
        }
        super.append(line);
    }

    int capacity() { return capacity; }
}

public class BackDoor {
    public static void main(String[] args) {
        BoundedBuffer b = new BoundedBuffer(3);
        b.append("a");
        b.append("b");
        b.append("c");
        try {
            b.append("d");                    // the subclass's own test: passes
        } catch (IllegalStateException e) {
            System.out.println("append rejected: " + e.getMessage());
        }
        System.out.println("size " + b.size() + "  [" + b.render() + "]");

        b.insertAt(0, "x");                   // inherited, never overridden
        System.out.println("size " + b.size() + "  [" + b.render() + "]");
        System.out.println("over capacity? " + (b.size() > b.capacity()));
    }
}
append rejected: buffer full at 3
size 3  [a | b | c]
size 4  [x | a | b | c]
over capacity? true

Guard chạy đúng, và object vẫn vượt capacity. insertAt là một method hoàn toàn hợp lý của Buffer, tác giả subclass chưa từng nghĩ tới nó, và extends vẫn public nó ra trên BoundedBuffer. Không có keyword nào cho ý "inherit phần implementation nhưng không lấy method này"; bề mặt đi kèm nguyên khối.

Đó là kiểu lỗi đáng nhớ nhất: extends không cho bạn chọn class của mình public ra những method nào. Mọi public method superclass đang có, và mọi public method nó sẽ có ở phiên bản sau, đều là method của bạn — kể cả những method ghi thẳng vào state mà guard của bạn đang bảo vệ.

Series cơ bản đã trình bày thành viên còn lại của họ lỗi này, khi bộ đếm của một subclass sai vì superclass tự gọi các method overridable của chính nó ở bên trong. Cả hai cùng gốc: subclass bị coupling vào implementation của superclass chứ không chỉ vào API của nó. Trường hợp ở đây không cần library nào và không cần ai refactor để tái hiện — nó sai ngay từ ngày được viết ra.

Cùng một guard capacity, một bên là subclass một bên là wrapper: bề mặt mỗi thiết kế public ra và thứ caller nhận được

Viết lại bằng delegation

Composition là giữ object trong field rồi forward sang nó, nên bề mặt là danh sách do bạn viết chứ không phải danh sách bạn được thừa hưởng:

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

class Buffer {
    private final List<String> lines = new ArrayList<>();

    void append(String line)          { lines.add(line); }
    void insertAt(int i, String line) { lines.add(i, line); }
    int size()                        { return lines.size(); }
    String render()                   { return String.join(" | ", lines); }
}

final class BoundedLog {
    private final Buffer inner = new Buffer();   // held, not inherited
    private final int capacity;

    BoundedLog(int capacity) { this.capacity = capacity; }

    void append(String line) {
        if (inner.size() == capacity) {
            throw new IllegalStateException("buffer full at " + capacity);
        }
        inner.append(line);
    }

    int size()      { return inner.size(); }
    String render() { return inner.render(); }
}

public class Composed {
    public static void main(String[] args) {
        BoundedLog log = new BoundedLog(3);
        log.append("a");
        log.append("b");
        log.append("c");
        try {
            log.append("d");
        } catch (IllegalStateException e) {
            System.out.println("append rejected: " + e.getMessage());
        }
        System.out.println("size " + log.size() + "  [" + log.render() + "]");
        System.out.println("over capacity? " + (log.size() > 3));
    }
}
append rejected: buffer full at 3
size 3  [a | b | c]
over capacity? false

Buffer không đổi một dòng, insertAt vẫn tồn tại trên nó, và từ bên ngoài BoundedLog thì không với tới được vì không có gì forward sang. Viết log.insertAt(0, "x") không còn là một sai lầm thiết kế vẫn chạy được — nó là compile error:

NoInsert.java:5: error: cannot find symbol
        log.insertAt(0, "x");
           ^
  symbol:   method insertAt(int,String)
  location: variable log of type BoundedLog
1 error

Invariant đã chuyển từ "mọi người làm ơn đừng gọi method này" sang "method này không tồn tại ở đây", và chỉ phiên bản thứ hai mới là thứ compiler giúp được bạn.

Câu hỏi cần đặt trước khi viết extends

Lời khuyên quen thuộc là thử câu "một BoundedBuffer là một Buffer". Câu đó đúng trong ví dụ trên, và nó không cứu được class. Một phép thử sắc hơn:

Câu hỏiNếu câu trả lời làThì
Mọi method superclass public ra có giữ được invariant của tôi không?khôngdùng composition
Tôi có cần truyền object của mình vào chỗ đang nhận supertype không?dùng inheritance, và đọc kỹ phần substitutability bên dưới
Superclass có tiếp tục phình ra qua các phiên bản không?dùng composition
Tôi có muốn toàn bộ public API của superclass nằm trên class của mình, mãi mãi không?khôngdùng composition

Delegation không miễn phí, và cái giá của nó thì trung thực và nhìn thấy được: một forwarding method cho mỗi operation bạn mở lại ra ngoài. BoundedLog viết ba cái. Một wrapper quanh interface bốn mươi method phải viết bốn mươi, và đó là lý do chính đáng để bạn cân nhắc — nhưng hãy để ý bạn mua được gì, vì bốn mươi method đó cũng là bốn mươi chỗ bạn có quyền từ chối, đổi tên, thu hẹp hoặc validate. Inheritance không thu phí trước, nó chỉ lấy đi quyền lựa chọn.

Polymorphism: type bạn sở hữu và type bạn không sở hữu

Thêm một trường hợp vào hệ thống nghĩa là thêm một class, và thêm một operation nghĩa là thêm một method. Việc nào rẻ thì phụ thuộc vào đúng một điều: ai sở hữu type đó.

Khi type là của bạn, thêm operation là một danh sách việc do compiler lập. Đặt label() lên interface và mọi implementation buộc phải trả lời:

import java.util.List;

interface PaymentMethod {
    String authorize(long cents);
    String label();                     // added in version 2
}

final class Card implements PaymentMethod {
    private final String last4;
    Card(String last4) { this.last4 = last4; }
    public String authorize(long cents) { return "captured " + cents; }
    public String label()               { return "Card ****" + last4; }
}

final class BankTransfer implements PaymentMethod {
    public String authorize(long cents) { return "queued " + cents; }
    public String label()               { return "Bank transfer"; }
}

public class Own {
    static void checkout(List<PaymentMethod> methods, long cents) {
        for (PaymentMethod m : methods) {          // one call site, every implementation
            System.out.println(m.label() + " -> " + m.authorize(cents));
        }
    }

    public static void main(String[] args) {
        checkout(List.of(new Card("4242"), new BankTransfer()), 1999);
    }
}
Card ****4242 -> captured 1999
Bank transfer -> queued 1999

Xóa label() khỏi BankTransfer thì build dừng lại và chỉ đích danh class:

Broken.java:6: error: BankTransfer is not abstract and does not override abstract method label() in PaymentMethod
class BankTransfer implements PaymentMethod {
^
1 error

Thông báo đó chính là toàn bộ giá trị của việc thiết kế quanh một type bạn sở hữu. Tập implementation là biết được, compiler duyệt hết giúp bạn, và "bọn mình quên update một chỗ" không còn là một loại bug production nữa.

Khi nào switch theo type là lựa chọn trung thực

Lời khuyên "thay type check bằng polymorphism" đúng đủ nhiều để được nhắc ở khắp nơi, và nó có hai ngoại lệ thật.

Ngoại lệ thứ nhất là operation không thuộc về type đó. Một document tree không nên biết về HTML, về plain-text export, về đếm từ, về format log của bạn. Đẩy tất cả những thứ đó lên các node class thì mọi mối bận tâm về rendering trong hệ thống sẽ chui vào ở luôn trong domain model. Thay vào đó hãy khai báo hierarchy là sealed, và compiler sẽ chứng minh switch phủ hết mọi trường hợp mà operation không cần chuyển vào trong:

import java.util.List;

sealed interface Node permits Text, Bold, Group {}

record Text(String value)          implements Node {}
record Bold(Node child)            implements Node {}
record Group(List<Node> children)  implements Node {}

public class Doc {
    static String html(Node n) {
        return switch (n) {                       // no default: the compiler proves coverage
            case Text t  -> t.value();
            case Bold b  -> "<b>" + html(b.child()) + "</b>";
            case Group g -> g.children().stream().map(Doc::html).reduce("", String::concat);
        };
    }

    static int words(Node n) {
        return switch (n) {
            case Text t  -> t.value().isBlank() ? 0 : t.value().trim().split("\\s+").length;
            case Bold b  -> words(b.child());
            case Group g -> g.children().stream().mapToInt(Doc::words).sum();
        };
    }

    public static void main(String[] args) {
        Node doc = new Group(List.of(new Text("hello "), new Bold(new Text("brave")), new Text(" world")));
        System.out.println(html(doc));
        System.out.println("words = " + words(doc));
    }
}
hello <b>brave</b> world
words = 3

Không có nhánh default nào, và đó mới là điểm chính. Thêm Link vào mệnh đề permits mà không đụng tới html, switch lập tức không compile được nữa:

Doc2.java:12: error: the switch expression does not cover all possible input values
        return switch (n) {
               ^
1 error

Một hierarchy sealed cộng với switch phủ hết cho bạn đúng cái đảm bảo "bạn còn sót một trường hợp" mà abstract method đem lại, trong khi vẫn để operation nằm ngoài type. Đó là một thiết kế hợp lệ, không phải phương án chữa cháy.

Ngoại lệ thứ hai đơn giản hơn: bạn không thể thêm method vào một type bạn không viết ra. Giá trị đọc lên từ file config là Object, và không cách thiết kế đẹp nào đặt được method describe() vào java.lang.Integer:

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class Foreign {
    static String describe(Object v) {
        return switch (v) {                       // default is mandatory: Object is open
            case null      -> "absent";
            case Integer i -> "int " + i;
            case Boolean b -> "flag " + b;
            case String s  -> "text " + s;
            case List<?> l -> "list of " + l.size();
            default        -> v.getClass().getSimpleName() + " (unhandled)";
        };
    }

    public static void main(String[] args) {
        Map<String, Object> config = new LinkedHashMap<>();
        config.put("port", 8080);
        config.put("debug", true);
        config.put("host", "localhost");
        config.put("tags", List.of("a", "b"));
        config.put("ratio", 0.5d);
        config.put("missing", null);

        config.forEach((k, v) -> System.out.println(k + " = " + describe(v)));
    }
}
port = int 8080
debug = flag true
host = text localhost
tags = list of 2
ratio = Double (unhandled)
missing = absent

Object không phải sealed, nên nhánh default là bắt buộc và ratio rơi vào đó. Switch ở đây trung thực vì không còn lựa chọn nào khác: type thuộc về JDK, operation thuộc về bạn, và ranh giới giữa hai bên phải được vượt qua ở đâu đó.

Khi nào switch là một abstraction còn thiếu

Phiên bản không trung thực trông gần như y hệt và chỉ khác ở một điểm: type là của bạn, nên các nhánh đó lẽ ra đã có thể là method. Ở đây channelretries được quyết định bên ngoài chính những class mà chúng mô tả:

abstract class Notification {
    abstract String recipient();
}

class EmailNotification extends Notification { String recipient() { return "a@example.com"; } }
class PushNotification  extends Notification { String recipient() { return "device-7"; } }
class SmsNotification   extends Notification { String recipient() { return "+84900000000"; } }  // added later

public class Silent {
    static String channel(Notification n) {
        if (n instanceof EmailNotification) return "smtp";
        if (n instanceof PushNotification)  return "fcm";
        return "unknown";
    }

    static int retries(Notification n) {
        if (n instanceof EmailNotification) return 3;
        if (n instanceof PushNotification)  return 1;
        return 0;
    }

    public static void main(String[] args) {
        Notification[] all = { new EmailNotification(), new PushNotification(), new SmsNotification() };
        for (Notification n : all) {
            System.out.printf("%-18s channel=%-8s retries=%d%n",
                    n.getClass().getSimpleName(), channel(n), retries(n));
        }
    }
}
EmailNotification  channel=smtp     retries=3
PushNotification   channel=fcm      retries=1
SmsNotification    channel=unknown  retries=0

SmsNotification compile được, chạy được, và bị định tuyến sai trong im lặng. Nhánh cuối của một chuỗi instanceof trên hierarchy mở không phải là default — nó là một lời hứa rằng sẽ không có type mới nào rơi vào đó, và không ai kiểm tra lời hứa ấy. Hai chuỗi, hai nhánh bị bỏ sót, không một warning.

Đưa cả hai operation lên type thì đúng cái thiếu sót đó trở thành lỗi build, gọi tên cả class lẫn method:

Fixed.java:19: error: SmsNotification is not abstract and does not override abstract method retries() in Notification
class SmsNotification extends Notification {          // added later, nothing else edited
^
1 error

Phép thử giữa hai trường hợp không phải là "code này có dùng instanceof không". Nó là: tập type có đóng không, và operation có phải thứ type nên biết không? Đóng và có thì dùng method. Đóng và không thì dùng hierarchy sealed với switch phủ hết. Mở và không thì dùng nhánh default cùng kỷ luật giữ cho nhánh đó luôn phòng thủ. Mở và có — một hierarchy không giới hạn gồm type của chính bạn, dispatch bằng chuỗi instanceof — chính là abstraction còn thiếu, và nó bắt bạn trả một lần sửa cho mỗi type ở mỗi call site, mãi mãi.

Abstraction: chọn thứ không expose ra ngoài

Một abstraction được định nghĩa bằng những gì nó từ chối nói ra. Mọi type trong signature là một lời hứa, và lời hứa bạn không định đưa ra là một coupling bạn sẽ phải trả giá. Interface dưới đây hứa nhầm hai lần chỉ trong một dòng:

import java.sql.SQLException;
import java.util.HashMap;

interface UserStore {
    HashMap<String, String> loadAll() throws SQLException;   // two leaks in one signature
}

class JdbcUserStore implements UserStore {
    public HashMap<String, String> loadAll() throws SQLException {
        HashMap<String, String> rows = new HashMap<>();
        rows.put("u1", "Mai");
        return rows;
    }
}

public class Leaky {
    static void report(UserStore store) {
        try {
            HashMap<String, String> users = store.loadAll();   // caller names HashMap
            System.out.println("users " + users);
        } catch (SQLException e) {                             // caller catches SQL
            System.out.println("db error " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        report(new JdbcUserStore());
    }
}
users {u1=Mai}

Nó chạy được, và interface đã nói cho mọi caller hai điều chúng không cần biết: kết quả là HashMap chứ không phải một Map nào đó, và dữ liệu đến từ database. Cả hai đều là implementation detail khoác lên mình một public type.

Hóa đơn tới khi implementation thứ hai xuất hiện. Một store đọc từ file trả về Map và hỏng với IOException, tuy vẫn mô tả đúng cùng một operation:

import java.io.IOException;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;

interface UserStore {
    HashMap<String, String> loadAll() throws SQLException;
}

class FileUserStore implements UserStore {
    public Map<String, String> loadAll() throws IOException {
        return Map.of("u1", "Mai");
    }
}
Swap.java:10: error: FileUserStore is not abstract and does not override abstract method loadAll() in UserStore
class FileUserStore implements UserStore {
^
Swap.java:11: error: loadAll() in FileUserStore cannot implement loadAll() in UserStore
    public Map<String, String> loadAll() throws IOException {
                               ^
  return type Map<String,String> is not compatible with HashMap<String,String>
2 errors

Hai error, và không error nào sửa được bên trong class mới. Map.of trả về một map immutable không phải HashMap, nên return type ép phải copy sang HashMap một cách vô nghĩa; mệnh đề throws ép file store phải catch IOException rồi ném lại thành thứ interface cho phép. Đó là cái giá của một abstraction rò rỉ, và đơn vị tính là call site: mọi chỗ đã gọi tên HashMap hay catch SQLException đều là một chỗ phải sửa.

Thu hẹp signature cho tới khi không còn rò rỉ

Cách sửa là nói ít đi. Interface hứa một Map và một loại lỗi thuộc về abstraction, chứ không thuộc về riêng implementation nào:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;

class StoreException extends Exception {
    StoreException(String message, Throwable cause) { super(message, cause); }
}

interface UserStore {
    Map<String, String> loadAll() throws StoreException;   // says nothing about the medium
}

class MemoryUserStore implements UserStore {
    public Map<String, String> loadAll() { return Map.of("u1", "Mai"); }   // may narrow throws
}

class FileUserStore implements UserStore {
    private final Path path;
    FileUserStore(Path path) { this.path = path; }

    public Map<String, String> loadAll() throws StoreException {
        Map<String, String> rows = new LinkedHashMap<>();
        try {
            for (String line : Files.readAllLines(path)) {
                String[] parts = line.split("=", 2);
                rows.put(parts[0], parts[1]);
            }
        } catch (IOException e) {
            throw new StoreException("cannot read " + path, e);
        }
        return rows;
    }
}

public class Ports {
    static void report(UserStore store) {          // unchanged for every implementation
        try {
            System.out.println("users " + store.loadAll());
        } catch (StoreException e) {
            System.out.println(e.getMessage() + " (cause " + e.getCause().getClass().getSimpleName() + ")");
        }
    }

    public static void main(String[] args) {
        report(new MemoryUserStore());
        report(new FileUserStore(Path.of("users.txt")));
        report(new FileUserStore(Path.of("missing.txt")));
    }
}
users {u1=Mai}
users {u1=Mai, u2=Khanh}
cannot read missing.txt (cause NoSuchFileException)

Một method report, ba tình huống lưu trữ khác nhau, không sửa dòng nào. MemoryUserStore không khai báo throws gì cả, điều này hợp lệ vì một override được phép thu hẹp danh sách checked exception, còn FileUserStore trả về LinkedHashMap vì interface chưa bao giờ đòi hỏi khác đi. IOException gốc vẫn còn nguyên ở vị trí cause, nên không mất thông tin — nó chỉ không còn là một phần của lời hứa public nữa.

Quy tắc đáng nhớ: return type nên là type rộng nhất vẫn trả lời được câu hỏi, và exception type nên gọi tên abstraction chứ không phải cơ chế. Signature là phần duy nhất của một class mà bạn không thể lặng lẽ đổi về sau.

Substitutability: subclass compile được mà vẫn làm hỏng caller

Mỗi subclass đưa ra một tuyên bố: ở đâu nhận supertype thì ở đó truyền tôi vào được. javac kiểm tra phần signature của tuyên bố đó và không kiểm tra gì thêm. Phần behaviour là việc của bạn.

Square là ví dụ kinh điển vì nó hợp lý đến mức khó cãi. Hình vuông là hình chữ nhật có hai cạnh bằng nhau, vậy thì giữ cho hai cạnh bằng nhau:

class Rectangle {
    private int width, height;

    Rectangle(int width, int height) { this.width = width; this.height = height; }

    void setWidth(int width)   { this.width = width; }
    void setHeight(int height) { this.height = height; }
    int area()                 { return width * height; }
}

class Square extends Rectangle {
    Square(int side) { super(side, side); }

    @Override void setWidth(int w)  { super.setWidth(w);  super.setHeight(w); }   // keeps the sides equal
    @Override void setHeight(int h) { super.setWidth(h);  super.setHeight(h); }
}

public class Substitution {
    /** Square's own test, written by the author of Square. */
    static void squareOwnTest() {
        Square s = new Square(3);
        System.out.println("square 3x3 area      " + s.area()   + (s.area() == 9  ? "  PASS" : "  FAIL"));
        s.setWidth(4);
        System.out.println("square resized to 4  " + s.area()   + (s.area() == 16 ? "  PASS" : "  FAIL"));
    }

    /** A caller that has only ever seen the Rectangle API. */
    static void resize(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        boolean ok = r.area() == 20;
        System.out.printf("%-10s after 5x4 -> area %2d  %s%n",
                r.getClass().getSimpleName(), r.area(), ok ? "PASS" : "FAIL");
    }

    public static void main(String[] args) {
        squareOwnTest();
        resize(new Rectangle(1, 1));
        resize(new Square(3));
    }
}
square 3x3 area      9  PASS
square resized to 4  16  PASS
Rectangle  after 5x4 -> area 20  PASS
Square     after 5x4 -> area 16  FAIL

Đọc bốn dòng theo đúng thứ tự, vì dòng nào cũng quan trọng. Square compile được. Square pass mọi test do chính tác giả của nó viết — hình vuông cạnh 4 đúng là có area 16. Và một method chưa từng nhắc tới Square, được viết và test xong từ trước khi Square tồn tại, lại cho ra kết quả khác khi nhận một Square.

Một caller chạy qua Rectangle và qua Square: width và height sau mỗi bước, cùng area mà caller nhận được

Không ai viết bug ở đây cả. Rectangle chưa bao giờ ghi rõ "set width thì height giữ nguyên", vì đó là loại điều không ai nghĩ tới chuyện phải viết ra. Square không thể vừa tôn trọng điều đó vừa còn là hình vuông. Hai class đều tự bảo vệ được mình và không thể cùng đúng, nghĩa là chính quan hệ inheritance mới là sai lầm — không phải code bên trong class nào.

Siết chặt precondition cũng hỏng theo đúng cách đó

Cùng kiểu lỗi đó xuất hiện mà không cần tới hình học. Một subclass thêm rule tức là thêm một điều kiện caller phải thỏa mãn, trong khi caller đang cầm supertype:

import java.util.List;

class Account {
    private long balance;

    Account(long balance) { this.balance = balance; }

    /** Contract: any amount up to the balance is accepted. */
    void withdraw(long amount) {
        if (amount > balance) {
            throw new IllegalArgumentException("insufficient funds: " + amount + " > " + balance);
        }
        balance -= amount;
    }

    long balance() { return balance; }
}

class DailyLimitAccount extends Account {
    private static final long LIMIT = 100;

    DailyLimitAccount(long balance) { super(balance); }

    @Override
    void withdraw(long amount) {
        if (amount > LIMIT) {                                  // a rule the supertype never had
            throw new IllegalStateException("daily limit " + LIMIT + " exceeded by " + amount);
        }
        super.withdraw(amount);
    }
}

public class Preconditions {
    static void payroll(List<Account> accounts) {
        for (Account a : accounts) {
            a.withdraw(150);                                   // legal for every Account: balance is 1000
            System.out.println("paid from " + a.getClass().getSimpleName() + ", left " + a.balance());
        }
    }

    public static void main(String[] args) {
        DailyLimitAccount d = new DailyLimitAccount(1000);
        d.withdraw(50);
        System.out.println("own test: withdrew 50, left " + d.balance() + "  PASS");

        payroll(List.of(new Account(1000), d));
    }
}
own test: withdrew 50, left 950  PASS
paid from Account, left 850
Exception in thread "main" java.lang.IllegalStateException: daily limit 100 exceeded by 150
	at DailyLimitAccount.withdraw(Preconditions.java:27)
	at Preconditions.payroll(Preconditions.java:36)
	at Preconditions.main(Preconditions.java:46)

payroll duyệt một List các Account, rút một số tiền mà mọi balance đều cover được, rồi chết ở phần tử thứ hai với một loại exception nó không có lý do gì để catch. Giới hạn rút trong ngày là một yêu cầu có thật. Nó không phải là một subclass, vì caller đang cầm Account không thể bị đòi hỏi phải biết nó tồn tại.

Những quy tắc giữ cho một subclass còn substitutable

Bốn ràng buộc, và các ví dụ ở trên vi phạm mỗi thứ một cái:

  • Đừng siết chặt điều kiện caller phải thỏa mãn trước lời gọi. Nếu supertype chấp nhận mọi số tiền không vượt balance thì bạn cũng phải chấp nhận.
  • Đừng làm yếu đi điều caller được phép tin sau lời gọi. setWidth phải để height nguyên chỗ caller đã đặt.
  • Đừng ném thứ mà contract của supertype không hề nhắc tới, kể cả unchecked exception. Compiler chỉ ép được điều này với checked exception và bó tay với phần còn lại.
  • Đừng đổi state mà caller không yêu cầu bạn đổi. Một side effect supertype không có tương đương thì không phải là cải tiến, nó là một method khác mang cùng cái tên.

Khi một subclass không đáp ứng được cả bốn, quan hệ đó không phải subtyping. Hãy giữ object trong field, expose đúng những operation thực sự là của bạn, và để hai type đứng riêng. Một bài sau trong series sẽ gọi ràng buộc này bằng cái tên quen thuộc của nó và đặt nó cạnh những nguyên tắc thiết kế cùng nhóm; ở đây chỉ cần bạn nhận ra nó khi nhìn một stack trace là đủ.

4 tính chất OOP như 4 câu hỏi thiết kế

Tính chấtCâu hỏi phần cơ bản trả lờiCâu hỏi một thiết kế phải trả lời
Encapsulationfield nào là private?caller có thể tới trạng thái không hợp lệ mà không đi qua behaviour của tôi không?
Inheritanceextends cho tôi những gì?mọi method tôi inherit có còn giữ được invariant của tôi không?
Polymorphismoverride nào sẽ chạy?ai sở hữu type này, và tôi có đặt được operation lên nó không?
Abstractionabstract nghĩa là gì?signature của tôi hứa những gì, và đang để lộ những gì?

Cột bên trái kiểm tra được bằng cách đọc một class. Cột bên phải chỉ kiểm tra được bằng cách đọc các caller, và đó là lý do nó qua được code review quá dễ còn qua production thì quá tệ.

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

Coi private là encapsulation. Mọi field trong AskInventory đều private, và tồn kho vẫn âm. Câu hỏi là trạng thái không hợp lệ có tới được hay không, chứ không phải field có đọc thẳng được hay không.

Trả về unmodifiable view rồi gọi nó là immutable. Wrapper chặn caller ghi; nó không chặn chủ sở hữu ghi, và caller nhìn thấy hết những lần ghi đó. Dùng List.copyOf khi caller cần sự ổn định, và dùng một method trả về giá trị khi caller chỉ cần một câu trả lời.

Viết extends để tái sử dụng implementation. Tái sử dụng là quan hệ has a, và forward ba method rẻ hơn nhiều so với việc public ra một API surface bạn không chọn và không thu hẹp lại được.

Kết thúc một chuỗi instanceof bằng nhánh dự phòng. Trên hierarchy còn có thể phình ra, nhánh đó là bộ xử lý im lặng cho mọi type chưa tồn tại. SmsNotification bị đẩy vào unknown với retries=0 và không có gì báo lại.

Đặt implementation type vào signature. HashMap thay vì Map, SQLException thay vì một exception ở mức storage. Cả hai compile hoàn hảo và cả hai đều tính tiền bạn đúng vào ngày implementation thứ hai xuất hiện.

FAQ

Encapsulation có phải chỉ là để field private rồi thêm getter không?

Không. Một getter trả về field mutable, hoặc một cặp getter cho phép caller tự làm bước kiểm tra rồi bước thực hiện tách rời nhau, đều đẩy invariant ra ngoài object. Encapsulation là tính chất: không chuỗi lời gọi public nào có thể đưa object vào trạng thái không hợp lệ.

Có nên cho mọi getter trả về defensive copy không?

Chỉ khi caller thật sự cần cả collection. Một bản copy tốn một lần cấp phát và size() lần copy reference cho mỗi lời gọi — lần chạy có gắn bộ đếm ở trên cho thấy một nghìn lời gọi trên list 500 phần tử tốn 1000 list và 500.000 lần copy reference. Một method trả lời đúng câu hỏi của caller thì trả về một giá trị và không cấp phát gì, nên hãy thử phương án đó trước.

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

Bất cứ khi nào bạn muốn phần code chứ không muốn phần type. Inheritance public toàn bộ API của superclass lên class của bạn, ngay bây giờ và ở mọi phiên bản sau của nó, nên một guard bạn thêm vào có thể bị đi vòng qua bởi một method bạn chưa từng nghĩ tới. Hãy dùng composition trừ khi bạn thật sự cần object của mình được chấp nhận ở chỗ đang nhận supertype.

Switch theo type có phải lúc nào cũng là thiết kế tệ không?

Không. Trên một hierarchy sealed, compiler chứng minh switch phủ hết mọi trường hợp — đúng cái đảm bảo mà abstract method đem lại — và nó giữ những việc như render HTML nằm ngoài domain model. Trên type bạn không viết ra, đó là lựa chọn duy nhất. Nó chỉ là code smell đúng khi type là của bạn, không giới hạn, và operation lẽ ra thuộc về chúng.

Điều gì làm một abstraction trong Java bị rò rỉ?

Một signature gọi tên thứ chỉ đúng một implementation thỏa mãn được. HashMap thay vì Map ép mọi implementation vào một cấu trúc dữ liệu; throws SQLException nói với mọi caller rằng dữ liệu nằm trong database. Chi phí được tính bằng số call site phải sửa khi implementation thứ hai xuất hiện.

Làm sao biết một subclass an toàn để truyền vào chỗ đang nhận superclass?

Lấy một method chỉ biết supertype, viết từ trước khi subclass của bạn tồn tại, rồi chạy nó với cả hai. Nếu kết quả khác nhau ở bất kỳ điểm nào mà contract của supertype không cho phép — giá trị khác, exception mới, state bị đổi thêm — thì subclass đó không substitutable, dù nó compile sạch tới đâu.

Kết luận

Không lỗi nào trong bài này là compiler error. Một field private mà vẫn để caller đưa tồn kho về số âm, một guard bị method inherit đi vòng qua, một chuỗi instanceof nuốt gọn type mới trong im lặng, một HashMap nằm trong signature, một subclass pass test của chính nó rồi làm hỏng caller — tất cả đều compile được, và tất cả đều là quyết định mà code đã chọn thay bạn. Đọc ra được những quyết định đó chính là kỹ năng mà series này hướng tới.

Bài tiếp theo chuyển từ nguyên tắc sang một tính năng ngôn ngữ mang những nguyên tắc đó: nested class và inner class. Static nested class, inner class cùng reference tới enclosing instance mà nó giữ, local class và anonymous class — mỗi loại thực sự compile ra cái gì, và chọn sai loại thì tốn những gì.

Bài viết liên quan

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Enum nâng cao trong Java: constructor, constant body, EnumMap và enum singleton

Enum nâng cao trong Java trên OpenJDK 21: javap cho thấy enum thực sự compile ra cái gì, field và constructor private ngầm định, constant-specific class body cùng những file class sinh thêm, abstract method, enum implement interface, EnumMap và EnumSet, switch exhaustive, enum singleton mà reflection không phá được, state machine bằng enum, và các bẫy quanh ordinal với values().

[Advanced Java] Design Pattern phổ biến trong Java: Singleton, Factory, Builder, Observer và Strategy

Năm design pattern trong Java trên OpenJDK 21, mỗi pattern đều được chỉ ra ngay bên trong JDK và đều kèm trường hợp nó gây hại: initialization của singleton và cuộc đua mà null check không đồng bộ thua, Integer.valueOf cùng cache của nó, staged builder được compiler kiểm tra, listener gây leak và listener làm dừng cả lượt broadcast, và Comparator chính là strategy type bạn vẫn dùng hằng ngày.