任务三 读写文件(二进制)
一、“流”
(一)“流”是什么
流(Stream) 就是数据在程序和外部设备之间传输的通道。
- 可以把流想象成**“水管”**:
- 数据 = 水
- 程序 = 水池
- 文件/网络/硬盘 = 另一个水池
- 流的作用:读数据、写数据、传输数据。
- 本质:连续的字节序列。
(二)“流”的分类
-
按方向分
- 输入流:从外部 → 程序(读)
- 输出流:从程序 → 外部(写)
-
按操作对象分
- 文件流(FileStream):操作文件
- 内存流(MemoryStream):操作内存
- 网络流(NetworkStream):操作网络
-
按数据类型分
- 字节流:以字节为单位(FileStream)
- 字符流:以字符为单位(StreamReader/StreamWriter)
二、FileStream
(一)FileStream 类是什么
FileStream 是 C# 中以字节为单位操作文件的流类,位于 System.IO。
作用:
- 读取文件字节
- 写入文件字节
- 对文件进行低级、底层的读写(图片、视频、文本都能用)
FileStream 就是一个“数据流”,它负责在你的程序(内存)和硬盘上的文件之间建立一条通道,让数据可以像水流一样持续地、顺序地读写。
FileStream是System.IO命名空间下的字节流类,用于以字节为单位读写文件;- 使用前需引入命名空间:
using System.IO;; - 所有示例均使用
using块管理FileStream资源,避免文件句柄泄漏; - 字节操作需注意编码(如UTF-8),文本内容需转换为字节数组后读写。
(二)字节流
可以把FileStream理解为“程序和文件之间传输字节的「管道」”,这个“管道”有以下关键特点:
FileStream本质是字节流,其传输/存储的最小单位是「字节(Byte)」,而字节最终由二进制0和1组成;FileStream作为“数据流”的核心特点,是围绕“字节”的传输规则展开的。FileStream只认「字节(Byte)」,不管你写入的是数字、字符串还是中文,最终都会转换成字节数组,再写入文件;- 1个字节 = 8位二进制(0/1),字节的取值范围是 0~255;比如字节65对应二进制
01000001 - 不同类型的数据,转换为字节的规则不同(这是理解的关键)。
1. 字节与二进制的关系
FileStream操作的是「字节」,但字节的本质是8位二进制数(0/1):
- 1字节 = 8位(bit),每位只能是0或1;
- 比如字节值
65→ 二进制01000001→ 对应ASCII码的字符'A'; - 比如bool值
true→ 存储为字节1(二进制00000001),false→字节0(二进制00000000)。
2. FileStream与二进制的关系(新手易理解)
- 传输层面:FileStream在程序和文件之间传输的是「字节」,你无需直接操作0和1(由系统自动处理);
- 存储层面:文件在硬盘上最终以二进制0和1存储,FileStream是程序访问这些二进制数据的“桥梁”;
- 示例:用
fs.WriteByte(65)写入1个字节,硬盘上会存储8位二进制01000001;用fs.ReadByte()读取时,系统把二进制转回字节65,返回给程序。
3. 文本文件 vs 二进制文件(FileStream视角)
对FileStream来说,文本文件和二进制文件没有本质区别——都是字节流:
- 文本文件:字节是“字符的编码值”(比如UTF8的“中”对应字节
0xE4 0xB8 0xAD); - 二进制文件:字节是“数值/对象的原始存储”(比如int 12345对应4个字节
0x30 0x39 0x00 0x00); - 区别仅在于“字节的解读方式”:用StreamReader(按编码解读)就是文本文件,用BinaryReader(按类型解读)就是二进制文件。
案例1:数字类
先明确:数字的“字面量”和“存储字节”是两回事
比如你看到的数字100,FileStream不会直接写“100”这三个字符,而是按「数值类型的字节存储规则」转换(以最常用的int类型为例,int占4个字节)。
| 原始数字 | 数据类型 | 转换为字节数组(十六进制) | 对应二进制(8位/字节) | FileStream实际写入的内容 |
|---|---|---|---|---|
| 100 | int | 0x64 0x00 0x00 0x00(小端存储) | 01100100 00000000 00000000 00000000 | 4个字节:64、0、0、0 |
| 123 | int | 0x7B 0x00 0x00 0x00 | 01111011 00000000 00000000 00000000 | 4个字节:123、0、0、0 |
| 123 | byte | 0x7B | 01111011 | 1个字节:123 |
关键解释:
- 你写
fs.WriteByte(100)(写入byte类型100):实际写入1个字节0x64,对应二进制01100100; - 你写
bw.Write(100)(BinaryWriter写入int类型100):实际写入4个字节(int占4字节),按“小端序”存储为64 00 00 00; - 数字
0123在代码里是123(前导0无意义),所以转换规则和123一致。
案例2:字符串类
关键:字符串转换为字节,必须指定「编码」(如UTF8、ASCII),不同编码的字节结果不同
FileStream本身不处理编码,需通过StreamWriter/BinaryWriter指定编码,再转换为字节。
子案例2.1:字符串 "helloworld"(ASCII/UTF8编码,英文兼容)
ASCII编码下,每个英文字母对应1个字节(值=ASCII码),UTF8编码对英文和ASCII完全一致。
| 字符串字符 | h | e | l | l | o | w | o | r | l | d |
|---|---|---|---|---|---|---|---|---|---|---|
| ASCII码值 | 104 | 101 | 108 | 108 | 111 | 119 | 111 | 114 | 108 | 100 |
| 十六进制 | 0x68 | 0x65 | 0x6C | 0x6C | 0x6F | 0x77 | 0x6F | 0x72 | 0x6C | 0x64 |
| 二进制 | 01101000 | 01100101 | 01101100 | 01101100 | 01101111 | 01110111 | 01101111 | 01110010 | 01101100 | 01100100 |
| FileStream写入 | 10个字节:104、101、108、108、111、119、111、114、108、100 |
代码验证(写入"helloworld",读取字节):
using (FileStream fs = new FileStream("test.txt", FileMode.Create, FileAccess.Write))
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
sw.Write("helloworld"); // 按UTF8编码转换为字节后写入
}
// 读取验证字节
using (FileStream fs = new FileStream("test.txt", FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[10];
fs.Read(buffer, 0, 10);
Console.WriteLine(string.Join(",", buffer)); // 输出:104,101,108,108,111,119,111,114,108,100
}
子案例2.2:字符串 "测试"(UTF8编码,中文占多字节)
UTF8编码下,1个中文汉字占3个字节,这也是为什么直接用FileStream读中文会乱码(没按编码转换)。
| 字符串字符 | 测 | 试 |
|---|---|---|
| UTF8字节(十六进制) | 0xE6 0xB5 0x8B | 0xE8 0xAF 0x95 |
| 二进制 | 11100110 10110101 10001011 | 11101000 10101111 10010101 |
| FileStream写入 | 6个字节:230、181、139、232、175、149 |
代码验证:
using (FileStream fs = new FileStream("test.txt", FileMode.Create, FileAccess.Write))
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
sw.Write("测试");
}
using (FileStream fs = new FileStream("test.txt", FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[6];
fs.Read(buffer, 0, 6);
Console.WriteLine(string.Join(",", buffer)); // 输出:230,181,139,232,175,149
}
案例3:布尔值(true/false)
布尔值在二进制文件中通常存储为1个字节:
| 布尔值 | 转换为字节 | 二进制 | FileStream写入内容 |
|---|---|---|---|
| true | 0x01 | 00000001 | 1个字节:1 |
| false | 0x00 | 00000000 | 1个字节:0 |
核心总结(新手必记)
- FileStream的“底层字节流”:不管你写入的是数字、字符串、中文,最终都会被转换成字节数组,再写入文件;字节的底层是二进制0和1(1字节=8位);
- 不同数据类型的转换规则:
- 数字(int):按类型长度转换(int=4字节、byte=1字节),小端存储;
- 字符串:按编码转换(UTF8英文1字节、中文3字节;ASCII仅支持英文);
- 布尔值:1字节(1=true,0=false);
- 通俗理解:
- 你看到的
100→ FileStream眼里是64(字节) → 硬盘里存的是01100100(二进制); - 你看到的
helloworld→ FileStream眼里是104,101,...100(字节数组) → 硬盘里是一串01组合; - 你看到的
测试→ FileStream眼里是230,181,...149(字节数组) → 硬盘里是更长的01组合。
- 你看到的
简单记:FileStream不认识“数字/文字”,只认识“字节”;字节是0和1组成的8位组合,是计算机存储的最小单位。
(三)特点
- 字节流
- 操作大文件、二进制文件最常用
- 需要手动控制字节数组、缓冲区
想象一下,你要用一个水管给花园浇水。
- 文件就是你的水桶(数据存储的地方)。
- FileStream就是那根水管。
- 数据就是水。
(四)FileStream类的基本用法
用法 1:传统语法
// 1. 引用命名空间
using System.IO;
// 2. 创建 FileStream 对象(打开/创建文件)
FileStream fs = new FileStream("文件路径", FileMode, FileAccess);
// 3. 读 / 写数据
fs.Read(byte数组, 偏移, 长度); // 读
fs.Write(byte数组, 偏移, 长度); // 写
// 4. 关闭并释放流
fs.Close();
用法 2:using 自动释放(推荐):
针对 FileStream 的场景,逐部分解释:
| 语法部分 | 作用说明 |
|---|---|
using | 关键字,标记“资源管理块”,告诉编译器:这个块内的对象是“一次性资源”,用完要自动释放 |
(FileStream fs = new FileStream(...)) | 1. 创建FileStream对象并赋值给变量fs;2. 这个对象必须实现 IDisposable 接口(FileStream天然实现),这是using的前提;3. 括号内只能声明“需要释放的资源对象” |
{ ... } | 资源的作用域: 1. fs 只能在花括号内使用,外部无法访问;2. 花括号执行完毕(无论正常结束还是抛出异常),编译器会自动调用 fs.Dispose() 方法 |
// 完整结构
using (资源类型 变量名 = 新建资源对象)
{
// 只有在花括号内,这个资源对象才可用
// 这里执行文件的读写操作
} // 花括号结束时,编译器自动调用资源对象的Dispose()方法,释放资源
示例
using (FileStream fs = new FileStream(...))
{
// 读写操作
}
(五)FileMode
FileMode 是枚举,用来指定文件打开/创建的方式。
| 枚举取值 | 核心用法 | 适用场景 | 考试易错点/注意事项 |
|---|---|---|---|
| Create | 创建新文件;若文件已存在,覆盖原有文件(清空所有内容) | 需重新生成文件(如写入全新数据) | 1. 会覆盖已有文件,慎用; 2. 文件不存在时创建,存在时直接覆盖,无任何提示 |
| CreateNew | 创建新文件;若文件已存在,抛出IOException异常 | 确保文件是全新的(如生成唯一日志文件) | 1. 避免重复创建相同文件; 2. 比Create更严格,考试常考与Create的区别 |
| Open | 打开已存在的文件;文件不存在则抛出FileNotFoundException异常 | 仅读取已有文件(如读取配置文件) | 1. 必须确保文件已存在; 2. 考试高频考点(最常用的读取模式) |
| OpenOrCreate | 文件存在则打开,不存在则创建 (默认值) | 不确定文件是否存在时(如首次运行的配置文件) | 1. 不会覆盖已有文件; 2. 若文件新建后未写入内容,会生成空文件 |
| Append | 打开文件并将流位置定位到文件末尾;文件不存在则创建 | 向文件末尾追加内容(如日志记录) | 1. 仅能配合FileAccess.Write使用; 2. 写入内容只会追加到末尾,不会修改原有内容 |
| Truncate | 打开已存在的文件并清空所有内容;文件不存在则报错 | 清空文件后重新写入(保留文件本身,仅删内容) | 1. 必须文件已存在; 2. 区别于Create:Truncate是“清空已有文件”,Create是“覆盖/新建文件” |
语法:
FileStream(string path, FileMode mode)
// FileAccess.ReadWrite(默认值)
参数解释:
-
string path:-
是什么:文件在磁盘上的位置,可以是绝对路径(如
C:\MyFiles\test.txt)或相对路径(如data.txt,表示在程序运行目录下)。 -
注意:路径中的反斜杠
\在C#字符串中是转义字符,所以要写两个\\或者使用@前缀。推荐使用@。string goodPath1 = "C:\\MyFiles\\test.txt";
string goodPath2 = @"C:\MyFiles\test.txt"; // 更清晰
-
-
FileMode mode(这是一个枚举类型):- 是什么:它告诉操作系统你打算如何打开这个文件。这是最关键的参数!
- 常见选项:
FileMode.Create:创建新文件。如果文件已存在,它会被覆盖(旧内容清空)。“不管有没有,给我一个新的!”FileMode.Open:打开已存在的文件。如果文件不存在,则抛出FileNotFoundException异常。“把那个已有的文件给我打开。”FileMode.CreateNew:创建新文件。如果文件已存在,则抛出异常。“我要创建一个全新的,不能有重名的!”FileMode.Append:打开文件并移动到末尾,准备添加数据。如果文件不存在,会创建一个新文件。“我要在文件后面接着写。”
(六)FileAccess 枚举
指定路径、模式和访问方式
这个构造函数在第一个的基础上增加了更精细的控制:你不仅可以决定如何打开文件,还能决定用这个文件来做什么(只读、只写、读写)。
语法:
FileStream(string path, FileMode mode, FileAccess access)
新增参数解释:
FileAccess access(这是一个枚举类型):- 是什么:它规定了你的程序对文件的访问权限。在多线程或需要安全控制的场景下非常有用。
- 常见选项:
FileAccess.Read:只读。你可以读取文件,但不能修改它。FileAccess.Write:只写。你可以向文件写入数据,但不能读取它。FileAccess.ReadWrite:可读可写。这是最灵活的选项。
FileAccess 是枚举,指定对流的访问权限。
| 枚举取值 | 核心用法 | 适用场景 | 考试易错点/注意事项 |
|---|---|---|---|
| Read | 仅赋予“读取”权限,无法写入/修改文件 | 仅读取文件内容(如查看配置、读取日志) | 1. 若尝试调用Write/WriteByte等写入方法,会抛出NotSupportedException; 2. 考试常考“权限与操作不匹配”的异常场景 |
| Write | 仅赋予“写入”权限,无法读取文件内容 | 仅写入/覆盖文件(如生成报告、写入日志) | 1. 若尝试调用Read/ReadByte等读取方法,会抛出NotSupportedException; 2. Append模式必须配合Write权限 |
| ReadWrite | 同时赋予“可读+可写”权限 默认值 | 需同时读写文件(如修改配置文件、编辑文档) | 1. 权限最大,但占用资源更多; 2. 非必要时优先用Read/Write(最小权限原则) |
补充:FileMode + FileAccess 常用组合(考试高频考点)
| 组合示例 | 场景说明 | 代码示例 |
|---|---|---|
| Open + Read | 读取已有文件(最常用) | new FileStream("test.txt", FileMode.Open, FileAccess.Read) |
| Create + Write | 新建/覆盖文件并写入 | new FileStream("test.txt", FileMode.Create, FileAccess.Write) |
| Append + Write | 向文件末尾追加内容 | new FileStream("log.txt", FileMode.Append, FileAccess.Write) |
| OpenOrCreate + ReadWrite | 兼容文件存在/不存在,且需读写 | new FileStream("config.dat", FileMode.OpenOrCreate, FileAccess.ReadWrite) |
(七)关键要点与最佳实践
- 务必使用
using语句:FileStream使用了非托管资源(如文件句柄)。using语句能确保即使在发生异常的情况下,流也能被正确关闭和释放,避免资源泄漏。这是极其重要的习惯!
- 理解组合效果:
- 不同的
FileMode和FileAccess组合有不同的效果。例如:FileMode.Open+FileAccess.Write:打开一个已存在的文件准备修改。FileMode.Create+FileAccess.Read:这没什么意义,因为你创建了一个新文件却只想读它(内容是空的)。
- 不同的
- 处理异常:
- 文件操作很容易出错(文件不存在、没有权限等)。一定要使用
try-catch块来捕获和处理可能的异常(如FileNotFoundException,UnauthorizedAccessException)。
- 文件操作很容易出错(文件不存在、没有权限等)。一定要使用
- 对于简单任务,有更简单的工具:
- 如果你只是读写一些文本,
File.WriteAllText和File.ReadAllText等方法更简单。 - 但
FileStream给你提供了最根本、最强大的控制能力,适用于处理大文件、二进制文件或需要特定读写模式的场景。
- 如果你只是读写一些文本,
(八)总结对比表
| 构造函数 | 特点 | 适用场景 |
|---|---|---|
FileStream(path, mode) | 基础,自动获得读写权限 | 大多数简单的文件创建、覆盖、读取场景 |
FileStream(path, mode, access) | 精细控制访问权限 | 需要明确限制为只读或只写,提高代码安全性和意图清晰度 |
给初学者的练习建议:
- 创建一个控制台程序。
- 分别使用
FileMode.Create和FileMode.Append向同一个文件写入文字,观察区别。 - 尝试用
FileMode.Open和FileAccess.Read去打开一个不存在的文件,看看会发生什么,然后学会用File.Exists或try-catch来处理它。
(九)总结(核心关键点)
- 单字节 vs 多字节:
WriteByte()/ReadByte():操作单个字节,适合逐字节处理(如二进制文件);Write()/Read():批量操作字节数组,适合文本/大文件(效率更高);
- 返回值规则:
ReadByte()返回int(0~255或-1),需判断-1表示文件末尾;Read()返回实际读取字节数,0表示文件末尾;
- 流位置:读写操作后流的位置自动后移,可通过
fs.Position手动调整位置; - 编码注意:文本内容读写需统一编码(如UTF-8),避免乱码;
- 资源释放:必须通过
using块/Close()释放FileStream,否则文件会被占用。
三、FileStream类的方法
一、FileStream.WriteByte() 方法
| 项 | 说明 |
|---|---|
| 用途 | 写入单个字节的数据;写入后流的位置自动后移1个字节;若流为只读模式,抛NotSupportedException。 |
| 语法 | fs.WriteByte(byte value)(fs为FileStream实例) |
| 参数 | value:byte类型,要写入的单个字节(取值范围0~255)。 |
| 返回值 | 无(void)。 |
基础示例
string filePath = "test.dat";
// 写入单个字节(ASCII码65对应字符'A')
using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
fs.WriteByte(65); // 写入字节65(二进制01100100)
fs.WriteByte((byte)'B'); // 也可直接传字符(自动转byte)
}
关键注意事项
- 写入后流的位置自动后移1个字节;
- 参数必须是
byte类型(超出0~255会编译报错); - 仅适合写入少量单个字节,批量写入效率低(优先用
Write())。
二、FileStream.Write() 方法
| 项 | 说明 |
|---|---|
| 用途 | 写入字节数组的指定片段;写入后流位置自动后移写入的字节数;是最常用的批量写方法。 |
| 语法 | fs.Write(byte[] buffer, int offset, int count) |
参数 buffer | 必传,byte[]类型,存储要写入的字节数组(数据源) |
参数 offset | 必传,int类型,从buffer的第几个索引开始读取字节(通常传0) |
参数 count | 必传,int类型,要从buffer中读取并写入的字节数 |
| 返回值 | void(无返回值) |
基础示例
string filePath = "test.dat";
// 批量写入字节数组
using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
byte[] data = new byte[] { 65, 66, 67, 68 }; // 对应字符A/B/C/D
fs.Write(data, 0, data.Length); // 从索引0开始,写入全部4个字节
// 进阶:只写入数组的一部分(比如索引1开始,写2个字节)
fs.Write(data, 1, 2); // 写入66、67(B、C)
}
关键注意事项
- 写入后流的位置自动后移
count个字节; - 若
offset + count超出数组长度,会抛出ArgumentOutOfRangeException; - 批量写入优先用此方法(减少IO交互,效率更高)。
三、FileStream.ReadByte() 方法
| 项 | 说明 |
|---|---|
| 用途 | 从FileStream当前位置读取单个字节;读取后流位置自动后移1个字节;若已到文件末尾,返回-1;若流为只写模式,抛NotSupportedException。 |
| 语法 | fs.ReadByte()(返回值需接收为int类型) |
| 参数 | 无。 |
| 返回值 | int类型:- 成功:读取到的字节值(0~255); - 失败(文件末尾):-1。 |
基础示例
string filePath = "test.dat";
// 读取单个字节
using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
int readByte;
// 循环读取直到流末尾(返回-1)
while ((readByte = fs.ReadByte()) != -1)
{
Console.WriteLine($"读取的字节:{readByte} → 字符:{(char)readByte}");
}
}
关键注意事项
- 读取后流的位置自动后移1个字节;
- 返回值是
int(而非byte),因为需要用-1标识流末尾(byte无负数); - 仅适合读取少量单个字节,批量读取效率低(优先用
Read())。
四、FileStream.Read() 方法
| 项 | 说明 |
|---|---|
| 用途 | 从FileStream当前位置读取批量字节到指定的字节数组中;读取后流位置自动后移实际读取的字节数;是最常用的批量读方法。 |
| 语法 | fs.Read(byte[] buffer, int offset, int count) |
参数 buffer | 必传,byte[]类型,用于存储读取到的字节(数据容器) |
参数 offset | 必传,int类型,从buffer的第几个索引开始存储读取的字节(通常传0) |
参数 count | 必传,int类型,期望读取的字节数 |
| 返回值 | int类型:✅ 成功:返回实际读取的字节数; ❌ 流末尾:返回0 |
基础示例
string filePath = "test.dat";
// 批量读取字节
using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[1024]; // 定义缓冲区(通常设1024/4096字节)
int actualRead;
// 循环读取直到流末尾(返回0)
while ((actualRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
Console.WriteLine($"实际读取字节数:{actualRead}");
// 输出读取的内容(仅处理实际读取的字节,避免缓冲区冗余)
for (int i = 0; i < actualRead; i++)
{
Console.Write($"{(char)buffer[i]} ");
}
Console.WriteLine();
}
}
关键注意事项
- 读取后流的位置自动后移
actualRead个字节; - 返回值是「实际读取的字节数」(可能小于
count,比如流末尾只剩3个字节,而count传5,则返回3); - 批量读取优先用此方法(缓冲区读取,减少IO交互);
- 缓冲区大小建议设为1024/4096字节(匹配系统IO块大小,效率最高)。
五、4个方法核心对比(考试必记)
| 方法 | 核心场景 | 效率 | 返回值关键 | 适用场景 |
|---|---|---|---|---|
| WriteByte() | 写入单个字节 | 低 | 无 | 少量单个字节写入 |
| Write() | 批量写字节数组 | 高 | 无 | 批量二进制数据写入(推荐) |
| ReadByte() | 读取单个字节 | 低 | 字节值/-1(末尾) | 少量单个字节读取 |
| Read() | 批量读字节到数组 | 高 | 实际读取数/0(末尾) | 批量二进制数据读取(推荐) |
六、总结(考试核心记忆点)
- 写入二选一:单字节用
WriteByte(),批量用Write()(优先Write()); - 读取二选一:单字节用
ReadByte(),批量用Read()(优先Read()); - 返回值易错点:
ReadByte()返回int(-1表示末尾),WriteByte()无返回值;Read()返回「实际读取字节数」(0表示末尾),Write()无返回值;
- 核心规则:所有方法操作后,流的位置会自动后移对应字节数;
- 考试考点:
- 能区分4个方法的用途和参数;
- 能用
Read()/Write()实现二进制文件的批量读写; - 理解
Read()返回“实际读取数”的意义(流末尾的处理)。
简单记:单字节用Byte后缀方法,批量用无后缀方法;写入无返回值,读取有返回值(标识末尾/实际读取数)。