Chuỗi ký tự
Chuỗi ký tự, thứ ta dùng để lưu tên người hay một câu văn, thực ra cũng chỉ là một
mảng đặc biệt. Trong C không có kiểu dữ liệu riêng cho chuỗi. Một chuỗi ký tự
(string) chỉ đơn giản là một mảng các ký tự kiểu char, với một quy ước quan
trọng: ký tự cuối cùng phải là ký tự kết thúc \0 (đọc là null, mã số 0).
Vì sao lại như vậy? Máy tính chỉ hiểu số, nó không có khái niệm chữ cái; cái ta gọi là
chữ 'A' thật ra là số 65 được quy ước vẽ thành hình chữ A. Một câu văn, dưới mắt máy,
chỉ là dãy số đặt cạnh nhau, nên muốn lưu nó ta cần một dãy ô nhớ liên tiếp, tức một
mảng. Nhưng đọc dãy ô ấy thì làm sao biết chỗ nào là hết câu? Nếu khai mảng 100 ô mà
chỉ dùng 5 ô đầu chứa Hello, thì 95 ô sau chứa rác còn sót của chương trình chạy
trước, in ra sẽ thấy Hello rồi một mớ ký tự vô nghĩa.
C giải bài toán này bằng quy ước thô sơ: đặt một dấu chấm hết ngay sau ký tự cuối, dấu ấy là byte có giá trị 0. Mọi hàm xử lý chuỗi tin tuyệt đối vào quy ước đó, chúng đọc từ ô đầu và chỉ dừng khi gặp byte 0, không ai kiểm tra hộ bạn. Byte 0 mà mất thì hàm đi lang thang vào vùng nhớ không phải của nó. Cả bài học hôm nay xoay quanh việc tôn trọng cái byte nhỏ bé đó.
Thử ngay: xem duyệt chuỗi chạy từng bước
Bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi biến chỉ số i đi qua từng ký tự
của chuỗi "Hello", đếm các chữ thường, và dừng đúng lúc gặp \0.
1int countLower(const char s[]) {2 int count = 0;3 for (int i = 0; s[i] != '\0'; i++)4 if (s[i] >= 'a' && s[i] <= 'z')5 count++;6 return count;7}
Mảng char và ký tự kết thúc
Ký tự \0 đánh dấu chỗ chuỗi kết thúc, để các hàm biết phải dừng đọc ở đâu. Hàm xử lý
chuỗi nhận biết điểm cuối nhờ ký tự này, không nhờ kích thước mảng.
char s1[6] = {'H', 'e', 'l', 'l', 'o', '\0'}; // explicit null
char s2[6] = "Hello"; // compiler adds the terminator automatically
char s3[] = "Hello"; // size inferred as 6 (5 chars plus the null)
Chữ "Hello" chỉ có 5 ký tự nhưng mảng cần 6 ô để còn chỗ cho \0. Đây là cái bẫy
hay gặp: nếu khai báo char s[5] = "Hello"; thì không còn chỗ cho ký tự kết thúc,
chuỗi trở nên hỏng và các hàm xử lý có thể đọc lan ra ngoài.
| chỉ số | [0] | [1] | [2] | [3] | [4] | [5] |
|---|---|---|---|---|---|---|
| giá trị | 'H' | 'e' | 'l' | 'l' | 'o' | '\0' |
Mảng chứa chuỗi phải dư một ô cho \0. Một chuỗi n ký tự cần mảng kích thước ít
nhất là n+1.
Kích thước mảng khác độ dài chuỗi
Người mới rất hay lẫn hai con số này. Kích thước mảng là số ô nhớ trình biên dịch cấp phát, cố định suốt đời mảng. Độ dài chuỗi là số ký tự thật đang nằm trong mảng, tính từ ô đầu tới ngay trước byte 0, và nó đổi mỗi khi ta gán nội dung mới.
Với char ten[50] = "Hoa"; thì kích thước mảng là 50 nhưng độ dài chuỗi chỉ là 3. Ba ô
đầu chứa 'H', 'o', 'a', ô thứ tư chứa \0, còn 46 ô sau vẫn tồn tại nhưng không
thuộc về chuỗi; chúng là chỗ trống dự phòng cho tên dài hơn sau này. Vì vậy
sizeof(ten) cho 50 còn strlen(ten) cho 3: hai hàm trả lời hai câu hỏi khác nhau,
đừng dùng lẫn. Quy tắc chọn kích thước: ước lượng độ dài xấu nhất, cộng một ô cho byte
0, rồi cộng một khoảng dự phòng.
Vì sao C làm khác C++
Kiểu string của C++ là một đối tượng biết tự quản lý: nó nhớ độ dài của chính mình,
tự xin thêm bộ nhớ khi nội dung dài ra, tự giải phóng khi hết phạm vi. Nhờ vậy trong
C++ bạn gán bằng =, so sánh bằng ==, nối bằng +, không bao giờ phải nghĩ tới byte
0.
C cố tình không làm vậy. C ra đời để viết hệ điều hành, nơi từng byte và từng chu kỳ máy đều phải đếm; nếu mỗi chuỗi phải mang theo một trường độ dài thì mọi chuỗi đều tốn thêm bộ nhớ và mọi thao tác đều tốn thêm lệnh. Người thiết kế C chọn cách rẻ nhất, và cái giá là toàn bộ trách nhiệm kiểm tra rơi lên đầu lập trình viên. Bạn đổi sự an toàn lấy tốc độ. Đây là lựa chọn có chủ đích, không phải khuyết điểm bỏ sót.
Trong C, s1 = s2 không sao chép nội dung chuỗi và s1 == s2 không so sánh nội dung
chuỗi. Nếu s1 là mảng thì phép gán còn không biên dịch được. Nếu cả hai là con trỏ
thì chương trình biên dịch trót lọt nhưng bạn chỉ đang so sánh hai địa chỉ, tức hỏi hai
chuỗi có nằm cùng một chỗ trong bộ nhớ hay không, chứ không hỏi chúng có cùng nội dung
hay không. Hai chuỗi "abc" nằm ở hai vùng nhớ khác nhau cho kết quả so sánh là sai,
dù nội dung giống hệt. Muốn so nội dung phải dùng strcmp.
Duyệt chuỗi tới ký tự kết thúc
Vì chuỗi kết thúc bằng \0, cách duyệt chuẩn mực là chạy vòng lặp cho tới khi gặp ký
tự này thì dừng, không cần biết trước độ dài.
#include <stdio.h>
int countLower(const char s[]) {
int count = 0;
for (int i = 0; s[i] != '\0'; i++) // stop at the terminator
if (s[i] >= 'a' && s[i] <= 'z')
count++;
return count;
}
int main(void) {
printf("%d\n", countLower("Hello")); // prints 4
return 0;
}
Từ khoá const báo rằng hàm chỉ đọc chứ không sửa chuỗi, một thói quen tốt.
Nhập và xuất chuỗi
Để in một chuỗi, ta dùng printf với định dạng %s. Để nhập, có hai cách. Cách thứ
nhất là scanf với %s, nhưng nó dừng ở khoảng trắng nên chỉ đọc được một từ, và
nguy hiểm hơn là không giới hạn độ dài nên dễ tràn mảng. Cách an toàn và được khuyến
khích là fgets, đọc được cả dòng (gồm khoảng trắng) và giới hạn rõ số ký tự.
#include <stdio.h>
#include <string.h>
int main(void) {
char name[50];
printf("Your full name: ");
fgets(name, sizeof(name), stdin); // read a whole line safely
// fgets keeps the trailing newline, remove it if present
name[strcspn(name, "\n")] = '\0';
printf("Hello, %s!\n", name); // %s prints the string
return 0;
}
fgets giữ lại ký tự xuống dòng \n mà người dùng nhấn. Dòng cắt nó bằng cách thay
bằng \0. Khác với scanf, ở đây không cần dấu & vì tên mảng đã là địa chỉ.
Tham số thứ hai của fgets là số ô tối đa hàm được phép ghi, kể cả byte 0. Đây là mấu
chốt: fgets cam kết không bao giờ ghi quá con số này, nên dù người dùng gõ một nghìn
ký tự thì mảng 50 ô của ta vẫn nguyên vẹn. Ta truyền sizeof(name) thay vì gõ tay số
50, để khi đổi kích thước mảng thì giới hạn tự đổi theo, khỏi sửa hai chỗ rồi quên một
chỗ.
Còn scanf("%s", name) sai ở đâu? Sai ở chỗ nó không hề biết mảng name rộng bao
nhiêu: ta chỉ đưa cho nó địa chỉ ô đầu tiên, mà địa chỉ thì không mang theo kích thước.
scanf cứ ghi liên tục kể từ địa chỉ đó cho tới khi gặp khoảng trắng; người dùng gõ dài
hơn mảng thì nó vẫn ghi tiếp, đè lên bất cứ thứ gì nằm sau mảng. Thêm nữa, %s dừng ở
khoảng trắng nên nhập Nguyen Van A chỉ lấy được Nguyen, phần còn lại kẹt trong bộ
đệm và sẽ phá lần nhập kế tiếp. Nếu buộc phải dùng scanf, hãy viết
scanf("%49s", name) cho mảng 50 ô, nhớ trừ một ô cho byte 0.
Tránh dùng hàm gets (không có tham số giới hạn độ dài): nó đã bị loại khỏi chuẩn C
vì luôn có nguy cơ tràn bộ đệm. Hãy dùng fgets với sizeof để truyền kích thước mảng.
Tràn bộ đệm nhìn từ bộ nhớ
Giả sử trong một hàm ta khai hai biến cạnh nhau, char ten[8]; rồi int diem = 100;.
Trình biên dịch xếp chúng vào ngăn xếp, thường nằm gần nhau: ten chiếm 8 ô liên tiếp,
ngay sau đó là 4 byte của diem. Bây giờ ta chạy strcpy(ten, "Nguyen Van A"), chép
vào một chuỗi dài 12 ký tự. Hàm strcpy không biết ten chỉ có 8 ô; nó nhận địa chỉ ô
đầu rồi chép từng byte của nguồn cho tới khi chép xong byte 0 của nguồn. Tám byte đầu
rơi đúng vào mảng. Từ byte thứ chín trở đi, nó ghi ra ngoài mảng, và chỗ đầu tiên nó
chạm phải chính là vùng nhớ của diem. Kết quả: diem bị đè, giá trị 100 biến thành
một con số vô nghĩa.
Điều đáng sợ là chương trình vẫn chạy, không một lời cảnh báo. Bạn in ten thấy đúng,
in diem thấy sai, rồi đi tìm lỗi ở chỗ tính điểm chứ không nghi ngờ dòng strcpy. Tệ
hơn, nếu vùng bị đè là địa chỉ trở về của hàm đang chạy, kẻ tấn công có thể lợi dụng để
bắt chương trình nhảy tới đoạn mã do họ cài vào. Đó là lý do tràn bộ đệm không chỉ là
lỗi lập trình mà còn là lỗ hổng bảo mật kinh điển.
Một, nhập chuỗi mà không giới hạn độ dài, tức dùng gets hoặc scanf("%s", ...). Hai,
strcpy vào một mảng nhỏ hơn chuỗi nguồn. Ba, strcat nối thêm vào một mảng đã gần
đầy, vì ta hay quên rằng mảng đích phải chứa được cả nội dung cũ lẫn phần nối thêm
lẫn byte 0. Trong cả ba trường hợp, gốc rễ đều giống nhau: hàm nhận địa chỉ nhưng
không nhận kích thước, nên nó không thể tự kiểm tra hộ bạn.
Thư viện string.h
Vì chuỗi chỉ là mảng char, ta không thể gán hay so sánh chuỗi bằng = hoặc ==
như với số. Viết s1 = s2 hay s1 == s2 là sai về mặt logic chuỗi. Thay vào đó, C
cung cấp thư viện chuẩn string.h với các hàm xử lý chuỗi.
| hàm | trả về | công dụng |
|---|---|---|
strlen(s) | size_t | độ dài chuỗi s, không tính ký tự kết thúc |
strcpy(d, s) | char* | sao chép chuỗi s vào d |
strcmp(a, b) | int | so sánh; trả 0 nếu hai chuỗi bằng nhau |
strcat(d, s) | char* | nối chuỗi s vào cuối d |
Ví dụ dưới minh hoạ bốn hàm cốt lõi mà người mới cần thuộc.
#include <stdio.h>
#include <string.h>
int main(void) {
char a[50] = "Hello";
char b[50];
printf("Length: %d\n", (int)strlen(a)); // Length: 5
strcpy(b, a); // now b is "Hello"
printf("Copy: %s\n", b);
strcat(b, ", world"); // b becomes "Hello, world"
printf("Concat: %s\n", b);
if (strcmp(a, "Hello") == 0) // 0 means equal
printf("a equals Hello\n");
return 0;
}
Bốn điều cần nhớ. Một, strlen đếm số ký tự thật, không kể \0, nên strlen của
"Hello" bằng 5. Hai, strcpy(b, a) chép nội dung a sang b, đây là cách đúng để
gán một chuỗi. Ba, strcat nối thêm vào cuối b, nên b phải đủ rộng để chứa cả
phần nối. Bốn, strcmp trả về 0 khi hai chuỗi giống hệt; nhớ là 0 nghĩa là bằng, dễ
nhầm vì thường ta quen 0 là sai.
strcpy và strcat không kiểm tra kích thước đích. Nếu chuỗi nguồn dài hơn mảng
đích, chúng sẽ ghi tràn ra ngoài và phá bộ nhớ. Khi không chắc chắn, hãy dùng
strncpy và strncat có tham số giới hạn, hoặc tự kiểm tra độ dài trước.
Bên trong bốn hàm đó
Bốn hàm trên trông như phép màu, nhưng chúng chỉ là vòng lặp mà bạn hoàn toàn viết được. Hiểu chúng làm gì bên trong sẽ thấy ngay vì sao chúng nguy hiểm.
strlen(s) chạy một biến đếm từ 0 tới khi gặp byte 0 rồi trả về số bước đã đi, nên chi
phí của nó tỉ lệ với độ dài chuỗi chứ không tức thời như sizeof. Hệ quả: đừng viết
for (int i = 0; i < strlen(s); i++), vì mỗi vòng lại quét lại toàn bộ chuỗi một lần;
hãy tính strlen một lần vào biến n, hoặc so thẳng s[i] != '\0'. Và nếu byte 0 bị
mất, strlen không dừng ở đâu cả, nó đếm lang thang vào vùng nhớ lạ.
strcpy(d, s) chép từng byte từ s sang d, chép cả byte 0 ở cuối, rồi dừng. Nó chỉ
nhìn chuỗi nguồn để biết khi nào ngừng, tuyệt nhiên không liếc qua kích thước của d
lấy một lần. Đó chính là lỗ hổng. Bản có giới hạn là strncpy(d, s, n), nhưng nó có
bẫy: nếu nguồn dài bằng hoặc hơn n, hàm ghi đủ n byte rồi dừng mà không thêm byte
0, để lại một mảng đầy ký tự nhưng không phải chuỗi hợp lệ.
strcat(d, s) chạy dọc d tìm byte 0 để biết d kết thúc ở đâu, rồi chép s vào từ
đúng chỗ đó và đặt byte 0 mới ở cuối. Nghĩa là d phải đủ chỗ cho độ dài cũ cộng độ dài
mới cộng một. Người mới hay đếm thiếu vì chỉ nghĩ tới phần nối thêm mà quên phần đã có.
strcmp(a, b) so từng cặp ký tự cùng vị trí; gặp cặp đầu tiên khác nhau thì trả về hiệu
hai mã ký tự, đi hết mà không cặp nào khác thì trả về 0. Chính vì vậy 0 mới nghĩa là
bằng nhau, ngược với trực giác 0 là sai ta quen dùng.
Cách ghép chuỗi an toàn nhất là snprintf, hàm luôn nhận kích thước đích và luôn tự
đóng byte 0:
#include <stdio.h>
#include <string.h>
int main(void) {
char ho[20] = "Nguyen";
char ten[20] = "Van A";
char day_du[64];
// snprintf never writes past the given size and always terminates
snprintf(day_du, sizeof(day_du), "%s %s", ho, ten);
printf("[%s] len=%d\n", day_du, (int)strlen(day_du));
return 0;
}
Nếu kết quả dài hơn chỗ chứa, snprintf cắt bớt phần thừa nhưng vẫn đặt byte 0 đúng
chỗ: chuỗi thu được có thể thiếu nội dung, nhưng bộ nhớ không bao giờ hỏng.
Thứ nhất, if (strcmp(a, b)) đọc như là "nếu a bằng b" nhưng thật ra nghĩa là "nếu a
khác b", vì giá trị khác 0 mới là đúng. Muốn kiểm tra bằng nhau phải viết đủ
if (strcmp(a, b) == 0). Thứ hai, strcpy(d, s) chép s vào d chứ không phải chiều
ngược lại; thứ tự tham số giống phép gán d = s, hãy nhớ theo cách đó. Thứ ba, dùng
strcat lên một mảng vừa khai báo mà chưa khởi tạo là sai, vì mảng đó chưa có byte 0
nào để strcat tìm thấy điểm nối; phải gán d[0] = '\0'; trước đã.
Một, chuỗi luôn kết thúc bằng \0. Hai, mảng phải dư một ô cho \0. Ba, không gán
chuỗi bằng =, dùng strcpy. Bốn, không so sánh chuỗi bằng ==, dùng strcmp và
nhớ 0 là bằng.
Bài tập thực hành
Bài tập 1: tự cài đặt lại strlen
Viết hàm int myStrlen(const char s[]) tự cài đặt lại chức năng của strlen, không
dùng thư viện. Kiểm tra với vài chuỗi khác nhau.
Lời giải chi tiết
Duyệt từ chỉ số 0, đếm số ký tự, dừng khi gặp \0. Giá trị đếm được chính là độ dài,
không kể ký tự kết thúc.
#include <stdio.h>
int myStrlen(const char s[]) {
int len = 0;
while (s[len] != '\0') // count until the terminator
len++;
return len;
}
int main(void) {
printf("%d\n", myStrlen("Hello")); // 5
printf("%d\n", myStrlen("")); // 0
return 0;
}
Bài tập 2: đếm nguyên âm
Viết chương trình nhập một chuỗi rồi đếm số nguyên âm (a, e, i, o, u, cả
thường lẫn hoa) có trong chuỗi.
Lời giải chi tiết
Duyệt từng ký tự tới \0. Với mỗi ký tự, đổi về chữ thường rồi so với năm nguyên âm.
Có thể viết một hàm phụ kiểm tra để phần thân gọn hơn.
#include <stdio.h>
int isVowel(char c) {
if (c >= 'A' && c <= 'Z') c = c + 32; // to lower case
return c == 'a' || c == 'e' || c == 'i'
|| c == 'o' || c == 'u';
}
int main(void) {
char s[100] = "Hello World";
int count = 0;
for (int i = 0; s[i] != '\0'; i++)
if (isVowel(s[i])) count++;
printf("Vowels = %d\n", count); // Vowels = 3
return 0;
}
Bài tập 3: kiểm tra chuỗi đối xứng
Viết hàm kiểm tra một chuỗi có phải chuỗi đối xứng (palindrome) hay không (đọc
xuôi và đọc ngược giống nhau, ví dụ radar). Hàm trả về 1 nếu đối xứng, 0 nếu không.
Lời giải chi tiết
Tìm độ dài chuỗi, rồi dùng hai chỉ số: i từ đầu, j từ cuối. So từng cặp s[i] với
s[j]; nếu có một cặp khác nhau thì không đối xứng, trả về 0 ngay. Nếu hai chỉ số gặp
nhau mà mọi cặp đều khớp thì đối xứng, trả về 1.
#include <stdio.h>
#include <string.h>
int isPalindrome(const char s[]) {
int n = (int)strlen(s);
for (int i = 0, j = n - 1; i < j; i++, j--)
if (s[i] != s[j]) // a mismatched pair
return 0;
return 1;
}
int main(void) {
printf("%d\n", isPalindrome("radar")); // 1
printf("%d\n", isPalindrome("hello")); // 0
return 0;
}
Bài tập 4: nối chuỗi an toàn
Viết hàm int noiAnToan(char dich[], int suc_chua, const char nguon[]) nối chuỗi
nguon vào cuối dich, nhưng chỉ nối khi chắc chắn còn đủ chỗ. Hàm trả về 1 nếu nối
thành công, trả về 0 và không đụng gì tới dich nếu không đủ chỗ. Tham số suc_chua
là tổng số ô của mảng dich.
Lời giải chi tiết
Ý tưởng là làm đúng cái việc strcat cố tình bỏ qua: đếm trước khi ghi. Sau khi nối,
chuỗi mới dài len_d + len_n ký tự và cần thêm một ô cho byte 0, nên điều kiện an toàn
là len_d + len_n + 1 <= suc_chua. Nếu bất đẳng thức này sai, ta trả về 0 và không động
vào bộ nhớ. Khi đã chắc đủ chỗ thì việc nối rất đơn giản: chép từng ký tự của nguon
vào dich bắt đầu từ vị trí len_d, tức đúng chỗ byte 0 cũ đang nằm, rồi đặt byte 0
mới ở cuối.
#include <stdio.h>
#include <string.h>
// append nguon to dich only if it certainly fits
int noiAnToan(char dich[], int suc_chua, const char nguon[]) {
int len_d = (int)strlen(dich);
int len_n = (int)strlen(nguon);
if (len_d + len_n + 1 > suc_chua) // one extra cell for the terminator
return 0; // refuse instead of overflowing
for (int i = 0; nguon[i] != '\0'; i++)
dich[len_d + i] = nguon[i]; // write starting at the old terminator
dich[len_d + len_n] = '\0'; // close the new string
return 1;
}
int main(void) {
char buf[12] = "Hello";
printf("%d %s\n", noiAnToan(buf, sizeof(buf), ", C"), buf); // 1 Hello, C
printf("%d %s\n", noiAnToan(buf, sizeof(buf), " world!"), buf); // 0 Hello, C
return 0;
}
Hãy đọc kỹ lần gọi thứ hai. Lúc đó buf đang chứa Hello, C dài 8 ký tự, mảng có 12
ô, phần muốn nối thêm dài 7 ký tự. Ta cần 8 + 7 + 1 = 16 ô nhưng chỉ có 12, nên hàm
từ chối và trả về 0. Nếu gọi strcat(buf, " world!") thay vào đó, chương trình sẽ lặng
lẽ ghi tràn ra ngoài mảng và có thể phá một biến khác. So sánh hai hành vi này để thấy
rõ giá trị của việc tự kiểm tra độ dài.
Câu hỏi tự kiểm
- 1strlen("Hello") trả về giá trị nào?
- 2strcmp(a, b) trả về 0 nghĩa là gì?
- 3Để lưu chuỗi "Hello", mảng char cần kích thước tối thiểu bao nhiêu ô?
- 4Vì sao nên dùng fgets thay cho scanf("%s", ...) khi nhập chuỗi?
Tóm tắt bài học
Chuỗi trong C là mảng char cộng một quy ước duy nhất: byte 0 đánh dấu chỗ hết chuỗi.
Mọi hàm chuẩn đều tin vào quy ước ấy, nên mảng chứa chuỗi n ký tự phải có ít nhất
n + 1 ô. Kích thước mảng và độ dài chuỗi là hai con số khác nhau, hỏi bằng sizeof và
strlen tương ứng.
C không có kiểu chuỗi thật sự vì nó ưu tiên tốc độ và quyền kiểm soát bộ nhớ, chấp nhận
đẩy trách nhiệm kiểm tra sang lập trình viên. Cái giá là chuỗi không gán được bằng =,
không so sánh được bằng ==, và mọi hàm trong string.h đều sẵn sàng ghi tràn ra ngoài
mảng nếu ta đưa cho chúng dữ liệu quá dài. Tràn bộ đệm không báo lỗi, nó âm thầm đè lên
biến khác, nên vừa khó gỡ vừa là lỗ hổng bảo mật.
Thói quen cần rèn từ bài này: nhập chuỗi bằng fgets với sizeof, ghép chuỗi bằng
snprintf, so sánh bằng strcmp và nhớ 0 nghĩa là bằng, và trước mỗi lần ghi vào mảng
hãy tự hỏi mảng đó có đủ chỗ hay không.