在C++基础上理解CSharp-7

1:本篇学习目标

  • 理解 C# 异常机制的底层原理,掌握try-catch-finally的执行流程
  • 熟悉.NET 异常类的完整层次结构,掌握常用系统异常的使用场景
  • 明确throwthrow ex的本质区别,掌握正确的异常抛出方式
  • 深入理解using语句与IDisposable的底层关联,掌握资源释放机制
  • 掌握.NET 文件 IO 的整体架构,理解流 (Stream) 的核心概念
  • 熟练使用File/Directory工具类、FileStreamStreamReader/Writer进行文件操作
  • 彻底理解字符编码原理,避开 C++ 开发者最容易踩的编码坑
  • 所有知识点均与 C++ 对应概念进行深度对比,讲清底层差异

第一部分:C#异常处理机制

2:异常的本质与底层原理

异常是程序运行时出现的错误情况或意外行为。C# 采用结构化异常处理 (SEH)机制,由 CLR 统一管理异常的抛出、传播与捕获。

1:C#异常机制的核心特点

  • 统一的异常基类 :所有异常都必须继承自System.Exception类,不能像 C++ 那样抛出任意类型(int、字符串、自定义类型等)。
  • 托管栈展开 :异常抛出后,CLR 会沿着调用栈向上回溯,查找匹配的catch块,这个过程称为栈展开 (Stack Unwinding)
  • 确定性资源清理 :配合finallyusing语句,保证异常发生时资源也能被正确释放。
  • 丰富的元数据:异常对象包含错误消息、调用栈追踪、帮助链接等完整信息,便于调试定位问题。

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
{
    // 无论是否发生异常都会执行的代码,通常用于释放资源
}

执行规则:

  1. try块中的代码正常执行完毕,跳过所有catch块,执行finally
  2. try块中抛出异常,CLR 按顺序匹配catch块,找到匹配的则执行对应catch,然后执行finally
  3. 没有匹配的catch块,异常继续向上传播,执行完finally后抛出到上层
  4. finally一定会执行 ,即使try中有returnbreak等跳转语句

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派生)

由用户代码抛出,代表业务逻辑错误。自定义异常通常继承自ExceptionApplicationException

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:自定义异常

当系统异常不能准确描述业务错误时,可以定义自定义异常类。

自定义异常规范:

  1. 类名以Exception结尾
  2. 继承自ExceptionApplicationException
  3. 提供三个标准构造函数:无参、带消息、带消息和内部异常
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命名空间下,分为三层架构:

  1. 工具类层FileDirectoryPathFileInfoDirectoryInfo,提供静态 / 实例方法快速操作文件与目录
  2. 流层Stream抽象基类及其派生类(FileStreamMemoryStreamBufferedStream),处理字节级别的读写
  3. 读写器层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:指定打开文件的方式(CreateOpenAppendCreateNew等)
  • FileAccess:指定访问权限(ReadWriteReadWrite
  • FileShare:指定共享方式(NoneReadWrite等)

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++ 开发者,核心要记住的差异点:

  1. C# 异常只能抛出Exception派生类,自带完整栈追踪,throwthrow ex有本质区别
  2. 非托管资源必须用using语句显式释放,不能依赖垃圾回收
  3. C# 字符串是 UTF-16 Unicode,读写文本文件必须显式处理编码转换
  4. .NET IO 分层清晰:工具类适合快速操作,流适合大文件和底层控制
相关推荐
Listen·Rain1 小时前
Springboot整合Ollama实现调用本地多模型
java·spring boot·后端
霸道流氓气质1 小时前
Java 工程师 AI 智能体(Agent)完整学习路线
java·人工智能·学习
aaPIXa6221 小时前
C++模板元编程:编译期计算Fibonacci数列
java·开发语言·c++
SunnyDays10111 小时前
Java 编辑 PDF:从增删改查到加密保护
java·修改 pdf·编辑 pdf
Augustzero2 小时前
`co_await` 按下暂停键之后:从零看懂 C++20 协程
c++·后端
球king2 小时前
CC GUI 插件:在 IDEA 中使用 CodeX
java·ide·intellij-idea
知无不研2 小时前
c语言和c++中的静态关键字
开发语言·c++·静态关键字
豆瓣鸡2 小时前
网关、服务 userId 透传
java·微服务·gateway
品尚公益团队2 小时前
C#在asp.net网页开发中的带cookie登录实现
前端·c#·asp.net
bksczm2 小时前
Linux之信号量(POSIX标准)
java·开发语言