Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 11.320 phút đọc

Nhập chuỗi

Sau bài này bạn sẽ làm được

  • Nhập chuỗi an toàn bằng fgets
  • Xóa ký tự xuống dòng còn sót đúng cách
  • Xử lý trường hợp dòng nhập dài hơn bộ đệm
  • Biết giới hạn của scanf với định dạng chuỗi

Nhập chuỗi là nơi C nguy hiểm nhất. Một hàm của thư viện chuẩn nguy hiểm tới mức chuẩn C11 đã xóa hẳn nó khỏi ngôn ngữ, chuyện chưa từng xảy ra với hàm nào khác. Bài này chỉ ra cách nhập chuỗi đúng, và đủ sâu để bạn xử lý được mọi trường hợp biên.

#gets đã bị xóa khỏi chuẩn

char s[10];

gets(s);      /* KHÔNG BAO GIỜ DÙNG. Đã bị xóa khỏi C11. */

#fgets, cách đúng

char *fgets(char *s, int n, FILE *f);
Tham sốÝ nghĩa
char *sBộ đệm nhận dữ liệu
int nKích thước bộ đệm, tính cả byte kết thúc
FILE *fLuồng đọc, dùng stdin cho bàn phím
Hành viChi tiết
Đọc nhiều nhấtn trừ 1 ký tự, chừa một ô cho byte kết thúc
Dừng khiGặp ký tự xuống dòng, hoặc đọc đủ n trừ 1 ký tự, hoặc hết dữ liệu
Giữ ký tự xuống dòngCó. Nó nằm trong bộ đệm nếu còn chỗ
Luôn thêm byte kết thúcCó, khi hàm thành công
Trả vềCon trỏ s khi thành công, NULL khi hết dữ liệu hoặc lỗi
doc.c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char ten[64];

    printf("Nhap ten: ");

    if (fgets(ten, sizeof ten, stdin) == NULL) {
        fprintf(stderr, "Khong doc duoc\n");
        return 1;
    }

    ten[strcspn(ten, "\n")] = '\0';      /* xóa ký tự xuống dòng */

    printf("Xin chao [%s], do dai %zu\n", ten, strlen(ten));

    return 0;
}
terminal
./doc
Nhap ten: Nguyen Van A
Xin chao [Nguyen Van A], do dai 12

#Xóa ký tự xuống dòng

Hàm fgets giữ lại ký tự xuống dòng, và nó gần như luôn gây phiền toái. So sánh chuỗi sẽ thất bại, in ra bị xuống dòng thừa, độ dài lớn hơn một so với mong đợi.

Cách xóaViết thế nàoĐánh giá
Dùng strcspns[strcspn(s, "\n")] = '\0';Gọn nhất, an toàn cả khi không có ký tự xuống dòng
Kiểm tra ô cuốiif (len > 0 && s[len-1] == '\n') s[len-1] = 0;Rõ ràng, cần gọi strlen trước
Dùng strchrchar *p = strchr(s, '\n'); if (p) *p = 0;Cũng tốt, hai dòng
Xóa vô điều kiệns[strlen(s)-1] = '\0';SAI, hỏng khi chuỗi rỗng hoặc khi dòng bị cắt
Xóa vô điều kiện
fgets(s, sizeof s, stdin);

s[strlen(s) - 1] = '\0';

/* Hai vấn đề:
   1. Nếu s rỗng thì strlen(s) - 1 với size_t cho ra số khổng lồ.
   2. Nếu dòng nhập dài hơn bộ đệm, ô cuối là ký tự thật, bị xóa oan. */
Chỉ xóa khi thật sự là ký tự xuống dòng
if (fgets(s, sizeof s, stdin) == NULL) {
    /* xử lý hết dữ liệu */
}

s[strcspn(s, "\n")] = '\0';

/* strcspn trả về vị trí ký tự đầu tiên thuộc tập cho trước,
   hoặc độ dài chuỗi nếu không tìm thấy. Cả hai trường hợp đều đúng. */

#Khi dòng nhập dài hơn bộ đệm

Nếu người dùng gõ nhiều ký tự hơn bộ đệm chứa được, fgets lấy phần đầu và để phần còn lại nằm nguyên trong luồng. Lần gọi tiếp theo sẽ đọc tiếp phần đó, chứ không phải dòng mới.

qua-dai.c
#include <stdio.h>
#include <string.h>

int main(void)
{
    char a[8], b[8];

    fgets(a, sizeof a, stdin);
    fgets(b, sizeof b, stdin);

    printf("a = [%s]\n", a);
    printf("b = [%s]\n", b);

    return 0;
}
terminal
printf 'abcdefghijklmn\nxyz\n' | ./qua-dai
a = [abcdefg]
b = [hijklmn]

Bộ đệm thứ hai nhận phần dư của dòng thứ nhất, còn dòng xyz chưa được đọc lần nào. Với chương trình có menu, hiện tượng này gây ra chuỗi phản ứng khó hiểu.

phat-hien.c
/* Phát hiện dòng bị cắt và bỏ phần dư. */
int doc_dong(char *s, size_t co, FILE *f)
{
    if (fgets(s, (int)co, f) == NULL)
        return -1;                       /* hết dữ liệu hoặc lỗi */

    size_t len = strlen(s);

    if (len > 0 && s[len - 1] == '\n') {
        s[len - 1] = '\0';
        return 0;                        /* đọc trọn một dòng */
    }

    /* Không có ký tự xuống dòng nghĩa là dòng còn dài hơn bộ đệm.
       Bỏ phần dư để lần gọi sau bắt đầu ở dòng mới. */
    int c;

    while ((c = fgetc(f)) != '\n' && c != EOF)
        ;

    return 1;                            /* báo cho người gọi biết đã bị cắt */
}

#scanf với chuỗi

char s[10];

scanf("%s", s);        /* NGUY HIỂM: không giới hạn độ dài, giống gets */
scanf("%9s", s);       /* an toàn: đọc nhiều nhất 9 ký tự cộng byte kết thúc */
scanf với %sfgets
Đọc tới đâuTới khoảng trắng đầu tiênTới hết dòng
Đọc được chuỗi có dấu cáchKhông
Giới hạn độ dàiPhải viết cứng vào chuỗi định dạngTruyền bằng tham số
Giữ ký tự xuống dòngKhông, nhưng để lại trong luồngCó, trong bộ đệm
Bỏ qua khoảng trắng đầuKhông

#Một hàm đọc dòng dùng được

doc-dong.c
#include <stdio.h>
#include <string.h>

/* Đọc một dòng vào s, bỏ ký tự xuống dòng, bỏ phần dư nếu dòng quá dài.
   Trả về:
      0  đọc trọn một dòng
      1  dòng dài hơn bộ đệm, phần dư đã bị bỏ
     -1  hết dữ liệu hoặc lỗi                              */
int doc_dong(char *s, size_t co, FILE *f)
{
    if (s == NULL || co == 0 || f == NULL) return -1;

    if (fgets(s, (int)co, f) == NULL) {
        s[0] = '\0';
        return -1;
    }

    size_t len = strlen(s);

    if (len > 0 && s[len - 1] == '\n') {
        s[len - 1] = '\0';
        return 0;
    }

    int c, bi_cat = 0;

    while ((c = fgetc(f)) != '\n' && c != EOF)
        bi_cat = 1;

    return bi_cat ? 1 : 0;
}

int main(void)
{
    char dong[16];
    int  kq;

    while ((kq = doc_dong(dong, sizeof dong, stdin)) != -1) {
        printf("[%s]%s\n", dong, kq == 1 ? "  (bi cat)" : "");
    }

    return 0;
}
terminal
printf 'ngan\nmot dong rat dai vuot bo dem\n\n' | ./doc-dong
[ngan]
[mot dong rat da]  (bi cat)
[]

Trên Linux có getline

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

char  *dong = NULL;
size_t co = 0;
ssize_t n;

while ((n = getline(&dong, &co, stdin)) != -1) {
    if (n > 0 && dong[n - 1] == '\n') dong[n - 1] = '\0';

    printf("[%s]\n", dong);
}

free(dong);

Hàm getline tự cấp phát bộ đệm và tự nới rộng khi dòng dài, nên không bao giờ bị cắt. Nhược điểm là nó thuộc chuẩn POSIX chứ không phải chuẩn C, nên không có trên Windows nếu không dùng thư viện tương thích. Chương 14 sẽ giải thích phần cấp phát động của nó.

Tự làm thử

  1. Viết chương trình đọc tên bằng fgets, xóa ký tự xuống dòng và in độ dài. Thử với tên có dấu cách.
  2. Nhập một dòng dài hơn bộ đệm, gọi fgets hai lần liên tiếp và quan sát bộ đệm thứ hai nhận gì.
  3. Cài doc_dong đầy đủ như trong bài, thử với cả năm trường hợp biên trong danh sách.
  4. Viết chương trình dùng scanf đọc số rồi fgets đọc chuỗi để tái hiện lỗi trộn hai hàm, sau đó sửa bằng cách chỉ dùng fgets.
  5. So sánh scanf("%s", s) fgets(s, sizeof s, stdin) khi nhập chuỗi có dấu cách.
  6. Viết chương trình đọc toàn bộ dữ liệu vào theo từng dòng, đếm số dòng, số từ và số ký tự, giống lệnh wc.

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

  • Không bao giờ dùng gets. Nó đã bị xóa khỏi chuẩn C11 vì không thể dùng an toàn.
  • fgets nhận kích thước bộ đệm đầy đủ, luôn thêm byte kết thúc, và giữ lại ký tự xuống dòng.
  • Xóa ký tự xuống dòng bằng s[strcspn(s, "\n")] = 0;, không bao giờ bằng s[strlen(s) - 1] = 0;.
  • Dòng dài hơn bộ đệm để lại phần dư trong luồng, phải chủ động bỏ đi bằng vòng lặp có kiểm tra hết dữ liệu.
  • Với scanf, độ rộng trường là số ký tự nên phải viết %9s cho mảng mười ô. Tốt hơn là dùng fgets rồi sscanf.