Chuyển tới nội dung chính

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.

Duyệt chuỗi tới ký tự kết thúc C
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}
Ngăn xếp stack
countLower()
s[0]= 'H's[1]= 'e's[2]= 'l's[3]= 'l's[4]= 'o's[5]= '\0'count= 0
Bộ nhớ động heap
(trống)
Vào hàm với chuỗi "Hello". Trong bộ nhớ nó là 6 ô: H, e, l, l, o và ký tự kết thúc \0 ở cuối. Khởi tạo count = 0.
1/9

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'
Luôn dư một ô

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.

Đừng mang thói quen C++ sang C

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 hàm gets

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.

Ba nguồn tràn bộ đệm hay gặp nhất

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àmtrả 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)intso 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

strcpystrcat 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 strncpystrncat 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.

Ba nhầm lẫn kinh điển về string.h

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 đã.

Bốn quy tắc vàng về chuỗi

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

Kiểm tra nhanh: chuỗi ký tự và string.h0/4 đúngchưa trả lời
  1. 1strlen("Hello") trả về giá trị nào?
  2. 2strcmp(a, b) trả về 0 nghĩa là gì?
  3. 3Để lưu chuỗi "Hello", mảng char cần kích thước tối thiểu bao nhiêu ô?
  4. 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 sizeofstrlen 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.