Command Palette

Search for a command to run...

[Java Basics] Field, method và constructor trong Java

Một class mô tả object của nó biết những gì và làm được những gì. Phần "biết" nằm trong các field; phần "làm được" là các instance method; còn đoạn code biến một object thô toàn số 0 thành một object dùng được chính là constructor.

Bài này đi qua cả ba, trong đó constructor chiếm phần lớn dung lượng, vì bẫy nằm hết ở đó: một keyword âm thầm hạ constructor xuống thành method thường, một constructor không tham số biến mất ngay khi bạn tự viết constructor đầu tiên, và một thứ tự khởi tạo mà gần như không tài liệu nào chỉ ra. Mọi error message và mọi dòng output bên dưới đều lấy từ lần compile và chạy thật trên OpenJDK 21.0.6.

Một lệnh new lấp đầy các field slot của object, giá trị default bị thay bằng giá trị thật

Bài 23 đã nói về class, object và new. Bài này nói về những gì nằm bên trong thân class.

Field: state của một object

Field là variable khai báo trực tiếp trong thân class, nằm ngoài mọi method. Mỗi object của class có bản riêng của từng field, và tập giá trị đó chính là state của object.

class Account {
    String owner;
    int balance;
    double rate;
    boolean frozen;
    char tier;
    int[] history;
}

Sáu khai báo, không initialiser nào, và class vẫn compile được. Đó là khác biệt đầu tiên so với local variable: field không bắt buộc phải được gán trước khi đọc.

Giá trị default của field đến từ đâu

Bài 6 đã nêu quy tắc này, và đây là lúc nó bắt đầu có ý nghĩa: field không có initialiser sẽ nhận giá trị default của type trước khi bất kỳ dòng code nào của bạn chạy.

Account a = new Account();
System.out.println("owner   " + a.owner);
System.out.println("balance " + a.balance);
System.out.println("rate    " + a.rate);
System.out.println("frozen  " + a.frozen);
System.out.println("tier    code " + (int) a.tier);
System.out.println("history " + a.history);
owner   null
balance 0
rate    0.0
frozen  false
tier    code 0
history null

Field số bắt đầu từ 0, boolean từ false, char từ code point 0, và mọi reference type từ null. Chính bảo đảm đó khiến một object dựng dở trở nên nguy hiểm thay vì bất khả thi: object vẫn tồn tại, field nào cũng có giá trị, và không có gì báo cho bạn biết những giá trị đó vô nghĩa. Làm cho chúng có nghĩa là việc của constructor.

Field initialiser chạy cho từng object

Field có thể kèm initialiser, và nó không được tính một lần cho cả class — nó được tính một lần cho mỗi object.

class Counter {
    int value = 10;
    String label = "counter";
    java.util.List<String> log = new java.util.ArrayList<>();
}
Counter a = new Counter();
Counter b = new Counter();
a.value = 99;
a.log.add("a only");
System.out.println("a.value " + a.value + ", b.value " + b.value);
System.out.println("a.log " + a.log + ", b.log " + b.log);
System.out.println("same list object? " + (a.log == b.log));
a.value 99, b.value 10
a.log [a only], b.log []
same list object? false

new java.util.ArrayList<>() đã chạy hai lần, nên ab giữ hai list khác nhau, ghi qua cái này không đụng tới cái kia. Hai object không bao giờ dùng chung một field slot.

Instance method: hành vi đọc và ghi field

Instance method là method khai báo không có static. Nó thuộc về một object, được gọi qua một reference, và đọc ghi field của chính object đó bằng tên trần.

Class giữ một bản code method dùng chung, bên cạnh hai object trên heap với field slot riêng, và constructor lấp đầy chúng

class Counter {
    int count;
    String name;

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

    void increment() {
        count++;
    }

    void add(int n) {
        count += n;
    }

    boolean isEmpty() {
        return count == 0;
    }

    String describe() {
        return name + " = " + count;
    }

    static int sum(int a, int b) {
        return a + b;
    }
}
Counter clicks = new Counter("clicks");
Counter errors = new Counter("errors");

clicks.increment();
clicks.increment();
clicks.add(5);

System.out.println(clicks.describe());
System.out.println(errors.describe());
System.out.println("errors empty? " + errors.isEmpty());
System.out.println("Counter.sum(2, 3) = " + Counter.sum(2, 3));
clicks = 7
errors = 0
errors empty? true
Counter.sum(2, 3) = 5

count++ trong increment() không viết receiver nào phía trước, mà vẫn có nghĩa là "count của object mà lời gọi này được thực hiện trên đó". clicks.increment() làm đổi clicks.count; errors.count vẫn nằm ở giá trị default.

Đó là toàn bộ khác biệt so với static method của bài 19. Method static thuộc về class, được gọi kiểu Counter.sum(2, 3), và không có object nào để đọc field — đó cũng là lý do tồn tại của error non-static method cannot be referenced from a static context trong bài 19. Instance method thì có object, nên chỉ cần tên field là đủ.

static methodinstance method
Thuộc vềclassmột object
Gọi bằngCounter.sum(2, 3)clicks.increment()
Đọc được field nàochỉ field staticfield của chính object này, gọi bằng tên
Số bản trong bộ nhớmộtmột, mọi object dùng chung

Dòng cuối đáng dừng lại: instance method không được nhân bản theo object. Chỉ có một thân increment() đã compile, bất kể tồn tại bao nhiêu object Counter. Thứ riêng cho từng object là dữ liệu field mà nó thao tác.

Constructor: cùng tên với class, không có return type

Constructor là thành phần chạy như một phần của new. Hai quy tắc định nghĩa nó, và không có quy tắc thứ ba:

  1. Tên của nó đúng bằng tên class.
  2. không khai báo return type nào cả — kể cả void.
class Book {
    String title;
    int pages;

    Book(String title, int pages) {
        this.title = title;
        this.pages = pages;
    }
}

Mọi thứ khác có tên và parameter list trong thân class đều là method. Compiler quyết định bạn viết cái nào chỉ bằng cách tìm return type, và đó là nguồn gốc của bug khó hiểu nhất trong bài này.

Bẫy void: constructor hoá ra không phải constructor

Viết void phía trước thì bạn không tạo ra một constructor hỏng. Bạn tạo ra một method hoàn toàn bình thường, tình cờ trùng tên class.

class Book {
    String title;
    int pages;

    void Book() {                 // BUG: void makes this an ordinary method
        title = "Effective Java";
        pages = 412;
    }
}

public class VoidBug {
    public static void main(String[] args) {
        Book b = new Book();
        System.out.println("title " + b.title);
        System.out.println("pages " + b.pages);
    }
}
title null
pages 0

Không error, không warning — javac -Xlint:all cũng không nói gì — và hai lệnh gán chưa từng chạy. new Book() đã gọi constructor không tham số ngầm định, thứ tồn tại chính vì class không khai báo constructor nào của riêng nó. Method tên Book cứ nằm đó, không ai gọi.

⚠️ Nếu một object ra đời với null0 ở mọi field dù constructor rõ ràng có gán, hãy kiểm tra return type trên constructor đó trước khi kiểm tra bất cứ thứ gì khác.

Bản có parameter thì ít nhất còn báo lỗi, vì constructor ngầm định không nhận argument nào:

class Note {
    String text;
    int priority;

    void Note(String text, int priority) {   // BUG: void
        this.text = text;
        this.priority = priority;
    }
}
VoidBugArgs.java:13: error: constructor Note in class Note cannot be applied to given types;
        Note n = new Note("call the bank", 2);
                 ^
  required: no arguments
  found:    String,int
  reason: actual and formal argument lists differ in length
1 error

Dấu hiệu nằm ở required: no arguments. Bạn viết một constructor hai tham số, còn compiler nói class chỉ có loại không tham số — nghĩa là thứ bạn viết không phải constructor.

Constructor cũng không được trả về giá trị, và message cho việc đó rõ một cách hiếm thấy:

Thing(int v) {
    this.v = v;
    return v;
}
CtorReturnValue.java:6: error: incompatible types: unexpected return value
        return v;
               ^
1 error

Nhưng return; trần thì hợp lệ, và nó thoát constructor sớm y hệt cách thoát một method void:

class Guarded {
    int value;

    Guarded(int value) {
        if (value < 0) {
            System.out.println("negative, leaving value at " + this.value);
            return;
        }
        this.value = value;
    }
}
7
negative, leaving value at 0
0

Object vẫn được tạo, chỉ là field đó giữ nguyên giá trị default. Ném exception gần như luôn là câu trả lời tốt hơn, và ví dụ hoàn chỉnh ở cuối bài làm đúng như vậy.

Constructor không tham số ngầm định

Class không có constructor riêng sẽ được cấp miễn phí một cái. Nó không nhận argument và thân rỗng, thế là đủ vì các field đã được đưa về giá trị default rồi.

public class Bare {
    int x;
    String s;
}

javap cho thấy constructor mà compiler đã thêm vào:

Compiled from "Bare.java"
public class Bare {
  int x;
  java.lang.String s;
  public Bare();
}

Nửa quan trọng của quy tắc lại là nửa còn lại: bạn chỉ được cấp nó khi không khai báo constructor nào. Khai báo một cái — bất kỳ cái nào — là constructor không tham số miễn phí biến mất.

class User {
    String name;
    int age;

    User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

public class NoDefaultCtor {
    public static void main(String[] args) {
        User u = new User();
        System.out.println(u.name);
    }
}
NoDefaultCtor.java:13: error: constructor User in class User cannot be applied to given types;
        User u = new User();
                 ^
  required: String,int
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error

Điều này làm hỏng code đang chạy ngon vào đúng ngày bạn thêm constructor đầu tiên cho một class mà người khác đã gọi bằng new Thing(). Cách sửa là khai báo lại constructor không tham số một cách tường minh, và đó là một quyết định chứ không phải tai nạn: nếu một User không có tên thì không phải User hợp lệ, việc không có constructor đó chính là mục đích.

Overloading constructor

Constructor overload theo đúng luật của method trong bài 20: parameter list là thứ phân biệt chúng, compiler chọn một cái ngay lúc compile dựa trên static type của argument, và hai constructor không được trùng parameter list.

class Rectangle {
    int width;
    int height;
    String label;

    Rectangle() {
        this.width = 1;
        this.height = 1;
        this.label = "unit";
    }

    Rectangle(int side) {
        this.width = side;
        this.height = side;
        this.label = "square";
    }

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

    int area() {
        return width * height;
    }

    public String toString() {
        return label + " " + width + "x" + height + ", area " + area();
    }
}
System.out.println(new Rectangle());
System.out.println(new Rectangle(4));
System.out.println(new Rectangle(3, 5));
unit 1x1, area 1
square 4x4, area 16
rectangle 3x5, area 15

Trùng parameter list là compile error, và đổi tên parameter cũng không cứu được, vì tên parameter không thuộc về signature:

DupCtor.java:10: error: constructor Pair(int,int) is already defined in class Pair
    Pair(int x, int y) {
    ^
1 error

Vấn đề của Rectangle ở trên không nằm ở overloading — mà ở chỗ đoạn gán field được viết lại ba lần. Thêm field thứ tư là có ba chỗ phải nhớ.

Chaining constructor bằng this(...)

this(...) gọi một constructor khác của cùng class. Viết phần khởi tạo đầy đủ đúng một lần rồi để các constructor ngắn hơn delegate vào đó.

class Rect {
    int width;
    int height;
    String label;

    Rect(int width, int height, String label) {
        this.width = width;
        this.height = height;
        this.label = label;
    }

    Rect(int side) {
        this(side, side, "square");
    }

    Rect() {
        this(1, 1, "unit");
    }

    int area() {
        return width * height;
    }

    public String toString() {
        return label + " " + width + "x" + height + ", area " + area();
    }
}
unit 1x1, area 1
square 4x4, area 16
rectangle 3x5, area 15

Output y hệt, và chỉ còn một chỗ gán field. Đây là dạng constructor của pattern forwarding trong bài 20, và JDK dùng nó ở khắp nơi.

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

Có đúng một quy tắc cứng: lời gọi this(...) phải là statement đầu tiên trong thân constructor. Không phải lệnh gán đầu tiên, không phải dòng đáng chú ý đầu tiên — mà là statement đầu tiên.

Ba constructor overload delegate qua this vào constructor đầy đủ nhất, kèm quy tắc phải đứng đầu và error thật của javac

Box(int side) {
    System.out.println("making a square");
    this(side, side, "square");
}
ThisNotFirst.java:14: error: call to this must be first statement in constructor
        this(side, side, "square");
            ^
1 error

Cũng message đó xuất hiện nếu bạn gọi this(...) từ một method thường, một cách viết reset() trông rất hợp lý nhưng không được phép:

ThisInMethod.java:9: error: call to this must be first statement in constructor
        this(0);
            ^
1 error

Quy tắc này không tuỳ tiện. Field initialiser và instance initialiser block được compile vào mọi constructor không bắt đầu bằng this(...), nên trong một chuỗi delegation chỉ đúng một constructor chạy chúng. Nếu có statement nào chạy được trước lời gọi delegation, nó sẽ chạy trước các initialiser đó, trên một object mà field vẫn còn toàn số 0.

Chaining nhìn rõ nhất qua một trace. Đây là class Session với ba constructor, một field initialiser và một instance block, được tạo bằng constructor ngắn nhất:

class Session {
    String user = trace("field initialiser", "guest");
    int timeoutMs;
    boolean tls;

    {
        System.out.println("instance block");
    }

    Session(String user, int timeoutMs, boolean tls) {
        System.out.println("Session(String, int, boolean)");
        this.user = user;
        this.timeoutMs = timeoutMs;
        this.tls = tls;
    }

    Session(String user, int timeoutMs) {
        this(user, timeoutMs, true);
        System.out.println("Session(String, int)");
    }

    Session(String user) {
        this(user, 5000);
        System.out.println("Session(String)");
    }

    static String trace(String msg, String value) {
        System.out.println(msg);
        return value;
    }

    public String toString() {
        return "Session[user=" + user + ", timeoutMs=" + timeoutMs + ", tls=" + tls + "]";
    }
}
Session s = new Session("ana");
System.out.println(s);
field initialiser
instance block
Session(String, int, boolean)
Session(String, int)
Session(String)
Session[user=ana, timeoutMs=5000, tls=true]

Có hai điều rút ra từ trace này. Field initialiser và instance block chạy một lần, và chạy bên trong Session(String, int, boolean) — constructor duy nhất không delegate. Còn các thân constructor kết thúc theo thứ tự ngược với thứ tự gọi, vì this(...) là một lời gọi: constructor trong cùng return trước.

Một constructor không thể tự gọi chính nó

Delegation phải kết thúc ở đâu đó, và javac chứng minh điều đó ngay lúc compile thay vì để bạn phát hiện bằng StackOverflowError. Cả tự gọi trực tiếp lẫn vòng lặp đều bị từ chối:

class Loop {
    int n;

    Loop() {
        this();
    }

    Loop(int n) {
        this(n, 0);
    }

    Loop(int n, int m) {
        this(n);
        this.n = n + m;
    }
}
RecursiveCtor.java:5: error: recursive constructor invocation
        this();
        ^
RecursiveCtor.java:13: error: recursive constructor invocation
        this(n);
        ^
2 errors

Error đầu là trường hợp hiển nhiên. Error thứ hai là vòng lặp: Loop(int) delegate sang Loop(int, int), rồi cái này delegate ngược lại. Một chuỗi bắt buộc phải kết thúc ở một constructor tự gán field.

Parameter che mất field

Đặt tên parameter trùng field mà nó gán là phong cách Java tiêu chuẩn, và nó khiến parameter shadow field bên trong constructor đó: tên trần x chỉ tới parameter. Điều đó làm cho statement vô dụng nhất trong ngôn ngữ này compile êm ru.

class Point {
    int x;
    int y;

    Point(int x, int y) {
        x = x;              // BUG: assigns the parameter to itself
        y = y;
    }

    Point(int x, int y, boolean fixed) {
        this.x = x;         // the fix
        this.y = y;
    }
}
Point broken = new Point(3, 4);
System.out.println("broken  x=" + broken.x + " y=" + broken.y);
Point good = new Point(3, 4, true);
System.out.println("good    x=" + good.x + " y=" + good.y);
broken  x=0 y=0
good    x=3 y=4

javac -Xlint:all ở đây cũng không báo gì. x = x đọc parameter rồi ghi lại vào parameter; field không hề bị đụng tới và giữ nguyên giá trị default. this.x mới là thứ vươn qua lớp shadow để chạm tới field, và this.x = x; là dòng bạn sẽ viết trong gần như mọi constructor. Bài 25 nói về this như một keyword đúng nghĩa — tạm thời cứ đọc nó là "object này".

Thứ tự khởi tạo: new thật sự chạy những gì

Field, initialiser và thân constructor không chạy theo thứ tự mà phần lớn mọi người tưởng. Quy tắc rất chính xác: field initialiser và instance initialiser block chạy theo source order, tất cả, trước thân constructor.

Thân class theo source order bên cạnh output thật xen kẽ, với thân constructor chạy sau cùng

Vài lệnh in là đủ chốt lại. Class dưới đây đặt xen kẽ một field initialiser, một instance block, một field initialiser nữa và một instance block nữa, rồi mới tới constructor:

class Widget {
    int a = trace("field a = 1", 1);

    { System.out.println("instance block 1"); }

    int b = trace("field b = 2", 2);

    { System.out.println("instance block 2"); }

    Widget() {
        System.out.println("constructor body, a=" + a + " b=" + b);
    }

    static int trace(String msg, int value) {
        System.out.println(msg);
        return value;
    }
}

public class InitOrder {
    public static void main(String[] args) {
        System.out.println("before new");
        new Widget();
        System.out.println("after new");
    }
}
before new
field a = 1
instance block 1
field b = 2
instance block 2
constructor body, a=1 b=2
after new

Field initialiser và instance block không phải hai giai đoạn tách rời; chúng là một giai đoạn duy nhất, chạy theo đúng thứ tự xuất hiện trong file. Thân constructor là giai đoạn thứ hai và luôn đi sau cùng, nên nó đã đọc được ab, và mọi thứ nó gán đều thắng.

Source order còn được áp đặt theo chiều ngược lại. Một initialiser không đọc được field khai báo bên dưới nó:

class Config {
    int total = base + 10;      // base is declared below
    int base = 5;
}
ForwardRef.java:2: error: illegal forward reference
    int total = base + 10;      // base is declared below
                ^
1 error

Bytecode nói đúng như vậy

javap -c cho thấy compiler thật sự làm gì với initialiser: nó copy chúng vào đầu mọi constructor.

public class Copied {
    int a = 1;

    Copied() {
        a = 10;
    }

    Copied(int a) {
        this.a = a;
    }
}
  Copied();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_1
       6: putfield      #7                  // Field a:I
       9: aload_0
      10: bipush        10
      12: putfield      #7                  // Field a:I
      15: return

  Copied(int);
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_1
       6: putfield      #7                  // Field a:I
       9: aload_0
      10: iload_1
      11: putfield      #7                  // Field a:I
      14: return

Cả hai constructor đều ghi 1 vào a rồi ghi đè ngay bằng giá trị của chính constructor. Đó là initialiser bị nhân đôi. Giờ đổi Copied() thành delegate:

public class Chained {
    int a = 1;

    Chained() {
        this(10);
    }

    Chained(int a) {
        this.a = a;
    }
}
  Chained();
    Code:
       0: aload_0
       1: bipush        10
       3: invokespecial #1                  // Method "<init>":(I)V
       6: return

Initialiser biến mất khỏi constructor delegate — ba instruction, toàn bộ là delegation. Đó là lý do cơ học khiến this(...) phải đứng đầu, và cũng là lý do chaining chỉ chạy initialiser đúng một lần. Trong bytecode, constructor mang tên <init>, nên nó cũng hiện ra như vậy trong stack trace.

Instance initialiser block

Instance initialiser block là một cặp { ... } trần trong thân class. Nó chạy ở mỗi lần new, theo source order chung với field initialiser, trước thân constructor. Công dụng thật sự duy nhất của nó là phần setup dùng chung cho nhiều constructor:

class Report {
    String title;
    java.util.List<String> rows;
    String generatedBy;

    {
        rows = new java.util.ArrayList<>();
        generatedBy = "report-tool";
        System.out.println("shared setup ran");
    }

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

    Report(String title, String firstRow) {
        this.title = title;
        rows.add(firstRow);
    }

    public String toString() {
        return title + " " + rows + " by " + generatedBy;
    }
}
System.out.println(new Report("Q3"));
System.out.println(new Report("Q4", "opening line"));
shared setup ran
Q3 [] by report-tool
shared setup ran
Q4 [opening line] by report-tool

Cả hai constructor đều có list và tên tool mà không cái nào phải nhắc tới chúng.

Thường thì bạn không nên viết block này. Field initialiser xử lý một giá trị default gọn hơn và dễ đọc hơn, còn chaining bằng this(...) lo phần setup dùng chung mà vẫn giữ code nằm trong constructor, đúng chỗ người đọc tìm. Block chỉ đáng dùng khi phần setup cần nhiều statement và không viết gọn thành một initialiser được — và ngay cả khi đó, một helper method gọi từ constructor đầy đủ nhất thường vẫn rõ hơn. Nó có đúng một công dụng riêng biệt là double-brace idiom cho anonymous subclass, chuyện của một bài rất xa sau này.

Object chứa object khác

Type của một field có thể là một class khác. Không cần gì mới: field giữ một reference, mặc định là null, và việc của constructor là trỏ nó vào đâu đó.

class Address {
    String city;
    String country;

    Address(String city, String country) {
        this.city = city;
        this.country = country;
    }

    public String toString() {
        return city + ", " + country;
    }
}

class Customer {
    String name;
    Address address;

    Customer(String name, String city, String country) {
        this.name = name;
        this.address = new Address(city, country);
    }

    Customer(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public String toString() {
        return name + " (" + address + ")";
    }
}
Customer a = new Customer("Ana", "Hanoi", "VN");
Address shared = new Address("Berlin", "DE");
Customer b = new Customer("Bo", shared);

System.out.println(a);
System.out.println(b);

shared.city = "Munich";
System.out.println("after shared.city = \"Munich\": " + b);
Ana (Hanoi, VN)
Bo (Berlin, DE)
after shared.city = "Munich": Bo (Munich, DE)

Hai constructor khác nhau ở một điểm đáng hiểu rõ. Cái thứ nhất tạo ra Address, nên không ai bên ngoài chạm tới nó được. Cái thứ hai giữ lại reference của caller, nên caller vẫn nắm cùng object đó và có thể đổi nó sau lưng bạn — đúng như hai dòng cuối cho thấy. Không cái nào sai; lựa chọn nằm ở chỗ object sở hữu các phần của nó hay chỉ mượn.

Quên trỏ một reference field vào đâu đó là kiểu hỏng lúc runtime kinh điển của phần này:

class Cart {
    String owner;
    java.util.List<String> items;      // never initialised

    Cart(String owner) {
        this.owner = owner;
    }

    void add(String item) {
        items.add(item);
    }
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.List.add(Object)" because "this.items" is null
	at Cart.add(NullField.java:10)
	at NullField.main(NullField.java:17)

Nó compile được, vì null là một giá trị hoàn toàn hợp lệ cho field. NullPointerException kiểu helpful gọi thẳng tên this.items, nên cách sửa không còn mơ hồ: cho field một initialiser, hoặc gán nó trong constructor.

Trả về this để chaining

Một instance method có return type là chính class đó có thể kết thúc bằng return this;. Caller nhận lại đúng object cũ và gọi tiếp method kế trên kết quả.

class Query {
    String table = "";
    String where = "";
    int limit = -1;

    Query from(String table) {
        this.table = table;
        return this;
    }

    Query where(String condition) {
        this.where = condition;
        return this;
    }

    Query limit(int n) {
        this.limit = n;
        return this;
    }

    String build() {
        String sql = "SELECT * FROM " + table;
        if (!where.isEmpty()) sql += " WHERE " + where;
        if (limit >= 0) sql += " LIMIT " + limit;
        return sql;
    }
}
String sql = new Query().from("orders").where("total > 100").limit(10).build();
System.out.println(sql);

Query q = new Query();
Query same = q.from("users");
System.out.println("same object? " + (q == same));
SELECT * FROM orders WHERE total > 100 LIMIT 10
same object? true

same object? true mới là điểm chính: không có gì bị copy. Mỗi lời gọi mutate đúng một Query rồi trả lại cùng reference đó. StringBuilder hoạt động y hệt, nên sb.append("a").append("b") mới compile được. Hãy dùng nó cho các API kiểu setup; method tính ra một kết quả thì nên trả về kết quả đó.

Class hoàn chỉnh: hai constructor, validation và toString

Ghép lại: field, một field initialiser, hai constructor trong đó một cái chaining, validation từ chối dựng object hỏng, vài instance method, và một toString.

class BankAccount {
    String owner;
    long balanceCents;
    java.util.List<String> log = new java.util.ArrayList<>();

    BankAccount(String owner, long openingCents) {
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("owner must not be blank");
        }
        if (openingCents < 0) {
            throw new IllegalArgumentException("opening balance must not be negative, got " + openingCents);
        }
        this.owner = owner;
        this.balanceCents = openingCents;
        log.add("opened at " + money(openingCents));
    }

    BankAccount(String owner) {
        this(owner, 0);
    }

    void deposit(long cents) {
        if (cents <= 0) {
            throw new IllegalArgumentException("deposit must be positive, got " + cents);
        }
        balanceCents += cents;
        log.add("deposit " + money(cents));
    }

    boolean withdraw(long cents) {
        if (cents <= 0 || cents > balanceCents) {
            log.add("refused withdrawal of " + money(cents));
            return false;
        }
        balanceCents -= cents;
        log.add("withdraw " + money(cents));
        return true;
    }

    static String money(long cents) {
        return String.format("%d.%02d", cents / 100, Math.abs(cents % 100));
    }

    public String toString() {
        return "BankAccount[owner=" + owner + ", balance=" + money(balanceCents)
                + ", events=" + log.size() + "]";
    }
}
BankAccount a = new BankAccount("Ana", 25_000);
a.deposit(5_000);
System.out.println(a.withdraw(100_000));
System.out.println(a.withdraw(10_000));
System.out.println(a);
System.out.println(a.log);

BankAccount b = new BankAccount("Bo");
System.out.println(b);

new BankAccount("Cam", -1);
false
true
BankAccount[owner=Ana, balance=200.00, events=4]
[opened at 250.00, deposit 50.00, refused withdrawal of 1000.00, withdraw 100.00]
BankAccount[owner=Bo, balance=0.00, events=1]
Exception in thread "main" java.lang.IllegalArgumentException: opening balance must not be negative, got -1
	at BankAccount.<init>(BankDemo.java:11)
	at BankDemo.main(BankDemo.java:62)

Bốn thứ đang làm việc thật ở đây. Field initialiser của log khiến mọi constructor đều có sẵn một list mà không cái nào phải nhắc tới. BankAccount(String) chaining, nên phần validation nằm đúng một chỗ. Validation ném exception trước khi gán bất cứ thứ gì, nên một BankAccount không hợp lệ không bao giờ trở thành reference mà ai đó cầm được — đây là thói quen giá trị nhất mà một constructor có thể có. Và stack trace gọi tên BankAccount.<init>, tên bytecode của constructor, nên một lỗi bên trong constructor rất dễ nhận ra trong log.

toString() là thói quen thứ hai đáng áp dụng ngay. Xoá nó khỏi class ở trên rồi chạy lại đúng lệnh System.out.println(a) thì kết quả là BankAccount@76ed5528 — tên class, dấu @, và một identity hash đổi theo từng lần chạy. Có toString, mọi dòng debug và mọi dòng log đều đọc được.

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

Sai ở đâuChuyện gì xảy raMessage
void trước constructor không tham sốcompile được, field giữ giá trị defaultkhông có
void trước constructor có parametercompile errorconstructor Note in class Note cannot be applied to given types;
new Foo() sau khi thêm Foo(int)compile errorconstructor User in class User cannot be applied to given types;
this(...) đứng sau một statement kháccompile errorcall to this must be first statement in constructor
this(...) nằm trong method thườngcompile errorcall to this must be first statement in constructor
Constructor delegate vòng về chính nócompile errorrecursive constructor invocation
return v; trong constructorcompile errorincompatible types: unexpected return value
Hai constructor trùng parameter listcompile errorconstructor Pair(int,int) is already defined in class Pair
Initialiser đọc field khai báo bên dướicompile errorillegal forward reference
x = x; thay vì this.x = x;compile được, field giữ giá trị defaultkhông có
Không bao giờ khởi tạo một reference fieldcompile được, ném exception lúc dùngNullPointerException ... because "this.items" is null

Ba dòng không có message mới là thứ phải để mắt, và cả ba cùng một triệu chứng: object tồn tại nhưng field toàn null với 0. Khi thấy vậy, bug gần như không bao giờ nằm trong đoạn code đọc object.

FAQ

Constructor và method khác nhau chỗ nào?

Constructor mang tên class, không có return type, không gọi được bằng tên, và chạy như một phần của new. Method có tên bất kỳ, luôn khai báo return type, và được gọi trên một object hoặc một class có sẵn. Viết void trước constructor là biến nó thành method — cùng tên, nhưng là một thành phần hoàn toàn khác.

Có bắt buộc phải viết constructor không?

Không. Class không khai báo constructor nào sẽ được cấp một constructor không tham số ngầm định với thân rỗng, và javap sẽ cho bạn thấy nó. Bạn mất nó ngay khi khai báo bất kỳ constructor nào của riêng mình, nên class cần cả constructor có parameter lẫn new Thing() thì phải khai báo tường minh cái không tham số.

Vì sao constructor của tôi trông như không chạy?

Kiểm tra return type trên nó. void Book() là một method tên Book chứ không phải constructor, nên new Book() chạy constructor rỗng ngầm định và mọi field giữ giá trị default. javac không báo gì, kể cả với -Xlint:all. Nguyên nhân còn lại là x = x; thay vì this.x = x;, cũng compile im lặng và cũng để field ở giá trị default.

Constructor có trả về giá trị được không?

Không. return v; bên trong nó cho error: incompatible types: unexpected return value. return; trần thì hợp lệ và thoát constructor sớm, để lại field chưa gán ở giá trị default; ném exception thường là cách tốt hơn để từ chối argument sai, vì nó chặn luôn việc caller nhận được một object dựng dở.

Vì sao cần this.x = x khi x = x vẫn compile?

Vì parameter tên x shadow field x trong suốt thân constructor, nên x = x đọc parameter rồi ghi thẳng ngược lại vào parameter. Field không hề tham gia và giữ nguyên giá trị default, mà compiler không cảnh báo gì. this.x gọi tên field trên object hiện tại một cách tường minh, nên this.x = x; mới copy được parameter vào đó.

Field initialiser và instance initialiser block khác nhau ra sao?

Chỉ khác ở cú pháp và sức chứa. int a = 1; là một expression gắn với một field; { ... } là một block chạy được nhiều statement và đụng tới nhiều field. Chúng không phải hai giai đoạn tách biệt: compiler chạy cả hai theo source order, xen kẽ đúng như bạn viết, trước thân constructor — và copy cả hai vào mọi constructor không bắt đầu bằng this(...).

Kết luận

Field giữ state, mỗi object một bộ; instance method đọc và ghi state đó qua tên field trần, với đúng một bản code dùng chung nằm trong class; còn constructor là thứ biến giá trị default thành giá trị thật. Phần còn lại là một danh sách ngắn các sự thật đáng nhớ, tất cả đều đã được minh hoạ ở trên.

Constructor mang tên class và không có return type, còn void âm thầm biến nó thành method. Bạn được cấp miễn phí constructor không tham số cho tới khi tự khai báo một cái. Constructor overload như method, và this(...) cho phép các cái ngắn delegate vào cái đầy đủ — với điều kiện đứng đầu tiên và không tạo vòng. Field initialiser và instance block chạy theo source order, đúng một lần, trước thân của constructor không delegate. Parameter shadow field trùng tên với nó, nên this.x = x; không phải là trang trí. Và một constructor validate trước khi gán là constructor mà không caller nào dùng sai được.

Bài tiếp theo trong series: keyword this, staticfinalthis thật sự trỏ vào đâu và khi nào bỏ được, static thay đổi gì ở field và method, static initialiser block, và final đóng băng cái gì trên một variable, một field và một parameter.

Bài viết liên quan

[Java Basics] Mảng nhiều chiều trong Java

Mảng nhiều chiều trong Java thực chất là array của array chứ không phải hình chữ nhật - cách tạo và duyệt grid, jagged array, deepToString, deepEquals, shallow vs deep copy, mảng 3 chiều và ví dụ matrix, mọi output đều compile và chạy thật trên JDK 21.

[Java Basics] Đọc và ghi file text trong Java

Đọc và ghi file text trong Java: FileReader và FileWriter, vì sao cần BufferedReader và BufferedWriter, try-with-resources, API hiện đại Files và Path, đường dẫn tương đối, các exception thật khi file không tồn tại, và character encoding quyết định dữ liệu tiếng Việt có sống sót hay không.

[Java Basics] Vòng lặp lồng nhau, break và continue trong Java

Vòng lặp lồng nhau trong Java và hai keyword cắt ngang vòng lặp: số lần thân vòng lặp trong chạy, break chỉ thoát vòng lặp gần nhất, continue bỏ qua update trong while, labeled break và continue, cùng bẫy switch nằm trong loop.

[Java Basics] Tham số, giá trị trả về và overloading trong Java

Parameter, giá trị trả về và overloading trong Java: vì sao Java luôn là pass-by-value, vì sao swap không chạy được, bốn phase của overload resolution, quy tắc varargs, cùng mọi compile error chạy thật trên JDK 21.