Mọi chương trình từ đầu series tới giờ đều có dữ liệu viết cứng trong source. Bài này lấy dữ liệu
từ bên ngoài: System.in cho input, System.out và System.err cho output. Trong Java, cửa
chính để đọc System.in là java.util.Scanner.
Scanner rất dễ bắt đầu và cũng đầy cạm bẫy. Cái nổi tiếng nhất làm nextLine() trả về chuỗi rỗng
mà không có dấu hiệu gì, và nó đã ngốn của người mới học vô số giờ. Tất cả đều bắt nguồn từ một
điều duy nhất: cách Scanner nhìn input của bạn. Nên bài viết bắt đầu từ đó. 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.
![]()
Nói về output trước, vì bạn cần nó để nhìn thấy đoạn code đọc input thật sự đã đọc được gì.
Xuất dữ liệu: print, println và printf
System.out cho bạn ba cách để ghi ra màn hình:
public class OutputDemo {
public static void main(String[] args) {
System.out.print("A");
System.out.print("B");
System.out.println("C");
System.out.println("next line");
System.out.printf("%s is %d years old%n", "An", 25);
System.err.println("this went to stderr");
}
}
ABC
next line
An is 25 years old
this went to stderr
printghi ra rồi để con trỏ nguyên chỗ cũ. Đây chính là thứ bạn cần cho một lời nhắc:System.out.print("Age: ")giữ con trỏ nằm cùng dòng với câu hỏi.printlnghi ra rồi xuống dòng.printfghi ra một khuôn mẫu, các chỗ trống được điền bằng các argument phía sau.
Các format specifier của printf bạn sẽ dùng nhiều nhất
Một specifier gồm %, các flag và độ rộng tuỳ chọn, rồi tới chữ cái chỉ kiểu chuyển đổi. Đây là
tập hợp đáng thuộc lòng, mỗi dòng được chạy riêng:
System.out.printf("%s%n", "An Nguyen");
System.out.printf("%d%n", 42);
System.out.printf("%f%n", 3.14159);
System.out.printf("%.2f%n", 3.14159);
System.out.printf("[%-10s]%n", "left");
System.out.printf("[%5d]%n", 42);
System.out.printf("%,d%n", 1234567);
System.out.printf("%b%n", true);
System.out.printf("100%%%n");
An Nguyen
42
3.141590
3.14
[left ]
[ 42]
1,234,567
true
100%
| Specifier | Ý nghĩa | Kết quả ở trên |
|---|---|---|
%s | giá trị bất kỳ, qua toString() | An Nguyen |
%d | các integer type | 42 |
%f | số thực, mặc định 6 chữ số thập phân | 3.141590 |
%.2f | số thực, 2 chữ số thập phân, có làm tròn | 3.14 |
%-10s | chuỗi đệm cho đủ rộng 10, canh trái | left |
%5d | số nguyên đệm cho đủ rộng 5, canh phải | 42 |
%,d | số nguyên có dấu phân cách hàng nghìn | 1,234,567 |
%b | boolean | true |
%% | một dấu phần trăm | % |
%n | dấu xuống dòng | kết thúc dòng |
Chữ cái chuyển đổi phải khớp với type của argument. Đưa một double vào %d không phải là yêu
cầu làm tròn, mà là một lỗi làm chết chương trình:
System.out.printf("%d%n", 3.14);
Exception in thread "main" java.util.IllegalFormatConversionException: d != java.lang.Double
at java.base/java.util.Formatter$FormatSpecifier.failConversion(Formatter.java:4515)
Nên dùng %n hay \n?
\n là một ký tự, line feed, byte 10. Còn %n là chỉ thị của printf, nó nở ra thành dấu xuống
dòng của hệ thống: line feed trên Linux và macOS, carriage return cộng line feed trên Windows.
Trên máy này hai thứ cho ra byte y hệt nhau:
System.out.println("line.separator bytes: "
+ java.util.Arrays.toString(System.lineSeparator().getBytes()));
System.out.printf("A%n");
System.out.printf("B\n");
line.separator bytes: [10]
A
B
java NewlineDemo | tail -2 | od -c
0000000 A \n B \n
0000004
Trong printf hãy dùng %n. Nó là cái chạy đúng trên mọi hệ thống mà chẳng tốn gì thêm. Lưu ý
\n vẫn dùng được trong printf, còn %n thì không có tác dụng trong println — lệnh
println("A%n") in ra đúng hai ký tự %n.
System.out và System.err
Cả hai đều ghi ra terminal nên nhìn trên màn hình y hệt nhau. Thực ra chúng là hai stream khác nhau, và shell tách được:
java OutputDemo 2>/dev/null # chỉ stdout
java OutputDemo 2>&1 1>/dev/null # chỉ stderr
ABC
next line
An is 25 years old
this went to stderr
Lời nhắc và kết quả thì đưa vào System.out, thông báo lỗi thì đưa vào System.err. Nhờ vậy
người dùng có chuyển output của chương trình vào file thì vẫn thấy lỗi hiện trên màn hình.
Scanner đọc input như thế nào?
import java.util.Scanner;
Scanner sc = new Scanner(System.in);
Mọi thứ còn lại trong bài đều suy ra từ đúng mô hình này:

Scanner không nhìn thấy dòng, cũng không nhìn thấy phím bạn gõ. Nó nhìn thấy một luồng ký tự dài, và trả cho bạn từng mảnh của luồng đó theo hai kiểu khác nhau:
- Nhóm method đọc token —
next(),nextInt(),nextDouble()và các anh em. Token là một cụm ký tự nằm giữa hai delimiter, mà delimiter mặc định là mọi cụm whitespace: dấu cách, tab và dấu xuống dòng đều tính. Nhóm này bỏ qua whitespace ở đầu, đọc đúng một token, rồi dừng ngay sau ký tự cuối của token đó. - Nhóm method đọc dòng —
nextLine(). Nó hoàn toàn không quan tâm tới token. Nó đọc mọi thứ từ vị trí hiện tại tới dấu xuống dòng gần nhất, trả về phần đó mà bỏ dấu xuống dòng, rồi nhảy qua dấu xuống dòng ấy.
Vì với nhóm đọc token thì whitespace chỉ là thứ để ngăn cách, cùng một chương trình sẽ nhận được input trình bày theo đủ kiểu khác nhau:
Scanner sc = new Scanner(System.in);
System.out.print("Three numbers: ");
int a = sc.nextInt();
int b = sc.nextInt();
int c = sc.nextInt();
System.out.printf("a=%d b=%d c=%d sum=%d%n", a, b, c, a + b + c);
Đưa vào 10 20 30 trên một dòng, hoặc 10, 20, 30 trên ba dòng, hoặc một mớ tab lẫn dòng
trống, đều cho ra cùng một kết quả:
Three numbers: a=10 b=20 c=30 sum=60
Tiện thật, và đó cũng là gốc rễ của mọi vấn đề trong phần còn lại của bài. Method đọc token đọc xong con số rồi để dấu xuống dòng nằm lại trong luồng, bởi với nó dấu xuống dòng chỉ là thứ ngăn cách. Method đọc dòng sau đó gặp đúng cái dấu ngăn cách ấy và coi nó là cả một dòng.
Bảng method của Scanner và thứ mỗi method để lại trong buffer
| Method | Đọc cái gì | Con trỏ dừng ở đâu |
|---|---|---|
next() | một token ngăn bởi whitespace, trả về String | ngay sau token |
nextInt() | một token, parse thành int | ngay sau các chữ số |
nextLong() | một token, parse thành long | ngay sau các chữ số |
nextDouble() | một token, parse thành double | ngay sau con số |
nextBoolean() | một token, true hoặc false, không phân biệt hoa thường | ngay sau token |
nextLine() | phần còn lại của dòng hiện tại, bỏ dấu xuống dòng | đầu dòng kế tiếp |
hasNext() | không đọc gì — chỉ báo còn token hay không | không đổi |
hasNextInt() | không đọc gì — chỉ báo token kế tiếp có parse được thành int không | không đổi |
hasNextLine() | không đọc gì — chỉ báo còn dòng nào không | không đổi |
Nửa quan trọng của bảng trên là nhóm hasNext*. Chúng không tiêu thụ gì cả; chúng nhìn trước một
bước rồi trả về boolean. Đó là thứ làm cho việc validate khả thi, và cũng là thứ làm cho vòng lặp
vô tận ở phần dưới khả thi.
Đây là một chương trình dò, in ra hành vi của con trỏ theo từng bước. Input gồm bốn dòng:
25, An Nguyen, Ha Noi Viet Nam, true 9007199254740993.
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
System.out.println("1. nextInt() -> " + n);
System.out.println(" hasNextLine() = " + sc.hasNextLine());
System.out.println("2. nextLine() -> [" + sc.nextLine() + "] (the leftover newline)");
System.out.println("3. nextLine() -> [" + sc.nextLine() + "] (the real name)");
System.out.println("4. next() -> [" + sc.next() + "]");
System.out.println("5. nextLine() -> [" + sc.nextLine() + "] (rest of that line)");
System.out.println("6. nextBoolean -> " + sc.nextBoolean());
System.out.println("7. nextLong -> " + sc.nextLong());
System.out.println("8. hasNext() = " + sc.hasNext());
1. nextInt() -> 25
hasNextLine() = true
2. nextLine() -> [] (the leftover newline)
3. nextLine() -> [An Nguyen] (the real name)
4. next() -> [Ha]
5. nextLine() -> [ Noi Viet Nam] (rest of that line)
6. nextBoolean -> true
7. nextLong -> 9007199254740993
8. hasNext() = false
Có ba điều đáng rút ra. Bước 2 trả về chuỗi rỗng, đó chính là cái bẫy. Bước 4 cho thấy next()
chỉ đọc được Ha chứ không lấy trọn cái tên, vì dấu cách đã kết thúc token. Bước 5 cho thấy dấu
cách nằm ở đầu chuỗi " Noi Viet Nam" — nextLine() trả về phần còn lại của dòng đúng y nguyên,
kể cả dấu cách vốn dùng để ngăn hai token.
Bẫy nextLine
Đây là cái bug đó. Nó ngốn của người mới học nhiều giờ hơn bất cứ thứ gì khác trong bài này.

Tái hiện lại bug
import java.util.Scanner;
public class Broken {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.print("Age: ");
int age = sc.nextInt();
System.out.print("Name: ");
String name = sc.nextLine();
System.out.println("age=" + age);
System.out.println("name=[" + name + "]");
System.out.println("length=" + name.length());
}
}
Chạy với hai dòng 25 và An Nguyen:
printf '25\nAn Nguyen\n' | java Broken
Age: Name: age=25
name=[]
length=0
Tuổi thì đúng. Tên là chuỗi rỗng độ dài 0, và chương trình còn chẳng thèm dừng lại chờ. Gõ trực
tiếp bằng bàn phím thì hiệu ứng vẫn vậy mà còn khó hiểu hơn: lời nhắc Name: loé lên rồi chương
trình kết thúc trước khi bạn kịp gõ.
Lý do đúng như mô hình ở trên. Luồng input là 25\nAn Nguyen\n. nextInt() đọc token 25 rồi
dừng ngay sau chữ 5, để con trỏ nằm trước dấu xuống dòng. Sau đó nextLine() làm đúng phận
sự: đọc từ con trỏ tới hết dòng hiện tại. Giữa con trỏ và chỗ kết thúc dòng đó chẳng còn gì, nên
nó trả về "" rồi bước qua dấu xuống dòng. Cái tên thậm chí chưa được chạm tới.
Chú ý cách diễn đạt: nextLine() không hề hỏng và cũng không bỏ qua gì cả. Nó đã đọc một dòng —
phần rỗng còn sót lại của dòng 1.
Cách 1: đọc nốt phần còn lại của dòng
Gọi nextLine() một lần, vứt kết quả đi, thế là con trỏ rơi vào đầu dòng thật:
System.out.print("Age: ");
int age = sc.nextInt();
sc.nextLine(); // consume the rest of the number's line
System.out.print("Name: ");
String name = sc.nextLine();
Age: Name: age=25
name=[An Nguyen]
Cách này chạy được, và cũng là cách hầu hết tutorial chỉ. Cái giá là từ giờ bạn phải nhớ thêm một
dòng sc.nextLine(); trơ trọi sau mỗi lần gọi method đọc token mà phía sau có đọc dòng. Sót
một chỗ là bug quay lại, im lặng.
Cách 2: chỉ dùng nextLine rồi tự parse số
Đừng bao giờ trộn hai nhóm method. Đọc mọi thứ bằng nextLine(), rồi mới chuyển đổi:
import java.util.Scanner;
public class FixTwo {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.print("Age: ");
int age = Integer.parseInt(sc.nextLine().trim());
System.out.print("Name: ");
String name = sc.nextLine();
System.out.println("age=" + age);
System.out.println("name=[" + name + "]");
}
}
Age: Name: age=25
name=[An Nguyen]
Chỗ .trim() là quan trọng: người dùng lỡ gõ thừa một dấu cách ở cuối thì Integer.parseInt sẽ
nhận chuỗi "25 ", và nó ném lỗi.
Nên chọn cách nào?
Cách 2, cho mọi chương trình đọc dữ liệu từ con người. Lý do:
- Một mô hình thay vì hai. Mỗi lần đọc tiêu thụ đúng một dòng. Không phải nhớ trạng thái con trỏ giữa các dòng code nữa.
- Không thể âm thầm hỏng lại. Cách 1 dựa vào một dòng code trơ trọi chỉ tồn tại vì tác dụng phụ của nó; lúc dọn code mà xoá nhầm là bug quay lại mà chẳng có gì báo.
- Đọc được tên có dấu cách.
sc.next()sẽ trả vềAnvà bỏ lạiNguyen. - Lỗi rõ ràng hơn. Số sai định dạng cho bạn
NumberFormatExceptionngay tại dòng bạn parse, chứ không phải mộtInputMismatchExceptiontừ đâu đó bên trong Scanner.
Cách 1 vẫn ổn cho đúng trường hợp mà Scanner sinh ra để phục vụ: input toàn số ngăn nhau bằng whitespace, nơi bạn vốn chẳng có ý định đọc một dòng chữ tự do nào.
Chuyện gì xảy ra khi input không phải là số?
nextInt() không trả về mã lỗi. Nó ném exception:

hasNextInt() chỉ nhìn — quên tiêu thụ token sai là lặp vô tận:
Scanner sc = new Scanner(System.in);
System.out.print("Age: ");
int age = sc.nextInt();
System.out.println("age=" + age);
printf 'abc\n' | java BadInput
Age: Exception in thread "main" java.util.InputMismatchException
at java.base/java.util.Scanner.throwFor(Scanner.java:947)
at java.base/java.util.Scanner.next(Scanner.java:1602)
at java.base/java.util.Scanner.nextInt(Scanner.java:2267)
at java.base/java.util.Scanner.nextInt(Scanner.java:2221)
at BadInput.main(BadInput.java:7)
java.util.InputMismatchException nghĩa là "có token đấy, nhưng nó không parse được thành thứ
bạn yêu cầu". Đưa 3.5 vào nextInt() cũng ném đúng exception này. Sau đó token vẫn còn nguyên
trong buffer — exception được ném ra trước khi Scanner kịp đi tiếp.
Exception là một chủ đề riêng và sẽ có bài sau nói cho tử tế. Ở đây, cứ coi try/catch và
hasNextInt() là hai công cụ cho đúng một việc: không để input sai làm chết chương trình.
Vòng lặp validate
hasNextInt() cho biết token kế tiếp có parse được không, mà không tiêu thụ nó. Lặp chừng nào nó
còn false, và phải vứt bỏ token sai bằng next() trước khi kiểm tra lại:
import java.util.Scanner;
public class Validate {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.print("Age: ");
while (!sc.hasNextInt()) {
String junk = sc.next(); // discard the offending token
System.out.println(junk + " is not a whole number.");
System.out.print("Age: ");
}
int age = sc.nextInt();
System.out.println("age=" + age);
}
}
printf 'abc\n3.5\n25\n' | java Validate
Age: abc is not a whole number.
Age: 3.5 is not a whole number.
Age: age=25
Vòng lặp vô tận khi quên discard token
Bỏ dòng sc.next() đi thì vòng lặp không còn gì để tiến tới:
System.out.print("Age: ");
while (!sc.hasNextInt()) {
System.out.println("Not a number, try again.");
// BUG: the bad token is still sitting in the buffer
}
System.out.println("age=" + sc.nextInt());
printf 'abc\n25\n' | java InfiniteLoop
Age: Not a number, try again.
Not a number, try again.
Not a number, try again.
Not a number, try again.
Not a number, try again.
Output trên bị cắt sau năm dòng; chương trình không tự dừng và phải kill nó. hasNextInt() nhìn
vào abc, trả lời false, và không thay đổi gì hết. Vòng lặp kế tiếp lại nhìn đúng cái abc đó.
Nếu chương trình của bạn từng xối một thông báo validate ra kín màn hình thì lý do là đây: phải
có thứ gì đó trong vòng lặp tiêu thụ cái input vừa trượt kiểm tra.
NoSuchElementException: input đã hết
Exception thứ hai mà người mới hay gặp là khi không còn token nào nữa — bạn pipe một file đã hết, hoặc bấm Ctrl-D (Ctrl-Z rồi Enter trên Windows) ngay tại lời nhắc:
printf '' | java BadInput
Age: Exception in thread "main" java.util.NoSuchElementException
at java.base/java.util.Scanner.throwFor(Scanner.java:945)
at java.base/java.util.Scanner.next(Scanner.java:1602)
at java.base/java.util.Scanner.nextInt(Scanner.java:2267)
at java.base/java.util.Scanner.nextInt(Scanner.java:2221)
at BadInput.main(BadInput.java:7)
Gọi nextLine() khi input đã hết thì ném đúng class đó kèm một thông báo: NoSuchElementException: No line found.
Hai exception này nhìn giống nhau nhưng nghĩa ngược nhau. InputMismatchException: có input,
nhưng sai dạng. NoSuchElementException: không còn input nào. Chặn cái thứ hai bằng hasNext()
hoặc hasNextLine() trước khi đọc, đó là cách duy nhất đáng tin để viết chương trình sống sót khi
bị chạy với input lấy từ file.
Bẫy locale: 3.14 hay 3,14?
Phần parse input và phần format output được điều khiển riêng, nên mỗi bên cần một cách sửa riêng.

nextDouble() parse theo locale mặc định của JVM, mà một nửa thế giới viết số thập phân bằng dấu
phẩy. Chỗ này đáng nắm chính xác, vì cùng một file source lại chạy khác nhau trên hai máy:
Scanner sc = new Scanner(System.in);
System.out.println("default locale: " + Locale.getDefault());
System.out.print("Price: ");
double price = sc.nextDouble();
System.out.println("price=" + price);
Dưới locale tiếng Đức, chuỗi 3.14 không phải là một con số:
printf '3.14\n' | java -Duser.language=de -Duser.country=DE LocaleDemo
default locale: de_DE
Price: Exception in thread "main" java.util.InputMismatchException
at java.base/java.util.Scanner.throwFor(Scanner.java:947)
at java.base/java.util.Scanner.next(Scanner.java:1602)
at java.base/java.util.Scanner.nextDouble(Scanner.java:2573)
at LocaleDemo.main(LocaleDemo.java:9)
Còn 3,14 thì có:
printf '3,14\n' | java -Duser.language=de -Duser.country=DE LocaleDemo
default locale: de_DE
Price: price=3.14
Locale tiếng Việt cũng y như vậy — vi_VN cũng dùng dấu phẩy làm dấu thập phân, nên một chương
trình chạy ngon trên máy để tiếng Anh sẽ hỏng trên máy để tiếng Việt và ngược lại. Ghim locale
ngay trên Scanner thì định dạng input thôi phụ thuộc vào máy:
Scanner sc = new Scanner(System.in);
sc.useLocale(Locale.US); // "3.14" now parses everywhere
printf '3.14\n' | java -Duser.language=vi -Duser.country=VN LocaleFix
default locale: vi_VN
Price: price=3,14
Hãy đọc kỹ output đó, vì nó lộ ra đúng nửa vấn đề mà hầu hết bài viết bỏ qua. Phần đọc input đã
được sửa — 3.14 đã được chấp nhận dưới vi_VN. Nhưng chương trình lại in ra 3,14, bởi
printf cũng định dạng theo locale mặc định, còn useLocale chỉ tác động lên Scanner.
Formatter và Scanner là hai object riêng với hai locale riêng:
System.out.printf("default : %.2f | %,d%n", 3.14159, 1234567);
System.out.printf(Locale.US, "US : %.2f | %,d%n", 3.14159, 1234567);
java -Duser.language=de -Duser.country=DE PrintfLocale
default locale: de_DE
default : 3,14 | 1.234.567
US : 3.14 | 1,234,567
Vậy nên: scanner.useLocale(Locale.US) cho input, và System.out.printf(Locale.US, ...) cho
output mỗi khi các chữ số phải chính xác. Ngược lại, nếu bạn muốn theo đúng thói quen của người
dùng ở mọi chỗ thì đừng đổi gì cả, cứ để locale mặc định làm việc của nó — sai lầm nằm ở chỗ tưởng
rằng chẳng có locale nào dính vào đây.
Đọc nhiều giá trị trên một dòng và useDelimiter
Nhiều token trên cùng một dòng thì không cần xử lý gì đặc biệt, vì whitespace vốn đã là delimiter.
Mọi ký tự khác thì cần. useDelimiter thay cái pattern whitespace bằng một regular expression của
bạn — ở đây là dấu phẩy, áp lên một Scanner đọc một chuỗi văn bản:
Scanner line = new Scanner(System.in);
String csv = line.nextLine();
Scanner fields = new Scanner(csv);
fields.useDelimiter(",");
while (fields.hasNext()) {
System.out.println("[" + fields.next() + "]");
}
fields.close();
printf 'An Nguyen,25,1250000\n' | java DelimiterDemo
[An Nguyen]
[25]
[1250000]
Để ý mẹo trong đoạn code đó: đọc dòng bằng nextLine(), rồi dựng một Scanner thứ hai trên chuỗi
thu được. Một Scanner có thể bọc chuỗi bất kỳ chứ không riêng gì System.in, và làm theo cách này
thì ranh giới giữa các dòng vẫn còn nguyên.
Nếu bạn gọi thẳng useDelimiter(",") trên Scanner của System.in, dấu xuống dòng thôi làm
delimiter và trở thành một ký tự bình thường nằm trong token cuối:
Scanner sc = new Scanner(System.in);
sc.useDelimiter(","); // the newline is now just a character
while (sc.hasNext()) {
String t = sc.next();
System.out.println("[" + t + "] length=" + t.length());
}
printf 'a,b,c\n' | java DelimiterTrap
[a] length=1
[b] length=1
[c
] length=2
Token cuối là c cộng một line feed, độ dài 2. Hãy viết pattern nhận cả hai loại ngăn cách:
sc.useDelimiter("\\s*,\\s*|\\R"); // comma with optional spaces, or a line break
printf 'a, b ,c\nd,e\n' | java DelimiterFix
[a] length=1
[b] length=1
[c] length=1
[d] length=1
[e] length=1
\R là ký hiệu regex cho mọi kiểu xuống dòng, nên nó xử lý được cả file Unix lẫn file Windows.
Đóng Scanner là đóng luôn System.in
Scanner có implement Closeable, nên try-with-resources dùng được và tự đóng giúp bạn:
try (Scanner sc = new Scanner(System.in)) {
System.out.print("Name: ");
String name = sc.nextLine();
System.out.println("hello " + name);
}
Còn đây là chỗ tinh tế mà không ai nhắc. Đóng một Scanner là đóng luôn cái stream nằm dưới nó, mà
stream nằm dưới chính là System.in — một object dùng chung cho cả JVM. Đã đóng rồi thì nó đóng
luôn tới hết chương trình:
Scanner first = new Scanner(System.in);
System.out.println("first: " + first.nextLine());
first.close();
Scanner second = new Scanner(System.in); // System.in is already closed
System.out.println("second: " + second.nextLine());
printf 'hello\nworld\n' | java CloseDemo
first: hello
Exception in thread "main" java.util.NoSuchElementException: No line found
at java.base/java.util.Scanner.nextLine(Scanner.java:1660)
at CloseDemo.main(CloseDemo.java:10)
Scanner thứ hai là một object hoàn toàn hợp lệ đang đọc một stream đã chết. Bản try-with-resources
cũng y hệt — sau khi ra khỏi block, gọi hasNextLine() trên một Scanner mới toanh trả về false
thay vì đứng chờ input.
⚠️ Quy tắc thực dụng: mỗi chương trình chỉ tạo đúng một Scanner trên
System.in, và đừng đóng nó. Dù sao thì JVM cũng đóngSystem.inlúc thoát. Nếu linter kêu ca về resource chưa đóng thì đóng nó đúng một lần ở cuốimain— tuyệt đối không đóng bên trong một method phụ.
Quy tắc này khác đi với Scanner bọc một String, hay bọc một file: những cái đó thật sự giữ một
resource đáng đóng, và try-with-resources là đúng cho chúng.
Scanner vs BufferedReader vs System.console()
Scanner | BufferedReader | System.console() | |
|---|---|---|---|
| Khởi tạo | new Scanner(System.in) | new BufferedReader(new InputStreamReader(System.in)) | System.console() |
| Parse sẵn | có: nextInt, nextDouble | không — bạn tự gọi Integer.parseInt | không |
| Đọc một dòng | nextLine() | readLine() | readLine() |
| Khi hết input | ném NoSuchElementException | trả về null | trả về null |
| Số sai định dạng | InputMismatchException (unchecked) | NumberFormatException tại chỗ bạn parse | tuỳ cách bạn parse |
| Checked exception | không có | readLine() ném IOException | không có |
| Phụ thuộc locale | có, nextDouble và các anh em | không, bạn tự kiểm soát | không |
| Chạy trong IDE hoặc input từ pipe | chạy được | chạy được | trả về null |
| Tốc độ | thấp nhất | cao | không dành cho input lớn |
| Thêm | useDelimiter, hasNextInt | chỉ có dòng | readPassword() giấu ký tự gõ vào |
BufferedReader viết dài hơn vài chữ lúc khởi tạo nhưng cho bạn mô hình đơn giản hơn hẳn — một
method, một dòng, hết thì null:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class ReaderDemo {
public static void main(String[] args) throws IOException {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
System.out.print("Age: ");
int age = Integer.parseInt(in.readLine().trim());
System.out.print("Name: ");
String name = in.readLine();
System.out.println("age=" + age);
System.out.println("name=[" + name + "]");
}
}
printf '25\nAn Nguyen\n' | java ReaderDemo
Age: Name: age=25
name=[An Nguyen]
Ở đây không có bẫy nextLine, vì chỉ có đúng một cách đọc: nguyên dòng. Hết input thì readLine()
trả về null chứ không ném exception, nên while ((line = in.readLine()) != null) là vòng lặp
chuẩn.
System.console() là trường hợp lạ. Nó cho bạn readPassword(), đọc mà không hiện ký tự lên màn
hình, nhưng chỉ có mặt khi chương trình gắn với một terminal thật. Input từ pipe, input chuyển
hướng từ file, và phần lớn cửa sổ run trong IDE đều cho bạn null:
printf 'An\nsecret\n' | java ConsoleDemo
System.console() = null
no console: input is redirected, or running under an IDE
Nên System.console() dành cho công cụ dòng lệnh cần hỏi mật khẩu, và không bao giờ dành cho
chương trình mà bạn muốn test bằng file input chuyển hướng.
Về tốc độ: Scanner chậm, và chuyện đó chỉ thành vấn đề khi input lớn. Cộng một triệu số nguyên lấy từ file qua pipe, lấy lần nhanh nhất trong năm lần chạy sau khi đã warm up, trên máy này:
| Cách làm | Thời gian tốt nhất |
|---|---|
Scanner.hasNextInt() / nextInt() | 309 ms |
BufferedReader.readLine() + Integer.parseInt | 48 ms |
Nhanh hơn khoảng sáu lần. Con số chỉ mang tính tham khảo và sẽ khác trên phần cứng của bạn, nhưng tỉ lệ đó là lý do dân competitive programming không dùng Scanner. Còn với chương trình đọc ba giá trị từ một con người thì khác biệt này chỉ là sai số, và sự tiện lợi của Scanner thắng.
Ví dụ hoàn chỉnh: hỏi, validate và in ra hoá đơn
Tất cả những gì ở trên gộp vào một chương trình: đọc theo dòng, validate có cho nhập lại, chặn trường hợp input hết giữa chừng, và output định dạng có ghim locale.
import java.util.Locale;
import java.util.Scanner;
public class Receipt {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
String name = ask(sc, "Name: ");
while (name.isEmpty()) {
System.out.println("Name cannot be empty.");
name = ask(sc, "Name: ");
}
int age;
while (true) {
try {
age = Integer.parseInt(ask(sc, "Age: "));
if (age > 0 && age < 130) break;
System.out.println("Age must be between 1 and 129.");
} catch (NumberFormatException e) {
System.out.println("Age must be a whole number.");
}
}
double price;
while (true) {
try {
price = Double.parseDouble(ask(sc, "Price: "));
if (price >= 0) break;
System.out.println("Price cannot be negative.");
} catch (NumberFormatException e) {
System.out.println("Price must be a number, e.g. 12.50");
}
}
System.out.println();
System.out.printf(Locale.US, "%-12s %3s %12s%n", "CUSTOMER", "AGE", "PRICE");
System.out.printf(Locale.US, "%-12s %3d %,12.2f%n", name, age, price);
}
private static String ask(Scanner sc, String prompt) {
System.out.print(prompt);
if (!sc.hasNextLine()) {
System.out.println();
System.out.println("Input ended unexpectedly.");
System.exit(1);
}
return sc.nextLine().trim();
}
}
Chạy với bộ input sai ba lần trước khi đúng:
printf 'An Nguyen\ntwenty\n25\nabc\n-5\n1250000.5\n' | java Receipt
Name: Age: Age must be a whole number.
Age: Price: Price must be a number, e.g. 12.50
Price: Price cannot be negative.
Price:
CUSTOMER AGE PRICE
An Nguyen 25 1,250,000.50
Các giá trị nhập vào không xuất hiện trong đoạn ghi lại đó vì input đến từ pipe chứ không phải bàn
phím, nên chẳng có gì hiện chúng lên. Cái bạn thấy là output của chính chương trình: các lời nhắc,
ba lần từ chối, và hai dòng cuối được canh cột bởi %-12s %3d %,12.2f.
Còn khi input hết giữa chừng, cái chốt hasNextLine() trong ask cho ra một thông báo thay vì
một stack trace:
printf 'An Nguyen\n' | java Receipt
Name: Age:
Input ended unexpectedly.
FAQ
Vì sao chương trình bỏ qua chỗ nhập tên?
Nó không bỏ qua đâu. Một lệnh nextInt(), nextDouble() hay next() trước đó đã để dấu xuống
dòng nằm lại trong buffer, và nextLine() của bạn đã đọc đúng cái phần rỗng còn sót của dòng cũ.
Hoặc thêm một dòng sc.nextLine(); trơ trọi sau lệnh đọc token, hoặc đọc mọi thứ bằng nextLine()
rồi parse số bằng Integer.parseInt.
Có thể tạo hai Scanner trên System.in không?
Được, và chúng sẽ giành nhau cùng một stream: mỗi cái đều buffer trước, nên phần input mà cái này
đã đọc sẽ biến mất trước khi cái kia nhìn thấy. Thêm nữa, chỉ cần một cái bị đóng là System.in
đóng với cả hai. Hãy tạo một Scanner rồi truyền nó cho các method cần dùng.
Vì sao nextDouble() báo lỗi với 3.14 trên máy tôi?
Vì locale mặc định của JVM trên máy bạn dùng dấu phẩy làm dấu thập phân — vi_VN và de_DE đều
vậy. Gọi sc.useLocale(Locale.US) ngay sau khi tạo Scanner nếu bạn muốn 3.14 parse được bất kể
máy nào, và nhớ rằng printf định dạng theo locale mặc định trừ khi bạn truyền Locale.US làm
argument đầu tiên.
Làm sao đọc tới khi người dùng dừng?
Lặp theo một method hasNext* thay vì theo số lượng: while (sc.hasNextInt()) cho số, hoặc
while (sc.hasNextLine()) cho dòng. Cả hai trả về false khi hết input, tức là Ctrl-D trên Linux
và macOS, Ctrl-Z rồi Enter trên Windows, hoặc đơn giản là hết file được chuyển hướng vào. Đọc quá
điểm đó chính là chỗ ném NoSuchElementException.
Scanner có đủ nhanh cho competitive programming không?
Không. Trong phép đo ở trên nó mất 309 ms để cộng một triệu số nguyên trong khi BufferedReader
chỉ mất 48 ms, mà các bài có input lớn thường được thiết kế với đúng khoảng cách đó trong đầu. Với
những bài như vậy hãy dùng BufferedReader và tự parse. Còn với chương trình thường ngày đọc vài
giá trị từ một con người thì Scanner là công cụ đúng.
Kết luận
Toàn bộ tính cách của Scanner đến từ một quyết định thiết kế: nhóm method đọc token và nhóm method
đọc dòng cùng đọc một luồng theo hai kiểu không tương thích nhau. Khi bạn hình dung được con trỏ
dừng ngay trước dấu xuống dòng thì chuỗi rỗng, vòng lặp vô tận và NoSuchElementException đều
thôi là bất ngờ. Hãy đọc nguyên dòng rồi tự parse, chặn bằng hasNextLine(), ghim locale khi các
chữ số phải chính xác, và đừng đóng Scanner bọc System.in.
Mọi chương trình trong bài này đều chạy một mạch từ trên xuống dưới. Bài tiếp theo giới thiệu chỗ
chương trình bắt đầu biết rẽ nhánh: conditional statement — if, else if, else và switch.