Các lỗi nhập liệu thường gặp
Sau bài này bạn sẽ làm được
- Xử lý ký tự xuống dòng còn sót lại trong bộ đệm
- Tránh vòng lặp vô hạn khi scanf thất bại
- Giải thích vì sao không nên trộn scanf với fgets
- Chọn đúng cách nhập cho từng loại dữ liệu
Năm lỗi trong bài này chiếm phần lớn số câu hỏi mà người học C đăng lên các diễn đàn. Chúng có chung một nguyên nhân gốc: bộ đệm nhập liệu là một dòng chảy liên tục, không phải một loạt ô nhập riêng biệt như bạn tưởng tượng.
#1. Ký tự xuống dòng còn sót
Khi bạn gõ 20 rồi bấm phím Enter, bộ đệm nhận được ba ký tự: 2, 0, và ký tự xuống dòng. Đặc tả %d chỉ lấy hai ký tự số, ký tự xuống dòng vẫn nằm nguyên đó.
#include <stdio.h>
int main(void)
{
int tuoi;
char gioi_tinh;
printf("Nhập tuổi: ");
scanf("%d", &tuoi);
printf("Giới tính (M/F): ");
scanf("%c", &gioi_tinh); /* đọc ngay ký tự xuống dòng còn sót */
printf("Tuổi %d, giới tính %c\n", tuoi, gioi_tinh);
return 0;
}Nhập tuổi: 20 Giới tính (M/F): Tuổi 20, giới tính
Ba cách sửa
/* Cách 1: thêm dấu cách trước %c, gọn nhất */
scanf(" %c", &gioi_tinh);
/* Cách 2: dọn sạch phần còn lại của dòng, chắc chắn nhất */
int c;
while ((c = getchar()) != '\n' && c != EOF) {
/* nuốt hết tới cuối dòng */
}
/* Cách 3: không dùng scanf nữa, dùng fgets cho tất cả */#2. Trộn scanf với fgets
Đây là hệ quả trực tiếp của lỗi thứ nhất, nhưng khó nhận ra hơn nhiều vì fgets trông có vẻ an toàn.
int tuoi;
char ten[64];
printf("Tuổi: ");
scanf("%d", &tuoi); /* để lại ký tự xuống dòng */
printf("Họ tên: ");
fgets(ten, sizeof ten, stdin);
/* fgets đọc ngay ký tự xuống dòng và trả về chuỗi rỗng.
Chương trình không chờ người dùng nhập tên. */int tuoi;
char ten[64];
char dong[128];
printf("Tuổi: ");
fgets(dong, sizeof dong, stdin);
sscanf(dong, "%d", &tuoi); /* phân tích trên chuỗi, bộ đệm đã sạch */
printf("Họ tên: ");
fgets(ten, sizeof ten, stdin);
ten[strcspn(ten, "\n")] = '\0';#3. Tràn bộ đệm
char buf[10];
scanf("%s", buf);
/* Người dùng nhập 50 ký tự.
40 byte bị ghi ra NGOÀI vùng nhớ của buf,
đè lên biến khác hoặc lên địa chỉ trở về của hàm. */Điều nguy hiểm là chương trình thường không dừng ngay. Nó chạy tiếp với dữ liệu đã bị hỏng, và lỗi xuất hiện ở một chỗ hoàn toàn khác, rất khó lần ra nguyên nhân.
/* Ba cách an toàn, theo thứ tự ưu tiên */
fgets(buf, sizeof buf, stdin); /* tốt nhất */
scanf("%9s", buf); /* chấp nhận được, nhớ trừ 1 */
scanf("%s", buf); /* không bao giờ */==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 1 at 0x7ffd...
#0 in main bai.c:9#4. Vòng lặp vô hạn
Khi scanf gặp dữ liệu không khớp định dạng, nó dừng lại và không tiêu thụ ký tự gây lỗi. Ký tự đó vẫn nằm trong bộ đệm, nên lần lặp sau lại gặp đúng nó và lại thất bại.
int n;
while (1) {
printf("Nhập số: ");
if (scanf("%d", &n) == 1) {
break;
}
printf("Sai rồi\n");
/* Người dùng gõ "abc".
scanf trả về 0, ký tự 'a' vẫn nằm trong bộ đệm.
Vòng lặp chạy lại, gặp 'a', lại trả về 0.
In "Sai rồi" vô hạn lần. */
}while (scanf("%d", &n) != 1) {
printf("Sai rồi\n");
}
/* Vòng lặp vô hạn khi nhập chữ */while (scanf("%d", &n) != 1) {
printf("Sai rồi\n");
int c;
while ((c = getchar()) != '\n' && c != EOF) {
/* phải dọn dữ liệu hỏng */
}
}#5. Quên bỏ ký tự xuống dòng
Lỗi này chỉ xảy ra với fgets, và biểu hiện của nó thường là so sánh chuỗi không bao giờ đúng.
char lenh[32];
while (1) {
printf("> ");
if (!fgets(lenh, sizeof lenh, stdin)) {
break;
}
if (strcmp(lenh, "thoat") == 0) { /* KHÔNG BAO GIỜ đúng */
break;
}
printf("Lệnh không hiểu: %s", lenh);
}Chuỗi thật sự nằm trong lenh là thoat kèm ký tự xuống dòng, nên nó không bằng thoat. Thêm một dòng là xong:
lenh[strcspn(lenh, "\n")] = '\0';#Bảng tổng kết
| Cần nhập | Cách đúng |
|---|---|
| Số nguyên | fgets rồi strtol, kiểm tra end và errno |
| Số thực | fgets rồi strtod |
| Một từ | fgets rồi sscanf(dong, "%49s", tu) |
| Cả dòng có khoảng trắng | fgets rồi s[strcspn(s, "\n")] = 0 |
| Một ký tự | fgets rồi lấy dong[0] |
| Nhiều trường một dòng | fgets rồi sscanf, kiểm tra số mục trả về |
Khuôn mẫu nhập liệu hoàn chỉnh
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
/* Đọc một dòng, bỏ ký tự xuống dòng, dọn phần thừa nếu dòng quá dài. */
static int doc_dong(char *buf, size_t n)
{
if (!fgets(buf, (int)n, stdin)) {
return -1;
}
size_t len = strcspn(buf, "\n");
if (buf[len] != '\n') {
int c;
while ((c = getchar()) != '\n' && c != EOF) {
}
}
buf[len] = '\0';
return 0;
}
/* Đọc số nguyên trong khoảng cho trước, hỏi lại tới khi đúng. */
static long doc_so(const char *nhac, long min, long max)
{
char dong[128];
for (;;) {
printf("%s", nhac);
if (doc_dong(dong, sizeof dong) != 0) {
fprintf(stderr, "\nHết dữ liệu vào\n");
exit(EXIT_FAILURE);
}
errno = 0;
char *end;
long v = strtol(dong, &end, 10);
if (end == dong || *end != '\0') {
printf("Phải nhập một số nguyên.\n");
continue;
}
if (errno == ERANGE || v < min || v > max) {
printf("Giá trị phải nằm trong khoảng %ld đến %ld.\n", min, max);
continue;
}
return v;
}
}
int main(void)
{
long tuoi = doc_so("Nhập tuổi (0 đến 150): ", 0, 150);
printf("Tuổi của bạn là %ld\n", tuoi);
return 0;
}Tự làm thử
- Tái hiện lỗi số 1: viết chương trình nhập tuổi rồi nhập giới tính bằng
%ckhông có dấu cách. Sau đó sửa bằng cả ba cách. - Tái hiện lỗi số 2: nhập số bằng
scanfrồi nhập tên bằngfgets. Quan sát rồi sửa. - Tái hiện lỗi số 4 và đếm xem chương trình in ra bao nhiêu dòng trước khi bạn phải bấm tổ hợp phím dừng.
- Viết chương trình dùng bộ đệm 8 byte, nhập một dòng 30 ký tự, gọi
fgetsba lần và in kết quả từng lần. - Chép khuôn mẫu nhập liệu ở trên vào file riêng, rồi viết chương trình menu dùng nó để nhập lựa chọn.
- Kiểm chứng lỗi tràn bộ đệm bằng
-fsanitize=addressvà đọc kỹ báo cáo.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Bộ đệm nhập liệu là một dòng chảy liên tục, phím Enter cũng là một ký tự nằm trong đó.
- Sau
scanf("%d"), ký tự xuống dòng vẫn còn, nên%choặcfgetstiếp theo sẽ đọc phải nó. - Không bao giờ gọi
fflush(stdin), đó là hành vi không xác định. - scanf thất bại thì không tiêu thụ ký tự gây lỗi, nên phải tự dọn nếu không muốn vòng lặp vô hạn.
- Chọn một cách nhập và dùng nhất quán. Trộn scanf với fgets chắc chắn sinh bug.
- fgets kèm strtol hoặc sscanf xử lý được cả năm lỗi trong bài.