
上一课同学们已经知道:
文件本质上是一串字节。
这一课我们就让孩子真正“动手造文件”:
用 C++ 把内存里的字节,一个一个写进硬盘;再把硬盘里的字节,一个一个读回来。
《C++文件魔法:我亲手制造一个二进制文件》
一、先回忆:文件到底是什么?
上一节我们看到:
hello.txt
里面写:
ABC
用十六进制编辑器打开,可以看到:
41 42 43
所以:
文件
↓
一串字节
↓
41 42 43
而 C++ 程序中的变量也存在内存里。
例如:
int x = 100;
可以粗略理解成:
内存
┌──────┬──────┬──────┬──────┐
│ 字节 │ 字节 │ 字节 │ 字节 │
└──────┴──────┴──────┴──────┘
于是产生了一个非常重要的问题:
能不能把内存里的这些字节直接保存到硬盘?
当然可以!
这就是:
write()
二、write():把内存里的字节搬到文件里
先看一个最简单的例子:
#include <fstream>
using namespace std;
int main()
{
char s[] = "ABC";
ofstream fout("data.bin", ios::binary);
fout.write(s, 3);
fout.close();
return 0;
}
这个程序干了什么?
可以画成:
内存
┌───┬───┬───┐
│ A │ B │ C │
└───┴───┴───┘
│
│ write()
↓
硬盘上的 data.bin
┌───┬───┬───┐
│ A │ B │ C │
└───┴───┴───┘
也就是说:
write()的工作,就是把内存中的一段字节复制到文件中。
三、write() 的两个参数是什么意思?
这一句:
fout.write(s, 3);
一定要理解。
它可以翻译成:
从
s指向的地方开始,拿 3 个字节,写进文件。
所以:
fout.write(s, 3);
相当于:
从这里开始
↓
s
│
▼
┌───┬───┬───┐
│ A │ B │ C │
└───┴───┴───┘
拿 3 个字节
↓
文件
因此:
write(哪里开始, 写多少字节)
可以把它记成:
“起点 + 长度”
四、为什么不是 write("ABC", 3)?
实际上这样写也可以:
fout.write("ABC", 3);
但学习的时候,更推荐:
char s[] = "ABC";
fout.write(s, 3);
因为这样孩子更容易理解:
s
↓
内存地址
↓
一串字节
这就和我们前面学习的指针联系起来了。
五、write() 到底写进去的是什么?
这是整节课最重要的问题。
很多初学者会认为:
fout.write(s, 3);
写进去的是:
ABC
其实更准确地说:
写进去的是
A、B、C对应的三个字节。
ASCII 情况下:
A
↓
65
↓
0x41
↓
01000001
所以:
ABC
实际上可以理解成:
41 42 43
因此:
fout.write(s, 3);
就是把:
41 42 43
写进文件。
六、那么 ios::binary 是干什么的?
这可能是同学们容易困惑的地方。
我们经常看到:
ofstream fout("data.bin", ios::binary);
这里:
ios::binary
到底是什么意思?
一句话:
告诉 C++:我要按照“原始字节”的方式处理这个文件。
可以把它理解成:
“不要帮我翻译,原样搬运!”
七、为什么需要 ios::binary?
因为在某些系统上,文本文件和二进制文件的处理方式可能不同。
尤其经典的例子就是:
\n
换行。
在 Windows 文本模式下,程序可能对换行进行特殊处理。
例如内存中的:
\n
写到文本文件时,系统可能涉及:
\n
↓
\r\n
而二进制模式强调:
你给我什么字节,我就按照原始字节处理。
所以:
ios::binary
可以给孩子一个非常形象的记忆:
文本模式
C++:
“这是文本,我帮你处理一下。”
二进制模式
C++:
“收到!一个字节都别动,原样搬运。”
八、但是要特别纠正一个误区
很多孩子会认为:
ios::binary就是“把数据变成二进制”。
不是!
这是非常重要的。
数据本来就是以二进制形式存在的。
ios::binary
不是:
把数据转换成 0 和 1
而是:
告诉文件流:
按照原始字节方式处理。
所以:
ofstream fout("data.bin", ios::binary);
并不是:
“我要把数据变成二进制。”
而是:
“我要按照二进制文件方式读写原始字节。”
九、真正厉害的来了:直接保存 int
前面我们保存的是:
char
那么:
int x = 100;
能不能直接保存?
当然可以。
#include <fstream>
using namespace std;
int main()
{
int x = 100;
ofstream fout("data.bin", ios::binary);
fout.write((char*)&x, sizeof(x));
fout.close();
return 0;
}
这段代码非常重要。
我们慢慢拆开。
十、sizeof(x) 是什么?
如果:
int x = 100;
通常:
sizeof(x)
得到:
4
也就是说:
x
↓
占 4 个字节
可以画成:
x
┌────┬────┬────┬────┐
│ │ │ │ │
└────┴────┴────┴────┘
4 个字节
所以:
fout.write((char*)&x, sizeof(x));
实际上就是:
把 x 占用的这 4 个字节,原封不动写进文件。
十一、为什么要 (char*)&x?
这是连接“文件”和“指针”的关键。
write() 需要的是:
一块内存的起始地址。
而:
&x
表示:
x的地址。
但是 write() 操作的是:
字节
所以我们把它看成:
char*
也就是:
“从这个地址开始,把它当成一个一个字节来看。”
于是:
(char*)&x
可以暂时理解为:
“从 x 的地址开始,把 x 的内存当成字节来看。”
这就是为什么:
fout.write((char*)&x, sizeof(x));
可以直接把 int 的内存内容写入文件。
十二、画出真正的内存搬运过程
假设:
int x = 100;
假设它占 4 个字节:
内存
x
↓
┌────┬────┬────┬────┐
│ xx │ xx │ xx │ xx │
└────┴────┴────┴────┘
↑
&x
执行:
fout.write((char*)&x, sizeof(x));
就是:
内存 硬盘
┌────┬────┬────┬────┐
│ xx │ xx │ xx │ xx │
└────┴────┴────┴────┘
│
│ write
↓
┌────┬────┬────┬────┐
│ xx │ xx │ xx │ xx │
└────┴────┴────┴────┘
不是把 100 转成字符串 "100"!
这一点非常重要。
十三、write() 和 << 的区别
这也是考试和实际编程中非常容易混淆的地方。
比如:
int x = 100;
使用:
fout << x;
通常是在做:
格式化输出
最终文件里通常是字符:
'1'
'0'
'0'
也就是:
31 30 30
而:
fout.write((char*)&x, sizeof(x));
保存的是:
x在内存中的原始字节
所以二者完全不同。
十四、用一个表格记住
| 写法 | 做什么 |
|---|---|
fout << x | 把 x 按“人类可读的形式”写出去 |
fout.write(...) | 把内存中的原始字节写出去 |
ios::binary | 按二进制文件方式处理 |
sizeof(x) | x 占多少字节 |
例如:
int x = 100;
<<
fout << x;
文件里更像:
100
write
fout.write((char*)&x, sizeof(x));
文件里是:
x 在内存中的原始字节
十五、既然能写,就一定能读
现在我们已经可以:
内存
↓
write()
↓
文件
那么反过来呢?
当然就是:
文件
↓
read()
↓
内存
这就是:
read()
十六、read():把文件中的字节搬回内存
例如:
int x;
ifstream fin("data.bin", ios::binary);
fin.read((char*)&x, sizeof(x));
fin.close();
这句话:
fin.read((char*)&x, sizeof(x));
可以翻译成:
从文件里读取
sizeof(x)个字节,放到x的内存里。
画出来:
硬盘
┌────┬────┬────┬────┐
│ xx │ xx │ xx │ xx │
└────┴────┴────┴────┘
│
│ read()
↓
内存
┌────┬────┬────┬────┐
│ xx │ xx │ xx │ xx │
└────┴────┴────┴────┘
↑
x
十七、完整实验:先写,再读
我们可以写一个完整的小程序。
#include <iostream>
#include <fstream>
using namespace std;
int main()
{
// 第一步:制造数据
int x = 12345;
// 第二步:写入文件
{
ofstream fout("data.bin", ios::binary);
fout.write((char*)&x, sizeof(x));
fout.close();
}
// 第三步:清空 x
x = 0;
// 第四步:从文件读取
{
ifstream fin("data.bin", ios::binary);
fin.read((char*)&x, sizeof(x));
fin.close();
}
// 第五步:看看读回来是什么
cout << x << endl;
return 0;
}
输出:
12345
这就完成了一次:
写入
内存 ───────→ 文件
↑ │
│ │
│ ↓
└─────────────
读取
十八、同学们要理解:文件就是“仓库”
我们可以把它比喻成仓库。
write()
小朋友手里有玩具
↓
把玩具放进仓库
↓
文件
read()
仓库里面有玩具
↓
把玩具拿出来
↓
内存
所以:
write = 写进去
read = 读出来
非常好记。
十九、一次保存多个数据
比如:
struct Student
{
int age;
int score;
};
我们有:
Student s;
s.age = 10;
s.score = 95;
能不能直接保存?
可以:
ofstream fout("student.bin", ios::binary);
fout.write((char*)&s, sizeof(s));
fout.close();
以后再读取:
Student s;
ifstream fin("student.bin", ios::binary);
fin.read((char*)&s, sizeof(s));
fin.close();
然后:
cout << s.age << endl;
cout << s.score << endl;
输出:
10
95
这就非常有意思了。
我们实际上制造了一个:
自己的二进制数据文件。
二十、这就是“二进制数据库”的雏形
比如我们有:
struct Student
{
int id;
int age;
int score;
};
保存 100 个学生:
Student a[100];
可以:
fout.write((char*)a, sizeof(a));
于是硬盘里就有:
学生1的数据
学生2的数据
学生3的数据
...
学生100的数据
这其实已经非常接近一些简单的数据文件了。
孩子以后学习:
-
文件随机访问
-
数据库存储
-
序列化
-
二进制协议
-
网络通信
都会再次遇到这种思想。
二十一、但是这里有一个非常重要的“坑”
如果我们写:
struct Student
{
int age;
string name;
};
然后:
fout.write((char*)&s, sizeof(s));
不能简单认为这样就能正确保存 name。
为什么?
因为:
string
里面本身还有指针、长度、容量等管理信息。
你保存的可能只是 string 对象内部的管理结构,而不是字符串真正的文字内容。
这是:
“直接保存对象内存”最大的陷阱之一。
所以:
int
double
char
这种简单类型比较适合直接研究这种二进制读写。
而:
string
vector
指针
就需要专门设计保存格式。
这个坑以后讲“序列化”时再详细展开。
二十二、还有一个更大的坑:不同电脑可能不一样
例如:
int x = 100;
我们直接保存它的内存:
fout.write((char*)&x, sizeof(x));
这种方式有一个问题:
不同机器的整数表示、字节序等可能存在差异。
比如常见的:
小端序 Little Endian
大端序 Big Endian
这也是为什么真正的:
-
网络协议
-
文件格式
-
数据库
-
跨平台数据交换
通常不能简单粗暴地:
write((char*)&object, sizeof(object));
就完事。
不过对于我们现在学的小学生课程:
先把“内存中的字节 ↔ 文件中的字节”这个核心思想理解清楚,比马上研究字节序更重要。
二十三、把 read() 和 write() 彻底记住
记住下面这张图:
内存
│
│
┌────┴────┐
│ │
write() read()
│ │
↓ ↑
文件 ←─────
更准确一点:
write()
内存 ─────────────────→ 文件
read()
内存 ←───────────────── 文件
所以:
write:内存 → 文件
read:文件 → 内存
二十四、再把 <<、>>、write()、read() 放在一起
这几个符号特别容易混。
可以这样记:
文本方式
fout << x;
内存
↓
格式化
↓
文本文件
读取:
fin >> x;
文本文件
↓
解析
↓
内存
二进制方式
fout.write((char*)&x, sizeof(x));
内存
↓
原始字节
↓
文件
读取:
fin.read((char*)&x, sizeof(x));
文件
↓
原始字节
↓
内存
二十五、给孩子一个超级好记的口诀
<<:翻译成人话再写。
>>:把人话读进来再翻译。
write():字节原样搬进去。
read():字节原样搬出来。
ios::binary:告诉系统,按原始字节处理。
二十六、最后做一个“小小文件魔法师”实验
同学们亲手完成。
实验 1:制造一个二进制文件
int x = 2026;
ofstream fout("test.bin", ios::binary);
fout.write((char*)&x, sizeof(x));
fout.close();
然后:
用 HxD 打开
test.bin。
观察:
文件里面到底是什么?
实验 2:再把它读回来
int x = 0;
ifstream fin("test.bin", ios::binary);
fin.read((char*)&x, sizeof(x));
fin.close();
cout << x << endl;
看看:
2026
实验 3:挑战题
把:
int x = 2026;
改成:
int a = 10;
int b = 20;
int c = 30;
然后:
fout.write((char*)&a, sizeof(a));
fout.write((char*)&b, sizeof(b));
fout.write((char*)&c, sizeof(c));
再读取:
fin.read((char*)&a, sizeof(a));
fin.read((char*)&b, sizeof(b));
fin.read((char*)&c, sizeof(c));
最后:
cout << a << " "
<< b << " "
<< c << endl;
输出:
10 20 30
然后再用 HxD 打开文件。
这时候孩子会看到:
“原来我写的 C++ 变量,真的变成了硬盘里的一个个字节!”
这就是这一课最有价值的“顿悟”。
二十七、把整个知识体系串起来
到这里,可以把孩子已经学过的知识串成一条线:
C++变量
│
↓
内存
│
│ 一个个字节
↓
(char*)&x
│
↓
write()
│
↓
二进制文件
│
↓
HxD
│
↓
看到十六进制
│
↓
还原成二进制
反过来:
二进制文件
│
↓
read()
│
↓
(char*)&x
│
↓
内存
│
↓
C++变量
如果孩子真正理解了这条线,那么他就已经开始理解一个非常重要的计算机思想:
计算机世界里,很多东西最终都可以落到“内存中的一串字节”。
C++的厉害之处,就是我们可以非常接近这些字节,直接控制它们。
188

被折叠的 条评论
为什么被折叠?



