Tại sao, trong khi (trong khi (!) (Tập tin)) thì luôn luôn sai?


573

Gần đây tôi đã thấy những người cố gắng đọc các tệp như thế này trong rất nhiều bài đăng:

#include <stdio.h>
#include <stdlib.h>

int
main(int argc, char **argv)
{
    char *path = "stdin";
    FILE *fp = argc > 1 ? fopen(path=argv[1], "r") : stdin;

    if( fp == NULL ) {
        perror(path);
        return EXIT_FAILURE;
    }

    while( !feof(fp) ) {  /* THIS IS WRONG */
        /* Read and process data from file… */
    }
    if( fclose(fp) != 0 ) {
        perror(path);
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

Có gì sai với vòng lặp này?



Câu trả lời:


453

Tôi muốn cung cấp một viễn cảnh trừu tượng, cấp cao.

Đồng thời và tính đồng thời

Hoạt động I / O tương tác với môi trường. Môi trường không phải là một phần của chương trình của bạn và không thuộc quyền kiểm soát của bạn. Môi trường thực sự tồn tại "đồng thời" với chương trình của bạn. Như với tất cả mọi thứ đồng thời, các câu hỏi về "trạng thái hiện tại" không có ý nghĩa: Không có khái niệm về "tính đồng thời" trong các sự kiện đồng thời. Nhiều thuộc tính của nhà nước đơn giản là không tồn tại đồng thời.

Hãy để tôi nói điều này chính xác hơn: Giả sử bạn muốn hỏi, "bạn có nhiều dữ liệu hơn không". Bạn có thể yêu cầu điều này của một container đồng thời hoặc hệ thống I / O của bạn. Nhưng câu trả lời nói chung là không thể thực hiện được, và do đó vô nghĩa. Vậy điều gì sẽ xảy ra nếu container nói "có" - vào thời điểm bạn thử đọc, nó có thể không còn dữ liệu nữa. Tương tự, nếu câu trả lời là "không", vào thời điểm bạn thử đọc, dữ liệu có thể đã đến. Kết luận là đơn giản là cókhông có tài sản nào như "Tôi có dữ liệu", vì bạn không thể hành động có ý nghĩa để đáp lại bất kỳ câu trả lời nào có thể. (Tình hình tốt hơn một chút với đầu vào được đệm, trong đó bạn có thể hình dung được "có, tôi có dữ liệu" cấu thành một loại bảo đảm nào đó, nhưng bạn vẫn phải có thể xử lý trường hợp ngược lại. chắc chắn là tệ như tôi đã mô tả: bạn không bao giờ biết nếu đĩa đó hoặc bộ đệm mạng đó đã đầy.)

Vì vậy, chúng tôi kết luận rằng không thể, và trên thực tế là không hợp lý , để hỏi một hệ thống I / O liệu nó thể thực hiện thao tác I / O không. Cách khả thi duy nhất chúng ta có thể tương tác với nó (giống như với một thùng chứa đồng thời) là thử hoạt động và kiểm tra xem nó đã thành công hay thất bại. Tại thời điểm đó, nơi bạn tương tác với môi trường, sau đó và chỉ sau đó bạn mới có thể biết liệu tương tác có thực sự khả thi hay không, và tại thời điểm đó, bạn phải cam kết thực hiện tương tác. (Đây là "điểm đồng bộ hóa", nếu bạn muốn.)

EOF

Bây giờ chúng ta đến EOF. EOF là phản hồi bạn nhận được từ thao tác I / O đã thử . Điều đó có nghĩa là bạn đã cố đọc hoặc viết một cái gì đó, nhưng khi làm như vậy bạn không thể đọc hoặc ghi bất kỳ dữ liệu nào, và thay vào đó, kết thúc đầu vào hoặc đầu ra đã gặp phải. Điều này đúng cho tất cả các API I / O, cho dù đó là thư viện chuẩn C, iostreams C ++ hoặc các thư viện khác. Miễn là các hoạt động I / O thành công, bạn chỉ đơn giản là không thể biết liệu các hoạt động trong tương lai có thành công hay không. Trước tiên, bạn phải luôn luôn thử hoạt động và sau đó đáp ứng thành công hay thất bại.

Ví dụ

Trong mỗi ví dụ, lưu ý cẩn thận rằng trước tiên chúng tôi thử thao tác I / O và sau đó sử dụng kết quả nếu nó hợp lệ. Lưu ý thêm rằng chúng ta luôn phải sử dụng kết quả của thao tác I / O, mặc dù kết quả có các hình dạng và hình thức khác nhau trong mỗi ví dụ.

  • C stdio, đọc từ một tập tin:

    for (;;) {
        size_t n = fread(buf, 1, bufsize, infile);
        consume(buf, n);
        if (n < bufsize) { break; }
    }

    Kết quả chúng ta phải sử dụng là n, số lượng phần tử đã được đọc (có thể chỉ bằng 0).

  • C stdio, scanf:

    for (int a, b, c; scanf("%d %d %d", &a, &b, &c) == 3; ) {
        consume(a, b, c);
    }

    Kết quả chúng ta phải sử dụng là giá trị trả về của scanf, số phần tử được chuyển đổi.

  • C ++, iostreams định dạng trích xuất:

    for (int n; std::cin >> n; ) {
        consume(n);
    }

    Kết quả chúng ta phải sử dụng là std::cinchính nó, có thể được đánh giá trong bối cảnh boolean và cho chúng ta biết liệu luồng có còn ở good()trạng thái không.

  • C ++, iostreams getline:

    for (std::string line; std::getline(std::cin, line); ) {
        consume(line);
    }

    Kết quả chúng ta phải sử dụng là một lần nữa std::cin, giống như trước đây.

  • POSIX, write(2)để xóa bộ đệm:

    char const * p = buf;
    ssize_t n = bufsize;
    for (ssize_t k = bufsize; (k = write(fd, p, n)) > 0; p += k, n -= k) {}
    if (n != 0) { /* error, failed to write complete buffer */ }

    Kết quả chúng ta sử dụng ở đây là k, số byte được ghi. Vấn đề ở đây là chúng ta chỉ có thể biết có bao nhiêu byte được ghi sau thao tác ghi.

  • POSIX getline()

    char *buffer = NULL;
    size_t bufsiz = 0;
    ssize_t nbytes;
    while ((nbytes = getline(&buffer, &bufsiz, fp)) != -1)
    {
        /* Use nbytes of data in buffer */
    }
    free(buffer);

    Kết quả chúng ta phải sử dụng là nbytes, số lượng byte lên đến và bao gồm cả dòng mới (hoặc EOF nếu tệp không kết thúc bằng một dòng mới).

    Lưu ý rằng hàm trả về một cách rõ ràng -1(và không phải EOF!) Khi xảy ra lỗi hoặc nó đạt đến EOF.

Bạn có thể nhận thấy rằng chúng tôi rất hiếm khi đánh vần từ "EOF" thực tế. Chúng tôi thường phát hiện tình trạng lỗi theo một số cách khác thú vị hơn ngay lập tức (ví dụ: không thực hiện được nhiều I / O như chúng tôi mong muốn). Trong mọi ví dụ, có một số tính năng API có thể cho chúng ta biết rõ ràng rằng trạng thái EOF đã gặp phải, nhưng thực tế đây không phải là một thông tin hữu ích khủng khiếp. Đó là nhiều chi tiết hơn chúng ta thường quan tâm. Điều đáng quan tâm là liệu I / O đã thành công, hơn-hơn so với nó như thế nào thất bại.

  • Một ví dụ cuối cùng thực sự truy vấn trạng thái EOF: Giả sử bạn có một chuỗi và muốn kiểm tra xem nó có đại diện cho một số nguyên không, không có bit thừa ở cuối trừ khoảng trắng. Sử dụng iostreams C ++, nó diễn ra như sau:

    std::string input = "   123   ";   // example
    
    std::istringstream iss(input);
    int value;
    if (iss >> value >> std::ws && iss.get() == EOF) {
        consume(value);
    } else {
        // error, "input" is not parsable as an integer
    }

    Chúng tôi sử dụng hai kết quả ở đây. Đầu tiên là iss, chính đối tượng luồng, để kiểm tra xem quá trình trích xuất được định dạng valuethành công. Nhưng sau đó, sau khi sử dụng khoảng trắng, chúng tôi thực hiện một thao tác I / O / khác iss.get()và hy vọng nó sẽ thất bại dưới dạng EOF, đó là trường hợp nếu toàn bộ chuỗi đã được sử dụng bởi trích xuất được định dạng.

    Trong thư viện chuẩn C, bạn có thể đạt được một cái gì đó tương tự với các strto*lhàm bằng cách kiểm tra xem con trỏ cuối đã đến cuối chuỗi đầu vào chưa.

Câu trả lời

while(!feof)là sai bởi vì nó kiểm tra một cái gì đó không liên quan và không kiểm tra cho một cái gì đó mà bạn cần biết. Kết quả là bạn đang thực thi mã sai, giả sử rằng nó đang truy cập dữ liệu được đọc thành công, trong khi thực tế điều này không bao giờ xảy ra.


34
@CiaPan: Tôi không nghĩ đó là sự thật. Cả C99 và C11 đều cho phép điều này.
Kerrek SB

11
Nhưng ANSI C thì không.
CiaPan

3
@JonathanMee: Thật tệ vì tất cả những lý do tôi đề cập: bạn không thể nhìn vào tương lai. Bạn không thể nói những gì sẽ xảy ra trong tương lai.
Kerrek SB

3
@JonathanMee: Vâng, điều đó sẽ phù hợp, mặc dù thông thường bạn có thể kết hợp kiểm tra này vào hoạt động (vì hầu hết các hoạt động của iostream đều trả về đối tượng luồng, bản thân nó có chuyển đổi boolean) và theo cách đó bạn thấy rõ rằng bạn không phải là bỏ qua giá trị trả về.
Kerrek SB

4
Đoạn thứ ba là sai lệch đáng kể / không chính xác cho một câu trả lời được chấp nhận và đánh giá cao. feof()không "hỏi hệ thống I / O xem nó có nhiều dữ liệu hơn không". feof(), Theo (Linux) manpage : "kiểm tra chỉ số end-of-file cho dòng trỏ đến bởi dòng, trở về khác không nếu nó được thiết lập." (cũng vậy, một cuộc gọi rõ ràng clearerr()là cách duy nhất để đặt lại chỉ báo này); Về mặt này, câu trả lời của William Pursell tốt hơn nhiều.
Arne Vogel

234

Điều đó là sai bởi vì (trong trường hợp không có lỗi đọc), nó sẽ vào vòng lặp một lần nữa so với mong đợi của tác giả. Nếu có lỗi đọc, vòng lặp không bao giờ chấm dứt.

Hãy xem xét các mã sau đây:

/* WARNING: demonstration of bad coding technique!! */

#include <stdio.h>
#include <stdlib.h>

FILE *Fopen(const char *path, const char *mode);

int main(int argc, char **argv)
{
    FILE *in;
    unsigned count;

    in = argc > 1 ? Fopen(argv[1], "r") : stdin;
    count = 0;

    /* WARNING: this is a bug */
    while( !feof(in) ) {  /* This is WRONG! */
        fgetc(in);
        count++;
    }
    printf("Number of characters read: %u\n", count);
    return EXIT_SUCCESS;
}

FILE * Fopen(const char *path, const char *mode)
{
    FILE *f = fopen(path, mode);
    if( f == NULL ) {
        perror(path);
        exit(EXIT_FAILURE);
    }
    return f;
}

Chương trình này sẽ liên tục in một số lớn hơn số lượng ký tự trong luồng đầu vào (giả sử không có lỗi đọc). Hãy xem xét trường hợp luồng đầu vào trống:

$ ./a.out < /dev/null
Number of characters read: 1

Trong trường hợp này, feof()được gọi trước khi bất kỳ dữ liệu nào được đọc, vì vậy nó trả về false. Vòng lặp được nhập, fgetc()được gọi (và trả về EOF) và số lượng được tăng lên. Sau đó feof()được gọi và trả về true, khiến vòng lặp hủy bỏ.

Điều này xảy ra trong tất cả các trường hợp như vậy. feof()không trả về true cho đến khi đọc xong trên luồng gặp phần cuối của tệp. Mục đích của feof()KHÔNG phải là kiểm tra xem lần đọc tiếp theo có đến cuối tập tin hay không. Mục đích của feof()việc phân biệt giữa lỗi đọc và đã đến cuối tập tin. Nếu fread()trả về 0, bạn phải sử dụng feof/ ferrorđể quyết định xem có gặp phải lỗi hay không nếu tất cả dữ liệu đã được sử dụng. Tương tự nếu fgetctrả về EOF. feof()chỉ hữu ích sau khi fread đã trả về 0 hoặc fgetcđã trở lại EOF. Trước khi điều đó xảy ra, feof()sẽ luôn trả về 0.

Luôn luôn cần phải kiểm tra giá trị trả về của một lần đọc (hoặc fread(), hoặc fscanf(), hoặc fgetc()) trước khi gọi feof().

Thậm chí tệ hơn, hãy xem xét trường hợp xảy ra lỗi đọc. Trong trường hợp đó, fgetc()trả về EOF, feof()trả về false và vòng lặp không bao giờ kết thúc. Trong tất cả các trường hợp while(!feof(p))được sử dụng, ít nhất phải có một kiểm tra bên trong vòng lặp ferror(), hoặc ít nhất là phải thay thế điều kiện trong khi while(!feof(p) && !ferror(p))có một khả năng rất thực của một vòng lặp vô hạn, có thể phun ra tất cả các loại rác như dữ liệu không hợp lệ đang được xử lý.

Vì vậy, tóm lại, mặc dù tôi không thể khẳng định chắc chắn rằng không bao giờ có tình huống nào có thể đúng về mặt ngữ nghĩa để viết " while(!feof(f))" (mặc dù phải có một kiểm tra khác bên trong vòng lặp để tránh một vòng lặp vô hạn về lỗi đọc ), đó là trường hợp gần như chắc chắn luôn luôn sai. Và ngay cả khi một trường hợp đã phát sinh khi nó đúng, thì nó sai đến mức không thể là cách viết mã đúng. Bất cứ ai nhìn thấy mã đó nên ngay lập tức do dự và nói, "đó là một lỗi". Và có thể tát tác giả (trừ khi tác giả là sếp của bạn trong trường hợp nên thận trọng.)


7
Chắc chắn là nó sai - nhưng ngoài ra nó không "xấu xí".
tộc

89
Bạn nên thêm một ví dụ về mã chính xác, vì tôi tưởng tượng nhiều người sẽ đến đây để tìm cách khắc phục nhanh.
jleahy

6
@Thomas: Tôi không phải là chuyên gia về C ++, nhưng tôi tin rằng file.eof () trả về hiệu quả tương tự như feof(file) || ferror(file)vậy, vì vậy nó rất khác nhau. Nhưng câu hỏi này không có ý định áp dụng cho C ++.
William Pursell

6
@ m-ric điều đó cũng không đúng, bởi vì bạn vẫn sẽ cố xử lý một lần đọc thất bại.
Đánh dấu tiền chuộc

4
Đây là câu trả lời đúng thực tế. feof () được sử dụng để biết kết quả của lần đọc trước. Vì vậy, có lẽ bạn không muốn sử dụng nó như là điều kiện phá vỡ vòng lặp của bạn. +1
Jack

63

Không, nó không phải lúc nào cũng sai. Nếu điều kiện vòng lặp của bạn là "trong khi chúng tôi chưa cố đọc phần cuối của tệp" thì bạn sử dụng while (!feof(f)). Tuy nhiên, đây không phải là một điều kiện vòng lặp phổ biến - thông thường bạn muốn kiểm tra một cái gì đó khác (chẳng hạn như "tôi có thể đọc thêm"). while (!feof(f))không sai, nó chỉ được sử dụng sai.


1
Tôi tự hỏi ... f = fopen("A:\\bigfile"); while (!feof(f)) { /* remove diskette */ }hoặc (sẽ kiểm tra điều này)f = fopen(NETWORK_FILE); while (!feof(f)) { /* unplug network cable */ }
pmg

1
@pmg: Như đã nói, "không phải là điều kiện vòng lặp chung" hehe. Tôi thực sự không thể nghĩ về bất kỳ trường hợp nào tôi cần, thường thì tôi quan tâm đến "tôi có thể đọc những gì tôi muốn không" với tất cả những gì liên quan đến việc xử lý lỗi
Erik

@pmg: Như đã nói, bạn hiếm khi muốnwhile(!eof(f))
Erik

9
Chính xác hơn, điều kiện là "trong khi chúng tôi chưa cố đọc qua phần cuối của tệp và không có lỗi đọc" feofkhông phải là về việc phát hiện phần cuối của tệp; đó là về việc xác định xem một lần đọc có bị lỗi do lỗi hay do đầu vào đã hết.
William Pursell

35

feof()cho biết nếu một người đã cố gắng đọc qua cuối tập tin. Điều đó có nghĩa là nó có ít hiệu ứng dự đoán: nếu nó đúng, bạn chắc chắn rằng thao tác nhập tiếp theo sẽ thất bại (bạn không chắc BTW trước đó đã thất bại), nhưng nếu nó sai, bạn không chắc chắn đầu vào tiếp theo hoạt động sẽ thành công. Hơn nữa, các hoạt động đầu vào có thể thất bại vì các lý do khác ngoài cuối tệp (lỗi định dạng cho đầu vào được định dạng, lỗi IO thuần túy - lỗi đĩa, hết thời gian mạng - cho tất cả các loại đầu vào), vì vậy ngay cả khi bạn có thể dự đoán về phần cuối của tệp (và bất kỳ ai đã cố gắng triển khai Ada một, dự đoán, sẽ cho bạn biết nó có thể phức tạp nếu bạn cần bỏ qua khoảng trắng và nó có tác dụng không mong muốn trên các thiết bị tương tác - đôi khi buộc đầu vào của phần tiếp theo dòng trước khi bắt đầu xử lý cái trước đó),

Vì vậy, thành ngữ chính xác trong C là lặp với thành công hoạt động IO là điều kiện vòng lặp, và sau đó kiểm tra nguyên nhân của sự thất bại. Ví dụ:

while (fgets(line, sizeof(line), file)) {
    /* note that fgets don't strip the terminating \n, checking its
       presence allow to handle lines longer that sizeof(line), not showed here */
    ...
}
if (ferror(file)) {
   /* IO failure */
} else if (feof(file)) {
   /* format error (not possible with fgets, but would be with fscanf) or end of file */
} else {
   /* format error (not possible with fgets, but would be with fscanf) */
}

2
Đến cuối tập tin không phải là một lỗi, vì vậy tôi đặt câu hỏi "các thao tác nhập liệu có thể thất bại vì những lý do khác ngoài việc kết thúc tập tin".
William Pursell

@WilliamPursell, tiếp cận eof không nhất thiết là một lỗi, nhưng không thể thực hiện thao tác nhập liệu vì eof là một. Và C không thể phát hiện ra eof đáng tin cậy mà không thực hiện thao tác nhập liệu thất bại.
AProgrammer

Đồng ý cuối cùng elsekhông thể với sizeof(line) >= 2fgets(line, sizeof(line), file)nhưng có thể với bệnh lý size <= 0fgets(line, size, file). Thậm chí có thể với sizeof(line) == 1.
chux - Tái lập lại Monica

1
Tất cả những gì nói về "giá trị dự đoán" ... Tôi chưa bao giờ nghĩ về nó theo cách đó. Trong thế giới của tôi, feof(f)không PREDICT bất cứ điều gì. Nó nói rằng một hoạt động PREVIOUS đã đánh vào cuối tập tin. Không hơn không kém. Và nếu không có thao tác nào trước đó (chỉ mở nó), nó sẽ không báo cáo kết thúc tập tin ngay cả khi tập tin trống để bắt đầu. Vì vậy, ngoài lời giải thích đồng thời trong một câu trả lời khác ở trên, tôi không nghĩ có bất kỳ lý do nào để không lặp lại feof(f).
BitTickler

@AProgrammer: Một "đọc lên đến N byte" yêu cầu rằng sản lượng bằng không, cho dù vì một "vĩnh viễn" EOF hoặc vì không có nhiều dữ liệu hơn có sẵn chưa , không phải là một lỗi. Mặc dù feof () có thể không dự đoán một cách đáng tin cậy rằng các yêu cầu trong tương lai sẽ mang lại dữ liệu, nhưng nó có thể chỉ ra một cách đáng tin cậy rằng các yêu cầu trong tương lai sẽ không . Có lẽ nên có một hàm trạng thái cho biết "Rất có thể các yêu cầu đọc trong tương lai sẽ thành công", với ngữ nghĩa là sau khi đọc đến cuối tệp thông thường, việc triển khai chất lượng sẽ nói rằng các lần đọc trong tương lai khó có thể thành công mà không có lý do để tin rằng họ có thể .
supercat

0

feof()không trực quan lắm. Theo ý kiến ​​rất khiêm tốn của tôi, trạng FILEthái cuối tập tin nên được đặt thành truenếu có bất kỳ thao tác đọc nào dẫn đến kết thúc tập tin. Thay vào đó, bạn phải kiểm tra thủ công xem đã kết thúc tập tin sau mỗi thao tác đọc chưa. Ví dụ, một cái gì đó như thế này sẽ hoạt động nếu đọc từ tệp văn bản bằng cách sử dụng fgetc():

#include <stdio.h>

int main(int argc, char *argv[])
{
  FILE *in = fopen("testfile.txt", "r");

  while(1) {
    char c = fgetc(in);
    if (feof(in)) break;
    printf("%c", c);
  }

  fclose(in);
  return 0;
}

Sẽ thật tuyệt nếu một cái gì đó như thế này sẽ hoạt động thay thế:

#include <stdio.h>

int main(int argc, char *argv[])
{
  FILE *in = fopen("testfile.txt", "r");

  while(!feof(in)) {
    printf("%c", fgetc(in));
  }

  fclose(in);
  return 0;
}

1
printf("%c", fgetc(in));? Đó là hành vi không xác định. fgetc()Trả lại int, không char.
Andrew Henle

Dường như với tôi rằng thành ngữ tiêu chuẩn while( (c = getchar()) != EOF)rất nhiều "đại loại như thế này".
William Pursell

while( (c = getchar()) != EOF)hoạt động trên một trong những máy tính để bàn của tôi chạy GNU C 10.1.0, nhưng không thành công trên Raspberry Pi 4 của tôi chạy GNU C 9.3.0. Trên RPi4 của tôi, nó không phát hiện ra phần cuối của tệp và cứ tiếp tục.
Scott Deagan

@AndrewHenle Bạn nói đúng! Thay đổichar c thành int ccông trình! Cảm ơn!!
Scott Deagan
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.