C++中使用fstream的一些坑



前言

最近在写一个简陋的xml parser, 因为要用到文件操作,这中间遇到了一些坑,记录下来备忘,也顺便和大家分享一下。

目录

  1. 判断文件是否结束的fstream::eof()的坑
  2. 进行fstream::seekg()操作之前一定要先判断文件是否已经结束,否则会出现文件结束标识符设置错误的问题
  3. 如果要对打开的文件进行fstream::seekg操作, 一定要确保文件是用二进制方式打开的。

1.判断文件是否结束的fstream::eof()的坑

关于标题中的描述,并不是在文件刚刚结束后eof就会返回1.

对于读取一个文件中的所有的内容,我们最开始的时候很可能会选用如下形式的代码:

#include <iostream>
#include <fstream>

using namespace std;

int main(void)
{
    fstream file;
    file.open("./test.txt", ios::in);

    if(!file.is_open())
    {
        cout<<"Unable to open test file!"<<endl;
        return 1;
    }

    while(!file.eof())
    {
        char temp;
        file.get(temp);
        cout<<temp;
    }
    cout<<endl;

    return 0;
}

其中test.txt中的内容如下

hello, world

通常情况下,我们期望程序会把text.txt中的内容全部原样输出,但是实际上输出结果确是这样的:

hello, worldd

可以看到,最后一个字符d输出了两次。这是因为fstream::eof()并不是当文件结束的时候立马返回真, 而是在一次文件读取操作失败之后才会设置文件结束标志,然后在这之后调用fstream::eof()才会返回真。

了解这以后,就比较容易修改了,最简单粗暴的办法只要在读取之后判断是否文件结束即可,就像下面这样:

while(1)
{
    char temp;
    file.get(temp);

    if(file.eof())
    {
        break;
    }
    cout<<temp;
}

2. 进行fstream::seekg()操作之前一定要先判断文件是否已经结束,否则会出现文件结束标识符设置错误的问题

首先来看下面的代码:

#include <iostream>
#include <fstream>

using namespace std;

int main(void)
{
    fstream file;
    file.open("./test.txt", ios::in | ios::binary);

    if(!file.is_open())
    {
        cout<<"Unable to open test file!"<<endl;
        return 1;
    }

    while(!file.eof())
    {
        char temp;
        file.read(&temp, 1);
        cout<<temp;
        file.seekg(-1, file.cur);
        file.read(&temp, 1);
        cout<<temp;
    }
    cout<<endl;

    return 0;
}

注意看上面的那个循环,循环里有两个fstream::read(),在第一个read之后会用fstream::seekg()把文件指针移动到前一个位置,也就是read之前的,然后再读一次,也就是说对每一个字符都会进行两次读取。在这段代码下,如果我们的输入文件test.txt是纯ASCII码编码的文本文件如下:

hello, world!

(注意前面hello的','后面有个空格),我们期望的输出应该是这样的:

hheelloo,,  wwoorrlldd!!

但是事实上,输出结果确实这样的

hheelloo,,  wwoorrlldd!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!<loop !>

程序永远不会结束,输出完我们预期的输出以后会持续输出‘!’, 进入死循环,也就是说虽然每一个循环里有两个read,再加上仅仅把指针往前移动一个字节的seekg,程序应该可以结束,但是这里却进入了死循环。这实际上是因为当文件指针位于文件末尾的时候再进行seekg会出错,并且导致文件结束标识符永远不会被设置(具体原理不是很了解,这是测试得出的结果,但是后面我会给出自己的一个猜想,如果你了解原理,感谢指出!)。

为了验证这一点,我们把代码中的那个while循环修改成这样的

while(!file.eof())
{
    char temp;
    file.read(&temp, 1);
    cout<<"char: ["<<temp<<"] on pos 1"<<"stream pos:"<<file.tellg()<<endl;
    file.read(&temp, 1);
    cout<<"char: ["<<temp<<"] on pos 2"<<"stream pos:"<<file.tellg()<<endl;
    file.seekg(-1, file.cur);
    file.read(&temp, 1);
    cout<<"char: ["<<temp<<"] on pos 3"<<"stream pos:"<<file.tellg()<<endl;
    file.read(&temp, 1);
    cout<<"char: ["<<temp<<"] on pos 4"<<"stream pos:"<<file.tellg()<<endl;
}

为了观察方便,我们把输入文件test.txt设置成这样的:

abcdefg

运行后得到的结果是这样的

char: [a] on pos 1stream pos:1
char: [b] on pos 2stream pos:2
char: [b] on pos 3stream pos:2
char: [c] on pos 4stream pos:3
char: [d] on pos 1stream pos:4
char: [e] on pos 2stream pos:5
char: [e] on pos 3stream pos:5
char: [f] on pos 4stream pos:6
char: [g] on pos 1stream pos:7
char: [g] on pos 2stream pos:-1
char: [g] on pos 3stream pos:-1
char: [g] on pos 4stream pos:-1
char: [g] on pos 1stream pos:-1
char: [g] on pos 2stream pos:-1
char: [g] on pos 3stream pos:-1
......

从上面的输出结果中我们可以看出,当第三次循环开始后第一个read正好读到文件末尾,然后下一个read后文件的结束标志位被设置为1, 这时候调用了seekg, 然后后面按照预期输出两个g, 然后进行下一次循环,这时候file.eof()的结果仍然不是真,仍然能进行下一次循环。然后我们吧seekg函数去掉以后在看,就不会进入循环。可以发现确实是seekg函数出了问题。

为了验证前面我所说的在文件末尾seekg才会出问题的说法,我们修改一下输入文件test.txt:

abcdefgh

在原来的基础上加上一个h,然后运行,我们会得到这样的输出结果:

char: [a] on pos 1stream pos:1
char: [b] on pos 2stream pos:2
char: [b] on pos 3stream pos:2
char: [c] on pos 4stream pos:3
char: [d] on pos 1stream pos:4
char: [e] on pos 2stream pos:5
char: [e] on pos 3stream pos:5
char: [f] on pos 4stream pos:6
char: [g] on pos 1stream pos:7
char: [h] on pos 2stream pos:8
char: [h] on pos 3stream pos:8
char: [h] on pos 4stream pos:-1

请按任意键继续. . .

从输出上可以看出,这次读取到文件结尾是在第三次循环的第二个read,这时候文件结束标识符还没有设定,仅仅只是到达了文件结尾,然后seekg正常工作, 第三个read正好再次读到文件结尾,第四个read读到文件末尾,然后回去判断是否文件结束,这时候文件结束标识符因为已经设定,循环结束。

但是为什么是这样的呢?由于并没有看过具体的实现代码,我有一个不成熟的猜想:

  1. 只有在read操作失败一次后才会设置文件结束标识位(如前面所述), 并且此时会把文件指针的当前的位置设置为-1
  2. 进行seekg操作的时候seekg会首先把文件标识位设为0, 然后再进行文件指针位置移动操作
  3. seekg移动文件指针位置的时候会检查当前位置是否为合法位置,如果不合法,将不进行任何操作
  4. read的时候也会检查当前文件指针是否为合法位置,如果不合法,不会进行任何操作。

这种情况下,我们再来看这个程序, 第三次循环的时候,第一个read读到文件结尾,没有设置文件结束位, 第二个read失败,设置文件结束位,并且把文件指针位置设置为-1,接下来进行seekg操作,取消前面设置的文件结束标志位,然后继续去移动指针,然后发现不合法,退出。这时候文件结束标志位已经被取消设置。第三个read,文件指针位置不合法,不进行任何操作。此后文件指针位置为-1, 因此后面所有的read以及seekg都不会有任何操作。但是由于这时候文件结束标志位已经被取消,从而永远得不到重新设置,从而fstream::eof()的结果永远为假, 从而循环永远继续进行, 不会结束。

这个也很容易解决,在seekg前面加上一句判断文件是否已经结束, 如果结束,break出循环即可。

3.如果要对打开的文件进行fstream::seekg操作, 一定要确定文件的打开方式中有ios::binary, 即一定要确保文件是用二进制方式打开的。

还是先来看一段代码:

#include <fstream>
#include <iostream>

using namespace std;

int main(void)
{
    fstream file;
    file.open("test.txt",ios::in);
    if(file.is_open()) cout<<"open!"<<endl;

    streampos pos = 0;
    char temp;

    for(int i = 0; i < 10; i++) file.read(&temp, (streamsize) 1);

    pos = file.tellg();
    cout<<"CurrentPos1: "<<file.tellg()<<endl;

    file.seekg(pos);

    cout<<"CurrentPos2: "<<file.tellg()<<endl;
    file.read(&temp, (streamsize) 1);

    cout<<"CurrentPos3: "<<file.tellg()<<endl;

    file.read(&temp, (streamsize) 1);
    cout<<"CurrentPos4: "<<file.tellg()<<endl;

    return 0;
}

我们的输入的test.txt差不多是这个内容:

aaaa
aaaa
aaaa
aaaa
aaaa
aaaa
aaaa
aaaa
aaaa
aaaa
aaaa
aaaa
aaaa

这个文本文件采用的换行格式为\r\n格式

对于这段代码,我们预期的输出应当是这样的:

open!
CurrentPos1: 10
CurrentPos2: 10
CurrentPos3: 11
CurrentPos4: 12
请按任意键继续. . .

但是事实上,我们的输出却是这样的:

open!
CurrentPos1: 29
CurrentPos2: 29
CurrentPos3: 44
CurrentPos4: 45
请按任意键继续. . .

这里需要注明的是此时用的编译器为MINGW-W64 g++ 7.1.0 seh 版本

先不忙分析原因,我们先看一下把文件换行符修改为\n模式后的结果:

open!
CurrentPos1: 10
CurrentPos2: 10
CurrentPos3: 11
CurrentPos4: 12
请按任意键继续. . .

确实是我们预期的输出。

然后再来看一下\r\n模式下VS编译运行的结果:

open!
CurrentPos: 12
CurrentPos: 12
CurrentPos: 13
CurrentPos: 14
请按任意键继续. . .

再来看一下\r模式下VS编译运行的结果:

open!
CurrentPos: -7
CurrentPos: -1
CurrentPos: -1
CurrentPos: -1
请按任意键继续. . .

再来看ubuntu下的运行结果(编译器是默认的gcc 5.3.1, 操作系统为wsl下的16.04.2版本)

\r\n模式:

open!
CurrentPos1: 10
CurrentPos2: 10
CurrentPos3: 11
CurrentPos4: 12

\n模式:

open!
CurrentPos1: 10
CurrentPos2: 10
CurrentPos3: 11
CurrentPos4: 12

为了更直观,我们来列一个表格展示输出结果:

\r\n模式下:

MINGWVSg++ on UBUNTU预期
CurrentPos129121010
CurrentPos229121010
CurrentPos344131111
CurrentPos445141212

\n模式下:

MINGWVSg++ on UBUNTU预期
CurrentPos110-121010
CurrentPos210-11010
CurrentPos311-11111
CurrentPos412-11212

这里面有四种结果,我们来看一下原因

  1. [10 10 11 12]这是我们预期的结果,每一次read之后文件指针后移一位的结果
  2. [12 12 13 14]这个要分析一下文件的结构,整个程序总共进行了12次read操作,前四次read的时候文件指针还是往后移动以一位,第五次的时候虽然调用的是read函数,但是依然处理了\r\n,一次性全部读入了,这时候移动两个,同理再四次read的时候后移四位, 下一次后移两位, 这时候前面的for中的十次read结束,文件指针移动到0 + 4 + 2 + 4 + 2 = 12 的位置,也就是第一个输出的结果,后面按照期望输出
  3. [29 29 44 45]这个比较离谱,w无法分析。但是这个出现在MINGW的\r\n模式下,然而MINGW是从linux上移植的,可能对这里移植的时候没有考虑read遇到换行符的问题,从而可能出现问题,因而出错。
  4. [-12 -1 -1 -1]这个也比较离谱,无法分析。但是这个是在VS下的\n模式下,同上一样,为非此平台的换行符格式,出现未定义行为。

现在是猜测时间,上面的处理结果差不多可能是这样的:

  1. MINGW: 对于windows平台下的换行符处理出现错误,对linux平台下的换行符的情况下识别文件为非当前平台文本文件,按照普通的二进制文件处理
  2. VS: windows平台换行符正常处理,linux平台换行符出错,文件指针位置为负值
  3. g++ on ubuntu: 所有read操作都直接视为对二进制文件处理,不处理任何形式的换行符

现在再回过头来看我们的open函数,我们的参数仅仅只是一个ios::in, 也就是默认的文本方式,如果我们把他换成ios::in | ios::binary再进行测试,所有编译器的输出结果均为 10 10 11 12的预期结果, 问题解决。

ps:最后展示一下我直接遇到的问题,代码被提取成下面这样:

#include "stdafx.h"
#include <fstream>
#include <iostream>

using namespace std;

int main(void)
{
	fstream file;
	file.open("./nasa.xml", ios::in);
	if (!(file.is_open()))
	{
		return 1;
	}

	for (int i = 0; i < 24280000; i++)
	{
		char temp;
		file.read(&temp, 1);
	}

	while (!file.eof())
	{
		char current = '
#include "stdafx.h"
#include <fstream>
#include <iostream>
using namespace std;
int main(void)
{
fstream file;
file.open("./nasa.xml", ios::in);
if (!(file.is_open()))
{
return 1;
}
for (int i = 0; i < 24280000; i++)
{
char temp;
file.read(&temp, 1);
}
while (!file.eof())
{
char current = '\0';
cout << "CurrentPos:" << file.tellg()<<"\ton line"<<__LINE__  << endl;
file.read(&current, 1);
if (file.eof()) break;
cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl;
file.read(&current, 1);
if (file.eof()) break;
cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl;
file.seekg(-1, file.cur);
if (file.eof()) break;
cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl;
file.read(&current, 1);
if (file.eof()) break;
cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl;
file.read(&current, 1);
if (file.eof()) break;
cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl;
}
file.close();
return 0;
}
'; cout << "CurrentPos:" << file.tellg()<<"\ton line"<<__LINE__ << endl; file.read(&current, 1); if (file.eof()) break; cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl; file.read(&current, 1); if (file.eof()) break; cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl; file.seekg(-1, file.cur); if (file.eof()) break; cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl; file.read(&current, 1); if (file.eof()) break; cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl; file.read(&current, 1); if (file.eof()) break; cout << "CurrentPos:" << file.tellg() << "\ton line" << __LINE__ << endl; } file.close(); return 0; }

其中那个nasa.xml是一个xml的20+M的一个benchmark文件,因为体积太大就不放上来了,换行符格式为\n(linux平台)在VS下编译运行这段代码会直接出现死循环,直接导致DEBUG的时候长期查不出原因。

4.总结

在对文件进行操作的时候还是要多多关注换行符、文件是否结束的问题,防止出现不可预知的诡异的BUG。以及遇到问题后还是要擅长查找原因,一点一点分析,多做实验,总能找出结果。

发表评论

电子邮件地址不会被公开。 必填项已用*标注