为什么值得认真学 C#
C# 是一门被低估的语言:它同时具备现代类型系统的表达能力、成熟的异步模型、可预测的垃圾回收,以及覆盖桌面、服务端、游戏与云原生的统一工具链。用一门语言就能写出 WPF 桌面程序、ASP.NET Core 接口服务和 Unity 游戏逻辑,这在主流语言里并不多见。
学 C# 的性价比在于:它的语言特性大多带着明确的设计动机,学会之后理解其他语言的同类特性会轻松很多。
- 类型系统静态强类型,支持泛型、可空引用类型与模式匹配。
- LINQ统一的查询语法,可以作用于集合、数据库与 XML。
- async/await语言级异步,编译器生成状态机,不额外占线程。
- 内存管理托管堆自动回收,非托管资源仍需显式释放。
值类型与引用类型
结构体、枚举与基础数值类型是值类型,赋值时按内容复制;类与接口是引用类型,赋值只复制地址。string 是特殊的引用类型——不可变,每次拼接都会产生新对象,因此循环里做字符串累加应当改用 StringBuilder,否则会生成大量中间对象给回收器加压。
可空引用类型是现代 C# 最重要的一个开关。开启后编译器会跟踪引用是否可能为 null,并提示你在解引用前做检查。它不改变运行时行为,但能把大量空引用异常提前暴露在编译期,这也是升级旧项目时收益最大的一项改动。
public readonly record struct Point(double X, double Y);
public sealed class Order
{
public required string Code { get; init; }
public string? Note { get; init; } // 明确可能为空
public decimal Total { get; set; }
}
var order = new Order { Code = "A-1001", Total = 128.5m };
Console.WriteLine($"{order.Code}: {order.Note?.Length ?? 0}");
这段代码用了三个现代特性:record struct 自动生成相等比较,required 强制初始化关键字段,? 与 ?? 把空值处理写进表达式。它们共同的效果是——把「这个字段一定存在」和「这个字段可能没有」在类型层面区分开。
LINQ:把集合操作写成声明式
LINQ 把筛选、投影、分组、排序抽象成一组延迟执行的操作符。延迟执行意味着查询在真正遍历时才计算,因此可以在链式调用中自由组合而不会产生中间集合。它是 C# 里最能提升代码可读性的特性:一段嵌套循环加上条件判断,往往能压缩成三行表达式。
var report = orders
.Where(o => o.Total > 0)
.GroupBy(o => o.Code[..2])
.Select(g => new
{
Prefix = g.Key,
Count = g.Count(),
Amount = g.Sum(o => o.Total)
})
.OrderByDescending(x => x.Amount)
.ToList();
需要留意的是边界:Count() 会遍历整个序列,在已实现 ICollection 的集合上应优先用 Count 属性;多次遍历同一个延迟查询会重复计算,必要时先 ToList() 固化结果。这些差别在数据量小时无关紧要,在百万级数据上就是秒级差异。
异步编程:async 与 await
async 方法在遇到 await 时把控制权交还给调用方,等待期间不占用线程,这正是它相对多线程的优势所在——I/O 密集场景下用极少的线程支撑大量并发请求。要记住几条纪律:不要在异步方法里用 .Result 或 .Wait() 同步等待,在有同步上下文的环境下会造成死锁;async void 只用于事件处理,其他场合一律返回 Task,否则异常无法被捕获。
- 取消支持对外接口接收
CancellationToken并向下传递。 - 并发聚合互不依赖的任务先启动再
Task.WhenAll统一等待。 - ConfigureAwait库代码中用
ConfigureAwait(false)避免不必要的上下文切换。 - 异常处理
WhenAll只抛第一个异常,需要全部结果时逐个捕获。
内存管理与资源释放
托管堆由垃圾回收器自动管理,但它只懂托管内存。文件句柄、网络连接、数据库连接、图形资源都属于非托管资源,必须显式释放——做法是实现 IDisposable 并配合 using 声明确保离开作用域就释放,而不是等回收器在不确定的时刻处理。
回收器的分代假设是「大多数对象很快就不再使用」。据此推出的实践建议是:避免大对象频繁分配(大对象进入大对象堆,回收代价高)、减少不必要的装箱(值类型转成 object 会在堆上分配)、用 Span<T> 与 ArrayPool 处理大块缓冲区以避免反复申请。这些优化在服务端吞吐场景下效果显著。
常见误区与调试建议
第一个误区是把 struct 当成「更轻的类」随意使用。结构体在传递时会被复制,字段一多、体积一大,复制成本就会超过堆分配的收益;建议只在体积小、生命周期短、语义上确实是「值」的场景使用。第二个误区是忽略字符串比较的文化差异,业务标识比较应当显式指定 StringComparison.Ordinal,否则在某些区域设置下会出现意料之外的相等判断结果。
第三个误区是把异常当流程控制。异常的开销包含堆栈采集,在热路径上频繁抛出会明显影响性能;能用返回值表达的失败,就不要用异常。与之相对,真正异常的情况不应该被吞掉——捕获后至少要记录日志,否则问题会被埋到线上才暴露。
using var stream = File.OpenRead("data.bin");
using var reader = new BinaryReader(stream);
try
{
var header = reader.ReadUInt32();
Console.WriteLine($"文件头标识:{header:X}");
}
catch (EndOfStreamException ex)
{
Console.Error.WriteLine($"文件被截断:{ex.Message}");
}
调试时建议打开「异常设置」面板,让调试器在异常首次抛出时就中断,而不是等到被上层捕获;这样能直接看到异常的源头,而不是一个被层层包装后的结果。
小结与延伸阅读
把 C# 学扎实的顺序是:类型与可空性 → 集合与 LINQ → 异步与并发 → 资源与性能。前三步决定代码能不能写对,最后一步决定它能不能跑得稳。
- 继续读《.NET 8 新特性与应用开发》,看语言特性如何落到运行时与框架。
- 做桌面界面,读《WinUI 3 桌面应用开发入门》与《WPF 桌面应用实战:MVVM 架构》。
- 运维自动化方向,读《PowerShell 自动化脚本实战》。