一、集合与泛型:告别类型不安全的时代
1.1 从ArrayList到List<T>:一次质的飞跃
在C# 2.0之前,ArrayList是处理动态集合的主要方式。它就像一个“万能容器”,什么类型的数据都能往里塞:
这种灵活性背后隐藏着巨大的代价。ArrayList的本质是一个object类型的数组——所有元素都被当作object来存储。当你存入一个值类型(如int)时,它会被装箱(从值类型转换为引用类型);取出时又需要拆箱(从引用类型转换回值类型)。
装箱和拆箱为什么是性能杀手?每次装箱都会在托管堆上分配新的内存,频繁操作会带来可观的内存压力和CPU开销。更糟糕的是,类型安全问题——如果你不小心把字符串当作整数来拆箱,程序会在运行时抛出InvalidCastException。
List<T>的出现彻底改变了这一局面。作为泛型集合,它在编译时就确定了元素类型:
泛型的核心优势有三点:
| 优势 | 说明 |
|---|---|
| 类型安全 | 编译时检查类型,错误提前暴露 |
| 性能卓越 | 值类型无需装箱拆箱,直接存储 |
| 代码复用 | 一套代码适用于多种数据类型 |
.NET的泛型在运行时是“保留类型信息”的——List<int>和List<string>在运行时是两个不同的类型,各自有独立的实现。这种设计既保证了类型安全,又实现了高性能。
实践建议:在现代C#开发中,
ArrayList已被官方标记为“仅用于兼容性目的”。动态集合场景请始终使用List<T>。
1.2 Queue与Stack:两种经典的数据结构
如果说List<T>是一个可以随意存取元素的“仓库”,那么Queue<T>和Stack<T>就是有着严格存取规则的“专用通道”。
Queue(队列)遵循先进先出(FIFO)原则。就像超市收银台前排队的顾客——先来的人先结账,后来的人排在队尾。在C#中,使用Enqueue入队,Dequeue出队:
Stack(栈)则遵循后进先出(LIFO)原则。想象一摞盘子——你总是先取走最上面那个最后放上去的盘子。使用Push入栈,Pop出栈:
什么时候用哪个?需要按到达顺序处理任务时用队列(如消息队列、请求排队);需要回溯或撤销操作时用栈(如浏览器历史、函数调用栈)。
1.3 Dictionary与Hashtable:键值对的进化
Hashtable和Dictionary<TKey, TValue>都是键值对集合,通过键来快速查找值。但它们之间的差异,与ArrayList和List<T>如出一辙。
Hashtable是非泛型的——键和值都是object类型,同样存在装箱拆箱和类型安全问题。而Dictionary<TKey, TValue>是泛型版本,提供了类型安全和更好的性能。
Dictionary的查找速度非常快,时间复杂度接近O(1)。它的典型应用场景包括:数据索引(用ID快速查记录)、统计频次(统计单词出现次数)、缓存数据等。
SortedList则是在Dictionary的基础上增加了按键排序的功能。它既可以通过键访问,也可以通过索引访问,元素始终按照键的顺序排列。
二、异常处理:让程序优雅地倒下
2.1 异常的本质
异常是程序运行过程中发生的“意外状况”——比如访问了空对象的成员(NullReferenceException)、数组索引越界(IndexOutOfRangeException)、除以零(DivideByZeroException)等。
在C#中,异常是对象——所有异常都派生自System.Exception类。当问题发生时,系统会创建异常对象并“抛出”(throw),包含错误位置、原因等诊断信息。
2.2 try-catch-finally 的正确姿势
异常处理的核心是try-catch-finally结构:
关键原则:
catch块按从具体到通用的顺序排列——先捕获
DivideByZeroException,最后才是Exception。只在能恢复的情况下捕获异常——如果无法恢复,让异常向上传递。
使用
finally释放资源——关闭文件、数据库连接等。
2.3 throw vs throw ex:一个天壤之别
这是C#新手最容易踩的坑之一:
使用throw ex会重置异常的堆栈跟踪信息,让你找不到真正的错误源头。正确的做法是:
或者,如果你需要添加额外的上下文信息:
总结:永远使用throw;而不是throw ex;。
2.4 using语句:自动的资源管理
using语句是C#的“语法糖”——它本质上是一个try-finally块,在finally中自动调用对象的Dispose()方法释放资源:
任何实现了IDisposable接口的类型都可以使用using语句——这包括了文件流、数据库连接、网络连接等几乎所有涉及非托管资源的类型。
三、IO与文件操作:让数据流动起来
3.1 流的哲学
在.NET中,流(Stream)是数据流动的抽象概念。它就像一根水管——数据从一端流入,从另一端流出。所有的流操作都围绕三个基本动作展开:读取(从流到内存)、写入(从内存到流)和查找(移动当前位置)。
3.2 FileStream:字节级别的文件操作
FileStream是最底层的文件流,操作的是字节。它适用于任何类型的文件——文本、图片、视频、音频等。
FileMode枚举控制文件的打开方式:
| FileMode | 行为 |
|---|---|
Create | 创建新文件,若存在则覆盖 |
CreateNew | 创建新文件,若存在则抛出异常 |
Open | 打开现有文件 |
OpenOrCreate | 存在则打开,不存在则创建 |
Append | 打开并定位到文件末尾(追加) |
3.3 StreamReader / StreamWriter:文本的便捷读写
如果你处理的是文本文件,StreamReader和StreamWriter是更便捷的选择——它们直接操作字符,自动处理编码转换:
选择指南:处理二进制文件(图片、视频)用
FileStream;处理文本文件用StreamReader/StreamWriter;需要精细控制(如加密、压缩)时用CryptoStream、GZipStream等装饰器流。
3.4 File与FileInfo:静态与实例的取舍
File类是静态类,提供了一系列静态方法进行文件操作:
FileInfo是实例类,需要创建对象后调用实例方法:
如何选择?单次操作用File类更简洁;多次操作同一个文件用FileInfo更高效(每次File的静态方法都会进行安全检查,而FileInfo只检查一次)。同理,Directory与DirectoryInfo也遵循同样的取舍原则。
四、多线程与同步:让程序并行起来
4.1 进程与线程:工厂与工人的比喻
理解多线程,先理解两个核心概念:
进程就像一座工厂的车间——每个车间独立运行,有自己的资源和空间。
线程就像车间里的工人——同一个车间的工人共享空间(内存),协同完成任务。
CPU就像工厂的电力供应,同一时刻只能为一个车间(进程)供电。操作系统通过时间片轮转让多个进程“看起来”在同时运行。
4.2 Thread:最基础的线程操作
在C#中,Thread类是最基础的线程操作方式:
线程优先级:通过Priority属性可以设置Lowest到Highest五个等级。高优先级的线程获得更多CPU时间,但不能滥用——优先级过高可能导致低优先级线程“饿死”。
前台线程 vs 后台线程:前台线程会阻止程序退出,而后台线程不会。将线程的IsBackground设为true使其成为后台线程。
4.3 lock与Mutex:同步的艺术
当多个线程同时访问共享资源时,竞态条件(race condition)就会出现。比如两个线程同时修改同一个变量,结果可能完全错误。
lock是最常用的同步机制,它确保同一时刻只有一个线程能进入被锁定的代码块:
lock本质上是Monitor的语法糖,编译器会自动生成try-finally来确保锁被释放。
Mutex(互斥体)则更强大——它支持跨进程同步:
lock与Mutex的选择:
| 特性 | lock | Mutex |
|---|---|---|
| 适用范围 | 同一进程内的线程 | 可跨进程 |
| 性能 | 轻量级,开销小 | 重量级,涉及系统调用 |
| 使用复杂度 | 简单 | 相对复杂 |
| 递归锁支持 | 不支持(同一线程重复锁定会死锁) | 支持 |
实践建议:绝大多数情况下使用
lock就足够了。只有在需要跨多个进程同步时,才考虑Mutex。
4.4 死锁:多线程的最大敌人
死锁(Deadlock)是指两个或多个线程互相等待对方释放资源,结果谁也无法继续执行。
典型的死锁场景:线程A持有资源1,等待资源2;线程B持有资源2,等待资源1。两者互不相让,永久阻塞。
预防死锁的原则:
固定锁的顺序——所有线程按相同的顺序获取锁
尽量缩短锁定时间——只在必要的时候才持有锁
使用超时机制——
Monitor.TryEnter可以设置等待超时
写在最后
从ArrayList到List<T>,从Hashtable到Dictionary,C#的进化始终围绕着类型安全和性能这两个核心目标。泛型让代码既安全又高效;异常处理让程序健壮可控;流和文件操作让数据自由流动;多线程让程序充分利用硬件资源。
记住几个关键原则:
能用泛型就别用非泛型——类型安全和性能都更好
能用
using就别手动Dispose——简洁且安全能用
throw;就别用throw ex;——保留完整的错误信息能用
lock就别用Mutex——除非你真的需要跨进程同步
掌握这些核心特性,C#代码将更加健壮、高效、可维护。