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

Nhập chuỗi bằng fgets

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

  • Đọc chuỗi an toàn bằng fgets và hiểu ba giá trị trả về của nó
  • Bỏ ký tự xuống dòng đúng cách bằng strcspn
  • Viết được hàm nhập số nguyên an toàn bằng fgets kèm strtol
  • Giải thích vì sao gets bị xóa hẳn khỏi chuẩn C11

Đây là bài quan trọng về mặt thực hành. Cách nhập liệu trình bày ở đây sẽ được dùng trong mọi bài tập và mọi dự án từ giờ tới hết khóa học. Nó dài dòng hơn scanf vài dòng, nhưng đổi lại là an toàn và có thể báo lỗi chính xác.

#Vì sao dùng fgets

Bài trước đã chỉ ra bốn vấn đề của scanf. Gốc rễ của cả bốn nằm ở một chỗ: scanf trộn lẫn hai việc vốn nên tách rời.

Việcscanffgets kèm phân tích
Lấy dữ liệu vàoĐọc bao nhiêu tùy theo định dạng, khó đoánLuôn đọc đúng một dòng
Phân tích dữ liệuLàm cùng lúc với việc đọcLàm riêng, trên chuỗi đã nằm trong bộ nhớ
Khi dữ liệu saiBộ đệm ở trạng thái khó lườngCả dòng đã tiêu thụ gọn, chỉ cần báo lỗi
Chuỗi có khoảng trắngCần tập ký tựTự nhiên
Giới hạn tràn bộ đệmPhải nhớ ghi độ rộngLà tham số bắt buộc

#Cách dùng và giá trị trả về

char *fgets(char *s, int n, FILE *stream);

Hàm đọc tối đa n - 1 ký tự vào s, hoặc dừng sớm nếu gặp ký tự xuống dòng. Sau đó nó tự thêm ký tự kết thúc chuỗi.

  • Ký tự xuống dòng, nếu có, được giữ lại trong chuỗi. Đây là điểm quan trọng nhất cần nhớ.
  • Trả về con trỏ tới s nếu đọc được, hoặc con trỏ rỗng nếu gặp cuối luồng hay lỗi mà chưa đọc được ký tự nào.
  • Tham số nkích thước bộ đệm, tính cả chỗ cho ký tự kết thúc. Nên luôn truyền sizeof buf.
doc-ten.c
#include <stdio.h>
#include <string.h>

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

    printf("Nhập họ tên: ");

    if (!fgets(ten, sizeof ten, stdin)) {
        fprintf(stderr, "Không đọc được dữ liệu\n");

        return 1;
    }

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

    printf("Xin chào, %s (dài %zu ký tự)\n", ten, strlen(ten));

    return 0;
}
terminal
./doc-ten
Nhập họ tên: Nguyễn Văn A
Xin chào, Nguyễn Văn A (dài 17 ký tự)

#Bỏ ký tự xuống dòng

Việc fgets giữ lại ký tự xuống dòng gây ra một lỗi rất hay gặp: so sánh chuỗi không bao giờ đúng.

Không nên
char lenh[32];

fgets(lenh, sizeof lenh, stdin);

if (strcmp(lenh, "thoat") == 0) {   /* KHÔNG BAO GIỜ đúng */
    return 0;
}
/* Vì lenh thật ra là "thoat\n" chứ không phải "thoat" */
Nên
char lenh[32];

fgets(lenh, sizeof lenh, stdin);
lenh[strcspn(lenh, "\n")] = '\0';   /* bỏ ký tự xuống dòng trước */

if (strcmp(lenh, "thoat") == 0) {    /* giờ mới đúng */
    return 0;
}
strcspn
Hàm này trả về độ dài đoạn đầu của chuỗi không chứa bất kỳ ký tự nào trong tập cho trước. Nếu chuỗi có ký tự xuống dòng, nó trả về vị trí của ký tự đó. Nếu không có, nó trả về độ dài cả chuỗi, và khi đó phép gán ghi đúng vào vị trí ký tự kết thúc cũ, tức là không đổi gì cả. Nhờ vậy một dòng lệnh xử lý được cả hai trường hợp.

#Xử lý dòng quá dài

Nếu người dùng nhập dòng dài hơn bộ đệm, fgets chỉ lấy phần đầu và để phần còn lại nằm trong bộ đệm. Lần đọc tiếp theo sẽ nhận được phần thừa đó, gây ra hành vi rất khó hiểu.

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

/* Trả về độ dài dòng nếu thành công.
   Trả về -1 nếu hết dữ liệu.
   Trả về -2 nếu dòng bị cắt vì quá dài. */
int nhap_dong(char *buf, size_t n)
{
    if (!fgets(buf, (int)n, stdin)) {
        return -1;
    }

    size_t len = strcspn(buf, "\n");

    if (buf[len] != '\n') {          /* không tìm thấy xuống dòng: dòng bị cắt */
        int c;

        while ((c = getchar()) != '\n' && c != EOF) {
            /* nuốt hết phần thừa để lần đọc sau sạch sẽ */
        }
        buf[len] = '\0';

        return -2;
    }

    buf[len] = '\0';

    return (int)len;
}

#Nhập số an toàn

Đây là hàm bạn nên chép vào một file tiện ích và dùng lại suốt khóa học. Nó xử lý đủ mọi trường hợp mà scanf bỏ sót.

  1. Đọc cả dòng

    Dùng fgets nên luôn tiêu thụ đúng một dòng, dù dữ liệu đúng hay sai.

  2. Chuyển đổi bằng strtol

    Khác với atoi, hàm strtol cho biết nó dừng ở đâu và có tràn hay không.

  3. Kiểm tra ba loại lỗi

    Không có chữ số nào, có ký tự rác phía sau, và giá trị vượt miền cho phép.

nhap-so.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

/* Trả về 0 nếu thành công, số âm nếu lỗi. */
int nhap_so_nguyen(const char *nhac, long *out)
{
    char dong[128];

    printf("%s", nhac);

    if (!fgets(dong, sizeof dong, stdin)) {
        return -1;                       /* hết dữ liệu */
    }

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

    errno = 0;

    char *end;
    long v = strtol(dong, &end, 10);

    if (end == dong) {
        return -2;                       /* không có chữ số nào */
    }

    while (*end == ' ' || *end == '\t') {
        ++end;                           /* bỏ qua khoảng trắng cuối */
    }

    if (*end != '\0') {
        return -3;                       /* còn ký tự rác, ví dụ 12abc */
    }

    if (errno == ERANGE) {
        return -4;                       /* vượt miền của long */
    }

    *out = v;

    return 0;
}

int main(void)
{
    long tuoi;

    while (nhap_so_nguyen("Nhập tuổi: ", &tuoi) != 0) {
        printf("Dữ liệu không hợp lệ, mời nhập lại.\n");
    }

    if (tuoi < 0 || tuoi > 150) {
        printf("Tuổi phải nằm trong khoảng 0 đến 150.\n");

        return 1;
    }

    printf("Tuổi của bạn là %ld\n", tuoi);

    return 0;
}

#Vì sao gets bị xóa

char *gets(char *s);   /* ĐÃ BỊ XÓA khỏi chuẩn C11 */

Hàm này đọc một dòng vào bộ đệm, nhưng không nhận tham số kích thước. Nghĩa là không có cách nào để nó biết bộ đệm rộng bao nhiêu, và không có cách nào dùng nó an toàn.

Cần nhậpCách đúng
Một dòng văn bảnfgets rồi bỏ ký tự xuống dòng bằng strcspn
Số nguyênfgets rồi strtol, kiểm tra đủ ba loại lỗi
Số thựcfgets rồi strtod
Một từfgets rồi sscanf với %63s
Một ký tựfgets rồi lấy phần tử đầu tiên của chuỗi
Nhiều trường trên một dòngfgets rồi sscanf

Tự làm thử

  1. Viết hàm nhap_dong đầy đủ như ở trên, rồi kiểm chứng bằng cách nhập một dòng dài hơn bộ đệm.
  2. Chép hàm nhap_so_nguyen vào file riêng, rồi kiểm thử với các đầu vào: 42, abc, 12abc, chuỗi rỗng, và 99999999999999999999.
  3. Viết hàm nhap_so_thuc tương tự dùng strtod.
  4. Viết hàm nhập số nguyên có kiểm tra miền giá trị, nhận thêm hai tham số cận dưới và cận trên, tự hỏi lại khi ngoài miền.
  5. Chứng minh vấn đề của fgets khi dòng quá dài: dùng bộ đệm 8 byte, nhập một dòng 30 ký tự, gọi fgets ba lần liên tiếp và quan sát.

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

  • fgets luôn tiêu thụ đúng một dòng, nên tách được việc đọc khỏi việc phân tích.
  • giữ lại ký tự xuống dòng, phải bỏ bằng s[strcspn(s, "\n")] = 0.
  • Dòng dài hơn bộ đệm sẽ để phần thừa lại, phải chủ động nuốt hết.
  • Nhập số an toàn là fgets kèm strtol, kiểm tra đủ ba lỗi: không có chữ số, còn ký tự rác, và tràn miền.
  • Không dùng atoi vì nó không báo lỗi được, và không bao giờ dùng gets vì nó đã bị xóa khỏi chuẩn.