String cơ bản
Xem thửTình huống mở đầu
Form đăng ký của trung tâm nhận tên học viên đúng như người dùng gõ: " nguyen van An ", gồm 17 ký tự vì có hai dấu cách ở mỗi đầu. Bạn viết raw.toUpperCase(); để in hoa, rồi in raw ra màn hình. Mong đợi: NGUYEN VAN AN. Thực tế màn hình vẫn hiện chữ thường, và bốn dấu cách thừa vẫn còn đó.
Không có lỗi biên dịch, không có lỗi khi chạy. Vậy dòng toUpperCase đã làm gì, và kết quả viết hoa của nó đi đâu? Hãy giữ câu hỏi này trong đầu, vì cuối bài bạn sẽ tự sửa nó.
Ôn nhanh bài trước: Arrays.sort(a) trên một mảng int[] a làm gì với a, và nó trả về gì?
Đáp án: nó sắp xếp chính mảng a ngay tại chỗ và trả về void. Hãy nhớ điều này, vì với String, mọi thứ sẽ ngược lại.
Mục tiêu bài học
Sau bài này, bạn có thể:
- Đọc đúng độ dài, một ký tự và một đoạn của chuỗi bằng
length,charAt,substringvàindexOf. - Giải thích được vì sao
raw.toUpperCase()không làmrawđổi, và viết đúng bằng cách gán lại kết quả. - So sánh được hai chuỗi bằng
equals(và"yes".equals(input)khiinputcó thể lànull), và nói được==đang so sánh gì. - Dự đoán đúng kết quả của phép nối chuỗi bằng
+theo thứ tự từ trái sang phải.
Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.
Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.
Bức tranh lớn (Big Picture)
Trước khi vào chi tiết, hãy xem một chuỗi trông ra sao bên trong.
Cách đọc hình: ô trắng ở trên là biến city, nó chỉ giữ địa chỉ. Năm ô vàng nằm ngang bên dưới là năm ký tự H, a, n, o, i với chỉ số từ 0 đến 4. Màu chỉ là tín hiệu phụ, mỗi ô đều có nhãn chữ. Ghi chú cuối hình nhắc rằng length() là 5 còn chỉ số cuối là 4.
Một String giống một dải chữ đánh số: ô nào cũng có số thứ tự, bắt đầu từ 0, giống mảng. Nhưng có hai khác biệt lớn. Độ dài viết là length() có ngoặc, và bạn không thể ghi vào từng ô bằng s[0] = 'x'. Mọi phương thức bạn gọi trên chuỗi chỉ đọc, và nếu cần "đổi", chúng trả về một chuỗi mới.
Xuyên suốt bài, mình dùng một hình ảnh: chuỗi là một tấm bảng tên có các ô chữ đánh số, đã đóng khung, không sửa được từng chữ. Dự án xuyên suốt là chuẩn hoá thông tin trong form đăng ký học viên (tên và email). Có chỗ hình ảnh này không còn đúng: bảng tên thật thì tẩy xoá được, còn chuỗi Java thì không bao giờ, và mình sẽ nói rõ khi tới.
Khái niệm cốt lõi
Bài này có bốn khái niệm. Mỗi khái niệm đi kèm một hình.
Khái niệm 1: chuỗi là dãy ký tự đánh số, đọc bằng length, charAt, substring, indexOf
Chuỗi (String) là một dãy ký tự có thứ tự. length() cho số ký tự. charAt(i) cho ký tự ở chỉ số i. substring(a, b) cho đoạn từ chỉ số a đến trước chỉ số b, tức là ô b không được lấy. indexOf(x) cho chỉ số lần xuất hiện đầu tiên của x, hoặc -1 nếu không có.
Cách đọc hình: hàng trên là năm ô của "Hanoi". Từ ô 0 có mũi tên xuống charAt(0) cho H. Từ ô 2 có mũi tên xuống substring(1, 4) cho ano, ghi rõ chỉ số 4 không được lấy. Từ ô 4 có mũi tên nét đứt xuống indexOf(z) cho -1 vì không có ký tự đó.
Phần lớn lỗi về chuỗi đến từ chỗ này: chỉ số bắt đầu từ 0 nên ký tự cuối ở length() - 1, và substring luôn "đóng đầu, mở cuối".
Khái niệm 2: phương thức của String trả về chuỗi mới, không sửa chuỗi cũ
Đây là khái niệm quan trọng nhất của bài, và là câu trả lời cho tình huống ở đầu bài. Chuỗi trong Java không thể sửa (immutable): sau khi tạo, các ký tự của nó không bao giờ đổi. Các phương thức như toUpperCase, trim, replace không sửa chuỗi, mà tạo ra một chuỗi mới và trả nó về. Nếu bạn không hứng kết quả vào một biến, kết quả bị bỏ đi.
Cách đọc hình: ô trắng name trỏ xuống ô vàng String "Java" luôn không đổi. Mũi tên "call" đi sang name.toUpperCase() rồi tới ô lục String "JAVA" là chuỗi mới, ghi chú "a result nobody holds". Nét đứt "assign it" đi xuống name = name.toUpperCase() là cách làm để name trỏ tới chuỗi mới.
Chỗ hình ảnh tấm bảng tên không còn đúng: bảng thật thì tẩy được chữ, còn chuỗi Java thì chỉ có thể làm một tấm bảng mới. Lý do Java thiết kế như vậy, và chuyện vì sao hai chuỗi giống nhau có thể dùng chung một đối tượng, sẽ được nói ở bài sau.
Concept Check 1. Cho String j = "Java"; j.toUpperCase(); j.concat("!"); rồi System.out.println(j);. Chọn đáp án trước khi đọc tiếp.
- A.
JAVA!, vì cả hai lệnh đều sửajlần lượt. - B.
JAVA, vì lệnh đầu sửajvà lệnh thứ hai bị bỏ qua. - C.
Java, vì cả hai lệnh đều tạo chuỗi mới mà không ai giữ. - D.
Java!, vìconcatthêm vào chuỗi gốc còntoUpperCasethì không.
Đáp án: C [Đã chạy: Java]. toUpperCase và concat đều trả về chuỗi mới, và kết quả của cả hai bị bỏ đi vì không được gán. A và B cùng nhầm rằng phương thức sửa chuỗi tại chỗ, như Arrays.sort làm với mảng. D tách concat ra khỏi quy luật chung, trong khi mọi phương thức của String đều như nhau.
Khái niệm 3: so sánh nội dung bằng equals, không dùng ==
Biến chuỗi giữ địa chỉ của chuỗi, nên == hỏi "hai biến này có cùng một đối tượng không", không hỏi "chúng có cùng chữ không". equals so sánh từng ký tự. Còn khi chuỗi đến từ người dùng và có thể là null, hãy viết hằng ở bên trái: "yes".equals(input), vì gọi input.equals(...) khi input là null sẽ ném NullPointerException.
Compare.java
public class Compare {
public static void main(String[] args) {
// Giả lập chuỗi do người dùng gõ vào: một đối tượng String mới
String typed = new String("yes");
// == so sánh địa chỉ, equals so sánh nội dung
System.out.println("== " + (typed == "yes"));
System.out.println("equals " + typed.equals("yes"));
String missing = null;
// Đặt hằng ở bên trái thì null không gây lỗi
System.out.println("null safe " + "yes".equals(missing));
}
}
Kết quả khi chạy [Đã chạy, JDK 17.0.15]:
== false
equals true
null safe false
Đọc code từng dòng:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
String typed = new String("yes"); | Tạo một đối tượng chuỗi mới có chữ yes | Giả lập chuỗi do người dùng gõ vào, nó không phải đối tượng của hằng chuỗi trong code |
typed == "yes" | So sánh địa chỉ của hai đối tượng | Cho false dù cùng chữ, vì là hai đối tượng khác nhau |
typed.equals("yes") | So sánh từng ký tự | Cho true, đây là phép so sánh bạn muốn |
String missing = null; | Cho biến không trỏ tới chuỗi nào | Giả lập người dùng bỏ trống ô nhập |
"yes".equals(missing) | Gọi equals trên hằng, truyền null vào | Cho false mà không crash, vì hằng luôn khác null |
Hình dưới gom ba phép so sánh vào một chỗ.
Cách đọc hình: hàng trên: typed đi vào ô đỏ typed == "yes" cho false vì hai địa chỉ khác nhau. Hàng dưới: "yes" literal đi vào ô lục typed.equals("yes") cho true, rồi nét đứt sang "yes".equals(input) là dạng an toàn hơn khi input có thể là null. Ghi chú cuối hình nói rằng việc == đôi khi lại cho true sẽ được giải thích ở bài sau; hiện giờ chỉ cần luôn dùng equals.
Khái niệm 4: nối chuỗi bằng + đi từ trái sang phải
Toán tử + làm hai việc: cộng hai số, hoặc nối khi một vế là chuỗi. Java tính từ trái sang phải, nên hai số đứng đầu sẽ được cộng trước, nhưng một khi một chuỗi xuất hiện thì mọi thứ sau nó là nối chữ.
Cách đọc hình: hàng trên là 1 + 2 + "3": 1 + 2 ra số 3, rồi 3 + "3" ra chuỗi "33". Hàng dưới là "1" + 2 + 3: "1" + 2 ra chuỗi "12", rồi "12" + 3 ra "123". Cùng ba phần tử, thứ tự khác nhau cho kết quả khác nhau.
Muốn cộng số trước khi nối, hãy đặt dấu ngoặc: "x" + (1 + 2) cho x3. Với ký tự, 'a' + 'b' là cộng hai mã số và cho 195, chỉ khi có một chuỗi đứng cùng mới là nối chữ.
Ví dụ đầu tiên (Worked Example) và "Xem từng bước"
Đây là chương trình đầu tiên của dự án: chuẩn hoá một tên học viên gõ vào form.
NameFixer.java
public class NameFixer {
public static void main(String[] args) {
String raw = " nguyen van An ";
// trim và toUpperCase đều tạo ra chuỗi mới, raw không đổi
String trimmed = raw.trim();
String upper = trimmed.toUpperCase();
System.out.println("raw [" + raw + "] length " + raw.length());
System.out.println("trimmed [" + trimmed + "] length " + trimmed.length());
System.out.println("upper " + upper + ", first char " + trimmed.charAt(0));
// substring(0, 6) lấy ô 0 đến ô 5, ô 6 không được lấy
System.out.println("family " + trimmed.substring(0, 6) + ", index of van " + trimmed.indexOf("van"));
// Gọi mà không gán kết quả thì kết quả bị bỏ đi
raw.toUpperCase();
System.out.println("raw after ignored call [" + raw + "]");
}
}
Predict: hãy dừng lại, lấy giấy nháp và đoán: chương trình in ra những dòng nào, và dòng cuối in raw có còn chữ thường không? Đoán xong rồi hãy đọc tiếp.
Kết quả mong đợi [Đã chạy, JDK 17.0.15]:
raw [ nguyen van An ] length 17
trimmed [nguyen van An] length 13
upper NGUYEN VAN AN, first char n
family nguyen, index of van 7
raw after ignored call [ nguyen van An ]
Đọc code từng dòng:
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
String raw = " nguyen van An "; | Tạo chuỗi 17 ký tự, raw trỏ tới nó | Dữ liệu người dùng gõ thường có dấu cách thừa |
String trimmed = raw.trim(); | Tạo chuỗi mới 13 ký tự, bỏ dấu cách hai đầu | Phải gán vào trimmed, nếu không kết quả mất |
String upper = trimmed.toUpperCase(); | Tạo chuỗi mới viết hoa | trimmed vẫn là chữ thường |
raw.length() và trimmed.length() | Đếm ký tự, cho 17 và 13 | Bốn dấu cách thừa làm chênh nhau 4 |
trimmed.charAt(0) | Đọc ký tự ở chỉ số 0, cho n | Chỉ số bắt đầu từ 0, không phải 1 |
trimmed.substring(0, 6) | Lấy ô 0 đến ô 5, cho nguyen | Ô 6 (dấu cách) không được lấy |
trimmed.indexOf("van") | Tìm vị trí đầu tiên của van, cho 7 | Cho -1 nếu không có |
raw.toUpperCase(); đứng một mình | Tạo chuỗi hoa rồi bỏ đi | Đây chính là lỗi ở tình huống mở đầu |
Bây giờ xem từng bước:
Đang tải Animation…
Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật):
| Dòng chạy | raw trỏ tới | trimmed trỏ tới | upper trỏ tới | Chuỗi mới được tạo |
|---|---|---|---|---|
String raw = " nguyen van An " | chuỗi 1, 17 ký tự | chưa có | chưa có | không |
raw.trim() | chuỗi 1 | chuỗi 2, 13 ký tự | chưa có | chuỗi 2 |
trimmed.toUpperCase() | chuỗi 1 | chuỗi 2 | chuỗi 3, chữ hoa | chuỗi 3 |
| các dòng println | chuỗi 1 | chuỗi 2 | chuỗi 3 | không, chỉ đọc |
raw.toUpperCase(); | chuỗi 1 | chuỗi 2 | chuỗi 3 | chuỗi 4, bị bỏ đi |
in raw | chuỗi 1, vẫn chữ thường | chuỗi 2 | chuỗi 3 | không |
Bạn vừa làm được: đọc một chương trình dùng String và nói đúng biến nào trỏ tới chuỗi nào, dòng nào tạo chuỗi mới.
Concept Check xen kẽ
Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.
Concept Check 2. Với String city = "Hanoi";, city.substring(1, 4) cho gì?
- A.
ano, vì lấy ô 1, 2, 3 và ô 4 không được lấy. - B.
anoi, vì chỉ số cuối là 4 nên ô 4 được lấy. - C.
Han, vìsubstringđếm từ đầu chuỗi đúng bốn ô. - D. Lỗi
StringIndexOutOfBoundsException, vì 4 là ô cuối cùng nên không dùng được.
Đáp án: A [Đã chạy: ano]. substring đóng đầu và mở cuối. B là lỗi hay gặp nhất, do nghĩ chỉ số cuối cũng được lấy. C nhầm tham số thứ nhất là điểm bắt đầu với tham số đếm. D sai vì chỉ số cuối hợp lệ có thể bằng chính length(), chỉ cần không vượt quá nó.
Concept Check 3. Giá trị của biểu thức 1 + 2 + "3" + 4 + 5 là gì?
- A.
12345, vì mọi thứ đều được nối thành chữ. - B.
15, vì các số được cộng trước khi nối. - C.
339, vì sau khi nối33, hai số 4 và 5 được cộng thành 9 trước. - D.
3345, vì1 + 2ra 3, rồi nối với "3", rồi nối thêm 4 và 5.
Đáp án: D [Đã chạy: 3345]. Java đi từ trái sang phải: 1 + 2 là 3, 3 + "3" thành "33", rồi "33" + 4 thành "334", rồi "334" + 5 thành "3345". A quên rằng hai số đầu được cộng trước. B nghĩ mọi số đều được cộng bỏ qua chuỗi. C đúng một nửa ý, nhưng 4 và 5 không được gom lại: sau khi chuỗi xuất hiện, từng số được nối vào chữ một lần, từ trái sang phải.
Làm cùng mình (Guided Practice)
Bạn sẽ dựng dần class SignupForm qua năm bước nhỏ. Mỗi bước thêm vào bên trong main của bước trước, hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 5 chỉ còn đề. Gõ vào file SignupForm.java rồi chạy bằng java SignupForm.java.
Bước 1: cắt dấu cách thừa.
public class SignupForm {
public static void main(String[] args) {
String raw = " An.Nguyen@Example.COM ";
String email = raw;
// TODO 1: cắt dấu cách thừa hai đầu và gán lại vào email
System.out.println("step 1: " + raw.length() + " " + email.length());
}
}
Chạy xong bạn sẽ thấy: step 1: 24 21. Gợi ý: trim() trả về chuỗi mới, nên phải gán kết quả vào email.
Bước 2: đưa về chữ thường. Ngay dưới dòng in của bước 1, thêm:
// TODO 2: đổi email sang chữ thường
System.out.println("step 2: " + email);
Chạy xong bạn sẽ thấy: step 2: an.nguyen@example.com.
Bước 3: tìm dấu @ và lấy tên miền.
int at = 0;
String domain = "";
// TODO 3: tìm chỉ số của '@' rồi lấy phần đứng sau nó làm domain
System.out.println("step 3: " + at + " " + domain);
Chạy xong bạn sẽ thấy: step 3: 9 example.com.
Vẽ ra giấy: viết an.nguyen@example.com thành một hàng ô, đánh số từ 0 phía trên. Đối chiếu: @ nằm ở ô số 9, nên tên miền bắt đầu từ ô 10. Gợi ý: substring(at + 1) lấy từ ô at + 1 tới hết chuỗi.
Bước 4: kiểm tra email hợp lệ. Bạn tự quyết cách viết.
boolean valid = false;
// TODO 4: valid là true khi '@' không đứng đầu và domain có chứa dấu chấm
System.out.println("step 4: " + valid);
Chạy xong bạn sẽ thấy: step 4: true. Gợi ý: indexOf trả về -1 nếu không có @, và contains(".") kiểm tra dấu chấm.
Bước 5: so sánh câu trả lời an toàn.
String answer = "YES";
String empty = null;
// TODO 5: so sánh answer và empty với "yes" mà không phân biệt hoa thường và không bị null làm lỗi
System.out.println("step 5: " + false + " " + false);
Chạy xong bạn sẽ thấy: step 5: true false. Gợi ý: phương thức có tên nghe như "equals ignore case", gọi trên hằng "yes". Lời giải đầy đủ nằm ở cuối bài.
Bạn vừa làm được: tự dựng một chương trình chuẩn hoá email qua năm bước, và tự vẽ được vị trí của từng ký tự trước khi chạy. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 05-chuoi/01-string-co-ban của kho GitHub, nhưng bạn không cần mở nó để học.
Thử phá nó đi (Lỗi thường gặp)
Dưới đây là bốn lỗi người mới gặp nhiều nhất với String. Mỗi dòng đã được chạy thật trên chuỗi "Hanoi" hoặc chuỗi tương tự [Đã chạy, JDK 17.0.15; thông báo lỗi có thể khác chữ ở phiên bản Java khác].
| Code lỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
name.charAt(5) khi chuỗi có 5 ký tự | StringIndexOutOfBoundsException: String index out of range: 5 | Ký tự đánh số từ 0 nên chuỗi năm ký tự chỉ có ô 0 đến 4 | Dùng chỉ số từ 0 đến length() - 1 |
name.substring(4, 2) | StringIndexOutOfBoundsException: begin 4, end 2, length 5 | Điểm bắt đầu phải nhỏ hơn hoặc bằng điểm kết thúc | Viết substring(2, 4) theo thứ tự nhỏ đến lớn |
input.equals("yes") khi input là null | NullPointerException: Cannot invoke "String.equals(Object)" because "<local1>" is null | Gọi phương thức trên một biến chưa trỏ tới chuỗi nào | Đặt hằng ở bên trái: "yes".equals(input) |
char c = name[0]; | error: array required, but String found | Chuỗi không phải mảng, nên không dùng được dấu ngoặc vuông | Viết name.charAt(0) |
Phần tên biến trong thông báo ở dòng ba có thể là <local1> thay vì input khi chạy bằng java Ten.java. Lỗi ở tình huống mở đầu không nằm trong bảng vì nó im lặng. Hình dưới gom mô hình sai và mô hình đúng về nó.
Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: nhiều người tưởng phương thức sửa chữ bên trong name, nên dự đoán in ra JAVA. Hàng dưới màu lục là mô hình đúng: phương thức trả về một chuỗi mới và name không bị đụng tới, nên in ra vẫn là Java. Ghi chú cuối hình nêu cách sửa là gán lại kết quả.
Bạn vừa làm được: đọc bốn thông báo lỗi phổ biến của String và giải thích được lỗi toUpperCase im lặng bằng khái niệm chuỗi mới.
Tự tay làm (Bài tập)
Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.
Mức Cơ bản
Bài 1. Vẽ trạng thái. Dữ liệu vào là đoạn code sau, chạy theo từng dòng:
String s = " Java ";
String t = s.trim();
s.toUpperCase();
String u = t.toUpperCase().substring(1, 3);
Điền bảng ba cột s, t, u sau mỗi dòng, kèm độ dài của s và t. Kết quả mong đợi: một bảng bốn dòng. Tiêu chí nghiệm thu: bạn nêu được s còn nguyên sau dòng ba và u là AV. Gợi ý: dòng ba tạo một chuỗi mà không biến nào giữ.
Bài 2. Dự đoán phép nối. Dữ liệu vào: bốn biểu thức 1 + 2 + "x", "x" + 1 + 2, "x" + (1 + 2) và 'a' + 'b' + "c". In cả bốn kết quả trên một dòng cách nhau một dấu cách. Kết quả mong đợi: 3x x12 x3 195c. Tiêu chí nghiệm thu: bạn dự đoán đúng bằng tay trước khi chạy, và giải thích được 195. Gợi ý: 'a' + 'b' là hai mã số cộng nhau.
Bài 3. Câu trả lời yes không phân biệt hoa thường. Dữ liệu vào: ba giá trị "YES", "no" và null. Viết phương thức boolean isYes(String input). Kết quả mong đợi: true false false. Tiêu chí nghiệm thu: không ném NullPointerException với null và không dùng ==. Gợi ý: gọi phương thức so sánh trên hằng "yes".
Mức Vận dụng
Bài 4. Chuẩn hoá email cho form đăng ký. Dữ liệu vào: " An.Nguyen@Example.com ", "bad-email" và "@x.com". Viết phương thức String email(String raw) trả về email đã bỏ dấu cách và chuyển chữ thường nếu hợp lệ, hoặc invalid nếu không có @ ở vị trí hợp lệ hoặc tên miền không có dấu chấm. Kết quả mong đợi: an.nguyen@example.com, invalid, invalid. Tiêu chí nghiệm thu: ba kết quả khớp và không ném lỗi với chuỗi không có @. Gợi ý: bạn đã làm đủ các bước này trong phần Làm cùng mình.
Mức Thử thách (tuỳ chọn)
Bài 5. Kiểm tra chuỗi đối xứng. Dữ liệu vào: "level", "Java", "Racecar". Viết boolean palindrome(String text) trả về true khi chuỗi đọc xuôi và ngược giống nhau, không phân biệt hoa thường. Kết quả mong đợi: true false true. Tiêu chí nghiệm thu: dùng charAt với hai chỉ số đi từ hai đầu vào giữa, không tạo chuỗi đảo ngược. Gợi ý: đưa về chữ thường một lần ở đầu phương thức.
Tự kiểm tra (Retrieval Practice)
Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.
Câu 1 (nhớ lại, mục tiêu 1). Với "Hanoi": length() bằng mấy, chỉ số của ký tự cuối là gì, substring(1, 4) cho gì, và indexOf('z') cho gì?
Câu 2 (dự đoán kết quả code, mục tiêu 1). Đoạn sau in ra gì?
String s = "banana";
System.out.println(s.indexOf("an") + " " + s.lastIndexOf("an") + " " + s.substring(2, 5));
Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 2). Cho String a = "cat"; String b = a; a = a + "s"; b.toUpperCase();. Viết giá trị của a và b.
Câu 4 (tìm lỗi, mục tiêu 3). Đoạn String input = null; if (input.equals("yes")) { ... } ném lỗi gì? Vì sao, và sửa thế nào?
Câu 5 (giải thích cho bạn chưa học, mục tiêu 4 và 2). Bạn của bạn hỏi: "Sao 1 + 2 + "3" ra 33 mà "1" + 2 + 3 lại ra 123?" Hãy giải thích, rồi nói một cách viết để ra 15 từ "1", 2, 3.
Điều cần nhớ
- Chuỗi là dãy ký tự đánh số từ 0:
length(),charAt(i),substring(a, b)đóng đầu mở cuối,indexOfcho-1nếu không có (mục tiêu 1). - Mọi phương thức của
Stringtrả về chuỗi mới và không sửa chuỗi cũ, nên phải gán lại:s = s.trim()(mục tiêu 2). - So sánh nội dung bằng
equals, viết hằng bên trái để chịu đượcnull;==chỉ so sánh địa chỉ (mục tiêu 3). +đi từ trái sang phải: hai số đầu cộng với nhau, nhưng từ khi có chuỗi thì mọi thứ sau là nối chữ (mục tiêu 4).
Cheat sheet một trang.
| Việc cần làm | Cú pháp | Ghi chú |
|---|---|---|
| Số ký tự | s.length() | Có ngoặc, khác với mảng |
| Một ký tự | s.charAt(i) | Chỉ số từ 0 đến length() - 1 |
| Một đoạn | s.substring(a, b) | Ô b không được lấy |
| Tìm vị trí | s.indexOf("x") | -1 nếu không có |
| Có chứa không | s.contains("x") | true hoặc false |
| Bỏ dấu cách hai đầu | s = s.trim() | Phải gán lại |
| Đổi chữ hoa, thường | s = s.toLowerCase() | Phải gán lại |
| So sánh nội dung | "yes".equals(s) | Không dùng == |
| Không phân biệt hoa thường | "yes".equalsIgnoreCase(s) | An toàn với null khi hằng ở trái |
Cách đọc hình: mỗi hàng là một việc ở bên trái và công cụ nên chọn ở bên phải. Đọc theo hàng ngang: đếm ký tự thì s.length(), cắt đoạn thì substring, so sánh thì "yes".equals(input), làm sạch hoặc đổi chữ hoa thì gán lại kết quả s = s.trim(). Ghi chú cuối hình: mọi phương thức đều trả về một giá trị, không hứng thì mất.
Câu hỏi để dành cho ngày mai: nếu chuỗi không bao giờ đổi, thì String s = "a"; s = s + "b"; đã làm điều gì với chuỗi "a"? Và nếu 10000 lần nối như vậy thì máy phải tạo bao nhiêu chuỗi? Hãy nghĩ trước khi sang bài sau.
Đào sâu
Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.
Cơ chế bên trong. Một String giữ các ký tự trong một mảng nội bộ mà bạn không chạm tới được, và chính vì không ai sửa được mảng đó nên chuỗi mới bất biến. Từ Java 9, OpenJDK còn lưu các chuỗi chỉ chứa ký tự Latin-1 bằng một byte mỗi ký tự để tiết kiệm bộ nhớ (compact strings). Đây là chi tiết cài đặt, không phải hợp đồng của lớp. Một lưu ý nhỏ khác: charAt trả về một đơn vị UTF-16, nên với chữ cần hai đơn vị (như một số biểu tượng), một "ký tự" nhìn thấy có thể chiếm hai ô.
So với lựa chọn khác. trim() chỉ bỏ các ký tự có mã không lớn hơn dấu cách, còn strip() (từ Java 11) hiểu cả dấu cách Unicode. Với chuỗi gồm dấu cách rộng \u2003, rồi hi, rồi dấu cách rộng nữa, trim() còn lại 6 ký tự còn strip() chỉ còn 2 [Đã chạy, JDK 17.0.15]. Tương tự, isEmpty() chỉ đúng khi độ dài bằng 0, còn isBlank() đúng cả với chuỗi toàn dấu cách [Đã chạy].
Hiểu lầm nâng cao. Hai bẫy hay gặp với chuỗi nhiều phần [Đã chạy, JDK 17.0.15]. Thứ nhất, replace(".", "-") thay đúng dấu chấm và cho a-b-c, nhưng replaceAll(".", "-") coi . là biểu thức chính quy khớp mọi ký tự nên cho -----. Thứ hai, "a,b,,".split(",") chỉ cho 2 phần tử vì các chuỗi rỗng ở cuối bị bỏ, còn split(",", -1) cho 4 phần tử, và "".split(",") cho 1 phần tử chứ không phải 0.
Giới hạn của bài. Bài này chưa nói về String.format, về StringBuilder khi nối nhiều lần (bài sau nữa), về biểu thức chính quy đầy đủ, hay về mã hoá ký tự. Phần giải thích vì sao == đôi khi cho true nằm ở bài sau.
Đọc thêm (tài liệu chính thống, Java SE 17):
- The Java Tutorials: Strings
- Javadoc: java.lang.String
- Java Language Specification: String Concatenation Operator
Bài tiếp theo
Bài sau là tính bất biến của String và String Constant Pool: bạn sẽ thấy vì sao chuỗi không thể sửa, vì sao hai chuỗi giống nhau đôi khi là một đối tượng, và khi nào == cho true.
Mini-dự án nối tiếp: ở bài sau, form đăng ký sẽ kiểm tra tên đăng nhập trùng, và bạn sẽ thấy == đúng ở chỗ nào và sai ở chỗ nào. Hãy giữ lại SignupForm.java làm điểm xuất phát.
Lời giải và đáp án
Hãy thử xong rồi mới đọc phần này.
Lời giải Làm cùng mình. Thay các dòng // TODO bằng những dòng dưới đây, ghép lại chạy ra đúng năm dòng step ở trên [Đã chạy, JDK 17.0.15]:
// trim trả về chuỗi mới nên phải gán lại
email = email.trim();
email = email.toLowerCase();
int at = email.indexOf('@');
String domain = email.substring(at + 1);
// at lớn hơn 0 vừa đảm bảo có '@', vừa đảm bảo nó không đứng đầu
boolean valid = at > 0 && domain.contains(".");
// Hằng ở bên trái nên null không gây lỗi
"yes".equalsIgnoreCase(answer);
"yes".equalsIgnoreCase(empty);
| Dòng code | Máy làm gì | Lý do chọn cách này |
|---|---|---|
email = email.trim(); | Tạo chuỗi mới đã cắt, cho email trỏ tới nó | Chuỗi cũ không đổi, nên không gán thì mất kết quả |
email.toLowerCase() | Tạo chuỗi chữ thường | Email nên được so sánh ở một dạng chữ duy nhất |
email.indexOf('@') | Tìm chỉ số của @, ở đây là 9 | Cho -1 nếu không có, nên không bị lỗi |
email.substring(at + 1) | Lấy từ ô sau @ tới hết chuỗi | Ô at là chính @, nên bắt đầu từ at + 1 |
at > 0 && domain.contains(".") | Kiểm tra hai điều kiện | Nếu không có @ thì at là -1, điều kiện sai ngay |
"yes".equalsIgnoreCase(answer) | So sánh không phân biệt hoa thường | Hằng ở trái, nên empty là null cho false thay vì lỗi |
Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật: s còn " Java " dài 8, t là "Java", u là "AV"]
| Dòng vừa chạy | s | t | u |
|---|---|---|---|
String s = " Java "; | " Java ", dài 8 | chưa có | chưa có |
String t = s.trim(); | " Java ", dài 8 | "Java", dài 4 | chưa có |
s.toUpperCase(); | " Java ", dài 8 | "Java" | chưa có |
String u = t.toUpperCase().substring(1, 3); | " Java " | "Java" | "AV" |
Dòng ba tạo chuỗi hoa mà không biến nào giữ. Dòng bốn tạo chuỗi "JAVA" rồi cắt ô 1 và 2 ra "AV".
Lời giải Bài 2. [Đã chạy, cho 3x x12 x3 195c]
1 + 2 + "x" cộng 1 và 2 trước nên thành 3x. "x" + 1 + 2 đã có chuỗi từ đầu nên nối thành x12. Ngoặc đổi thứ tự thành x3. 'a' + 'b' là hai ký tự cộng nhau, mỗi ký tự là một mã số (97 và 98) nên ra 195, rồi mới nối "c".
Lời giải Bài 3. [Đã chạy, cho true false false]
static boolean isYes(String input) {
// Hằng ở bên trái, nên input là null vẫn cho false
return "yes".equalsIgnoreCase(input);
}
Lời giải Bài 4. [Đã chạy, cho an.nguyen@example.com, invalid, invalid]
String e = raw.trim().toLowerCase();
int at = e.indexOf('@');
// at nhỏ hơn hoặc bằng 0 gồm cả không có '@' và '@' đứng đầu
if (at <= 0 || !e.substring(at + 1).contains(".")) {
return "invalid";
}
return e;
Hai lệnh trim().toLowerCase() nối liền nhau vì mỗi lệnh trả về chuỗi mới cho lệnh sau. Với "bad-email", at là -1 nên điều kiện đầu đã đúng và trả về invalid mà không gọi substring. Với "@x.com", at bằng 0 nên cũng không hợp lệ.
Lời giải Bài 5. [Đã chạy, cho true false true]
String s = text.toLowerCase();
for (int i = 0, j = s.length() - 1; i < j; i++, j--) {
// Hai ký tự ở hai đầu khác nhau thì chắc chắn không đối xứng
if (s.charAt(i) != s.charAt(j)) {
return false;
}
}
return true;
Hai chỉ số i và j đi từ hai đầu vào giữa, dừng khi gặp nhau, nên chỉ so sánh nửa chuỗi. So sánh ký tự dùng != vì char là kiểu nguyên thuỷ, không dùng equals.
Đáp án Tự kiểm tra.
length()là 5, chỉ số ký tự cuối là 4,substring(1, 4)choano,indexOf('z')cho-1. Kiểm tra mục tiêu 1.- In ra
1 3 nan[Đã chạy].indexOf("an")là 1,lastIndexOf("an")là 3,substring(2, 5)lấy ô 2, 3, 4 lànan. Kiểm tra mục tiêu 1. alàcatsvàblàcat[Đã chạy]. Dònga = a + "s"làmatrỏ sang chuỗi mới,bvẫn trỏ chuỗi cũ, vàb.toUpperCase()không được gán nên bị bỏ đi. Kiểm tra mục tiêu 2.- Ném
NullPointerExceptionvìinputlànullnên không có đối tượng nào để gọiequals. Sửa bằng"yes".equals(input). Kiểm tra mục tiêu 3. - Java tính từ trái sang phải. Ở
1 + 2 + "3",1 + 2là hai số nên cộng thành 3, rồi 3 nối với"3"ra33. Ở"1" + 2 + 3, chuỗi"1"đứng đầu nên mọi+sau đó là nối, ra123. Để ra15, viết"1" + (2 + 3), vì ngoặc buộc cộng 2 và 3 trước [Đã chạy]. Kiểm tra mục tiêu 4 và 2.