ALL About EOF

introduction

C/C++的初学者在 redditstack overflow 之类的网站发布的所有问题中,最常见的困惑是当程序中需要读取用户的输入或者读取文件时,对end of file条件的处理.(这篇文章写于 2012 年,10 年后还是这样 🤭)
据我估计,超过 95%的问题都表现出对end-of-file这个概念的完全误解.
本文试图解释与这个令人困惑的话题的所有相关问题,尤其是对于使用windowsUnix-like(比如我即将用作例子的 Linux)的C C++程序员

The myth of the EOF character

许多初学者面对的第一个end-of-file问题是关于EOF字符,尽管实际上并没有这样的一个字符,但是人们认为有(不幸的是,译者初学 C 时也以为有这么个 b 字符 👿).无论 windows 或者 linux 操作系统都没有这种概念:用一个标记字符来表示文件的结束

如果你用NotepadVim或者其他文本编辑器创建一个文本文件,文件本身并不会包含一个特殊字符来标志文件结束.Windows 和 Linux 的文件系统都可以知道文件的确切字节长度,因此绝无必要用一个特殊字符标志文件结束.

既然 Windows 和 Linux 都不使用EOF字符,那么EOF这个概念从何而来?

well,在很久很久以前(上世纪 70 年代),有一个叫做CP/M的操作系统(为了丝滑的使用终端),CP/M可以在Zilog Z80,Intel 8080之类的 8 位处理器上运行.CP/M的文件系统并不知道一个文件的字节长度,它只知道文件占据了多少个block(注: block是文件系统层面上的概念,存储设备本身没有这一概念,因为存储设备的基本读写单位是扇区,但是扇区太小了,所以文件系统将block作为I/O操作的基本单位,通常block的大小4k,对应8个扇区).这意味着如果一个文件的内容为hello worldCP/M不知道这个文件的大小为 11 字节-它只知道该文件占了一个长度至少为 128 字节的block.因为人们通常都想知道文件看起来多大,而不是占据了多少个block,因此需要一个end-of-file字符.CP/M重新使用了ASCII字符集的Control-Z字符(十进制为 26,十六进制为0x1A,最初的用途随着时间流逝已经消失了,image-20220406161103269)用于这个目的-当CP/M系统上的程序读取到Control-Z字符时,通常会认为读取到了文件末尾…

没有任何规定强迫程序去做这件事;处理二进制数据的程序需要其他一些手段来确认是否已经读取到了文件末尾,并且操作系统本身不会特别处理Control-Z字符

注:很抽象的一句话,让我来粗暴的解释一下

因为CP/M的文件系统只知道文件占据的block数,所以提供的I/O接口也必定是按block读写,但是文件的实际大小并不一定是block的整数倍,最后一块block的内容可能只占了一部分

程序需要知道文件在哪结束,但是系统只支持读取block,那只能人为地在文件末尾加一个文件结束的标志,最后大家决定用Control-Z来标志文件结束,也就是EOF字符,当程序读取到EOF,一般就认为读取完了

不过这只是程序层面的规定,对于操作系统本身,不会读到EOF字符就认为文件结束了,它只会按block读取

MS-DOS出现时,兼容CP/M系统非常重要(CP/M一度非常流行),鉴于许多初版MS-DOS程序仅仅是CP/M上的程序的暴力移植,通过机器码转码器(这 mechanical translators 实在不知道咋翻译,通俗的讲就是将一种机器语言翻译成另一种机器语言)把Z80/8080机器码转码为8086机器码.因为这些程序没有被重写,所以仍然把Control-Z字符当作文件的结束标志,直到今天还是这样.事实上,对Control-Z的处理被内置到了Microsoft C Runtime Library(微软为自家操作系统上的 C 语言程序提供的函数库),当以文本模式打开文件时.

有必要再重申一次,windows系统本身不在乎Control-Z字符,这一行为完全取决于 MS 库,并且非常不幸的是几乎所有软件都引用了这个库.此外,这一问题仅会发生在 windows 上,Linux 及其他类 Unix 系统从不以任何形式将Control-Z或其他任何字符作为文件结束的标志.

注:解释一下,MCR库提供了文件I/O接口,而大部分 win 程序进行I/O操作时都会调用这个接口,读取文件的行为由库中的函数定义,并且程序不需要自行判断文件读取结束(毕竟我微软的操作系统更先进一些)

win 系统本身也不会特殊处理Control-Z,也不需要处理,因为它知道文件的实际长度,唯一有可能处理Control-Z字符的是MCR库,当然它也不一定把它视作EOF,我实在是懒得去看MCR的源码,有兄弟看了记得告诉我一声

Some demo code

用下面的代码可以证明MS库的这个意料之外的特性.首先,写一个程序,在程序中向文本文件写入Control-Z字符

#include<iostream>
#include<fstream>
using namespace std;
int main(){
    ofstream ofs("a.txt");
    ofs<<"第一行 1\n";
    ofs<<char(26);	//control-z的ascii码是0x1A,十进制是26
    ofs<<"第二行 2\n";
    return 0;
}

不论在 windows 或 Linux 上运行这个程序,都会创建一个文本文件,在文件的两行之间夹着一个Control-Z字符

line1

0x1A(实际上是看不到的,因为这不是个可见字符)line2

在 win 或 Linux 平台上,Control-Z字符作为输出时都没有特殊的含义.可以用命令行工具读取这个文件a.txt

Windows:

c:\Users\Me type a.txt

输出为:

第一行 1

注意只有第一行被打印出来了

注:在 2022 的 4 月 6 日,译者重现了这段代码,惊喜的发现 windows 平台打印的是两行,可喜可贺,可喜可贺啊!巨硬,你还是有点可取之处的 👍👍

image-20220406221531999image-20220406221552995image-20220406221606880

不过让我们先假定没有打印出第二行

Linux:

~> $ cat a.txt

输出为:

第一行 1

?第二行 2

两行都能打印出来,但是出现了一个奇怪的字符?,因为cat命令把Control-Z视作普通字符,但是Control-Z不是可打印字符,所以用别的字符替换了它,具体是什么样取决于你的终端,比如译者的就是:

image-20220406221945054

这好像暗示 windows 知道Control-Z字符,但是那不重要,本质上是某些软件知道这个字符.如果用记事本打开这个文件:img1

两行都被打印出来了,夹着一个Control-Z,显然记事本并未把Control-Z当作end-of-file标志

Text versus binary mode

那么,上面用的 type 命令和记事本之间到底有什么区别.很难说.可能 type 命令对输入流中的Control-Z字符有特殊处理.然而,用c++ iostreamC stream库的 win 程序员可以选择以文本模式或二进制模式打开文件,这会在读取文件时产生一些微小的区别

一个经典的读取文本文件的C++范例:

#include<iostream>
#include<fstream>
#include<string>
using namespace std;

int main(){
    ifstream ifs("a.txt");
    string line;
    while (getline(ifs,line)){
        cout<<line<<endl;
    }
}

在 win 上运行这个文件,会看到Control-Z被当作end-of-file字符

输出是:

第一行 1(经译者测试,确实是这样,看来巨硬这么多年还是没更新MCR)

但是如果以 2 进制模式打开文件的话:ifstream ifs("a.txt",ios::binary)

输出就是:

line 1

?line 2 (经测试,译者的电脑上并没有打印出?)

所以Control-Z字符只有在默认的以文本模式打开文件时才会被特殊处理,在二进制模式下就是普通字符.注意只有在 windows 上才会这样

在 Linux 上两种模式表现基本相同:

image-20220407122942033image-20220407123119552

那么究竟该怎么做?记住两件事:

  1. 如果希望文件在文本模式下能被正确读取,不要在文件里插入Control-Z字符
  2. 如果文件中必须包含Control-Z,并且希望文件被正确读取,那么就以二进制模式打开文件

正确读取表示用任何软件,即使你只在 windows 上编程(是家人就用 linux)

But what about Control-D?

一些 Linux 用户此刻可能会想,”但是我用来结束 shell 输入的Control-D字符又是怎么回事捏?难道Control-D不是一个end-of-file字符吗?”

还真不是,就算输入了Control-D,linux 也绝对不会注意到它.实际上在终端输入的Control-D仅仅是一个信号,用来结束 shell 的标准输入流(注:关于linux信号系统,可以参考wiki).在输入中实际上并没有Control-D这个字符.可以用stty命令把结束 shell 标准输入流从Control-D改成任何其他字符,但是控制字符本身不会被当作字符处理,插入到输入流中,并且就算被放入输入中,linux 也不会把它当作end-of-file标志

The EOF value in C++ and C

更让人困惑的是,C++和 C 都定义了一个特殊的EOF变量,在 C 中,被定义在<stdio.h>

#define EOF (-1),在 C++中也差不多.

注意这里说的EOF变量和Control-Z没有任何关系.它的值不是0x1A并且它甚至不是一个字符,而是一个整型变量.EOF变量通常被用作函数的返回值,像这样:

int getchar(void);

getchar函数的作用是从标准输入中读取单个字符,当输入结束时,返回值为EOF.正如上面所说,文件结尾可能以一个Control-Z字符来表示文件的结束,但是任何情况下都不会有EOF的值和Control-Z的值相等.实际上getchar返回的是整形,而不是一个字符型,整形和字符型的比较不总是正确的(注:char 和 int 是不同类型,在比较时会进行隐式类型转换,通常是把char转换为int然后比较).

getchar函数读取标准输入的典型用法是:

#include<stdio.h>
int main(){
    int c;
    while ((c=getchar())!=EOF)
    putchar(c);
}

注:输入的是char,返回的是转换为int的字符(高位全部填充为 0),而putchar又会经过一次类型转换,把getchar的返回值转换为char,缩减型转换或截断高位,但是可打印的ascii最高不会超过一个字节,所以不会造成影响

至于为什么返回的是int,是因为需要用返回值与EOF比较来判断输入是否结束

至于为什么-1 打不出来,因为-1=0xFFFF FFFF,从 char 转为 int 的过程是高位填 0,所以输入不会和EOF冲突

The eof() and feof() functions

另一个令人困惑的部分是 C 和 C++提供的用于检查输入流状态的函数.几乎所有初学者都被它们困扰过,所以预先声明它们做了什么以及它们不应该被如何使用是个好主意:

eof() & feof()都会检查输入流是否发生了end-of-file状态.而end-of-file状态只会发生在读取操作后.

在读取操作前就调用eof函数是错误的做法!不要把eof函数放在循环中.

为了阐明这些,让我们写一个读取文件的函数,像这样:

#include <iostream>
#include <fstream>
#include <string>
using namespace std;
int main(){
    ifstream f1("a.txt");
    int n=0;
    while (!f1.eof()){
        string line;
        getline(f1,line);
        cout<<++n<<" "<<line<<endl;
    }
}

上面这段代码似乎很合理,但是别忘了 - 如果在读取操作之前就调用eof函数,是相当愚蠢的做法!

在这个例子中我们确实在读取操作之前就调用了eof().那么,为什么这是错误的,考虑a.txt为空的情况.尽管a.txt是一个空文件,但是第一次判断条件不会失败,因此还没有进行过读取操作.在我们读取之后,end-of-file条件会被出发,但是太晚了,然后我们的输出会是这样image-20220407173701108这一行在文件中实际上是不存在的.通过简单的逻辑推理,程序始终会输出多余的一行.

注:首先,要知道eof在什么条件下会发生,通俗的讲,在读完文件的最后一个字节之后,eof的返回值仍为false,只有在读完之后再试图读取时,eof才会返回true,因此永远会多输出一行

不过这是之前的说法了,在 2022 的四月 7 日,译者重现了这段代码,发现如果a.txt不是空文件的话,并不会输出多余的一行

不过如果a.txt为空,还是会输出一行

简单的推理可得,eof函数的判断条件有两个read && end,如果没读过,那么一定会返回false,如果读完所有数据后,返回false,注意,是读取完数据,不是读取完数据后再读取一次

而原作者想表达的大概是:即使a.txt不为空,还是会多输出一行,因为在读取到最后一行时,eof返回的还是false,只有再往后读取一行(实际上这行并不存在)后才会返回true退出循环,但这时已经进入循环了,所以会多输出一行

简单来说就是:

1 读取最后有效的一行->eof=false->2 进入下一次循环getline->eof=true->退出循环

多输出的那一行是因为 2

可喜可贺的是如今这个问题已经被修复了~

为了保证程序正确,你应该在读取操作之后调用eof函数,或者干脆不要用.如果你不希望遇到上面的问题,可以像这样写:

while (getline(f1,line)){
    cout<<++n<<"  "<<l
}

这里将getline()的返回值转化为了while的条件,当输入流没有发生end-of-file(或者别的错误)条件,就会一直循环下去

C 语言也类似,不应该写出下面这样的代码:

#include<stdio.h>
int main(){
    FILE * f1=fopen("a.txt","r");
    char line[100];
    int n=0;
    while (!feof(f1)){
        fgets(line,100,f1);
        printf("%d %s",++n,line);
    }
    fclose(f1);
}

这会在打开一个空文件时多打印一行,你应该:

while (fgets(line,100,f1)!=NULL){
    printf(...)
}
fclose(f1);

因此,显然eof feof都没什么用,那么为什么 C/C++标准库要提供它们?

Well,在判断读取操作是产生其他错误还是end-of-file条件的情况下,他们很有用,如果想区分这两种情况的话:

#include<bits/stdc++>
using namespace std;
int main(){
    ifstream f1("a.txt");
    int n=0;
    string line;
    while (getline(f1,line)){
        cout<<++n<<" "<<line<<endl;
    }
    if (f1.eof()){
        cout<<"ok,file has read over"<<endl;
    } else {
      cout<<"sth wrong happend and read is interrupted"<<endl;
    }
}

Summary

以上的内容好像让EOF问题变得很复杂,但实际上可以归纳为 3 点:

  1. 没有EOF字符,除非在 windows 上以文本模式打开文件,或者你自己造了个(当然,2022 的 windows 也不把control-z当作结束字符了)
  2. C/C++的EOF符号不是end-of-file字符,它只是库函数在特定条件下的返回值
  3. 不要在循环中用eof() feof()

如果牢记这些规则,你就可以避免被大多数与误解 C++/C 中关于end-of-file条件的错误所困扰

译者注:

总的来说,EOF的起源是因为CP/M的文件系统不知道文件的具体长度,从而需要用户自行用一个特殊字符表示文件结束 - Control-Z

C/C++的EOF宏变量值为-1,之所以为-1,是因为根据经典的Unix规范,程序正确运行后返回 0,而错误一般返回-1,所以对于getline()这样的函数,如果读取完最后一行后再试图读取,显然会产生错误,因此是否读取完文件可以用

if (getline(fs,line)!=EOF),其实就是if (getline(fs,line)!=-1),用EOF更好记罢

至于feof() eof()这样的函数,只是判断是否达成了end-of-file条件,就译者目前的试验经历来看(2022),想达成该条件需要

  1. 输入流至少进行一次读取
  2. 读到最后一个字节(不是读完最后一个字节后再读取)

而原作者之所以说不要把eof用作循环条件,其实是不要在读取操作之前调用eof,因为eof只有在试图读取不存在的字节后才会触发,当然,这个说法有时代的局限性,可能在 10 年前,就算文件非空,eof也只有在读完最后一个字节之后,再进行一次读取操作才会触发(最后一行的再下一行,实际上并不存在这一行)

– 2022.4.8 11:49🐟🐟🐟