introduction
C/C++的初学者在 reddit
和 stack overflow
之类的网站发布的所有问题中,最常见的困惑是当程序中需要读取用户的输入或者读取文件时,对end of file
条件的处理.(这篇文章写于 2012 年,10 年后还是这样 🤭)
据我估计,超过 95%的问题都表现出对end-of-file
这个概念的完全误解.
本文试图解释与这个令人困惑的话题的所有相关问题,尤其是对于使用windows
和Unix-like
(比如我即将用作例子的 Linux)的C C++
程序员
The myth of the EOF character
许多初学者面对的第一个end-of-file
问题是关于EOF
字符,尽管实际上并没有这样的一个字符,但是人们认为有(不幸的是,译者初学 C 时也以为有这么个 b 字符 👿).无论 windows 或者 linux 操作系统都没有这种概念:用一个标记字符来表示文件的结束
如果你用Notepad
,Vim
或者其他文本编辑器创建一个文本文件,文件本身并不会包含一个特殊字符来标志文件结束.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 world
,CP/M
不知道这个文件的大小为 11 字节-它只知道该文件占了一个长度至少为 128 字节的block
.因为人们通常都想知道文件看起来多大,而不是占据了多少个block
,因此需要一个end-of-file
字符.CP/M
重新使用了ASCII
字符集的Control-Z
字符(十进制为 26,十六进制为0x1A
,最初的用途随着时间流逝已经消失了,)用于这个目的-当
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 平台打印的是两行,可喜可贺,可喜可贺啊!巨硬,你还是有点可取之处的 👍👍
不过让我们先假定没有打印出第二行
Linux:
~> $ cat a.txt
输出为:
第一行 1
?第二行 2
两行都能打印出来,但是出现了一个奇怪的字符?
,因为cat
命令把Control-Z
视作普通字符,但是Control-Z
不是可打印字符,所以用别的字符替换了它,具体是什么样取决于你的终端,比如译者的就是:
这好像暗示 windows 知道Control-Z
字符,但是那不重要,本质上是某些软件知道这个字符.如果用记事本打开这个文件:
两行都被打印出来了,夹着一个Control-Z
,显然记事本并未把Control-Z
当作end-of-file
标志
Text versus binary mode
那么,上面用的 type 命令和记事本之间到底有什么区别.很难说.可能 type 命令对输入流中的Control-Z
字符有特殊处理.然而,用c++ iostream
或C 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 上两种模式表现基本相同:
那么究竟该怎么做?记住两件事:
- 如果希望文件在文本模式下能被正确读取,不要在文件里插入
Control-Z
字符 - 如果文件中必须包含
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
条件会被出发,但是太晚了,然后我们的输出会是这样这一行在文件中实际上是不存在的.通过简单的逻辑推理,程序始终会输出多余的一行.
注:首先,要知道
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 点:
- 没有
EOF
字符,除非在 windows 上以文本模式打开文件,或者你自己造了个(当然,2022 的 windows 也不把control-z
当作结束字符了)- C/C++的
EOF
符号不是end-of-file
字符,它只是库函数在特定条件下的返回值- 不要在循环中用
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),想达成该条件需要
- 输入流至少进行一次读取
- 读到最后一个字节(不是读完最后一个字节后再读取)
而原作者之所以说不要把
eof
用作循环条件,其实是不要在读取操作之前调用eof
,因为eof
只有在试图读取不存在的字节后才会触发,当然,这个说法有时代的局限性,可能在 10 年前,就算文件非空,eof
也只有在读完最后一个字节之后,再进行一次读取操作才会触发(最后一行的再下一行,实际上并不存在这一行)
– 2022.4.8 11:49🐟🐟🐟