Command Palette

Search for a command to run...

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

Một vòng lặp nằm trong một vòng lặp khác là cấu trúc đầu tiên trong series này có chi phí nhân lên. Bài trước đã nói về bản thân các dạng vòng lặp — for, while, do-while — và mỗi dạng chạy phần thân một số lần nào đó. Lồng cái này vào cái kia thì phần thân chạy bằng tích của hai số lần, và đó là nơi sinh ra cả những pattern hữu ích lẫn những chương trình chậm.

Hai keyword cắt ngang vòng lặp, breakcontinue, rất đơn giản khi đứng một mình và trở nên dễ hiểu nhầm ngay khi có hai vòng lặp. break chỉ thoát một vòng lặp, không bao giờ thoát cả hai. continue bỏ qua phần còn lại của thân vòng lặp, nhưng nó xử lý bước update thế nào thì lại phụ thuộc vào dạng vòng lặp bạn dùng. Mọi chương trình và mọi thông báo lỗi bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6.

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

Bắt đầu từ việc đếm, vì mọi thứ còn lại trong bài đều là biến thể của nó.

Vòng lặp lồng nhau chạy như thế nào?

Quy tắc gói gọn trong một câu: với mỗi lần lặp của vòng ngoài, vòng trong chạy từ điều kiện khởi đầu tới tận lúc kết thúc. Không phải một bước của nó — mà là toàn bộ.

public class NestedBasics {
    public static void main(String[] args) {
        int bodyRuns = 0;
        for (int i = 1; i <= 3; i++) {
            System.out.println("outer i=" + i + " starts");
            for (int j = 1; j <= 4; j++) {
                bodyRuns++;
                System.out.println("  (i=" + i + ", j=" + j + ")");
            }
            System.out.println("outer i=" + i + " ends");
        }
        System.out.println("inner body ran " + bodyRuns + " times");
    }
}
outer i=1 starts
  (i=1, j=1)
  (i=1, j=2)
  (i=1, j=3)
  (i=1, j=4)
outer i=1 ends
outer i=2 starts
  (i=2, j=1)
  (i=2, j=2)
  (i=2, j=3)
  (i=2, j=4)
outer i=2 ends
outer i=3 starts
  (i=3, j=1)
  (i=3, j=2)
  (i=3, j=3)
  (i=3, j=4)
outer i=3 ends
inner body ran 12 times

Một lần chạy 3 nhân 4 thật, đánh số theo thứ tự thực thi

Ba điều mà đoạn output đó làm rõ:

  • Counter của vòng trong khởi động lại mỗi lượt. j chạy 1, 2, 3, 4 rồi được khai báo lại từ đầu ở lượt ngoài kế tiếp. Nó không mang số 4 sang hàng thứ hai.
  • i++ chạy ba lần, j++ chạy mười hai lần. Counter của vòng ngoài chỉ nhích lên sau dòng outer i=1 ends, đúng chỗ điều kiện của vòng trong không còn đúng nữa.
  • Phần thân chạy 3 × 4 = 12 lần. Phép nhân đó chính là toàn bộ ý tưởng. Số lần ngoài nhân số lần trong, không phải cộng.

Chi phí của việc lồng vòng lặp, tính theo số lần lặp

Hai vòng lặp trên cùng một tập n phần tử sẽ chạy phần thân n × n lần. Con số thật đáng nhìn hơn công thức, nên đây là một bộ đếm — không đo thời gian, chỉ đếm số lần statement trong cùng được chạy tới:

public class NestedCost {
    public static void main(String[] args) {
        int[] sizes = {10, 100, 1000};
        System.out.printf("%6s %12s %14s %18s%n", "n", "one loop", "two nested", "three nested");
        for (int n : sizes) {
            long one = 0, two = 0, three = 0;
            for (int i = 0; i < n; i++) one++;
            for (int i = 0; i < n; i++)
                for (int j = 0; j < n; j++) two++;
            for (int i = 0; i < n; i++)
                for (int j = 0; j < n; j++)
                    for (int k = 0; k < n; k++) three++;
            System.out.printf("%6d %12d %14d %18d%n", n, one, two, three);
        }
    }
}
     n     one loop     two nested       three nested
    10           10            100               1000
   100          100          10000            1000000
  1000         1000        1000000         1000000000
nmột vòng lặphai vòng lồng nhauba vòng lồng nhau
10101001.000
10010010.0001.000.000
1.0001.0001.000.0001.000.000.000

Dữ liệu nhiều gấp mười thì một vòng lặp tốn gấp mười, hai vòng tốn gấp trăm, ba vòng tốn gấp nghìn. Không có cấu hình máy nào đổi được tỉ lệ đó, và đó là lý do nên nhìn số lần lặp thay vì nhìn đồng hồ: chính cấu trúc của code quyết định con số này.

Với một vòng lặp lồng nhau chạy trên vài chục dòng của một bảng thì điều này không đáng bận tâm. Với một vòng lặp lồng nhau chạy trên danh sách lớn dần theo số người dùng, nó là khác biệt giữa một trang tải được và một trang timeout.

Ba dạng vòng lặp lồng nhau bạn sẽ viết đi viết lại

Gần như mọi vòng lặp lồng nhau bạn viết trong thực tế đều thuộc một trong ba dạng.

Lưới toạ độ

Vòng ngoài đi theo hàng, vòng trong đi theo cột, và việc xuống dòng thuộc về vòng ngoài:

public class Grid {
    public static void main(String[] args) {
        for (int row = 0; row < 3; row++) {
            for (int col = 0; col < 5; col++) {
                System.out.printf("%8s", "(" + row + "," + col + ")");
            }
            System.out.println();
        }
    }
}
   (0,0)   (0,1)   (0,2)   (0,3)   (0,4)
   (1,0)   (1,1)   (1,2)   (1,3)   (1,4)
   (2,0)   (2,1)   (2,2)   (2,3)   (2,4)

Lệnh System.out.println() không có argument nằm trong thân vòng ngoài, sau khi vòng trong đã đóng lại. Đưa nó vào trong vòng trong thì bạn được mười lăm dòng, mỗi dòng một ô; bỏ hẳn nó đi thì bạn được đúng một dòng rất dài.

Bảng cửu chương

Vẫn dạng đó, nhưng hai counter được dùng chung trong phần thân thay vì in riêng lẻ:

public class TimesTable {
    public static void main(String[] args) {
        for (int i = 1; i <= 5; i++) {
            for (int j = 1; j <= 5; j++) {
                System.out.printf("%4d", i * j);
            }
            System.out.println();
        }
    }
}
   1   2   3   4   5
   2   4   6   8  10
   3   6   9  12  15
   4   8  12  16  20
   5  10  15  20  25

%4d lo phần căn cột. Chèn thêm space vào chuỗi cũng ra kết quả tương tự, nhưng độ rộng sẽ lệch ngay khi có tích ba chữ số.

So sánh từng cặp trong một danh sách

Đây là dạng có mẹo thật sự. Để so sánh mỗi cặp đúng một lần, vòng trong bắt đầu từ i + 1 thay vì từ 0:

public class Pairs {
    public static void main(String[] args) {
        String[] names = {"An", "Binh", "Chi", "Dung"};
        int comparisons = 0;
        for (int i = 0; i < names.length; i++) {
            for (int j = i + 1; j < names.length; j++) {
                comparisons++;
                System.out.println(names[i] + " vs " + names[j]);
            }
        }
        System.out.println("comparisons = " + comparisons);
    }
}
An vs Binh
An vs Chi
An vs Dung
Binh vs Chi
Binh vs Dung
Chi vs Dung
comparisons = 6

Sáu lần so sánh cho bốn cái tên. Cho vòng trong bắt đầu từ 0 thì con số là mười sáu: mỗi cặp hai lần, cộng thêm bốn lần so sánh vô nghĩa giữa một phần tử với chính nó.

for (int i = 0; i < names.length; i++) {
    for (int j = 0; j < names.length; j++) {
        comparisons++;
    }
}
comparisons = 16

j = i + 1 tốn n × (n − 1) / 2 thay vì n × n. Vẫn là bậc hai, nhưng làm một nửa khối lượng và — quan trọng hơn — không bao giờ đưa cho code so sánh của bạn một cặp mà hai vế là cùng một phần tử, vốn là một nguồn bug riêng.

break chỉ thoát khỏi vòng lặp gần nhất

break kết thúc đúng vòng lặp mà nó được viết bên trong. Trong một vòng lặp lồng nhau, đó là vòng trong, còn vòng ngoài vẫn chạy tiếp như chưa có gì xảy ra:

public class BreakInner {
    public static void main(String[] args) {
        for (int i = 1; i <= 3; i++) {
            System.out.println("outer i=" + i);
            for (int j = 1; j <= 4; j++) {
                if (j == 3) {
                    System.out.println("    break at j=3");
                    break;
                }
                System.out.println("    inner j=" + j);
            }
            System.out.println("  after inner loop, i=" + i);
        }
        System.out.println("done");
    }
}
outer i=1
    inner j=1
    inner j=2
    break at j=3
  after inner loop, i=1
outer i=2
    inner j=1
    inner j=2
    break at j=3
  after inner loop, i=2
outer i=3
    inner j=1
    inner j=2
    break at j=3
  after inner loop, i=3
done

after inner loop in ra ba lần, mỗi lượt ngoài một lần. break cũng nổ ba lần — mỗi lượt một lần — và lần nào nó cũng chỉ huỷ những giá trị j còn lại. Chương trình chạy tiếp từ statement đầu tiên sau dấu ngoặc đóng của vòng trong, tức là vẫn nằm trong thân vòng ngoài.

Đây là hiểu nhầm phổ biến nhất về break, và cũng là lý do labeled break tồn tại ở phần dưới.

continue bỏ qua phần còn lại của lượt lặp

continue không kết thúc vòng lặp. Nó bỏ dở lượt hiện tại và đi tiếp sang lượt kế:

for (int i = 1; i <= 6; i++) {
    if (i == 4) {
        break;
    }
    System.out.println("i=" + i);
}
System.out.println("after the loop");
i=1
i=2
i=3
after the loop
for (int i = 1; i <= 6; i++) {
    if (i == 4) {
        continue;
    }
    System.out.println("i=" + i);
}
System.out.println("after the loop");
i=1
i=2
i=3
i=5
i=6
after the loop

Cùng một vòng lặp, cùng một điều kiện, khác đúng một keyword. break dừng hẳn ở 3. continue chỉ bỏ qua giá trị 4 rồi chạy tiếp tới 5 và 6.

break thoát khỏi vòng lặp, continue nhảy tới bước update

Điều quan trọng là continue nhảy tới đâu, và chỗ đó không giống nhau ở mọi dạng vòng lặp.

Trong vòng for, continue vẫn chạy bước update

continue trong vòng for nhảy tới phần update trên header của vòng lặp, rồi mới tới điều kiện. Phần update không thuộc thân vòng lặp, nên bỏ qua thân không có nghĩa là bỏ qua nó. Muốn chứng minh thì cần một biểu thức update tự lên tiếng:

public class ContinueUpdateProof {
    public static void main(String[] args) {
        for (int i = 1; i <= 4; i = bump(i)) {
            if (i == 2) {
                System.out.println("i=2 -> continue");
                continue;
            }
            System.out.println("body i=" + i);
        }
    }

    private static int bump(int i) {
        System.out.println("    update: " + i + " -> " + (i + 1));
        return i + 1;
    }
}
body i=1
    update: 1 -> 2
i=2 -> continue
    update: 2 -> 3
body i=3
    update: 3 -> 4
body i=4
    update: 4 -> 5

Dòng update: 2 -> 3 xuất hiện ngay sau continue. Đó chính là bảo đảm khiến continue an toàn trong vòng for: counter luôn nhích lên, nên vòng lặp luôn có ngày kết thúc.

Trong vòng while, continue bỏ qua luôn bước update

Vòng while không có chỗ dành riêng cho update. Lệnh tăng chỉ là một statement như mọi statement khác trong thân, thường là dòng cuối cùng — nên continue nhảy vọt qua nó, quay thẳng về điều kiện, với counter y nguyên:

public class ContinueWhileInfinite {
    public static void main(String[] args) {
        int i = 1;
        while (i <= 6) {
            if (i % 2 == 0) continue;
            System.out.println("i=" + i);
            i++;
        }
        System.out.println("done");
    }
}
i=1

Đó là toàn bộ output. Chương trình in i=1, tăng lên 2, thấy 2 là số chẵn, gặp continue, quay lại i <= 6 với i vẫn là 2, lại thấy 2 chẵn, và cứ thế mãi mãi. Phải kill nó từ bên ngoài; nó không bao giờ tới được done.

Muốn nhìn thấy trạng thái nó bị kẹt, đây vẫn là vòng lặp đó nhưng có một bộ đếm vòng quay ép nó thoát ra:

public class ContinueWhileBug {
    public static void main(String[] args) {
        int i = 1;
        int spins = 0;
        while (i <= 6) {
            if (++spins > 10) {          // safety net, only so this can be printed
                System.out.println("stuck: i is still " + i + " after 10 spins");
                break;
            }
            if (i % 2 == 0) {
                continue;                // jumps straight back to the condition
            }
            System.out.println("i=" + i);
            i++;
        }
        System.out.println("done");
    }
}
i=1
stuck: i is still 2 after 10 spins
done

Mười vòng quay và i không hề rời khỏi số 2. Cách sửa là để bước update chạy trước khi có thể chạm tới bất kỳ continue nào:

public class ContinueWhileFix {
    public static void main(String[] args) {
        int i = 0;
        while (i < 6) {
            i++;                         // update FIRST, before any continue
            if (i % 2 == 0) {
                continue;
            }
            System.out.println("i=" + i);
        }
        System.out.println("done");
    }
}
i=1
i=3
i=5
done
Dạng vòng lặpBước update nằm ở đâucontinue làm gì với nó
fortrên header của vòng lặpchạy nó, rồi kiểm tra lại điều kiện
whileđâu đó trong thânbỏ qua nếu nó nằm sau continue
do-whileđâu đó trong thânbỏ qua, rồi kiểm tra điều kiện ở cuối

Quy tắc thực dụng: trong vòng while, hoặc đặt bước update ngay đầu thân vòng lặp, hoặc chuyển sang dùng for. Vòng for chính là vòng while mà bước update không thể bị bỏ qua.

Labeled break và labeled continue

Label là một identifier kèm dấu hai chấm, đặt ngay trước một vòng lặp. break labelcontinue label khi đó tác động lên chính vòng lặp đó thay vì vòng gần nhất. Cùng một cặp vòng lặp lồng nhau, cùng một điều kiện, chạy ba kiểu:

for (int i = 1; i <= 3; i++) {
    for (int j = 1; j <= 3; j++) {
        if (i * j == 4) {
            break;
        }
        System.out.println("i=" + i + " j=" + j);
    }
}
System.out.println("done");
i=1 j=1
i=1 j=2
i=1 j=3
i=2 j=1
i=3 j=1
i=3 j=2
i=3 j=3
done

break thường nổ khi i bằng 2 và j bằng 2, huỷ phần còn lại của lượt trong đó, rồi vòng ngoài đi tiếp sang i=3. Bây giờ thêm label:

outer:
for (int i = 1; i <= 3; i++) {
    for (int j = 1; j <= 3; j++) {
        if (i * j == 4) {
            break outer;
        }
        System.out.println("i=" + i + " j=" + j);
    }
}
System.out.println("done");
i=1 j=1
i=1 j=2
i=1 j=3
i=2 j=1
done

Label biến break thành lối thoát khỏi cả hai vòng lặp

Chương trình nhảy qua luôn dấu ngoặc đóng của vòng lặp có label, tới thẳng lệnh println đứng sau nó. Còn continue outer bỏ dở phần còn lại của lượt ngoài hiện tại và bắt đầu lượt kế tiếp:

outer:
for (int i = 1; i <= 3; i++) {
    for (int j = 1; j <= 3; j++) {
        if (i * j == 4) {
            continue outer;
        }
        System.out.println("i=" + i + " j=" + j);
    }
    System.out.println("  end of row i=" + i);
}
System.out.println("done");
i=1 j=1
i=1 j=2
i=1 j=3
  end of row i=1
i=2 j=1
i=3 j=1
i=3 j=2
i=3 j=3
  end of row i=3
done

Để ý dòng end of row i=2 biến mất. continue outer bỏ qua cả phần còn lại của thân vòng ngoài chứ không riêng vòng trong — statement đó thuộc về lượt lặp mà nó vừa bỏ dở. Giống mọi continue trong vòng for, nó vẫn chạy bước update của vòng ngoài:

outer:
for (int i = 1; i <= 3; i = bump(i)) {
    for (int j = 1; j <= 2; j++) {
        if (j == 2) {
            System.out.println("  continue outer at i=" + i + " j=" + j);
            continue outer;
        }
        System.out.println("body i=" + i + " j=" + j);
    }
    System.out.println("  never reached");
}
body i=1 j=1
  continue outer at i=1 j=2
    outer update: 1 -> 2
body i=2 j=1
  continue outer at i=2 j=2
    outer update: 2 -> 3
body i=3 j=1
  continue outer at i=3 j=2
    outer update: 3 -> 4

Label phải trỏ tới một vòng lặp thật sự bao quanh statement đó, và phải viết đúng chính tả. Gõ sai là lỗi compile chứ không phải bất ngờ lúc runtime:

outer:
for (int i = 1; i <= 3; i++) {
    for (int j = 1; j <= 3; j++) {
        break outter;
    }
}
BadLabel.java:6: error: undefined label: outter
                break outter;
                ^
1 error

Label rất hiếm, tách method rồi return thường tốt hơn

Label là cú pháp hợp lệ, đôi khi đúng là thứ bạn cần, và bạn có thể viết Java thật hàng tháng trời mà không gặp cái nào. Lý do là một cặp vòng lặp lồng nhau đáng để thoát ra thường cũng là một mảng logic đáng được đặt tên, và khi nó đã thành method riêng thì return làm đúng việc đó mà không cần cú pháp mới:

public class FirstPairMethod {
    public static void main(String[] args) {
        printUntilProductIsFour();
        System.out.println("done");
    }

    private static void printUntilProductIsFour() {
        for (int i = 1; i <= 3; i++) {
            for (int j = 1; j <= 3; j++) {
                if (i * j == 4) {
                    return;              // leaves the method, so both loops
                }
                System.out.println("i=" + i + " j=" + j);
            }
        }
    }
}
i=1 j=1
i=1 j=2
i=1 j=3
i=2 j=1
done

Output giống hệt bản dùng label. Bản tách method còn có thêm một cái tên nói rõ hai vòng lặp đó để làm gì, và có thể test riêng. Hãy dùng label khi việc tách method đồng nghĩa với truyền sáu biến local ra vào; những lúc còn lại thì tách method.

break trong switch là thoát switch, không phải thoát loop

Đây là một cái bẫy thật sự, vì cùng một keyword mang hai nghĩa khác nhau tuỳ theo cái gì đang bao quanh nó. Bên trong switch, break kết thúc switch. Nếu switch đó tình cờ nằm trong một vòng lặp thì vòng lặp không hề bị đụng tới:

public class SwitchBreakTrap {
    public static void main(String[] args) {
        String[] commands = {"add", "add", "quit", "add"};
        for (String cmd : commands) {
            switch (cmd) {
                case "add":
                    System.out.println("adding");
                    break;
                case "quit":
                    System.out.println("quitting");
                    break;          // this leaves the SWITCH, not the for loop
            }
        }
        System.out.println("loop finished");
    }
}
adding
adding
quitting
adding
loop finished

Chương trình in quitting rồi vẫn xử lý thêm một lệnh nữa. break đã làm đúng việc nó luôn làm trong switch: chặn không cho rơi xuống case kế tiếp. Nó chưa bao giờ có khả năng dừng vòng lặp.

Label là cách sửa trực tiếp, và đây là chỗ hiếm hoi label thật sự xứng đáng, vì không có keyword nào khác với tới được qua khỏi switch:

loop:
for (String cmd : commands) {
    switch (cmd) {
        case "add":
            System.out.println("adding");
            break;
        case "quit":
            System.out.println("quitting");
            break loop;     // this one leaves the for loop
    }
}
System.out.println("loop finished");
adding
adding
quitting
loop finished

Cách còn lại là một cờ hiệu được kiểm tra ngay trên điều kiện của vòng lặp, và dùng kèm case dạng mũi tên thì đọc rất gọn vì case mũi tên không cần break:

boolean running = true;
for (int i = 0; i < commands.length && running; i++) {
    switch (commands[i]) {
        case "add" -> System.out.println("adding");
        case "quit" -> {
            System.out.println("quitting");
            running = false;
        }
    }
}
System.out.println("loop finished");
adding
adding
quitting
loop finished

Bản dùng cờ hiệu có một khác biệt về hành vi đáng nhớ: nó chạy hết lượt hiện tại rồi mới dừng, trong khi break loop dừng ngay lập tức. Khi phần còn lại của lượt lặp không làm gì cả, như ở đây, output của hai bản là như nhau.

Tìm trong cấu trúc hai chiều và dừng ngay khi thấy

Ứng dụng kinh điển của labeled break là quét một lưới để tìm một giá trị rồi thoát ra ngay khi tìm thấy. Mỗi lần dò thêm sau đó đều là công vô ích:

public class SearchLabeled {
    public static void main(String[] args) {
        int[][] grid = {
            {4, 8, 15},
            {16, 23, 42},
            {7, 23, 99}
        };
        int target = 23;
        int foundRow = -1;
        int foundCol = -1;
        int probes = 0;

        search:
        for (int r = 0; r < grid.length; r++) {
            for (int c = 0; c < grid[r].length; c++) {
                probes++;
                if (grid[r][c] == target) {
                    foundRow = r;
                    foundCol = c;
                    break search;
                }
            }
        }

        System.out.println("probes=" + probes);
        System.out.println("found at row=" + foundRow + " col=" + foundCol);
    }
}
probes=5
found at row=1 col=1

Năm lần dò trên chín ô, và số 23 thứ hai ở hàng 2 không bao giờ bị chạm tới. Bản dùng cờ hiệu làm đúng chuyện đó với một boolean phụ và một điều kiện thêm trên vòng ngoài:

boolean found = false;

for (int r = 0; r < grid.length && !found; r++) {
    for (int c = 0; c < grid[r].length; c++) {
        probes++;
        if (grid[r][c] == target) {
            foundRow = r;
            foundCol = c;
            found = true;
            break;
        }
    }
}
probes=5
found at row=1 col=1

Cùng số lần dò, cùng kết quả. Đánh đổi rất rõ ràng: bản dùng label có một cú nhảy và không cần trạng thái phụ, bản dùng cờ hiệu không có cú pháp lạ nhưng phải giữ hai thứ đồng bộ — found = true và lệnh break ngay sau đó. Quên break là vòng trong chạy hết hàng rồi vòng ngoài mới kiểm tra lại điều kiện.

return là lối thoát mạnh nhất

Cả hai bản trên đều là code tìm kiếm nằm giữa main. Chuyển nó vào một method thì bài toán thoát ra biến mất, vì return rời khỏi mọi thứ cùng lúc — mọi vòng lặp bao quanh, và cả method:

public class SearchMethod {
    public static void main(String[] args) {
        int[][] grid = {
            {4, 8, 15},
            {16, 23, 42},
            {7, 23, 99}
        };
        int[] hit = find(grid, 23);
        if (hit == null) {
            System.out.println("not found");
        } else {
            System.out.println("found at row=" + hit[0] + " col=" + hit[1]);
        }
        System.out.println("missing 100 -> " + (find(grid, 100) == null ? "not found" : "found"));
    }

    private static int[] find(int[][] grid, int target) {
        for (int r = 0; r < grid.length; r++) {
            for (int c = 0; c < grid[r].length; c++) {
                if (grid[r][c] == target) {
                    return new int[] {r, c};
                }
            }
        }
        return null;
    }
}
found at row=1 col=1
missing 100 -> not found

Không label, không cờ hiệu, không có foundRow khởi tạo bằng giá trị canh chừng -1 mà người gọi phải nhớ kiểm tra. Trường hợp "không tìm thấy" gọn trong một return null ở cuối, chạy tới đúng khi hai vòng lặp kết thúc mà không trúng gì.

Lối thoátRời khỏiDùng khi
breakvòng lặp hoặc switch gần nhấtbạn xong việc với riêng vòng lặp này
break labelvòng lặp mang label và mọi thứ bên trongphải thoát nhiều hơn một vòng lặp tại chỗ
continuelượt lặp hiện tạiphần tử này không đáng quan tâm
continue labellượt lặp hiện tại của vòng mang labelcả phần tử ngoài này không đáng quan tâm
returnmethod, cùng mọi vòng lặp trong nóhai vòng lặp là một phép tìm kiếm và method chính là phép tìm đó

Lồng sâu tới đâu là quá sâu?

Hai tầng là bình thường. Ba tầng là dấu hiệu đáng ngờ. Bốn tầng thì gần như chắc chắn đang có một method ẩn trong đó. Triệu chứng đo được là phần thân trôi dần sang phải và các điều kiện không còn đọc chung với nhau được nữa:

public class DeepNesting {
    public static void main(String[] args) {
        String[][] rows = {
            {"An", "", "HN"},
            {"Binh", "b@x.vn", "SG"},
            {"", "c@x.vn", "DN"}
        };
        for (int r = 0; r < rows.length; r++) {
            boolean complete = true;
            for (int c = 0; c < rows[r].length; c++) {
                if (rows[r][c].isEmpty()) {
                    complete = false;
                    break;
                }
            }
            System.out.println("row " + r + " complete=" + complete);
        }
    }
}
row 0 complete=false
row 1 complete=true
row 2 complete=false

Code chạy đúng, và nó cần cờ complete chỉ để mang câu trả lời của vòng trong ra ngoài cho vòng ngoài. Tách vòng trong ra thì cái cờ đó thành giá trị trả về:

public class ExtractedLoop {
    public static void main(String[] args) {
        String[][] rows = {
            {"An", "", "HN"},
            {"Binh", "b@x.vn", "SG"},
            {"", "c@x.vn", "DN"}
        };
        for (int r = 0; r < rows.length; r++) {
            System.out.println("row " + r + " complete=" + isComplete(rows[r]));
        }
    }

    private static boolean isComplete(String[] row) {
        for (String field : row) {
            if (field.isEmpty()) {
                return false;
            }
        }
        return true;
    }
}
row 0 complete=false
row 1 complete=true
row 2 complete=false

Output giống hệt, chỉ còn một tầng lồng, không còn cờ thay đổi được, và vòng lặp trong giờ có tên nói rõ nó quyết định điều gì. Đây đúng là nước đi của method find phía trên, và cũng là câu trả lời cho gần như mọi câu hỏi "làm sao thoát khỏi cả hai vòng lặp".

Những lỗi hay gặp

Dùng lại counter của vòng ngoài cho vòng trong

Khai báo counter của vòng trong trùng tên với vòng ngoài thì không compile được, và đó là kết cục may mắn:

for (int i = 1; i <= 3; i++) {
    for (int i = 1; i <= 3; i++) {
        System.out.println(i);
    }
}
ShadowCounter.java:4: error: variable i is already defined in method main(String[])
            for (int i = 1; i <= 3; i++) {
                     ^
1 error

Bản nguy hiểm là bản compile được: bỏ chữ int đi và dùng lại đúng biến cũ. Giờ hai vòng lặp dùng chung một counter, và số lần lặp của vòng ngoài bị vòng trong phá hỏng:

int runs = 0;
for (int i = 1; i <= 3; i++) {
    for (i = 1; i <= 3; i++) {     // same i, no new declaration
        runs++;
    }
}
System.out.println("body ran " + runs + " times, expected 9");
body ran 3 times, expected 9

Ba, chứ không phải chín. Vòng trong đẩy i lên 4, lệnh i++ của vòng ngoài đưa nó lên 5, và 5 <= 3 sai ngay ở lượt ngoài đầu tiên. Hãy đặt tên khác nhau cho hai vòng lặp — ij, hoặc rowcol — và khai báo mỗi cái ngay trên header của nó.

Khai báo counter của vòng trong ở ngoài vòng lặp

Nửa còn lại của cùng một vấn đề. Counter khai báo trước vòng ngoài sẽ không được reset ở mỗi lượt ngoài, nên vòng trong chạy một lần rồi cạn kiệt vĩnh viễn:

int j = 1;                          // declared outside, never reset
int runs = 0;
for (int i = 1; i <= 3; i++) {
    for (; j <= 3; j++) {
        runs++;
        System.out.println("i=" + i + " j=" + j);
    }
}
System.out.println("body ran " + runs + " times, expected 9");
i=1 j=1
i=1 j=2
i=1 j=3
body ran 3 times, expected 9

i=2i=3 không in ra gì cả, vì j đã bằng 4 từ trước khi chúng bắt đầu. Hãy khai báo counter ngay trên header của forfor (int j = 1; ...) — để phần khởi tạo chạy lại mỗi lần bước vào vòng lặp, đúng như bạn muốn.

Quên rằng continue bỏ qua bước update của while

Đã nói đầy đủ ở trên, và đáng nhắc lại vì đây là lỗi làm treo chương trình chứ không phải chỉ in ra một con số sai. Nếu một vòng whilecontinue, hãy kiểm tra xem counter được tăng trước nó hay sau nó.

Statement không bao giờ chạy tới sau break

Java từ chối những đoạn code chứng minh được là không thể chạy tới. Một statement nằm ngay sau break trong cùng một block chính là loại đó:

for (int i = 1; i <= 3; i++) {
    break;
    System.out.println("i=" + i);
}
Unreachable.java:5: error: unreachable statement
            System.out.println("i=" + i);
            ^
1 error

Điều tương tự xảy ra sau continue và sau return. Thường thì nó có nghĩa là break đã lọt ra ngoài cái if lẽ ra phải bao nó — chính cái if đó mới làm statement phía sau trở nên có thể chạy tới.

FAQ

Làm sao thoát khỏi hai vòng lặp cùng lúc trong Java?

Đặt label cho vòng ngoài rồi dùng break label, hoặc tách hai vòng lặp thành một method rồi return. break thường không làm được: nó luôn kết thúc vòng lặp hoặc switch gần nhất. Bản tách method thường là code tốt hơn, vì thứ bạn muốn thoát ra gần như luôn xứng đáng có một cái tên.

Vì sao vòng lặp lồng nhau của tôi chỉ chạy đúng một lần?

Gần như luôn là do counter của vòng trong được khai báo bên ngoài và không bao giờ được reset, hoặc do hai vòng lặp dùng chung một biến. Hãy khai báo mỗi counter ngay trên header for của nó, và đặt tên khác nhau.

continue có bỏ qua lệnh tăng không?

Trong vòng for thì không — continue nhảy tới phần update trên header, chạy nó, rồi mới kiểm tra điều kiện. Trong while hay do-while thì có, nếu lệnh tăng nằm sau continue trong thân vòng lặp, và đó chính là cách continue tạo ra vòng lặp vô tận.

break trong switch nằm trong loop có dừng được loop không?

Không. Nó chỉ kết thúc switch, còn vòng lặp đi tiếp lượt kế. Hãy dùng break label trên vòng lặp, hoặc đặt một cờ hiệu để điều kiện của vòng lặp kiểm tra, hoặc return nếu cả khối đó là một method.

Vòng lặp lồng nhau có luôn chậm không?

Không — kích thước dữ liệu mới quyết định. Hai vòng lặp trên n phần tử chạy vào thân n² lần, tức 100 lần với 10 phần tử và một triệu lần với một nghìn phần tử. Lưới nhỏ cố định thì mãi mãi không sao; đáng chú ý là những vòng lặp có cận lớn dần theo dữ liệu. Khi các cặp là đối xứng, cho vòng trong bắt đầu từ i + 1 giảm được một nửa số lần lặp mà không mất gì.

Có thể đặt label cho thứ không phải vòng lặp không?

Có. Mọi statement đều mang được label, và break label nhảy tới cuối statement đó — kể cả một block thường. Nó hợp lệ nhưng cực kỳ hiếm; hãy coi break trên một block có label là thứ để nhận ra khi đọc code, không phải thứ để viết.

Kết luận

Vòng lặp lồng nhau nhân lên: phần thân trong chạy bằng số lần ngoài nhân số lần trong, tức là 12 với một lần chạy 3 × 4 và một triệu với hai vòng lặp trên một nghìn phần tử. break kết thúc vòng lặp hoặc switch gần nhất, không hơn. continue bỏ dở lượt hiện tại, chạy bước update của vòng for trên đường ra và bỏ qua bước update của vòng while, và đó là khác biệt giữa một vòng lặp kết thúc được với một vòng lặp bạn phải kill. Label cho phép breakcontinue gọi tên một vòng lặp xa hơn, và chúng là công cụ đúng để thoát khỏi switch nằm trong loop — nhưng phần lớn thời gian, một method với return sớm mới là câu trả lời.

Đến đây là hết nửa control flow của series: điều kiện, vòng lặp, và các lối ra khỏi chúng. Bài kế tiếp là một bộ bài tập tổng hợp về điều kiện và vòng lặp — một tập bài tập tăng dần độ khó, ghép if, switch, for, while, lồng vòng lặp, breakcontinue lại với nhau, kèm lời giải và output của từng bài.

Bài viết liên quan

[Java Basics] Từ khóa this, static và final trong Java

Ba từ khóa this, static và final trong Java trên JDK 21 — this trỏ vào đâu, vì sao static field dùng chung cho mọi object, thứ tự static initialiser, constant inlining soi bằng javap, và vì sao final không hề làm object immutable.

[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] Field, method và constructor trong Java

Field, instance method và constructor trong Java: giá trị default của field, field initialiser, overloading constructor và chaining bằng this(...), thứ tự khởi tạo chứng minh bằng lệnh in, cùng mọi error message thật của javac từ việc viết void trước constructor tới việc đặt this(...) không phải đầu tiên.

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