1:本篇学习目标
- 理解 C# 异常机制的底层原理,掌握
try-catch-finally的执行流程 - 熟悉.NET 异常类的完整层次结构,掌握常用系统异常的使用场景
- 明确
throw与throw ex的本质区别,掌握正确的异常抛出方式 - 深入理解
using语句与IDisposable的底层关联,掌握资源释放机制 - 掌握.NET 文件 IO 的整体架构,理解流 (Stream) 的核心概念
- 熟练使用
File/Directory工具类、FileStream、StreamReader/Writer进行文件操作 - 彻底理解字符编码原理,避开 C++ 开发者最容易踩的编码坑
- 所有知识点均与 C++ 对应概念进行深度对比,讲清底层差异
第一部分:C#异常处理机制
2:异常的本质与底层原理
异常是程序运行时出现的错误情况或意外行为。C# 采用结构化异常处理 (SEH)机制,由 CLR 统一管理异常的抛出、传播与捕获。
1:C#异常机制的核心特点
- 统一的异常基类 :所有异常都必须继承自
System.Exception类,不能像 C++ 那样抛出任意类型(int、字符串、自定义类型等)。 - 托管栈展开 :异常抛出后,CLR 会沿着调用栈向上回溯,查找匹配的
catch块,这个过程称为栈展开 (Stack Unwinding)。 - 确定性资源清理 :配合
finally和using语句,保证异常发生时资源也能被正确释放。 - 丰富的元数据:异常对象包含错误消息、调用栈追踪、帮助链接等完整信息,便于调试定位问题。
2:与C++异常的对比
| 对比维度 | C# 异常 | C++ 异常 |
|---|---|---|
| 异常类型 | 必须继承自System.Exception |
可以抛出任意类型(int、字符串、自定义类等) |
| 管理方式 | CLR 托管执行,统一栈展开 | 编译器生成展开代码,运行时库处理 |
| 异常规范 | 不支持异常规格说明(已废弃) | C++11 后支持noexcept |
| 性能开销 | 无异常时零开销,抛出异常时有较大开销 | 无异常时零开销,抛出异常时开销也较大 |
| 资源释放 | 依赖finally/using |
依赖 RAII(析构函数自动释放) |
| 栈追踪 | 异常对象自带完整调用栈信息 | 需要手动捕获栈信息,标准库不提供 |
3:try-catch-finally基础语法
1:基本结构
cs
try
{
// 可能抛出异常的代码
}
catch (ExceptionType1 ex)
{
// 处理ExceptionType1类型的异常
}
catch (ExceptionType2 ex)
{
// 处理ExceptionType2类型的异常
}
finally
{
// 无论是否发生异常都会执行的代码,通常用于释放资源
}
执行规则:
try块中的代码正常执行完毕,跳过所有catch块,执行finally块try块中抛出异常,CLR 按顺序匹配catch块,找到匹配的则执行对应catch,然后执行finally- 没有匹配的
catch块,异常继续向上传播,执行完finally后抛出到上层 finally块一定会执行 ,即使try中有return、break等跳转语句
2:代码示例
cs
public static int Divide(int dividend, int divisor)
{
try
{
return dividend / divisor;
}
catch (DivideByZeroException ex)
{
Console.WriteLine($"捕获到除零异常:{ex.Message}");
return 0;
}
catch (ArithmeticException ex)
{
Console.WriteLine($"捕获到算术异常:{ex.Message}");
return -1;
}
finally
{
Console.WriteLine("Divide方法执行完毕,finally块被调用");
}
}
注意:
catch块必须从最具体的异常类型 到最通用的异常类型排列,否则编译报错- 不推荐直接捕获
Exception(捕获所有异常),应该只捕获你能处理的异常
4:.NET异常类层次结构
.NET 提供了完整的异常类体系,所有异常都继承自System.Exception。常用的系统异常分为两大类:
1:系统级异常(SystemException派生)
由 CLR 抛出,代表运行时错误:
| 异常类型 | 触发场景 | 对应 C++ 概念 |
|---|---|---|
NullReferenceException |
访问 null 对象的成员 | 空指针解引用 |
IndexOutOfRangeException |
数组索引越界 | 数组越界(C++ 默认不检查) |
DivideByZeroException |
整数除以零 | 整数除零(C++ 是未定义行为) |
InvalidCastException |
类型转换失败 | dynamic_cast返回 null / 抛出 bad_cast |
ArgumentNullException |
参数为 null | 无对应,需手动判断 |
ArgumentOutOfRangeException |
参数值超出有效范围 | 无对应,需手动判断 |
InvalidOperationException |
对象当前状态不支持该操作 | 无对应,需手动抛出 |
IOException |
IO 操作失败(文件不存在、权限不足等) | std::ios_base::failure |
2:应用级异常(ApplicationException派生)
由用户代码抛出,代表业务逻辑错误。自定义异常通常继承自Exception或ApplicationException。
5:异常的抛出:throw与throw ex的本质区别
这是 C# 异常处理中最容易踩的坑,也是 C++ 开发者最容易混淆的点。
1:throw重新抛出原始异常
cs
try
{
// 可能出错的代码
}
catch (Exception ex)
{
// 记录日志
LogError(ex);
throw; // 重新抛出原始异常,保留完整的调用栈信息
}
效果:异常的栈追踪信息保持不变,上层能看到异常最初抛出的位置。
2:throw ex抛出新的异常实例
cs
try
{
// 可能出错的代码
}
catch (Exception ex)
{
throw ex; // 重置异常栈,栈追踪从这一行开始,原始位置丢失
}
效果 :异常的调用栈被重置,上层只能看到异常在throw ex这一行抛出,看不到最初出错的位置。
3:内部异常InnerException
如果需要包装异常并添加额外信息,应该使用新的异常对象并传入内部异常:
cs
try
{
File.ReadAllText("data.txt");
}
catch (FileNotFoundException ex)
{
throw new InvalidOperationException("读取配置文件失败", ex);
}
这样上层捕获异常时,可以通过InnerException属性获取原始异常,完整保留错误链路。
6:自定义异常
当系统异常不能准确描述业务错误时,可以定义自定义异常类。
自定义异常规范:
- 类名以
Exception结尾 - 继承自
Exception或ApplicationException - 提供三个标准构造函数:无参、带消息、带消息和内部异常
cs
// 自定义异常
public class InsufficientBalanceException : Exception
{
public decimal CurrentBalance { get; }
public decimal RequiredAmount { get; }
public InsufficientBalanceException() : base("余额不足")
{
}
public InsufficientBalanceException(string message) : base(message)
{
}
public InsufficientBalanceException(string message, Exception innerException)
: base(message, innerException)
{
}
public InsufficientBalanceException(decimal currentBalance, decimal requiredAmount)
: base($"余额不足,当前余额:{currentBalance},需要:{requiredAmount}")
{
CurrentBalance = currentBalance;
RequiredAmount = requiredAmount;
}
}
// 使用自定义异常
public static void Withdraw(decimal balance, decimal amount)
{
if (amount > balance)
{
throw new InsufficientBalanceException(balance, amount);
}
balance -= amount;
}
7:using语句的底层原理
using语句是 C# 中管理非托管资源的核心语法,本质是try-finally的语法糖。
1:底层展开逻辑
cs
using (FileStream fs = new FileStream("test.txt", FileMode.Open))
{
// 使用文件流
}
编译器会自动展开为:
cs
FileStream fs = new FileStream("test.txt", FileMode.Open);
try
{
// 使用文件流
}
finally
{
if (fs != null)
{
((IDisposable)fs).Dispose();
}
}
2:核心要点
using语句的对象必须实现IDisposable接口- 离开
using作用域时,自动调用Dispose()方法释放资源 - 即使
using块内发生异常,finally也会保证资源释放 - C# 8.0 + 支持 using 声明,作用域覆盖整个代码块:
cs
using FileStream fs = new FileStream("test.txt", FileMode.Open);
// 后续代码都在using作用域内
3:与RAII对比
- C++ RAII:对象离开作用域时自动调用析构函数释放资源,语言原生支持,无需额外接口
- C# using :需要手动包裹,依赖
IDisposable接口,垃圾回收不保证释放时机,必须显式释放非托管资源
第二部分:C#文件IO系统
8:.NET的IO整体框架
.NET 的文件 IO 系统位于System.IO命名空间下,分为三层架构:
- 工具类层 :
File、Directory、Path、FileInfo、DirectoryInfo,提供静态 / 实例方法快速操作文件与目录 - 流层 :
Stream抽象基类及其派生类(FileStream、MemoryStream、BufferedStream),处理字节级别的读写 - 读写器层 :
StreamReader/StreamWriter(文本)、BinaryReader/BinaryWriter(二进制),在流的基础上提供高级读写能力
1:流(Stream)的核心概念
流是字节序列的抽象,是.NET IO 的核心。所有 IO 操作最终都通过流完成。
- 底层本质:流封装了非托管的操作系统句柄(文件句柄、网络句柄等),托管代码通过流与内核交互
- 三大基本操作:读取 (Read)、写入 (Write)、查找 (Seek)
- 缓冲区:流默认带有缓冲区,减少内核态切换次数,提高 IO 性能
2:与C++ IO流的对比
| 对比维度 | C# Stream 体系 | C++ iostream 体系 |
|---|---|---|
| 基类 | Stream(字节流抽象) |
std::basic_streambuf |
| 文本处理 | StreamReader/Writer 独立于 Stream |
iostream 直接支持文本与字节 |
| 编码处理 | 显式指定 Encoding,默认 UTF-8 | 依赖本地环境编码,默认窄字符 |
| 异常处理 | 默认抛出异常 | 默认设置错误标志,需手动开启异常 |
| 资源释放 | 必须调用Dispose/ 使用using |
对象析构自动关闭 |
9:文件与目录操作工具类
1:File类(静态文件操作工具)
File类提供了大量静态方法,用于快速执行常见文件操作,适合一次性读写小文件。
cs
// 判断文件是否存在
bool exists = File.Exists("test.txt");
// 一次性读写全部文本(最常用)
File.WriteAllText("test.txt", "Hello C#");
string content = File.ReadAllText("test.txt");
// 按行读写
string[] lines = File.ReadAllLines("test.txt");
File.WriteAllLines("test.txt", new string[] { "第一行", "第二行" });
// 读写字节数组
byte[] bytes = File.ReadAllBytes("test.bin");
File.WriteAllBytes("test.bin", bytes);
// 复制、移动、删除
File.Copy("source.txt", "dest.txt", overwrite: true);
File.Move("source.txt", "dest.txt");
File.Delete("test.txt");
注意: File类的一次性读写方法会将整个文件加载到内存中,大文件禁止使用,应该用流逐段读写。
2:Directory类(静态目录操作工具)
cs
// 创建、删除目录
Directory.CreateDirectory("subdir");
Directory.Delete("subdir", recursive: true); // recursive=true删除子目录和文件
// 判断目录是否存在
bool dirExists = Directory.Exists("subdir");
// 枚举目录下的文件和子目录
string[] files = Directory.GetFiles(".", "*.txt");
string[] dirs = Directory.GetDirectories(".");
3:Path类(路径处理工具)
用于处理路径字符串,避免手动拼接出错:
cs
// 拼接路径(自动处理分隔符)
string fullPath = Path.Combine("dir1", "dir2", "file.txt");
// 获取文件名、扩展名、目录名
string fileName = Path.GetFileName(fullPath);
string extension = Path.GetExtension(fullPath);
string directory = Path.GetDirectoryName(fullPath);
// 获取临时文件、临时目录
string tempFile = Path.GetTempFileName();
string tempDir = Path.GetTempPath();
10:字节流FileStream
FileStream是最底层的文件操作类,直接对应操作系统的文件句柄,以字节为单位进行读写。
1:基础读写操作
cs
// 使用using保证资源释放
using (FileStream fs = new FileStream("data.bin", FileMode.Create, FileAccess.Write))
{
byte[] data = { 0x48, 0x65, 0x6C, 0x6C, 0x6F }; // "Hello"的ASCII码
fs.Write(data, 0, data.Length);
}
// 读取文件
using (FileStream fs = new FileStream("data.bin", FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[1024];
int bytesRead = fs.Read(buffer, 0, buffer.Length);
Console.WriteLine($"读取了{bytesRead}个字节");
}
2:核心枚举参数
FileMode:指定打开文件的方式(Create、Open、Append、CreateNew等)FileAccess:指定访问权限(Read、Write、ReadWrite)FileShare:指定共享方式(None、Read、Write等)
3:底层原理
FileStream内部持有一个非托管的SafeFileHandle,封装了 Windows 的文件句柄- 默认启用 8KB 的内部缓冲区,减少系统调用次数
Dispose()时会刷新缓冲区并关闭文件句柄,不释放会导致文件被占用
11:文本流StreamReader与StreamWriter
专门用于处理文本文件,在FileStream的基础上封装了字符编码转换功能。
1:字符编码详解
这是从 C++ 转 C# 最容易踩的坑:
- C++ 的
std::string本质是字节数组,编码由开发者决定 - C# 的
string是 UTF-16 编码的 Unicode 字符序列,内存中固定是 2 字节一个字符 - 读写文本文件时,必须进行字节序列 ↔ Unicode 字符的编码转换
常用编码:
Encoding.UTF8:最通用,默认推荐Encoding.Unicode:UTF-16,和 C# 字符串内存一致Encoding.ASCII:纯英文场景Encoding.GetEncoding("GB2312"):中文简体,Windows 旧系统常用
2:StreamWriter写文本
cs
// 写入文本文件,指定UTF-8编码
using (StreamWriter sw = new StreamWriter("output.txt", append: false, Encoding.UTF8))
{
sw.WriteLine("第一行文本");
sw.WriteLine("第二行文本");
sw.Write("第三行不换行");
}
3:StreamReader 读文本
cs
// 读取文本文件
using (StreamReader sr = new StreamReader("output.txt", Encoding.UTF8))
{
// 逐行读取
string line;
while ((line = sr.ReadLine()) != null)
{
Console.WriteLine(line);
}
// 一次性读取全部
// string all = sr.ReadToEnd();
}
注意: 如果不指定编码,StreamReader会尝试自动检测 BOM,可能导致中文乱码。建议始终显式指定编码。
12:二进制读写BinaryReader和BinaryWriter
用于读写基本数据类型的二进制表示,适合存储结构化数据,对应 C++ 的二进制文件读写。
cs
// 写入二进制
using (BinaryWriter bw = new BinaryWriter(File.Open("data.bin", FileMode.Create)))
{
bw.Write(123); // int
bw.Write(3.14); // double
bw.Write(true); // bool
bw.Write("Hello C#"); // string(会自动写入长度前缀)
}
// 读取二进制
using (BinaryReader br = new BinaryReader(File.Open("data.bin", FileMode.Open)))
{
int intVal = br.ReadInt32();
double doubleVal = br.ReadDouble();
bool boolVal = br.ReadBoolean();
string strVal = br.ReadString();
Console.WriteLine($"{intVal}, {doubleVal}, {boolVal}, {strVal}");
}
13:总结
本篇我们深入了 C# 的两大基础系统:异常处理与文件 IO。对于 C++ 开发者,核心要记住的差异点:
- C# 异常只能抛出
Exception派生类,自带完整栈追踪,throw和throw ex有本质区别 - 非托管资源必须用
using语句显式释放,不能依赖垃圾回收 - C# 字符串是 UTF-16 Unicode,读写文本文件必须显式处理编码转换
- .NET IO 分层清晰:工具类适合快速操作,流适合大文件和底层控制