Command Palette

Search for a command to run...

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

Encapsulation là việc giữ state của một object nằm dưới quyền kiểm soát của chính object đó, sao cho một luật mà class đã cam kết không thể bị code bên ngoài phá vỡ. Luật mới là trọng tâm. private và setter chỉ là bộ máy để thi hành luật đó.

Cách đặt vấn đề này quan trọng, vì encapsulation thường được dạy như một nghi thức — để field là private, sinh getter, sinh setter, xong — và kết quả là những class dễ vỡ đúng bằng đám public field mà chúng vừa thay thế. Bài này đi từ đầu kia: chọn một luật mà object không bao giờ được vi phạm, rồi xem cần những gì để thật sự giữ được luật đó. 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.

Một object với phần lõi private: một lệnh ghi bị chặn ở tường, một lệnh đi qua setter, một lệnh đọc đi ra qua getter

Các ví dụ giả định bạn đã có class, field, constructor, this, staticfinal từ những bài trước.

Encapsulation thật ra bảo vệ điều gì

Một class thường cam kết điều gì đó về state của chính nó mà type system không diễn đạt được. Balance không bao giờ âm. Phần trăm nằm trong khoảng 0 đến 100. Một array các sample luôn được sort. Một danh sách order line không bao giờ rỗng. Những cam kết đó gọi là invariant, và một invariant chỉ có thật khi không có cách nào phá nó từ bên ngoài.

Từ đó có một phép thử cụ thể cho mọi class: chọn luật, rồi hỏi code nào có thể vi phạm nó. Nếu câu trả lời là "bất kỳ code nào chạm được tới object", thì class đó không có invariant — nó chỉ có một dòng comment.

Encapsulation chính là thứ thu hẹp câu trả lời đó xuống còn "chỉ chính class này". Access modifier làm việc thu hẹp; getter, setter, constructor và defensive copy quyết định class chọn để lộ ra những gì. Toàn bộ phần dưới đều suy ra từ một ý đó.

Public field không bảo vệ được gì cả

Bắt đầu bằng một account ngân hàng, với luật balanceCents >= 0 và owner không bao giờ để trống.

public class OpenAccount {
    public String owner;
    public long balanceCents;

    public static void main(String[] args) {
        OpenAccount a = new OpenAccount();
        a.owner = "Mai";
        a.balanceCents = 50_00;

        // nothing stops this
        a.balanceCents = -999_99;
        a.owner = null;

        System.out.println("owner   = " + a.owner);
        System.out.println("balance = " + a.balanceCents);
    }
}
owner   = null
balance = -99999

Object giờ đang ở một trạng thái mà chính class nói là không thể xảy ra, và không có đoạn code nào kịp lên tiếng. Cũng không có chỗ nào để đặt kiểm tra: gán vào field không phải là một lời gọi method, nên không có dòng code nào của bạn chạy trên đường đi vào.

Giờ vẫn class đó nhưng field là private và lệnh ghi phải đi qua một method có quyền từ chối.

public class SafeAccount {
    private String owner;
    private long balanceCents;

    public SafeAccount(String owner, long balanceCents) {
        setOwner(owner);
        setBalanceCents(balanceCents);
    }

    public String getOwner() {
        return owner;
    }

    public void setOwner(String owner) {
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("owner must not be blank");
        }
        this.owner = owner;
    }

    public long getBalanceCents() {
        return balanceCents;
    }

    public void setBalanceCents(long balanceCents) {
        if (balanceCents < 0) {
            throw new IllegalArgumentException("balance must not be negative: " + balanceCents);
        }
        this.balanceCents = balanceCents;
    }

    public static void main(String[] args) {
        SafeAccount a = new SafeAccount("Mai", 50_00);

        try {
            a.setBalanceCents(-999_99);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }

        try {
            a.setOwner(null);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }

        System.out.println("owner   = " + a.getOwner());
        System.out.println("balance = " + a.getBalanceCents());
    }
}
rejected: balance must not be negative: -99999
rejected: owner must not be blank
owner   = Mai
balance = 5000

Public field bị ghi thẳng một giá trị sai, so với private field nằm sau một setter có validate

Lệnh ghi sai bị từ chối và object chưa bao giờ rời khỏi trạng thái hợp lệ. Đi vòng qua setter thì không compile được:

public class Breaker {
    public static void main(String[] args) {
        SafeAccount a = new SafeAccount("Mai", 5000);
        a.balanceCents = -99999;
    }
}
Breaker.java:4: error: balanceCents has private access in SafeAccount
        a.balanceCents = -99999;
         ^
1 error

private mới là thứ biến setter thành cánh cửa duy nhất. Thiếu nó thì setter chỉ là một lời đề nghị.

Bốn mức access

Java có bốn mức access cho member, và chỉ ba trong số đó có keyword. Mức thứ tư — package-private — là thứ bạn nhận được khi không viết modifier nào cả, và cũng vì thế nó là mức mà nhiều người không bao giờ học tới.

Muốn demo cho tử tế thì phải có hơn một package, vì hai trong bốn boundary đó chính là boundary của package. Một ví dụ nằm gọn trong một file chỉ demo được private.

Dựng một source tree hai package

src/
  com/example/model/Vault.java
  com/example/model/Ledger.java
  com/example/model/Neighbour.java
  com/example/client/Outsider.java
  com/example/client/SubVault.java

Vault khai báo mỗi mức một member, cộng thêm một private helper method:

package com.example.model;

public class Vault {
    private   int secret   = 1;   // this class only
              int internal = 2;   // no keyword: this package only
    protected int shared   = 3;   // this package, plus subclasses anywhere
    public    int open     = 4;   // anywhere

    private String stamp() {       // a private helper
        return "v1";
    }

    public void report() {
        System.out.println("inside Vault  " + secret + " " + internal + " "
                + shared + " " + open + " " + stamp());
    }
}

Ledger là một top-level class không có modifier, nên bản thân class đó là package-private:

package com.example.model;

class Ledger {                     // no keyword: package-private top-level class
    static String name() {
        return "Ledger";
    }
}

Neighbour nằm cùng package và chạm được tới mọi thứ trừ secret:

package com.example.model;

public class Neighbour {
    public static void main(String[] args) {
        Vault v = new Vault();
        System.out.println("same package  " + v.internal + " " + v.shared + " "
                + v.open + " " + Ledger.name());
        v.report();
    }
}

Compile cả tree vào một thư mục output rồi chạy:

javac -d out $(find src -name '*.java')
java -cp out com.example.model.Neighbour
same package  2 3 4 Ledger
inside Vault  1 2 3 4 v1

Cùng package, nên internal, shared, open và cả package-private class Ledger đều chạm được. secret thì không, kể cả ở đây:

package com.example.model;

public class NeighbourFail {
    void tryIt() {
        Vault v = new Vault();
        System.out.println(v.secret);     // private
        System.out.println(v.stamp());    // private method
    }
}
NeighbourFail.java:6: error: secret has private access in Vault
        System.out.println(v.secret);     // private
                            ^
NeighbourFail.java:7: error: stamp() has private access in Vault
        System.out.println(v.stamp());    // private method
                            ^
2 errors

private nghĩa là top-level class này, không phải object này và cũng không phải package này. Một instance khác của cùng class đọc được private field của bạn, và đó chính là cách người ta viết equals. Một top-level class riêng thì không, kể cả khi nằm cùng file. Các class nested bên trong cùng một top-level class là ngoại lệ duy nhất: chúng tạo thành một nest và nhìn thấy private member của nhau.

Giờ vượt qua boundary của package. com.example.client.Outsider không phải subclass:

package com.example.client;

import com.example.model.Vault;
import com.example.model.Ledger;

public class OutsiderFail {
    void tryIt() {
        Vault v = new Vault();
        System.out.println(v.secret);
        System.out.println(v.internal);
        System.out.println(v.shared);
        System.out.println(Ledger.name());
    }
}
OutsiderFail.java:4: error: Ledger is not public in com.example.model; cannot be accessed from outside package
import com.example.model.Ledger;
                        ^
OutsiderFail.java:9: error: secret has private access in Vault
        System.out.println(v.secret);
                            ^
OutsiderFail.java:10: error: internal is not public in Vault; cannot be accessed from outside package
        System.out.println(v.internal);
                            ^
OutsiderFail.java:11: error: shared has protected access in Vault
        System.out.println(v.shared);
                            ^
OutsiderFail.java:12: error: Ledger is not public in com.example.model; cannot be accessed from outside package
        System.out.println(Ledger.name());
                           ^
5 errors

Ba message khác nhau cho ba mức khác nhau, và package-private class thì hỏng ngay ở dòng import chứ không đợi tới chỗ dùng. Chỉ open sống sót qua boundary:

package com.example.client;

import com.example.model.Vault;

public class Outsider {
    public static void main(String[] args) {
        Vault v = new Vault();
        System.out.println("other package " + v.open);
        v.report();
    }
}
other package 4
inside Vault  1 2 3 4 v1

Để ý dòng thứ hai: report() là public method của Vault, và bên trong Vault thì cả bốn field đều nhìn thấy được. Access được kiểm tra tại call site chứ không phải tại field, nên một public method hoàn toàn có quyền công bố private state — và đó chính là lý do getter hoạt động được.

Mỗi mức cho phép những gì

Bốn vùng lồng nhau với mỗi mức access một member và error của javac chặn từng cái

ModifierCùng classCùng packageSubclass, package khácMọi nơi
privateđượckhôngkhôngkhông
(không keyword)đượcđượckhôngkhông
protectedđượcđượcchỉ qua reference đúng kiểu của subclasskhông
publicđượcđượcđượcđược

Tầm với tăng dần đều, nên mỗi mức là tập cha của mức phía trên. Có hai chi tiết đáng ghim lại vì rất hay bị hiểu ngược:

  • protected rộng hơn package-private, chứ không hẹp hơn. Một protected member nhìn thấy được từ cả package từ subclass ở ngoài package. Java không có modifier nào mang nghĩa "chỉ subclass".
  • Top-level class chỉ nhận public hoặc không gì cả. privateprotected là modifier dành cho member; đặt lên top-level class thì không compile nổi.
private class Helper {
}

protected class Other {
}
TopLevelPrivate.java:1: error: modifier private not allowed here
private class Helper {
        ^
TopLevelPrivate.java:4: error: modifier protected not allowed here
protected class Other {
          ^
2 errors

Một nested class thì lại là member, nên private hoàn toàn hợp lệ:

public class Outer {
    private static class Helper {
        static String tag() {
            return "helper";
        }
    }

    public static void main(String[] args) {
        System.out.println(Helper.tag());
    }
}
helper

Quy tắc protected mà phần lớn tài liệu nói sai

Câu tóm tắt quen thuộc — "subclass truy cập được protected member của superclass" — không phải là thứ compiler thật sự cài đặt. Từ một package khác, subclass chỉ chạm được protected member qua một reference có static type là chính subclass đó (hoặc subclass sâu hơn). Reference mang type của superclass thì bị từ chối, kể cả khi nó là this.

Đây là một subclass trong com.example.client, chỉ dùng những gì được phép:

package com.example.client;

import com.example.model.Vault;

public class SubVault extends Vault {
    public static void main(String[] args) {
        SubVault s = new SubVault();
        System.out.println("own inherited copy   " + s.shared);
        s.show();
    }

    void show() {
        System.out.println("through this         " + this.shared);
        System.out.println("through SubVault ref " + new SubVault().shared);
        System.out.println("public is always ok  " + new Vault().open);
    }
}
own inherited copy   3
through this         3
through SubVault ref 3
public is always ok  4

Giờ vẫn class đó, với tay tới đúng field đó nhưng qua một reference mang type Vault:

package com.example.client;

import com.example.model.Vault;

public class SubVaultFail extends Vault {
    void tryIt() {
        Vault other = new Vault();
        System.out.println(other.shared);          // superclass reference
        Vault asSuper = this;
        System.out.println(asSuper.shared);        // this, seen as Vault
        System.out.println(((Vault) this).shared); // cast to Vault
        System.out.println(this.shared);           // fine
    }
}
SubVaultFail.java:8: error: shared has protected access in Vault
        System.out.println(other.shared);          // superclass reference
                                ^
SubVaultFail.java:10: error: shared has protected access in Vault
        System.out.println(asSuper.shared);        // this, seen as Vault
                                  ^
SubVaultFail.java:11: error: shared has protected access in Vault
        System.out.println(((Vault) this).shared); // cast to Vault
                                         ^
3 errors

Dòng cuối mới là ca đáng chú ý: this((Vault) this) là cùng một object lúc runtime, vậy mà chỉ cái thứ hai bị chặn. Việc kiểm tra hoàn toàn ở mức static, dựa trên type khai báo của biểu thức.

Lý do là protected sinh ra để subclass làm việc với state kế thừa của chính nó, chứ không phải để mở một cửa sổ nhìn vào mọi instance khác của superclass. Không có quy tắc này thì chỉ cần đặt một class vào package rồi subclass nó từ bất kỳ đâu là đã có access ngang mức package-private lên những object tuỳ ý.

Subclass nằm cùng package thì không bị ảnh hưởng — package access đã bao trọn rồi. Còn package-private member thì subclass ở package khác vẫn không thấy, dù có kế thừa hay không:

package com.example.client;

import com.example.model.Vault;

public class SubInternal extends Vault {
    void tryIt() {
        System.out.println(this.internal);   // package-private, other package
        System.out.println(this.secret);     // private
    }
}
SubInternal.java:7: error: internal is not public in Vault; cannot be accessed from outside package
        System.out.println(this.internal);   // package-private, other package
                               ^
SubInternal.java:8: error: secret has private access in Vault
        System.out.println(this.secret);     // private
                               ^
2 errors

Getter và setter

Getter đọc state; setter ghi state sau khi kiểm tra. Không cái nào bắt buộc, và một class gồm toàn cặp getX/setX với thân rỗng thì chẳng đóng gói được gì — nó chỉ đổi tên cho việc truy cập field.

Quy ước đặt tên

Quy ước này đến từ JavaBeans và nên theo, vì rất nhiều công cụ dựa vào nó: thư viện JSON, template engine, ORM và IDE đều tìm property qua tên method.

DạngQuy ướcVí dụ
Đọc một propertygetX()getBalanceCents()
Đọc một booleanisX()isActive()
Ghi một propertysetX(value), trả về voidsetOwner(String owner)
Giá trị tính ra, không phải propertymột động từavailable(), totalCents()
Record componentđúng tên component, không prefixcents()

Một method đi tính toán thì không phải getter và đừng đặt tên như getter. getTotal() ngụ ý có field lưu sẵn; total() hoặc computeTotal() nói đúng bản chất.

Khi nào không nên có setter

Chỉ viết setter khi giá trị thật sự có ý định thay đổi sau khi khởi tạo. Bỏ hẳn setter là cách bảo vệ mạnh nhất bạn có, mà không tốn gì:

  • Field mang tính danh tính — id, số tài khoản, currency. Đổi một trong số đó là biến object thành một object khác.
  • Field chỉ có nghĩa khi đi cùng nhau — ngày bắt đầu và ngày kết thúc cần một setPeriod(start, end) để kiểm tra thứ tự, chứ không phải hai setter độc lập đi qua một trạng thái sai ở giữa.
  • Bất cứ thứ gì suy ra được — cứ để lộ phép tính, đừng để lộ field để gán.

Cái shortcut của IDE sinh getter và setter cho mọi field chính là nguyên nhân phổ biến nhất tạo ra những class không còn invariant nào. Cứ sinh getter; setter thì thêm từng cái một, khi thật sự có chỗ cần.

Getter trả về mutable field làm lộ invariant

Đây là lỗi lọt qua được code review, vì nhìn thì class rất "đóng gói". Field nào cũng private, mọi truy cập đều qua method, mà object thì vẫn mở toang.

LeakyLog giữ mảng sample luôn được sort để max() chỉ việc đọc ô cuối:

import java.util.Arrays;

public class LeakyLog {
    private final int[] samples;   // invariant: always sorted ascending

    public LeakyLog(int[] samples) {
        this.samples = samples;    // stores the caller's array
        Arrays.sort(this.samples);
    }

    public int[] getSamples() {
        return samples;            // hands out the internal array
    }

    public int max() {
        return samples[samples.length - 1];  // correct only while sorted
    }

    public static void main(String[] args) {
        int[] input = {21, 19, 23};
        LeakyLog log = new LeakyLog(input);
        System.out.println("1 samples " + Arrays.toString(log.getSamples()) + "  max " + log.max());

        int[] view = log.getSamples();
        view[2] = -40;             // the caller writes straight into the object
        System.out.println("2 samples " + Arrays.toString(log.getSamples()) + "  max " + log.max());

        input[0] = 999;            // the constructor argument still aliases too
        System.out.println("3 samples " + Arrays.toString(log.getSamples()) + "  max " + log.max());
    }
}
1 samples [19, 21, 23]  max 23
2 samples [19, 21, -40]  max -40
3 samples [999, 21, -40]  max -40

max() trả về -40, tức là giá trị nhỏ nhất trong array. Không có gì throw, không có cảnh báo nào, và object cũng không có cách nào phát hiện — giữa hai lần in đó không một method nào của LeakyLog chạy cả. Dòng 3 cho thấy lỗ thứ hai: constructor giữ luôn array của caller thay vì copy, nên biến argument vẫn là một alias trỏ vào bên trong object.

⚠️ Trả về this.someArray, this.someList hay bất kỳ mutable object nào từ getter là đưa cho caller một tay nắm sống vào state của bạn. Field có privatefinal cũng không thay đổi được điều đó.

Defensive copy

Copy lúc đi vào và copy lúc đi ra. Hai lời gọi clone() bịt cả hai lỗ:

import java.util.Arrays;

public class SafeLog {
    private final int[] samples;   // invariant: always sorted ascending

    public SafeLog(int[] samples) {
        this.samples = samples.clone();   // copy in
        Arrays.sort(this.samples);
    }

    public int[] getSamples() {
        return samples.clone();           // copy out
    }

    public int max() {
        return samples[samples.length - 1];
    }

    public static void main(String[] args) {
        int[] input = {21, 19, 23};
        SafeLog log = new SafeLog(input);
        System.out.println("1 samples " + Arrays.toString(log.getSamples()) + "  max " + log.max());

        int[] view = log.getSamples();
        view[2] = -40;             // mutates a copy, not the object
        System.out.println("2 samples " + Arrays.toString(log.getSamples()) + "  max " + log.max());

        input[0] = 999;
        System.out.println("3 samples " + Arrays.toString(log.getSamples()) + "  max " + log.max());
    }
}
1 samples [19, 21, 23]  max 23
2 samples [19, 21, 23]  max 23
3 samples [19, 21, 23]  max 23

Cùng các bước của caller được trace trên một getter làm lộ field và một getter có copy

Cùng code caller, cùng một lần cố sửa, mà object thì nguyên vẹn. Cái giá là một lần copy array cho mỗi lời gọi, chỉ đáng cân nhắc khi profiler chỉ đích danh vào đó; cái giá của phương án còn lại là một bug không có stack trace nào.

final không làm array trở nên immutable

final trên field đóng băng reference, chứ không đóng băng object mà nó trỏ tới:

import java.util.Arrays;

public class FinalIsNotDeep {
    private final int[] data = {1, 2, 3};

    void demo() {
        data[0] = 99;          // legal: the array object is not final
        System.out.println(Arrays.toString(data));
    }

    public static void main(String[] args) {
        new FinalIsNotDeep().demo();
    }
}
[99, 2, 3]

Trong Java không tồn tại khái niệm final array. final int[] data chỉ có nghĩa là data sẽ không bao giờ trỏ sang một array khác.

Collection cũng lộ y hệt

Một field kiểu List trả về thẳng là đúng bug đó với type khác:

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

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

    public void add(String item) {
        if (item == null || item.isBlank()) {
            throw new IllegalArgumentException("item must not be blank");
        }
        items.add(item);
    }

    public List<String> getItems() {
        return items;              // the real list
    }

    public static void main(String[] args) {
        LeakyCart c = new LeakyCart();
        c.add("book");
        System.out.println("items: " + c.getItems());

        c.getItems().add("");      // the validation never runs
        System.out.println("items: " + c.getItems());

        c.getItems().clear();
        System.out.println("items: " + c.getItems());
    }
}
items: [book]
items: [book, ]
items: []

Chuỗi rỗng mà add từ chối đã lọt vào qua getter, rồi sau đó cả cart bị một caller xoá sạch. Collections.unmodifiableList bọc field lại thành một view chỉ đọc, nên mọi lệnh sửa đều hỏng:

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

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

    public void add(String item) {                    // the only way in
        if (item == null || item.isBlank()) {
            throw new IllegalArgumentException("item must not be blank");
        }
        items.add(item);
    }

    public List<String> getItems() {
        return Collections.unmodifiableList(items);   // a read-only view
    }

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

        List<String> view = c.getItems();
        System.out.println("items: " + view);
        try {
            view.add("");
        } catch (UnsupportedOperationException e) {
            System.out.println("blocked: " + e.getClass().getSimpleName());
        }
        try {
            c.add("  ");
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
        System.out.println("items: " + c.getItems());
    }
}
items: [book, pen]
blocked: UnsupportedOperationException
rejected: item must not be blank
items: [book, pen]

Cái bọc đó là một view chứ không phải bản copy: caller không ghi xuyên qua nó được, nhưng nó vẫn hiển thị các thay đổi mà chính object thực hiện về sau. List.copyOf(items) cho một bản chụp độc lập. Chọn view khi caller nên thấy cập nhật, chọn copy khi không nên.

Validate trong constructor, không chỉ trong setter

Setter có kiểm tra mà constructor thì không — hoặc ngược lại — là để hở một cánh cửa. HalfChecked validate lúc khởi tạo rồi quên luôn:

public class HalfChecked {
    private int percent;

    public HalfChecked(int percent) {
        if (percent < 0 || percent > 100) {
            throw new IllegalArgumentException("percent out of range: " + percent);
        }
        this.percent = percent;
    }

    public int getPercent() {
        return percent;
    }

    public void setPercent(int percent) {
        this.percent = percent;   // no check
    }

    public static void main(String[] args) {
        try {
            new HalfChecked(500);
        } catch (IllegalArgumentException e) {
            System.out.println("constructor rejected: " + e.getMessage());
        }

        HalfChecked h = new HalfChecked(50);
        h.setPercent(500);
        System.out.println("after setPercent(500): " + h.getPercent());
    }
}
constructor rejected: percent out of range: 500
after setPercent(500): 500

Giá trị sai mà constructor vừa chặn đã đi vào qua setter chỉ một dòng sau đó. Viết phần kiểm tra một lần, trong một private method, rồi gọi từ cả hai chỗ:

private static void requirePercent(int value) {
    if (value < 0 || value > 100) {
        throw new IllegalArgumentException("percent out of range: " + value);
    }
}

IllegalArgumentException là type đúng khi caller truyền vào một giá trị mà method không chấp nhận, còn IllegalStateException dành cho lời gọi hợp lệ nói chung nhưng sai với trạng thái hiện tại của object. Cả hai đều unchecked nên không cần mệnh đề throws. Exception đầy đủ — checked và unchecked, try/catch, type tự định nghĩa — là bài 31; ở đây nó chỉ là cơ chế giúp setter có quyền nói không.

Kèm theo là hai quy tắc nhỏ. Đặt thông tin vào message của exception, gồm cả giá trị gây lỗi, vì một stack trace chỉ ghi "invalid" sẽ ngốn của bạn một tiếng đồng hồ về sau. Và validate trước khi gán, để một lời gọi bị từ chối để lại object y như cũ.

Immutable object: dạng mạnh nhất

Nếu state không bao giờ đổi sau khi khởi tạo thì chẳng còn gì để bảo vệ. Một immutable class cần bốn thứ: field final, không setter, constructor vừa validate vừa copy các argument mutable vào, và getter copy mutable state ra.

import java.util.Arrays;

public final class Route {
    private final String name;
    private final int[] waypoints;

    public Route(String name, int[] waypoints) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name must not be blank");
        }
        if (waypoints == null || waypoints.length < 2) {
            throw new IllegalArgumentException("a route needs at least 2 waypoints");
        }
        this.name = name;
        this.waypoints = waypoints.clone();   // copy in
    }

    public String getName() {
        return name;
    }

    public int[] getWaypoints() {
        return waypoints.clone();             // copy out
    }

    public int stops() {
        return waypoints.length;
    }

    public Route withName(String newName) {
        return new Route(newName, waypoints); // a new object, never a mutation
    }

    @Override
    public String toString() {
        return "Route[" + name + ", " + Arrays.toString(waypoints) + "]";
    }

    public static void main(String[] args) {
        int[] pts = {1, 4, 9};
        Route r = new Route("north", pts);

        pts[0] = -1;                 // the caller's array
        r.getWaypoints()[1] = -1;    // the array the getter returned
        System.out.println(r);
        System.out.println(r.withName("north express"));
        System.out.println("original still " + r);

        try {
            new Route("  ", pts);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
        try {
            new Route("solo", new int[] {5});
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}
Route[north, [1, 4, 9]]
Route[north express, [1, 4, 9]]
original still Route[north, [1, 4, 9]]
rejected: name must not be blank
rejected: a route needs at least 2 waypoints

Hai lần cố sửa và cả hai đều trượt. Tấn công từ một class khác cũng không compile được:

public class RouteBreak {
    void tryIt(Route r) {
        r.name = "hijacked";
        r.setName("hijacked");
    }
}
RouteBreak.java:3: error: name has private access in Route
        r.name = "hijacked";
         ^
RouteBreak.java:4: error: cannot find symbol
        r.setName("hijacked");
         ^
  symbol:   method setName(String)
  location: variable r of type Route
2 errors

Ngay cả chính class đó cũng không đổi ý được về sau:

public class FinalAssign {
    private final String name = "north";

    void rename() {
        name = "south";
    }
}
FinalAssign.java:5: error: cannot assign a value to final variable name
        name = "south";
        ^
1 error

withName là pattern thay thế cho setter: trả về một instance mới thay vì sửa instance hiện tại. Class được đánh dấu final để không subclass nào thêm được mutable state hay override getter để trả về thứ khác. Một immutable object còn an toàn khi chia sẻ giữa nhiều thread và an toàn khi dùng làm key của map, và đó là lý do JDK dựng String, IntegerLocalDate theo đúng kiểu này.

record: immutable carrier gọn nhất

Từ Java 16, record cho bạn đúng hình dạng đó trong một dòng. Compiler sinh ra các private final field, canonical constructor, một accessor cho mỗi component, cộng thêm equals, hashCodetoString.

public record Money(String currency, long cents) {
    public Money {                       // compact constructor: validation still yours
        if (cents < 0) {
            throw new IllegalArgumentException("cents must not be negative: " + cents);
        }
        if (currency == null || currency.length() != 3) {
            throw new IllegalArgumentException("currency must be a 3-letter code");
        }
    }
}
public class RecordDemo {
    public static void main(String[] args) {
        Money a = new Money("VND", 250_000);
        Money b = new Money("VND", 250_000);

        System.out.println(a.currency() + " " + a.cents());  // no get prefix
        System.out.println(a);
        System.out.println("a.equals(b) = " + a.equals(b));
        System.out.println("same hash   = " + (a.hashCode() == b.hashCode()));

        try {
            new Money("VND", -1);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}
VND 250000
Money[currency=VND, cents=250000]
a.equals(b) = true
same hash   = true
rejected: cents must not be negative: -1

javap cho thấy chính xác những gì đã được sinh ra:

Compiled from "Money.java"
public final class Money extends java.lang.Record {
  public Money(java.lang.String, long);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String currency();
  public long cents();
}

Accessor là currency()cents() — record cố tình bỏ prefix get, nên code viết theo quy ước JavaBeans sẽ không tìm thấy chúng:

RecordFail.java:3: error: cannot find symbol
        System.out.println(m.getCurrency());
                            ^
  symbol:   method getCurrency()
  location: variable m of type Money
1 error

Record là lựa chọn mặc định đúng cho một value carrier — DTO, một toạ độ, một dòng kết quả query, một key. Nó không thay thế được việc suy nghĩ về encapsulation, vì hai lý do. Thứ nhất, component của nó public theo định nghĩa: record tuyên bố rằng state của nó chính là API của nó, điều đó sai với bất cứ thứ gì có cách biểu diễn cần giấu đi. Thứ hai, tính immutable của nó chỉ ở bề mặt, y hệt final:

import java.util.Arrays;

public record Trip(String name, int[] legs) {
    public static void main(String[] args) {
        int[] legs = {10, 20};
        Trip t = new Trip("north", legs);

        legs[0] = -1;            // the caller's array is the record's array
        t.legs()[1] = -2;        // the accessor hands the same array back
        System.out.println(Arrays.toString(t.legs()));

        Trip u = new Trip("north", new int[] {10, 20});
        System.out.println("equals = " + new Trip("north", legs).equals(u));
    }
}
[-1, -2]
equals = false

Constructor và accessor được sinh ra không copy gì cả, nên một record có component là array lộ đúng như LeakyLog. equals được sinh ra so sánh component array theo reference, nên hai record có nội dung giống nhau lại không bằng nhau. Cả hai đều sửa được — copy trong compact constructor, override accessor và equals — nhưng tới lúc đó thì bạn đang viết tay cả class rồi. Record chỉ thật sự đáng dùng khi mọi component của nó đều đã immutable sẵn.

Encapsulation không chỉ là chuyện field

Một method chỉ tồn tại để phục vụ method khác là phần cài đặt, và đánh dấu nó private chính là thứ cho phép bạn đổi nó về sau. Vault.stamp() ở trên là một ví dụ; gọi nó từ bên ngoài thì nhận stamp() has private access in Vault.

Lập luận nằm ở chỗ bạn đã hứa những gì. Bất cứ thứ gì public đều là một lời hứa: sẽ có người gọi nó, và đổi tên, đổi parameter hay đổi hành vi đều có thể làm hỏng code của họ. Bất cứ thứ gì private đều tự do — đổi tên, tách ra, xoá đi, gộp vào, và không ai bên ngoài class nhận ra. Nên bề mặt public nên là tập method nhỏ nhất đủ để caller làm việc của họ, còn lại thì bắt đầu bằng private và chỉ mở rộng khi có caller thật sự cần.

Dạng thực dụng của cùng quy tắc đó:

  • Helper method, parse, format, tính index và validate: private.
  • Field: private, luôn luôn, trừ các hằng số nói ở phần sau.
  • Class chỉ sinh ra để phục vụ một package: không modifier, để nó nằm yên trong package đó.
  • public chỉ ở nơi caller bên ngoài thật sự cần.

Mở rộng access về sau thì dễ. Thu hẹp lại là một breaking change với tất cả những ai đã compile theo nó, và đó là lý do rất nhiều API mắc kẹt với những method mà chính tác giả cũng hối hận.

Khi nào một public field là chấp nhận được?

Hiếm, nhưng không phải là không bao giờ, và biết rõ những trường hợp thật sự hợp lý sẽ giúp quy tắc này không nghe như mê tín.

Một hằng số public static final có type immutable. Không có state nào để bảo vệ: không ai gán lại được và cũng không ai sửa được. JDK đầy những thứ như vậy — Integer.MAX_VALUE, Math.PI, System.out.

public static final int MAX_RETRIES = 3;
ConfigFail.java:3: error: cannot assign a value to static final variable MAX_RETRIES
        Config.MAX_RETRIES = 9;
              ^
1 error

Cái bẫy là final chỉ bảo vệ reference, nên một public constant có type mutable thực chất vẫn là một public field, chỉ dài dòng hơn:

import java.util.Arrays;

public class Config {
    public static final int MAX_RETRIES = 3;                          // safe: primitive
    public static final String[] HOSTS = {"a.example", "b.example"};  // not safe

    public static void main(String[] args) {
        System.out.println(Arrays.toString(Config.HOSTS));
        Config.HOSTS[0] = "evil.example";                             // legal
        System.out.println(Arrays.toString(Config.HOSTS));
    }
}
[a.example, b.example]
[evil.example, b.example]

Code ở bất kỳ đâu cũng vừa ghi đè được một hằng số dùng chung. Dùng List.of(...) cho một dãy hằng số; nó thật sự không sửa được.

Một cụm dữ liệu nhỏ, cục bộ, không có invariant nào. Một private nested class hay một helper package-private giữ hai giá trị rời mà mọi tổ hợp đều hợp lệ — record giờ mới là câu trả lời tốt hơn cho đúng trường hợp này, và nó tặng luôn equals với toString.

Còn lại thì để private hết. Cái giá của một getter là một method; cái giá của một public field là bạn sẽ không bao giờ thêm được một phép kiểm tra, một phép tính, một dòng log hay một lần khởi tạo lười mà không làm hỏng mọi caller.

Lỗi thường gặp và error thật sự nhận được

LỗiChuyện gì xảy raMessage
Đọc private member từ class kháccompile errorsecret has private access in Vault
Đọc package-private member từ package kháccompile errorinternal is not public in Vault; cannot be accessed from outside package
Dùng package-private class từ package kháccompile errorLedger is not public in com.example.model; cannot be accessed from outside package
protected member qua superclass reference, khác packagecompile errorshared has protected access in Vault
private class Foo ở top levelcompile errormodifier private not allowed here
Gán final field ngoài chỗ khởi tạocompile errorcannot assign a value to final variable name
Gọi getX() trên record componentcompile errorcannot find symbol: method getCurrency()
Getter trả về mutable fieldcompile được, invariant vỡ âm thầmkhông có
Constructor giữ mutable argument mà không copycompile được, caller còn nguyên aliaskhông có
Setter bỏ qua phần validate của constructorcompile được, state saikhông có
Array hoặc list public static finalcompile được, mutable state dùng chungkhông có
Sinh setter cho mọi field theo phản xạcompile được, không còn invariant nàokhông có

Nửa dưới của bảng mới là nửa nguy hiểm: không có gì báo lỗi cả. Cả năm dòng đó đều sinh ra từ cùng một câu hỏi, nên hãy hỏi nó một cách có chủ đích cho từng class — code nào bên ngoài class này có thể đẩy nó vào một trạng thái mà chính nó nói là không thể? Nếu có bất kỳ câu trả lời nào, đó chính là chỗ rò.

FAQ

Tính đóng gói trong Java là gì, nói gọn trong một câu?

Là giữ state của object ở mức private và chỉ để lộ ra những thao tác mà object sẵn sàng bảo đảm, để những luật nó cam kết về chính nó không bị phá từ bên ngoài. Private field là cơ chế; luật được bảo đảm mới là mục tiêu.

Default (package-private) và protected khác nhau thế nào?

Package-private, tức là không viết modifier nào, chỉ cho phép truy cập từ cùng package. protected cho phép cùng package cộng thêm subclass ở package khác, nên nó rộng hơn hẳn. Niềm tin phổ biến rằng protected nghĩa là "chỉ subclass" sai theo cả hai hướng: nó còn mở cho cả package, và từ package khác thì subclass chỉ chạm được member qua reference đúng kiểu của chính nó.

Vì sao subclass của tôi không truy cập được protected field của superclass?

Gần như luôn là vì reference đang mang type của superclass. Từ package khác, this.shared compile được còn ((Vault) this).shared thì không, với error: shared has protected access in Vault. Quy tắc này giới hạn subclass vào phần state kế thừa của riêng nó thay vì mọi instance của superclass. Nếu subclass nằm cùng package thì package access đã bao trọn và hạn chế này không bao giờ áp dụng.

Có cần getter và setter cho mọi field không?

Không, và sinh chúng theo phản xạ là cách chuẩn mực để có một class chẳng đóng gói được gì. Thêm getter ở chỗ caller thật sự cần đọc giá trị, và thêm setter chỉ ở chỗ giá trị được thiết kế để đổi sau khi khởi tạo. Rất nhiều class tốt có vài getter và không có setter nào.

Để field là private thì object có immutable không?

Không. private kiểm soát ai được gọi tên field; nó không nói gì về việc giá trị có đổi hay không. Immutable cần field final, không setter, và defensive copy cho mọi component mutable ở cả constructor lẫn getter. Bản thân final cũng chỉ ở bề mặt: private final int[] data vẫn cho phép data[0] = 99 từ bên trong class.

Record có thay thế được encapsulation không?

Với một value carrier mà mọi component đều immutable, record cho bạn nguyên bộ — final field, không setter, accessor, equals, hashCode, toString — và vẫn validate được trong compact constructor. Nó thay thế phần boilerplate, không thay thế phần suy nghĩ. Component của record là public theo thiết kế, việc copy không tự động, và equals được sinh ra so sánh component array theo reference, nên bất cứ thứ gì có cách biểu diễn cần giấu hoặc có component mutable vẫn cần một class viết tay.

Kết luận

Encapsulation không phải là cái getter. Nó là invariant, còn getter chỉ là một trong nhiều công cụ để giữ invariant đó: private để setter thành cánh cửa duy nhất, validate ở cả constructor lẫn setter để không cái nào thành đường vòng, defensive copy để array hay list trả ra không phải là tay nắm vào state của bạn, field final và bỏ hẳn setter khi giá trị lẽ ra không bao giờ nên đổi, và record khi cả object chỉ là một bó giá trị immutable.

Bốn mức access nên nhớ theo tầm với chứ đừng nhớ theo chữ: private dừng ở class, không keyword thì dừng ở package, protected thêm subclass ở nơi khác — chỉ chạm được qua reference đúng kiểu của subclass — còn public thì không dừng ở đâu cả. Hãy bắt đầu mọi member ở mức hẹp nhất mà vẫn compile được rồi chỉ mở rộng khi bị ép, vì mở rộng là sửa một dòng còn thu hẹp là làm hỏng build của tất cả những người ở phía sau.

Bài tiếp theo trong series: inheritance với extendssuper — subclass thật sự thừa kế những gì, constructor nối chuỗi qua super(...) ra sao, và protected nằm ở đâu khi subclass bắt đầu làm việc thật.

Bài viết liên quan

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

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

[Java Basics] Câu lệnh điều kiện trong Java: if, else if, else và switch

Câu lệnh điều kiện trong Java: chuỗi if / else if / else, vì sao phải có ngoặc nhọn, bẫy dangling else, điều kiện chỉ nhận boolean, switch với break và fall-through, label mũi tên, switch expression cùng yield và pattern matching của Java 21.

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