前三堂處理的東西全都是數字:算面積、判斷分數、用陣列存很多個數字。這份講義整理 Vol. 17 直播的內容,開始處理文字:先處理一個字(字元),再處理一串字(字串)。沒看過前面幾堂的話,建議先讀 #1 到 #3 的講義,尤其是上一堂的陣列,今天會一直用到。
這是 2021 年 8 月的內容。工具一樣用 Dev-C++,換成其他 IDE 也可以,這裡教的語法跟工具沒有關係。直播裡讀字串用的是
gets,但gets已經從 C11 標準移除,講義裡的範例都改用fgets,原因在後面說明。
整場分成三個部分:
- 複習:輸入輸出、條件判斷、迴圈和陣列
- 字元:ASCII 表、
getchar/putchar、強制轉型、判斷與轉換大小寫 - 字串:字元陣列與
'\0'、輸入輸出字串、換行殘留的陷阱、string.h的函式
開始寫之前,一樣從骨架開始:複製之前寫過的程式,把中間的部分挖空再寫。不用背,知道每一段在做什麼、刪掉不要的部分就好。
一、先複習前三堂
第一堂:輸入、運算、輸出
用三角形面積當例子:跟記憶體要兩塊空間存底和高,用 printf 提示使用者、scanf 讀進來,算完再印出來。
- 用
int存的話,底 3、高 5 算出來是 7 而不是 7.5,因為整數只會留下整數的部分。 - 改成
double,scanf和printf的格式也要跟著改成%lf。 - 公式裡的
2寫成2.0,讓電腦知道這是浮點數。
第二、三堂:條件判斷、迴圈、陣列
只有第一堂的內容,程式就只能一行一行往下跑。加上 if,就能決定某段要不要執行;加上迴圈,就能執行很多遍;再配合陣列,就能存很多筆資料。
下面把三堂的東西組在一起:輸入三個三角形,算出面積,面積大於等於 30 就說很廣闊(30 是隨便定的)。
#include <stdio.h>
int main(){
int n = 3, i;
double bottom[3], height[3], area[3];
for (i = 0; i < n; i++) {
printf("請輸入第 %d 個三角形的底和高:", i + 1);
scanf("%lf%lf", &bottom[i], &height[i]);
}
for (i = 0; i < n; i++) {
area[i] = bottom[i] * height[i] / 2.0;
printf("三角形的面積是 %lf,", area[i]);
if (area[i] >= 30) {
printf("面積很廣闊\n");
} else {
printf("面積很狹窄\n");
}
}
return 0;
}
輸入 5 10、3 5、20 3,會印出:
三角形的面積是 25.000000,面積很狹窄
三角形的面積是 7.500000,面積很狹窄
三角形的面積是 30.000000,面積很廣闊
陣列的編號從 0 開始,3 格就是 [0] 到 [2],所以 i 從 0 跑到 n - 1。
你會發現,到目前為止處理的都是數字。今天就來處理文字。
二、字元
電腦怎麼存文字
一個字叫字元,很多字元組起來叫字串。
電腦裡所有東西都是用 0 和 1 存的。數字有二進位這套規則可以存,但文字沒有規則可言,你不會去算 K 加 L 等於多少。所以做法是:存一個數字,再用一張表規定這個數字代表哪個符號。
就像班上 30 個同學編 1 到 30 號,說「5 號」就知道是誰,只要記住 5 這個數字就好。
表示英文字母和數字最基本的一張表叫 ASCII 表,可以自己搜尋「ASCII」找到完整的表。幾個例子:
| 數字 | 字元 | 數字 | 字元 |
|---|---|---|---|
| 48 | '0' |
80 | 'P' |
| 57 | '9' |
89 | 'Y' |
| 65 | 'A' |
97 | 'a' |
| 70 | 'F' |
112 | 'p' |
要特別注意 48 對應到的 '0':48 是一個數值,'0' 則是「畫一個圈圈」的那個符號。電腦裡存的是 48,畫面上顯示成 0 這個字。
表的最前面(0 到 31)不是看得到的符號,而是早期給電腦下指令用的控制字元,例如換行。換行 '\n' 也是一個字元,在 ASCII 表裡也有編號(10)。
char、getchar 與 putchar
存一個字元用 char 型態,大小是 1 個 byte,剛好可以放下整張 ASCII 表。
getchar():從鍵盤讀一個字元,讀到的字元會回傳回來,用=存進變數。putchar(c):把一個字元印出來。
#include <stdio.h>
int main(){
char c;
c = getchar();
putchar(c);
putchar('\n');
return 0;
}
輸入 a 會印出 a,輸入大寫 A 會印出 A。輸入 ABCDE 呢?只會印出 A。按下 Enter 時,整行才交給程式讀,而我們只讀一個字元,所以只拿到第一個。
最後的 putchar('\n') 就是把換行字元印出來。字元要用單引號包起來。
強制轉型
想知道字元在表裡是幾號,可以把它轉型成整數:在想轉換的值前面加上小括號和型態。轉型對數字也有用:
#include <stdio.h>
#include <math.h>
int main(){
int n = 3;
double d = (double)n; // 3 轉成 3.0
double f = 3.7;
int a = (int)f; // 小數部分直接丟掉,得到 3
int b = (int)round(f); // 先四捨五入成 4.0,再轉成整數 4
int c = (int)(f + 0.5); // 加 0.5 再轉型,也是 4
printf("%lf\n", d);
printf("%d %d %d\n", a, b, c);
return 0;
}
3.000000
3 4 4
- 小數轉成整數不會四捨五入,只會把小數部分丟掉。
- 要四捨五入可以用
math.h裡的round。數學相關的函式都在math.h裡。 - 另一個常見的做法是加 0.5 再轉型。不過這招只對正數正確,負數會出錯,例如 -2.5 加 0.5 轉型後是 0,而
round(-2.5)是 -3。
聊天室問加 0.5 是不是比較直覺。現在剛好在講四捨五入,所以會覺得加 0.5 很聰明;但在一段沒人解釋的程式碼裡看到「a + 0.5 再轉成 int」,你不見得知道這是在四捨五入,說不定是在算某個邊長要加上 0.5。寫成 round(a),看的人馬上知道你要做什麼。程式寫大了以後,會希望程式碼像英文文章一樣,從命名就看得出意圖,所以比較推薦用名字說清楚的寫法。
用 gcc 在 Linux 上編譯時,用到
math.h的函式可能要在指令最後加-lm;Dev-C++ 不用。
印出字元的編號
也可以用老方法 scanf 和 printf,字元的格式是 %c。把同一個字元用 %d 搭配轉型印出來,就能看到它的編號:
#include <stdio.h>
int main(){
char c;
scanf("%c", &c);
printf("%c\n", c);
printf("%d\n", (int)c);
return 0;
}
輸入 A 會印出 A 和 65,去查 ASCII 表,65 號就是 A。你打的 A,存在記憶體裡就只是 65 這個數字。
用 getchar/putchar 還是 scanf/printf 都可以,只是提供不同的方法做同一件事。
判斷大小寫和數字
看 ASCII 表會發現一件有趣的事:
'0'到'9'是連號的:48 到 57'A'到'Z'是連號的:65 到 90'a'到'z'也是連號的:97 到 122
字元也是數字,所以可以用大於等於、小於等於判斷:數值介在 'A' 和 'Z' 之間,就是大寫字母。
連號也代表大寫和小寫的差距都一樣:'a' 是 97、'A' 是 65,差 32;'b' 是 98,減 32 是 66,剛好是 'B'。所以小寫轉大寫就是減 32,大寫轉小寫就是加 32。
#include <stdio.h>
int main(){
char c;
c = getchar();
if (c >= '0' && c <= '9') {
printf("數字\n");
} else if (c >= 'A' && c <= 'Z') {
printf("大寫字母,轉成小寫是 %c\n", c + ('a' - 'A'));
} else if (c >= 'a' && c <= 'z') {
printf("小寫字母,轉成大寫是 %c\n", c - ('a' - 'A'));
} else {
printf("其他字元\n");
}
return 0;
}
| 輸入 | 輸出 |
|---|---|
7 |
數字 |
G |
大寫字母,轉成小寫是 g |
b |
小寫字母,轉成大寫是 B |
? |
其他字元 |
為什麼寫 'A' 而不直接寫 65?因為根本不用背 ASCII 表。32 也一樣不用背,寫成 'a' - 'A' 讓程式自己算。
ctype.h
上面這些判斷,C 已經準備好現成的函式,放在 ctype.h,記得 #include <ctype.h>。直播沒有實際示範,可以自己試試看:
| 函式 | 做什麼 |
|---|---|
isdigit(c) |
是不是數字 |
isupper(c)、islower(c) |
是不是大寫、小寫字母 |
isalpha(c) |
是不是英文字母 |
isalnum(c) |
是不是數字或英文字母 |
toupper(c)、tolower(c) |
轉成大寫、小寫 |
今天最重要的一句話:字元就是用數字存的,對字元做的運算其實都是數字運算,只是要查表才知道是哪個數字。
為什麼從 C 入門
休息時聊到這個話題。C++ 就是 C 再「加加」一些東西,加的主要是物件導向。所以條件判斷、迴圈、陣列這些在 C++ 裡都一樣,前面講完,C++ 也講完一大部分了。
越早期的語言越會讓你看到資料真正的樣子:字元其實是數字、陣列存在記憶體的哪個位置都拿得到。新的語言會把這些細節藏起來,例如不讓你對字元做加一加二的運算(中文字「我」加一是什麼意思?),也不讓你直接碰記憶體位置。
從 C 入門有兩個好處:
- 現在常用的語言,語法大多是從 C、C++ 延伸出來的(Java、JavaScript、C#,以及 iOS 早期用的 Objective-C),學會 C 的語法,換別的語言只要了解差異就好。
- 你會知道資料實際上怎麼存在電腦裡。寫現代語言時用不到,但哪天遇到奇怪的問題,你會知道原因在哪。
三、字串
字串就是字元陣列
一個字元是一個數字,很多個字元就是很多個數字。還記得存很多個數字的方法嗎?用陣列。所以字串就是字元陣列,這也是上一堂要先講陣列的原因。
問題來了:做 RPG 遊戲讓玩家取名字時,你不知道他會打幾個字。小明兩個字、木村遙三個字、玥悅悅玥四個字。就算限制最多 15 個字,宣告 15 格的陣列,玩家也不一定剛好打 15 個字。
C 的解法是:在字串的最後面加上 '\0',代表字串到這裡結束。'\0' 是 ASCII 表裡編號 0 的字元(叫 NUL 字元,不是看得到的 0 那個符號 '0')。
所以 "Hello" 存起來是這樣:
[0] |
[1] |
[2] |
[3] |
[4] |
[5] |
|---|---|---|---|---|---|
'H' |
'e' |
'l' |
'l' |
'o' |
'\0' |
5 個字要 6 格,要記得多留一格放 '\0'。陣列後面可能還有更多格,但字串到 '\0' 就結束了。
初始化與輸出
字串可以像一般陣列一個一個字元給,也可以直接用雙引號包一串字,這時後面會自動幫你加上 '\0':
#include <stdio.h>
int main(){
char a[] = "Hello";
char b[] = {'H', 'e', 'l', 'l', 'o', '\0'};
char c[6];
c[0] = 'H'; c[1] = 'e'; c[2] = 'l'; c[3] = 'l'; c[4] = 'o'; c[5] = '\0';
printf("%s\n", a);
printf("%s\n", b);
printf("%s\n", c);
printf("%d\n", (int)sizeof(a)); // 6:5 個字加上 '\0'
return 0;
}
Hello
Hello
Hello
6
三種寫法的結果一樣。一個一個給的時候,最後要記得自己放 '\0'。
輸出字串用 %s,給它的是陣列名稱。上一堂說過,陣列名稱代表陣列開頭的記憶體位置;printf 會從那個位置開始一個字元一個字元印,印到遇到 '\0' 為止。
單引號和雙引號
在 C 裡單引號和雙引號是不一樣的:
- 單引號只能包一個字元:
'A' - 雙引號包的是字串:
"Hello"。雙引號也能只包一個字,但"A"是字串,後面還有一個'\0',佔兩格。
其他語言不一定這樣分,例如 JavaScript 的單引號和雙引號都能包字串。這是每個語言設計時的選擇。
用 scanf 讀字串
輸入字串一樣用 %s,給的也是陣列名稱。注意這裡不用加 &:scanf 要的是存放資料的記憶體位置,而陣列名稱本身就是位置了。
#include <stdio.h>
int main(){
char str[80];
scanf("%79s", str); // 陣列名稱就是位址,不用加 &;79 是長度上限,後面會講
printf("%s\n", str);
return 0;
}
%79s 中間的 79 是最多讀幾個字,留一格給 '\0',後面「限制讀進來的長度」會再說明。
輸入 xdxd 會印出 xdxd。陣列有 80 格,但字串只有 4 個字:陣列長度是能存多少資料,字串長度是 '\0' 前面有幾個字元,兩者不一樣。
輸入 xdxd xdxd2 呢?只會印出 xdxd。scanf 用空白字元分隔,不只空白鍵,Tab 和 Enter 也算。所以它認為這是兩個字串,只讀了第一個。
讀一整行:用 fgets,不要用 gets
想把包含空白的一整行讀進來,直播用的是 gets(str),它用 Enter 分隔,遇到空白不會斷開。
但 gets 已經從 C11 標準移除了。它不知道陣列有多大,使用者打的字比陣列長,就會寫到陣列外面去(後面會講到這有多危險),新的編譯器會警告,甚至找不到這個函式。請改用 fgets:
fgets(陣列名稱, 陣列大小, stdin);
#include <stdio.h>
int main(){
char str[80];
fgets(str, 80, stdin);
printf("%s", str); // fgets 會把換行也存進去,所以這裡不用再加 \n
puts(str); // puts 印完會自己再換一行
return 0;
}
輸入 xdxd xdxd2,會印出兩次完整的 xdxd xdxd2,最後多一個空行。
stdin是標準輸入,也就是鍵盤。fgets也能從其他地方讀,例如檔案,之後講檔案處理時會再提。fgets跟gets有一點不同:它會把最後的換行'\n'也存進陣列(只要陣列放得下)。所以上面用printf("%s", str)就會換行;puts(str)印完又自己換一行,才會多一個空行。puts是跟gets對應的輸出函式,就像getchar對應putchar。
不想要那個換行時,可以用 string.h 的 strcspn 找到換行的位置,把它換成 '\0':
str[strcspn(str, "\n")] = '\0'; // 把結尾的換行去掉
這行先當成固定寫法記起來就好,後面的範例會用到。
陷阱:scanf 後面接 fgets,換行殘留
這個問題在第一堂就遇過,當時說之後再講,就是現在。
scanf 讀 %s 時,你打 hello 再按 Enter,實際輸入的是 hello\n。鍵盤打的東西不會直接被 scanf 拿走,而是先放在輸入緩衝區(可以想成也是一個陣列),再讓 scanf 一個一個拿出來處理。scanf 把 \n 當成分隔符號,只拿走 hello,\n 還留在緩衝區裡。
接下來 fgets(或直播用的 gets)來讀的時候,第一個碰到的就是那個 \n,而它們讀到換行就結束了:
#include <stdio.h>
int main(){
char a[80], b[80];
scanf("%79s", a);
fgets(b, 80, stdin);
printf("a = [%s]\n", a);
printf("b = [%s]\n", b);
return 0;
}
輸入 abcd 按 Enter,程式就直接結束了,根本沒機會輸入第二行:
a = [abcd]
b = [
]
b 裡只有一個換行(直播用 gets,它不會存換行,所以 b 是空字串,也就是第 0 格就是 '\0')。
解法的想法很簡單:把那個 \n 吃掉就好。 最簡單的做法是多讀一次,把殘留的換行讀掉:
#include <stdio.h>
int main(){
char a[80], b[80];
scanf("%79s", a);
fgets(b, 80, stdin); // 先把 scanf 留下的換行吃掉
fgets(b, 80, stdin); // 這次才是真正的輸入
printf("a = [%s]\n", a);
printf("b = [%s]\n", b);
return 0;
}
輸入 abcd 和 efgh ijk:
a = [abcd]
b = [efgh ijk
]
(b 後面的換行是 fgets 存進去的,用上一節的 strcspn 去掉就好。)這招假設 abcd 後面直接按 Enter;如果同一行還打了別的東西,第一次 fgets 會把那些也一起吃掉。
其他做法:
- 全部都用
fgets讀,不要混用scanf。 - 直播也試了在
scanf的格式字串後面加\n或空白,例如scanf("%s\n", a)。格式字串裡的空白字元(空白、\n、Tab 都一樣)會跳過所有連續的空白字元,所以換行確實會被吃掉。但它會一直等到你打出下一個不是空白的字元才結束,在鍵盤上用起來會像「要多打一行才往下走」,直播覺得「有時成功有時不會」就是這個原因,不太推薦。
直播也提到 Windows 和 Mac 的換行字元不一樣,擔心 \n 吃不乾淨。從鍵盤(文字模式)讀進來時,C 的標準函式庫會把系統的換行統一轉成 \n,所以讀鍵盤時不用擔心這件事。
兩個 scanf 連著用,或兩個 fgets 連著用,都沒有這個問題;交替使用時才要注意。剛開始學不知道也沒關係,碰到「怎麼會這樣?」時記得有這回事就好。
限制讀進來的長度
假設陣列只宣告 5 格,使用者卻打了十幾個字,用 gets 或 scanf("%s") 硬吃下來,陣列就會爆掉。打 4 個字就滿了,因為還要留一格給 '\0'。
fgets 的第二個參數就是在限制長度:給 5,它最多只讀 4 個字,留一格放 '\0'。
#include <stdio.h>
int main(){
char a[5];
fgets(a, 5, stdin); // 最多只讀 4 個字,留一格給 '\0'
printf("[%s]\n", a);
return 0;
}
輸入 abcdefghij,只會印出 [abcd]。
scanf 也可以限制:在 % 和 s 中間寫最多讀幾個字,例如陣列 5 格就寫 scanf("%4s", a),同樣留一格給 '\0'。前面的範例寫 %79s 就是這個原因,陣列有 80 格。
例外:%c 不會跳過空白
scanf 用空白和換行分隔,但有一個例外:%c。字元只有一個字,scanf 很清楚要讀多長,所以空白和換行它也照讀。整數就不一樣,不知道你要打多長,只能靠空白和換行分開。
這也是 #2 講義裡,想輸入 y 卻被直接跳過的原因:前面輸入數字時按的 Enter 還留在緩衝區,%c 就把它讀走了。在 %c 前面加一個空白,寫成 " %c",就會先跳過空白和換行:
#include <stdio.h>
int main(){
int n;
char c1, c2;
scanf("%d", &n);
scanf("%c", &c1); // 讀到的是輸入數字後留下的換行
scanf(" %c", &c2); // %c 前面加空白:先跳過空白和換行
printf("c1 的 ASCII 是 %d\n", c1);
printf("c2 是 %c\n", c2);
return 0;
}
輸入 5 和 x:
c1 的 ASCII 是 10
c2 是 x
10 就是換行字元 '\n' 在 ASCII 表裡的編號。
二維陣列存多個字串
一個字元,很多個就是字串;很多個字串,就再多一個維度,用二維陣列。例如存三個人的名字,每人 80 格:
#include <stdio.h>
#include <string.h>
int main(){
char name[3][80];
int i;
for (i = 0; i < 3; i++) {
printf("請輸入第 %d 個人的姓名:", i + 1);
fgets(name[i], 80, stdin);
name[i][strcspn(name[i], "\n")] = '\0'; // 把結尾的換行去掉
}
for (i = 0; i < 3; i++) {
printf("第 %d 個人是 %s\n", i + 1, name[i]);
}
return 0;
}
輸入 Yuuta、Tails、Knuckles:
第 1 個人是 Yuuta
第 2 個人是 Tails
第 3 個人是 Knuckles
把維度拆開看:name[0]、name[1]、name[2] 各自都是一個 80 格的一維字元陣列,也就是一個字串。
四、string.h 的字串函式
為什麼不能直接寫 a == b
字串是用陣列存的,而陣列名稱只是開頭的記憶體位置,不代表整個陣列。所以對陣列做處理很麻煩:
a == b:比較的是兩個陣列的位置一不一樣,不是內容。兩個不同的陣列,位置當然不一樣。例如要檢查使用者輸入的密碼對不對,這樣寫是錯的。a = b:想把b的位置放進a。但用陣列宣告的a,它代表的位置是鎖死的,不能改,編譯器會報錯。a += b:變成在對記憶體位置做加減法,也不對。
這些事要用 string.h 裡的函式來做,記得 #include <string.h>。函式名稱都是兩個英文單字各取前三個字母組成的:
| 函式 | 全名 | 意思 |
|---|---|---|
strcmp(a, b) |
string compare | 比較兩個字串 |
strcpy(a, b) |
string copy | 把 b 複製到 a,就像 a = b |
strcat(a, b) |
string concatenate | 把 b 接到 a 後面,就像 a += b |
strlen(a) |
string length | 字串長度 |
strcmp:比較字串
strcmp(a, b) 會回傳一個整數:
- 等於 0:兩個字串一樣。
- 大於 0:從第一個字開始比,比到第一個不一樣的字時,
a的那個字 ASCII 比較大。 - 小於 0:反過來,
b的那個字比較大。
記法:想像它對每一格做減法。a 的字減 b 的字,等於 0 就跳到下一格;減出來是正的,就是 a 比較大;負的就是 b 比較大。全部減出來都是 0,兩個字串就一樣。標準只規定正負號,不保證回傳的就是那個差,所以判斷時寫 > 0、< 0、== 0,不要寫 == 1 這種。
#include <stdio.h>
#include <string.h>
int main(){
char a[80];
char b[80] = "Book";
fgets(a, 80, stdin);
a[strcspn(a, "\n")] = '\0';
if (strcmp(a, b) == 0) {
printf("輸入正確\n");
} else {
printf("輸入不正確\n");
}
return 0;
}
| 輸入 | 輸出 |
|---|---|
apple |
輸入不正確 |
book |
輸入不正確(B 要大寫) |
Book |
輸入正確 |
要一模一樣才算相同。這裡一定要先去掉 fgets 存進來的換行,不然 "Book\n" 跟 "Book" 永遠不一樣。
strcpy、strcat、strlen
#include <stdio.h>
#include <string.h>
int main(){
char a[80] = "Mac";
char b[80] = "Book";
char c[80];
int aLen;
strcpy(c, b); // c = b
printf("c = %s\n", c);
strcat(a, b); // a += b
printf("a = %s\n", a);
aLen = strlen(a);
printf("a 的長度是 %d\n", aLen);
printf("%d\n", strcmp("apple", "Book") > 0); // 'a'(97) 比 'B'(66) 大
return 0;
}
c = Book
a = MacBook
a 的長度是 7
1
strcpy(c, b):左邊是目的地、右邊是來源,跟c = b的方向一樣。strcat(a, b):Mac 接上 Book 變成 MacBook。a要夠大,放得下接起來的結果和'\0'。strlen(a):不含'\0'的長度。- 最後一行:
'a'是 97、'B'是 66,所以strcmp回傳大於 0 的值,比較結果印出 1。
自己用迴圈寫
這些函式其實都是在做陣列處理,自己用 for 迴圈也寫得出來,這樣才看得出 '\0' 真正的用處。
複製:80 格全部複製也是對的,但字串可能只有 4 個字,不需要比到 80 格。所以改成複製到遇到 '\0' 就停。不知道要跑幾次,用 while 也可以;用 for 的話,條件寫 b[i] != '\0'。
長度:長度其實就是 '\0' 所在的那一格的編號。"Mac" 的 '\0' 在第 3 格,長度就是 3。所以一路往後找,找到 '\0' 就知道長度了。
連接:先找到 a 的結尾('\0' 在哪一格),再從那裡開始,把 b 一格一格複製過去。
#include <stdio.h>
int main(){
char a[80] = "Mac";
char b[80] = "Book";
char c[80];
int i, aEnd;
// 自己寫 strcpy:從 b 複製到 c,直到遇到 '\0'
for (i = 0; b[i] != '\0'; i++) {
c[i] = b[i];
}
c[i] = '\0'; // 別忘了結尾
printf("c = %s\n", c);
// 自己寫 strlen + strcat:先找到 a 的結尾
for (aEnd = 0; a[aEnd] != '\0'; aEnd++) {
}
printf("a 的長度是 %d\n", aEnd);
// 再把 b 一格一格接在 a 的結尾後面
for (i = 0; b[i] != '\0'; i++) {
a[aEnd + i] = b[i];
}
a[aEnd + i] = '\0';
printf("a = %s\n", a);
return 0;
}
c = Book
a 的長度是 3
a = MacBook
找結尾那個 for 的大括號裡什麼都沒做,它純粹是讓 aEnd 一路加到 '\0' 的位置:a[0] 是 M,不是 '\0',加 1;a[1] 是 a,加 1;a[2] 是 c,加 1;a[3] 是 '\0',停下來,aEnd 就是 3。接著 a[3 + 0] = b[0],把 B 放到第 3 格;a[3 + 1] = b[1],把 o 放到第 4 格,依此類推。
複製和連接最後都要自己補上 '\0'。這兩個範例的陣列有初始化,沒用到的格子本來就是 0,忘了補也剛好不會出事;但陣列沒初始化時,裡面可能是任何值,printf 找不到 '\0' 就會一直印下去,印出一堆亂碼,甚至讀到陣列外面去。直播想示範這件事,但宣告出來的記憶體剛好很乾淨,大多是 0,所以示範不太出來;另一個沒初始化的陣列則印出了奇怪的字。結果每次都可能不一樣,正是它危險的地方。
先用 strlen 算好長度
如果覺得「b[i] != '\0'」這種條件很怪,可以先用 strlen 算出長度,迴圈就能維持熟悉的 i = 0; i < 長度; i++ 寫法,aLen + i 的意思(a 的長度再往後跳幾格)也比較清楚:
#include <stdio.h>
#include <string.h>
int main(){
char a[80] = "Mac";
char b[80] = "Book";
int i;
int aLen = strlen(a);
int bLen = strlen(b); // 先算好存起來,不要寫在迴圈條件裡
for (i = 0; i < bLen; i++) {
a[aLen + i] = b[i];
}
a[aLen + bLen] = '\0';
printf("a = %s\n", a);
return 0;
}
印出 a = MacBook。
能不能直接寫 i < strlen(b)?可以,但要小心:strlen 本身就是一個找 '\0' 的迴圈。字串有 100 萬個字,它就要比較 100 萬次;放在迴圈條件裡,每跑一次都要重算一次,會非常花時間。所以先用變數把結果存起來。
五、Q&A:C++ 的 string
C++ 用物件導向把字串包裝成 string,用起來方便很多,不用管它是不是陣列:可以直接寫 a = b、a += b、a == b。
不過包起來的底下,做的還是今天看到的這些事。以前有人提醒,C++ 的字串在迴圈裡讀長度要小心,可能跟 strlen 一樣是陷阱;不過 C++ 的 string 會自己記住長度,跟它要長度時不用重新數一遍。這些等講到 C++ 時再試試看。
小複習
- 字元就是數字,靠 ASCII 這類表格對應到符號;
'A'是 65、'a'是 97、'0'是 48。 - 數字、大寫、小寫在表裡各自連號,可以用大於小於判斷;大小寫差
'a' - 'A'(32)。 - 轉型寫成
(型態)值;小數轉整數會直接丟掉小數,四捨五入用round。 - 字串是字元陣列,最後有
'\0',所以 n 個字要 n + 1 格;陣列長度不等於字串長度。 - 單引號包字元,雙引號包字串。
scanf("%s")遇到空白就斷開,不用加&,要寫上長度上限,例如%79s;讀整行用fgets(陣列, 大小, stdin),不要用已經被移除的gets。fgets會保留換行。scanf之後接fgets,要先把殘留的換行吃掉;%c不會跳過空白,用" %c"。- 字串不能用
==、=、+=,要用strcmp、strcpy、strcat;長度用strlen,而且不要放在迴圈條件裡重算。
演練
把一串字裡的小寫字母都轉成大寫。 直播特別不給答案,自己寫寫看:一個字元會轉了,一串字元就是用迴圈一格一格處理,處理到 '\0' 為止。只轉小寫字母,其他字元保持原樣。
寫好的話截圖發到 Twitter,加上 tag #悠太作業。有問題可以到 Discord 群組問,群裡也有其他會寫程式的 Vtuber 可以幫忙回答。
下一堂預告:指標
下一堂就是傳說中 C 語言最難的部分:指標。今天一直說「陣列名稱是開頭的記憶體位置」、scanf 要的是「存放的位置」,下一堂就會把記憶體位置這件事講清楚。
之後的順序預計是:指標 → 函式 → 結構 → 檔案處理,接著再進 C++ 多出來的部分和物件導向。