學完本章你將能夠:
解釋字串的不可變性與字串常數池的基本概念。
正確使用 equals 比較字串內容,遠離 == 陷阱。
熟練運用查詢、擷取、轉換、分割等常用字串方法。
完成字串與數字的互相轉換,並認識轉換失敗的例外。
說明何時該用 StringBuilder 取代字串串接。
使用 String.format 與 printf 做格式化輸出。
回顧一下:我們其實從第 1 章的 |"Hello, World!"| 開始就天天在用 字串(|String|)了。字串是程式與人類交會的地方——使用者輸入的 是文字,網頁與報表輸出的是文字,檔案與網路傳輸的內容大多也是文字。 可以說,實務程式有一半的工作是在「搬運與加工文字」,字串因此是所有 型別中方法最豐富、使用頻率最高的一個。
字串也是你正式深入研究的第一個參考型別。它有兩個與直覺相悖的 特性——「不可變」與「== 比較的陷阱」——每年榨乾無數初學者的除錯時間。 本章先把這兩顆地雷拆掉,再帶你巡禮常用方法,最後用一個文字分析器 收尾。
建立字串與不可變性
建立字串最自然的方式就是字面值:|String s = "Java";|。關於它的 第一個重要事實是:字串物件一旦建立,內容就永遠不能改變 (immutable,不可變)。所有看起來像「修改」的方法,其實都是造一個 新字串還給你,原字串分毫未動:
/** * 字串的不可變性:所謂「修改」其實是產生新字串。 */ public class ImmutableDemo
public static void main(String[] args)
String original = "Hello";
// concat 不會改動 original,而是回傳一個新字串 String result = original.concat(", Java!"); System.out.println("original = " + original); System.out.println("result = " + result);
// toUpperCase 也一樣:原字串不動如山 String upper = original.toUpperCase(); System.out.println("upper = " + upper); System.out.println("original 還是:" + original);
original = Hello result = Hello, Java! upper = HELLO original 還是:Hello
所以「想改字串」的正確姿勢是把回傳值接回來: |s = s.toUpperCase();|。忘記接回傳值、以為呼叫方法就會就地 修改,是這個章節第一常見的錯誤。
為什麼要設計成不可變?主因有三:(1)安全——字串被大量用於 檔名、帳號、網址,若可變,傳出去的字串可能被別人偷改;(2) 共享——正因為不可變,JVM 才敢把相同內容的字面值放進「字串 常數池」(string pool)共用一份,省下大量記憶體:寫兩次 |"java"| 字面值,拿到的其實是同一個物件;(3)執行緒安全——多執行緒 同時讀它也不會出事(進階篇再談)。
比較字串:== 與 equals
初學者陷阱排行榜的不動冠軍來了。比較兩個字串的內容是否相同, 必須用 |equals()| 方法;|==| 比的是「兩個變數是否指向 同一個物件」(還記得遙控器嗎?|==| 問的是「同一台電視嗎」, 不是「畫面一樣嗎」):
/** * == 與 equals 的差異:比「同一個物件」還是比「內容」。 */ public class EqualsDemo
public static void main(String[] args)
String s1 = "java"; String s2 = "java"; // 與 s1 共用常數池的同一份 String s3 = new String("java"); // 強迫建立全新的物件
System.out.println("s1 == s2:" + (s1 == s2)); System.out.println("s1 == s3:" + (s1 == s3)); System.out.println("s1.equals(s3):" + s1.equals(s3));
// 忽略大小寫的內容比較 String s4 = "JAVA"; System.out.println("忽略大小寫:" + s1.equalsIgnoreCase(s4));
// compareTo:依字典順序比較,回傳負數、零或正數 System.out.println("apple 對 banana:" + "apple".compareTo("banana")); System.out.println("apple 對 apple:" + "apple".compareTo("apple"));
s1 == s2:true s1 == s3:false s1.equals(s3):true 忽略大小寫:true apple 對 banana:-1 apple 對 apple:0
最陰險的地方在於 |s1 == s2| 竟然是 true——因為兩個字面值共用 常數池的同一個物件,讓你誤以為 |==| 可以用。等到字串來自 使用者輸入、檔案或 |new String()| 時,|==| 就會突然 回報 false,臭蟲難找得要命。鐵律:字串內容比較,永遠用 equals,沒有例外。順帶的防禦技巧:把已知常數放前面寫成 |"yes".equals(input)|,即使 input 是 null 也不會爆炸。
常用方法巡禮
String 的方法非常多,依用途分成「查詢」與「加工」兩批來認識。先是 查詢類——問長度、問位置、問內容:
/** * 查詢類方法:length、charAt、substring、indexOf 等。 */ public class InspectDemo
public static void main(String[] args)
String s = "Hello, Java!"; // 索引尺標:H0 e1 l2 l3 o4 逗5 空6 J7 a8 v9 a10 驚11
System.out.println("長度:" + s.length()); System.out.println("索引 7 的字元:" + s.charAt(7));
// substring(起點):從起點取到底 System.out.println("取 7 到底:" + s.substring(7)); // substring(起點, 終點):含起點、不含終點! System.out.println("取 7 到 10:" + s.substring(7, 11));
System.out.println("第一個 a 在:" + s.indexOf("a")); System.out.println("最後一個 a 在:" + s.lastIndexOf("a")); System.out.println("包含 Java?" + s.contains("Java")); System.out.println("以 Hello 開頭?" + s.startsWith("Hello")); System.out.println("以驚嘆號結尾?" + s.endsWith("!"));
長度:12 索引 7 的字元:J 取 7 到底:Java! 取 7 到 10:Java 第一個 a 在:8 最後一個 a 在:10 包含 Java?true 以 Hello 開頭?true 以驚嘆號結尾?true
特別注意 |substring(7, 11)| 的規則:含起點、不含終點 (數學上的半開區間)。好處是「終點減起點」剛好等於取出的長度: \(11 - 7 = 4\) 個字元,正是 Java 四個字母。找不到時,|indexOf| 回傳 \(-1\),這是慣用的「沒找到」訊號,寫條件判斷時記得處理。
接著是加工類——去空白、取代、切割、組合:
/** * 加工類方法:trim、replace、split、join 與大小寫轉換。 */ public class TransformDemo
public static void main(String[] args)
String messy = " Java 好好玩 ";
// trim 去掉頭尾半形空白;strip(Java 11+)更聰明, // 連 Unicode 的各種空白字元(如全形空白)都能處理 System.out.println("[" + messy.trim() + "]"); System.out.println("[" + messy.strip() + "]");
// replace:把「所有」出現的目標全部換掉 System.out.println("banana".replace(’a’, ’o’));
// 大小寫轉換 System.out.println("Java".toUpperCase()); System.out.println("Java".toLowerCase());
// split:依分隔符號切成字串陣列 String csv = "蘋果,香蕉,芭樂"; String[] fruits = csv.split(","); for (String fruit : fruits) System.out.println("水果:" + fruit);
// String.join:split 的相反——把多段文字接起來 String joined = String.join(" > ", "青銅", "白銀", "黃金"); System.out.println(joined);
// isEmpty:長度為 0 才 true;isBlank:全是空白也 true System.out.println("".isEmpty() + " / " + " ".isEmpty()); System.out.println("".isBlank() + " / " + " ".isBlank());
bonono JAVA java 水果:蘋果 水果:香蕉 水果:芭樂 青銅 > 白銀 > 黃金 true / false true / true
逐字元走訪
需要逐字檢查內容時(統計、驗證、加密都用得到),有兩種慣用姿勢: 用索引搭配 |charAt|,或先 |toCharArray()| 轉成字元陣列 再用增強式 for:
/** * 逐字元走訪的兩種方式:統計母音數量。 */ public class VowelCounter
public static void main(String[] args)
String word = "programming"; int count = 0;
// 方式一:索引搭配 charAt for (int i = 0; i < word.length(); i++) char c = word.charAt(i); if (c == ’a’ || c == ’e’ || c == ’i’ || c == ’o’ || c == ’u’) count++; // 是母音,計數器加一 System.out.println("母音數量:" + count);
// 方式二:轉成字元陣列,用增強式 for for (char c : word.toCharArray()) System.out.print(c + "-"); System.out.println();
母音數量:3 p-r-o-g-r-a-m-m-i-n-g-
字串與數字互轉
使用者輸入、檔案內容讀進來都是字串,運算前得先轉成數字;反向的 轉換也一樣常見。轉換失敗(例如把 |"abc"| 當數字)會拋出 NumberFormatException:
public class ParseDemo
public static void main(String[] args)
// 字串轉數字:Integer.parseInt 與 Double.parseDouble String input = "123"; int number = Integer.parseInt(input); System.out.println(number + 1); // 數字加法
double d = Double.parseDouble("3.14"); System.out.println(d * 2);
// 數字轉字串:String.valueOf(或直接串接空字串) String text = String.valueOf(42); System.out.println(text + 1); // 這是字串串接!
// 轉換失敗會拋出例外,程式中斷 int bad = Integer.parseInt("abc"); System.out.println(bad); // 永遠執行不到
124 6.28 421 Exception in thread "main" java.lang.NumberFormatException: For input string: "abc" at java.base/java.lang.Integer.parseInt(Integer.java:668) at ParseDemo.main(ParseDemo.java:16)
(例外訊息的中段行號依 JDK 版本而略有差異。)對照第三行輸出與 第一行輸出,體會 |124| 與 |421| 的差別:一個是數字 加法,一個是字串串接——第 3 章的加號陷阱在轉換場景再次現身。 如何優雅地「接住」轉換失敗的例外而不讓程式中斷,是中級篇例外 處理章節的主題。
StringBuilder:可變的字串工作檯
字串不可變帶來一個效能隱憂:在迴圈裡用 + 反覆串接時,每一圈 都要建立新字串、把舊內容全部複製過去。串接 n 次的總複製量約是 \(O(n^2)\)——一萬次串接就要搬動約五千萬個字元。解法是 |StringBuilder|:一個可變的字串工作檯,在同一塊緩衝區上修改, 接近 \(O(n)\):
/** * StringBuilder 基本操作:append、insert、delete、reverse。 */ public class BuilderDemo
public static void main(String[] args)
StringBuilder sb = new StringBuilder("Java");
sb.append(" 好好玩"); // 在尾端追加(就地修改!) System.out.println(sb);
sb.insert(0, "學 "); // 在索引 0 處插入 System.out.println(sb);
sb.delete(0, 2); // 刪除索引 0 到 1 的字元 System.out.println(sb);
sb.reverse(); // 就地反轉 System.out.println(sb);
// 需要真正的 String 時,呼叫 toString 轉換 String result = sb.reverse().toString(); System.out.println("最終結果:" + result);
Java 好好玩 學 Java 好好玩 Java 好好玩 玩好好 avaJ 最終結果:Java 好好玩
效能差距不是理論,跑給你看——同樣串接五萬次,兩種寫法的耗時:
/** * 效能對決:迴圈中 String 串接 vs StringBuilder。 */ public class ConcatPerformance
public static void main(String[] args)
int times = 50_000;
// 方式一:String 的 +=(每圈都複製整個舊字串) long start1 = System.currentTimeMillis(); String s = ""; for (int i = 0; i < times; i++) s += "x"; long time1 = System.currentTimeMillis() - start1;
// 方式二:StringBuilder 的 append(就地追加) long start2 = System.currentTimeMillis(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < times; i++) sb.append("x"); long time2 = System.currentTimeMillis() - start2;
System.out.println("String 串接耗時:" + time1 + " 毫秒"); System.out.println("StringBuilder 耗時:" + time2 + " 毫秒");
String 串接耗時:1268 毫秒 StringBuilder 耗時:3 毫秒
(實際數字依電腦而異,但數量級的差距一目瞭然。)
選擇準則很簡單:少量、一次性的串接用 + 就好(編譯器會 自動優化單一運算式裡的串接);迴圈中的累積式串接,一律改用 StringBuilder。另外你可能會查到另一個類似的類別 StringBuffer: 它是執行緒安全但較慢的舊版本,單執行緒程式(目前你寫的都是)用 StringBuilder 即可。
格式化輸出
把變數塞進固定格式的文字時,用 + 串接會又長又醜。 |String.format| 回傳格式化後的字串,|System.out.printf| 則直接輸出,兩者共用同一套「格式符號」:以百分號開頭的佔位符:
| 符號 | 意義 | 範例輸出 |
|---|---|---|
| | | | | | | | | | | |
/** * String.format 與 printf:同一套格式符號的兩種用法。 */ public class FormatDemo
public static void main(String[] args)
String name = "小華"; int age = 20; double height = 1.687;
// String.format:組出字串,先存起來再使用 String intro = String.format(" System.out.println(intro);
// printf:直接輸出。 System.out.printf("身高: System.out.printf("存款: System.out.printf("靠右對齊:[ System.out.printf("達成率:
小華 今年 20 歲 身高:1.69 公尺 存款:12,345,678 元 靠右對齊:[ 3.1] 達成率:95
文字區塊:多行字串的救星
Java 15 正式加入「文字區塊」(text block):用三個雙引號包住多行 文字,不必再用 || 與跳脫字元拼裝,寫 JSON、SQL、HTML 範本 時特別舒服:
/** * 文字區塊(Java 15+):所見即所得的多行字串。 */ public class TextBlockDemo
public static void main(String[] args)
// 傳統寫法:跳脫字元讓人眼花 String oldJson = "n̈ame:̈ J̈ava,̈äge:̈ 30"; System.out.println(oldJson);
// 文字區塊:內容怎麼排,輸出就怎麼排 // (共同的前導縮排會被自動去除) String newJson = """ "name": "Java", "age": 30 """; System.out.println(newJson);
"name": "Java", "age": 30 "name": "Java", "age": 30
綜合範例:文字分析器
期末專題:給定一段文字,統計字元數與單字數、找出出現最多次的字母, 並提供迴文判斷(正著讀與倒著讀相同,如 level)。本例綜合了走訪、 計數器陣列(第 5 章)、方法拆分(第 6 章)與本章的各路字串技巧:
/** * 文字分析器:字元數、單字數、最高頻字母與迴文判斷。 */ public class TextAnalyzer
public static void main(String[] args)
String text = "banana boat";
// 統計不含空白的字元數 int charCount = 0; for (char c : text.toCharArray()) if (c != ’ ’) charCount++; System.out.println("字元數(不含空白):" + charCount);
// 單字數:用空白切開後數段數 String[] words = text.split(" "); System.out.println("單字數:" + words.length);
// 找最高頻字母:長度 26 的計數器陣列 int[] freq = new int[26]; for (char c : text.toCharArray()) if (c >= ’a’ && c <= ’z’) freq[c - ’a’]++; // ’a’ 對應 0,’b’ 對應 1…… int maxIndex = 0; for (int i = 1; i < 26; i++) if (freq[i] > freq[maxIndex]) maxIndex = i; // 出現更高頻的字母,更新 char mostFrequent = (char) (’a’ + maxIndex); System.out.println("最高頻字母:" + mostFrequent + "(" + freq[maxIndex] + " 次)");
// 迴文判斷交給獨立方法,main 保持清爽 System.out.println("level 是迴文?" + isPalindrome("level")); System.out.println("java 是迴文?" + isPalindrome("java"));
/** 頭尾雙指標逐一比對,全部相同即為迴文 */ static boolean isPalindrome(String s) int left = 0; int right = s.length() - 1; while (left < right) if (s.charAt(left) != s.charAt(right)) return false; // 一發現不同,立刻下結論 left++; right–; return true;
字元數(不含空白):10 單字數:2 最高頻字母:a(4 次) level 是迴文?true java 是迴文?false
程式裡的 |freq[c - ’a’]++| 是精巧的一手:字元本質是整數 (第 2 章),|’b’ - ’a’| 等於 1,於是 26 個小寫字母恰好對應 到陣列索引 0 到 25——不需要 26 個 if,一行搞定所有字母的計數。
本章小結
字串不可變:所有「修改」方法都回傳新字串,記得把回傳值 接回來。
相同的字面值共用字串常數池的同一個物件;|new String| 則強制建立新物件。
比較內容永遠用 |equals|(或 equalsIgnoreCase); |==| 比的是「是否同一個物件」。
查詢類方法:length、charAt、substring(含頭不含尾)、 indexOf(找不到回傳 \(-1\))、contains、startsWith 與 endsWith。
加工類方法:replace、trim 與 strip、大小寫轉換、split 切割 與 String.join 組合、isEmpty 與 isBlank。
字串轉數字用 |Integer.parseInt| 等;失敗會拋出 NumberFormatException;數字轉字串用 |String.valueOf|。
迴圈中累積串接請用 StringBuilder(append、insert、delete、 reverse),效能與 + 串接有數量級差距。
格式化輸出:|String.format| 回傳字串、|printf| 直接輸出;常用符號 | |
文字區塊(三個雙引號)讓多行字串所見即所得。
本章練習
觀念題:|String a = "hi"; String b = new String("hi");| 之後,|a == b| 與 |a.equals(b)| 各是什麼? 為什麼?(提示:常數池與 new 的差別。)
觀念題:|"HelloWorld".substring(5, 8)| 的結果是什麼? (提示:含起點、不含終點,先畫索引尺標。)
實作題:讀入一個 email 字串,檢查是否恰好包含一個小老鼠 符號且不以它開頭結尾,並取出帳號與網域分別印出。 (提示:indexOf、lastIndexOf 與 substring 的組合。)
實作題:把句子 |"the quick brown fox"| 的每個單字 字首轉成大寫,輸出 The Quick Brown Fox。(提示:split 切開,charAt(0) 配 toUpperCase,用 StringBuilder 組回去。)
實作題:寫方法統計一段文字中每個「數字字元」出現的次數, 例如 |"a1b22c333"| 輸出 1 出現 1 次、2 出現 2 次、 3 出現 3 次。(提示:計數器陣列配 |c - ’0’|。)
挑戰題:強化迴文判斷——忽略大小寫與空白,讓「A man a plan a canal Panama」也判定為迴文。(提示:先 toLowerCase 再 replace 掉空白,或雙指標跳過空白。)