做C#开发的这些年,我见过太多人一上来就背语法,结果真写项目的时候处处踩坑。C#这门语言表面上看规规矩矩,语法糖也不少,但真正决定代码质量的,往往是底层那套类型系统、内存管理机制,以及面向对象设计能力。所以你问我C#怎么学,我的答案从来都是:先把类型搞清楚,再把面向对象的设计原则揉进肌肉记忆,最后才去追那些漂亮的高级特性。
这篇文章我打算按自己实际用过的路径来讲,不整教科书式的目录堆砌。第一部分先把C#语言基础中人人都容易忽略的底层问题讲透,第二部分重点拆面向对象编程的核心概念,第三部分挑几个工作中真正高频的进阶特性展开,最后把这几年的排查经验整理成问题清单。每一段都有代码示例,例子都是能直接跑起来的形态,不是那种“示意一下”的伪代码。
1. C#语言基础:先把底层逻辑吃透再谈语法
1.1 类型系统:值类型与引用类型的本质差异
C#的类型系统看起来简单,就分两大类,但这里面的坑能写好几页。值类型包括int、double、bool、char这些内置数值类型,还有struct和enum;引用类型包括class、interface、string、数组、委托这些。它们的核心差异不在于“存栈还是存堆”,而是赋值时的语义完全不同:值类型赋值等于复制一份完整数据,引用类型赋值只是把引用地址复制过去,底层对象还是同一个。
看这段代码:
struct Point { public int X; public int Y; } class Rect { public int Width; public int Height; } Point p1 = new Point { X = 1, Y = 2 }; Point p2 = p1; p2.X = 100; Console.WriteLine(p1.X); // 输出 1,值类型互不影响 Rect r1 = new Rect { Width = 5, Height = 6 }; Rect r2 = r1; r2.Width = 50; Console.WriteLine(r1.Width); // 输出 50,引用类型指向同一对象第一段输出1,第二段输出50。很多刚转C#的朋友在这里第一次懵掉:为什么明明只改了r2,r1也跟着变了?因为r1和r2指向的是托管堆上同一个Rect对象,改r2.Width就是改那个对象的Width字段。值类型则不然,p2就是p1的一份完整副本,改p2完全碰不到p1。
明白了这个差异,才能真正理解为什么方法传参有时候“改了外部对象”,有时候“外面看不到改动”。默认情况下参数都是按值传递的,但引用类型的“值”本身就是那个引用地址。所以你在方法里修改引用类型对象的成员,外部能感知到;但如果你在方法里给参数重新赋值,比如param = new Rect(),外部还是指向原来的对象,这个新对象在方法结束后就没人管了。
Struct和Class的选择我也给个经验:大多数业务对象应该用class,只有那些逻辑上就是“一个值”的小数据载体才考虑struct,比如坐标、RGB颜色、金额组合这种。并且struct最好设计成不可变的,字段只读,避免复制后各改各的导致困惑。性能优化需要大结构体的时候,再结合ref返回、readonly struct这些特性去深入,新手阶段不用过早纠结。
1.2 基础语法要点:从变量声明到控制流
C#语法整体走的是“严谨带点优雅”的路线。变量声明上,var并不是弱类型,它只是让编译器根据右侧表达式推断类型,这一点和JavaScript完全是两码事:
var number = 42; // 编译器知道这是 int var name = "张三"; // 编译器知道这是 string我见过不少项目强制要求所有变量显式写类型,其实对于局部变量,用var反而能让代码更清爽。真正的规范应该是:类型一目了然时可用var,类型不明确或者写出来能增强可读性时就显式声明。比如说Dictionary<string, List<Order>>这种,用var可以少写一遍冗长类型,代码反而更清晰。
运算符这块有个容易忽略的点:?空值运算符家族。?.表示如果左边是null,整个表达式就返回null而不继续访问;??表示左边为null时用右边兜底;??=则是左边是null才赋值。这三个运算符在写防御性代码时极其常用,比如:
string city = user?.Address?.City ?? "未知";这行代码任何一层是null,结果都是“未知”,不用写一串if嵌套。
控制流方面,除了常规的if/else、for、foreach,C#新版本里switch表达式比传统switch简洁太多。传统switch要写break,switch表达式直接赋值:
string grade = score switch { >= 90 => "优秀", >= 75 => "良好", >= 60 => "及格", _ => "不及格" };_代表默认分支。如果你还在写一堆if-else去判断区间,试试这个写法,代码量直接少一半。另外注意foreach迭代的变量在旧版C#是同一个循环变量,在lambda捕获时有些坑,现在新版已经改成每次迭代一个独立变量了,但如果你在维护老项目,还是要小心。
1.3 字符串处理与集合:高频操作的正确姿势
字符串在C#里是引用类型,但表现得很像值类型,因为它是不可变的。任何字符串操作方法,比如Replace、Substring、ToUpper,都不会修改原字符串,而是创建一个新的字符串对象。这在编写大量拼接逻辑时会产生性能问题:
string result = ""; for (int i = 0; i < 10000; i++) { result += i.ToString(); // 每次循环都会创建新字符串 }这段代码每次+=都会分配一个新字符串然后释放旧的,一万次循环就是一万次垃圾回收压力。正确做法是用StringBuilder:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.Append(i); } string result = sb.ToString();它内部维护一个可变缓冲区,容量不足时才会扩容,绝大多数append操作不会产生额外对象。
集合方面,日常最常用的三个是List 、Dictionary<TKey, TValue>和HashSet 。List是有序可重复集合,Dictionary是键值映射,HashSet是无序去重集合。选型的原则很简单:需要快速按键取值就用Dictionary,只需要判断“在不在集合里”就用HashSet,需要保持顺序且允许重复就用List。很多人上来就用List然后Contains去判断存在性,这在数据量大时就是O(n)遍历,换成HashSet就是O(1)。
字符串比较还有一个非常容易踩的坑:==和Equals在C#里对string做的是值比较,看起来没毛病。但字符串比较有个区域文化问题,某些语言环境下Equals默认行为可能和你预期不一样。在写业务代码时,比如判断文件名、URL、标识符,我强烈建议用带StringComparison的静态方法,例如string.Equals(a, b, StringComparison.OrdinalIgnoreCase),明确指定比较规则,既提升性能也避免歧义。
1.4 垃圾回收与内存管理:看懂规则才能不拖后腿
C#有自动垃圾回收,不等于你可以完全不管内存。GC把托管堆分成三代:第0代是刚分配的对象,第1代是熬过一轮GC的对象,第2代是长期存活的对象。GC回收时优先处理第0代,因为大部分对象都是短命的。这种分代设计让GC不用每次都扫描全堆。
什么时候会触发GC?一般有三个时机:第0代对象占满时触发;大型对象堆需要分配且空间不足时触发;系统内存吃紧时触发。另外我们可以手动调用GC.Collect(),但这基本是个反面教材,尤其是生产环境。我见过有的项目在图省事,在每次操作后面调GC.Collect(),结果就是频繁Full GC导致界面卡顿,性能比不调还差。
内存管理真正要关注的,是让对象“及时死亡”:
public class FileManager : IDisposable { private FileStream _stream; public void Open(string path) { _stream = File.OpenRead(path); } public void Dispose() { _stream?.Dispose(); _stream = null; } } // 使用方式 using (var manager = new FileManager()) { manager.Open("data.txt"); // 用完自动释放底层句柄 }IDisposable模式针对的是非托管资源,比如文件句柄、数据库连接、网络Socket。这些资源GC管不了,必须靠Dispose显式释放。用using语句包裹是最稳妥的方式,它会保证代码块结束甚至抛异常时也执行Dispose。记住一个原则:谁打开了资源,谁负责释放。特别是在处理System.IO、数据库上下文这类对象时,不要依赖GC给你兜底。
还有个隐蔽的内存泄漏来源是事件订阅。一个对象订阅了另一个对象的事件,订阅者就会被发布者通过委托引用着,发布者不释放,订阅者就一直活着。后面讲事件特性时我会再细说。
2. 面向对象编程核心概念:封装、继承、多态
2.1 封装:用字段加属性把数据关进笼子里
封装不是简单的“把字段设成private”,而是隐藏实现细节,对外提供稳定的访问接口。C#里实现封装的标配是字段加属性。字段存数据,属性控制访问行为:
public class BankAccount { private decimal _balance; public decimal Balance { get { return _balance; } private set { if (value < 0) { throw new ArgumentException("余额不能为负数"); } _balance = value; } } public void Deposit(decimal amount) { if (amount <= 0) throw new ArgumentException("存入金额必须为正数"); _balance += amount; } }看到没,属性本质上就是一个方法壳。get访问器是读方法,set访问器是写方法,private set意味着外部只能读不能写。这种设计让“余额”字段永远不可能被外部改成负数,因为所有写操作都被拦在set里或者必须通过Deposit方法走业务规则。
C#还提供自动属性简化写法,比如public int Age { get; set; },编译器在背后自动生成私有字段。日常开发中90%的属性都可以用自动属性,真正需要手工写完整属性的时候,往往是你要加校验逻辑、要计算派生值、或者要控制访问级别。另外较新的C#支持init访问器,表示属性只能在初始化时赋值:
public class Product { public string Name { get; init; } } var product = new Product { Name = "键盘" }; product.Name = "鼠标"; // 编译错误,init后不可修改这种不可变风格在配置对象、DTO上非常好用,对象一旦创建就不能被误改,从设计上消灭一类bug。
封装的收益不是当下看到的,而是几个月后。你把内部表示从List改成数组,只要属性签名不变,所有调用方都不用改。这就是封装的价值:它让变化被限制在类的内部。
2.2 继承:类与类之间的关系如何正确建立
继承解决的是“is-a”关系的复用问题。C#只支持单继承,一个类只能有一个基类,但可以无限层继承。定义派生类时,构造器执行顺序一定要搞清楚:先执行基类构造器,再执行派生类构造器。看这段代码:
public class Animal { public string Name { get; } public Animal(string name) { Name = name; Console.WriteLine("Animal构造器执行"); } } public class Dog : Animal { public string Breed { get; } public Dog(string name, string breed) : base(name) { Breed = breed; Console.WriteLine("Dog构造器执行"); } } var dog = new Dog("旺财", "柯基"); // 输出: // Animal构造器执行 // Dog构造器执行如果你在派生类里忘了调用base(...)并且基类没有无参构造器,编译器会直接报错。这是新手很容易撞墙的地方——父类构造函数带参数子类必须用base传递。理解了构造器链,你调试那些初始化顺序相关的问题会轻松很多。
继承里另一个核心规则是虚方法和重写。基类方法标virtual,派生类方法标override,才能实现动态分派:
public class Animal { public virtual void Speak() { Console.WriteLine("动物在叫"); } } public class Dog : Animal { public override void Speak() { Console.WriteLine("汪汪"); } } Animal animal = new Dog(); animal.Speak(); // 输出"汪汪",而不是"动物在叫"这里最关键的是:编译时animal的静态类型是Animal,但运行时是Dog,调用虚方法时CLR会根据实际对象类型找到Dog重写的版本。这就是多态的基础。如果派生类方法用的是new关键字而不是override,那就只是隐藏了基类方法,运行时会根据“变量的静态类型”来决定调用版本,上面的例子就会输出“动物在叫”。
继承还有一个陷阱是过度设计。我见过有人为了复用几个方法,硬造出三层的继承体系,最后改一个底层方法,上面所有类都跟着受罪。现代C#开发普遍倾向组合优于继承:能通过持有对象实现复用,就尽量不要用继承表达复用。继承更适合表达真正的“is-a”关系,而不是仅仅为了抽公共代码。
2.3 多态:让同一套代码应对不同类型
多态的本质是“一个接口、多种形态”。实现多态有两条路:虚方法重写和接口实现。虚方法适用于“有共同基类且行为可扩展”的场景,接口适用于“行为契约”式场景。两者在日常开发中经常配合使用。
看一个使用接口实现多态的典型例子:
public interface IPayment { void Pay(decimal amount); } public class AlipayPayment : IPayment { public void Pay(decimal amount) { Console.WriteLine($"支付宝支付 {amount} 元"); } } public class WechatPayment : IPayment { public void Pay(decimal amount) { Console.WriteLine($"微信支付 {amount} 元"); } } public class PaymentService { private readonly IPayment _payment; public PaymentService(IPayment payment) { _payment = payment; } public void Checkout(decimal amount) { _payment.Pay(amount); } }调用方只需要依赖IPayment接口,至于背后是支付宝还是微信,完全不用关心。以后新增一种支付方式,只需新加一个实现类,PaymentService一行代码都不用改。这就是多态带来的扩展能力:面向抽象编程,真的做到了“对扩展开放,对修改关闭”。
多态在运行时是靠类型信息查找完成的,虚方法调用会走虚方法表,所以性能上比普通方法调用稍有开销,但绝大多数业务场景完全不需要在意这点差别。真正要注意的是滥用:不是每个方法都需要定义成virtual,更不是每个对象都要抽接口。优先考虑实际有多态需求的地方,比如策略模式、工厂模式、插件化扩展等场景再去抽象,过度设计往往比不设计更糟糕。
2.4 接口与抽象类:该用哪个,很多人体会错了
抽象类和接口的差异,本质上是有没有“实现”和有没有“状态”的问题。抽象类可以有字段、构造器、已经实现的方法,可以承载公共逻辑;接口在C# 8之后虽然也可以有默认实现,但接口不能有实例字段,不能有构造器,更适合定义纯粹的“契约”。
选型推荐从这几个维度看:
- 多个不相关的类需要遵循同一套行为契约,选接口。比如文件处理器、数据库驱动、消息发送器,互相之间没有共同的基类逻辑,但都要实现Read/Write/Send这类方法。
- 几个类有相同的实现逻辑,并且逻辑可以共用,选抽象类。把公共方法写在抽象类里,子类继承后直接复用。
- 想表达“is-a”的层级关系,选抽象类。
- 想给类增加“能力”而非“身份”,选接口。
实际项目中最常见的错误是把接口当万能药。一个只有两三个类的内部模块,也要先建接口再造实现,然后构造函数注入,其实增加的间接性远大于收益。我自己的经验是:当接口只能有一个实现且短时间内看不到第二种实现时,可以先不建接口,等真正出现多态需求再引入。面向接口编程是好习惯,但别用自己的设计癖去买单。
抽象类还有一个容易被忽视的优势:它可以承载构造器参数。比如:
public abstract class BaseRepository { protected readonly string ConnectionString; protected BaseRepository(string connectionString) { ConnectionString = connectionString; } public abstract void Save(string data); }子类构造时必须传连接字符串,这就强制约束了子类的初始化条件。接口做不到这一点,因为接口没有构造器。如果你的抽象类需要有初始化参数、需要共享内部状态,那就用抽象类准没错。
3. 高级特性详解:泛型、委托事件、LINQ、异步与反射
3.1 泛型:让代码在类型安全的同时保持高性能
泛型解决的痛点很直接:你写一套逻辑,既想适配多种类型,又不想丢失编译期类型检查,还想避免装箱拆箱的性能损耗。在泛型出现之前,人们用object收一切类型,但拿回来用的时候要强转,转错了运行期才崩,而且值类型转object会发生装箱,性能损失明显。有了泛型之后,List<int>就是真正的int集合,不会有任何装箱操作。
泛型方法的类型推断也很好用,多数时候不用显式写类型参数:
public static T Max<T>(T a, T b) where T : IComparable<T> { return a.CompareTo(b) > 0 ? a : b; } int maxInt = Max(3, 7); // 推断 T 为 int string maxStr = Max("abc", "xyz"); // 推断 T 为 stringwhere T : IComparable<T>就是泛型约束,它告诉编译器:T必须实现了IComparable接口,所以下面才能调用CompareTo方法。约束不只是给编译器看,也是给调用方的“使用说明书”:不是我什么类型都能传,而是必须有某种能力才行。
常用约束有这些:where T : struct(必须是值类型)、where T : class(必须是引用类型)、where T : new()(必须有无参构造器)、where T : BaseClass(必须是某个基类或其子类)、where T : IInterface(必须实现某接口)。合理使用约束能把很多运行期错误提前到编译期。
泛型还有一个高级话题是协变和逆变。简单说,IEnumerable<T>的T用out标注,意味着IEnumerable<string>可以被当作IEnumerable<object>使用,这是协变;Action<T>的T用in标注,意味着Action<object>可以被当作Action<string>使用,这是逆变。理解这个不需要死记概念,只要记住一条应用规则:只从接口取出T的场景用out,只向接口传入T的场景用in。泛型参数既进又出,那就不能变,老老实实保持不变量。
3.2 委托与事件:从回调到发布订阅的自然演进
委托在C#里的定位是类型安全的函数指针。定义一个委托,就是定义一种能指向某个方法的类型,该方法必须和委托的签名匹配:
public delegate void LogHandler(string message); public class Logger { public LogHandler Log { get; set; } public void Write(string message) { Log?.Invoke(message); } } Logger logger = new Logger(); logger.Log = msg => Console.WriteLine($"[INFO] {msg}"); logger.Write("系统启动");现在开发中很少需要自定义委托类型了,因为内置的Action和Func覆盖了绝大多数场景。Action是无返回值的委托,Func是带返回值的委托,它们的泛型参数分别对应形参和返回值类型:
Action<string> logAction = msg => Console.WriteLine(msg); Func<int, int, int> addFunc = (a, b) => a + b;lambda表达式在这里的用处就是把函数当作值传来传去。这个能力是很多框架的核心,比如LINQ里到处是x => x.Age > 18这种表达式。
事件和委托的区别常被问起。最直观的差异:事件是委托的安全封装版,只能通过+=和-=订阅,不能直接赋值覆盖,也不能在类外部直接触发。定义事件通常是配合一个委托或内置的EventHandler:
public class OrderService { public event EventHandler<OrderEventArgs> OrderCreated; protected virtual void OnOrderCreated(Order order) { OrderCreated?.Invoke(this, new OrderEventArgs { Order = order }); } public void CreateOrder(Order order) { // 业务逻辑... OnOrderCreated(order); } }事件发布者只需要提供订阅入口和触发时机,订阅者用+=挂接自己的处理方法。一个对象可以订阅多个处理器,一个事件也可以被多个对象订阅,这就是发布订阅模式。
事件泄漏这事必须单独提一下。发布者持有事件的委托引用,订阅者被发布者引用着,如果发布者是长生命周期对象,订阅者是短生命周期对象,订阅者永远无法被GC回收。解决办法就是在合适的时机-=退订,尤其是在窗体、页面关闭时要手动清理事件订阅。我反复强调这点,因为内存泄漏往往不会当场爆发,但它会随着时间慢慢吃掉你的内存。
3.3 LINQ:用声明式思维操作数据集合
LINQ最让人惊艳的地方,是它把“怎么一步一步处理数据”的流程式思维,换成了“我要什么数据”的声明式思维。它有两种写法,查询表达式和方法链:
var result = from p in products where p.Price > 100 orderby p.Sales descending select new { p.Name, p.Sales }; // 等价的方法链写法 var result2 = products .Where(p => p.Price > 100) .OrderByDescending(p => p.Sales) .Select(p => new { p.Name, p.Sales });我个人的偏好是:简单场景用方法链,复合连接查询用查询表达式更清晰。两种写法在功能上等价,编译器最终都会翻译成方法调用。
LINQ里最核心的概念之一是延迟执行。很多LINQ方法返回的是某个序列的“查询描述”,而不是查询结果。只有当你遍历这个序列,或者调用ToList、ToArray、Count、First这些立即执行的方法时,查询才真正发生:
IEnumerable<Product> query = products.Where(p => p.Price > 100); // 到这里还没有执行 var list = query.ToList(); // ToList才真正遍历并执行where过滤这个特性在很多场景下是性能大杀器。你可以先组装条件,再决定何时真正执行;也可以在遍历中途用Take(10)只取前十条,前面所有的Where条件都不会白白跑一遍。但延迟执行也有陷阱,比如在循环里反复遍历同一个延迟查询,每一次遍历都会重新执行。所以如果你要遍历多次,先ToList一次,冻结住结果。
IQueryable是另一个要特别注意的接口。它和IEnumerable的关键区别是,它的查询在真正枚举时才翻译成表达式树,交给底层提供程序去执行。最典型的场景就是ORM框架:你写的Where筛选,可能在数据库端就变成SQL的WHERE子句,而不是把所有数据拉回内存再过滤。这种能力很强大,但容易踩的坑是:在表达式里用了提供程序无法翻译的方法,数据库端执行不了,或者延迟到内存端才执行,数据量大时直接卡死。处理这类查询时,始终记得“尽量先过滤再加载”。
3.4 异步编程:async/await的正确姿势
C#的异步编程在整个语言体系里都算特色。async/await不是简单的“开辟另一个线程”,而是把一段代码拆成状态机:线程执行到await时,如果任务还没完成,就立即返回,不阻塞调用线程;等任务完成后再回到之前的上下文继续执行。
最基本的示例:
public async Task<string> FetchDataAsync() { using var httpClient = new HttpClient(); string result = await httpClient.GetStringAsync("https://example.com/api/data"); return result; }当await遇到尚未完成的网络请求时,当前方法立即返回未完成的Task给调用方,线程去做别的事,不会被阻塞。这是异步和普通线程阻塞的本质区别:异步不会多占用一个线程干等着,而是把等待期间让出去。这在Web服务端尤其重要,因为线程池资源有限,大量请求同时阻塞线程,服务器就到瓶颈了。
使用async/await有几条铁律:
- 不要用async void,除非是事件处理器。async void方法抛出的异常无法被catch捕获,可能导致进程崩溃。
- 不要用
.Result或.Wait()去阻塞地等待异步方法。在存在同步上下文的场景(如UI程序、ASP.NET请求上下文),这会导致死锁。 - 库代码中尽量使用
ConfigureAwait(false),避免因等待捕获不必要的上下文,提升性能和健壮性。
Task.WhenAll和Task.WhenAny也是高频方法,前者等待所有任务完成,后者只要任意一个完成就返回。需要并发执行多个独立任务时,用它们比一个一个await高效得多:逐个await是串行,WhenAll是并发。
值类型任务ValueTask也是一个值得关注的优化选项。在低延迟场景下,如果异步方法绝大多数时候是同步完成的(比如从缓存读数据,缓存有值就直接返回),用ValueTask可以减少Task对象的分配。但ValueTask只能消费一次,不像Task可以多次await,所以日常场景用Task更稳妥,性能敏感再换ValueTask。
3.5 反射、特性与模式匹配:元数据编程的实战应用
反射就是运行时查看和操作类型信息的能力。通过Type对象,可以获取类的属性、方法、字段、特性等元数据,甚至动态创建实例和调用方法。典型场景包括插件系统、序列化框架、依赖注入容器。
写一个自定义特性来标记枚举的显示名称,是反射加特性组合的经典用法:
[AttributeUsage(AttributeTargets.Enum | AttributeTargets.Field)] public class ChineseNameAttribute : Attribute { public string Name { get; } public ChineseNameAttribute(string name) => Name = name; } public enum OrderStatus { [ChineseName("待支付")] Pending = 0, [ChineseName("已支付")] Paid = 1, [ChineseName("已发货")] Shipped = 2 } public static class EnumExtensions { public static string GetChineseName(this Enum value) { Type type = value.GetType(); string fieldName = value.ToString(); var field = type.GetField(fieldName); var attr = field?.GetCustomAttribute<ChineseNameAttribute>(); return attr?.Name ?? fieldName; } } OrderStatus status = OrderStatus.Paid; Console.WriteLine(status.GetChineseName()); // 输出"已支付"我们把描述信息直接写在枚举值旁边的特性里,然后读取出来,要加新状态、新中文名,只要改枚举定义就能同步,数据库、接口层都不用动。
反射有性能开销是共识,但它的性能问题需要理性看待。获取MemberInfo、MethodInfo等元数据的开销,相比动态调用是小事。真正昂贵的是MethodInfo.Invoke这种通过反射动态调用方法的路径。性能敏感场景建议想办法缓存MethodInfo或把反射调用编译成强类型委托,用表达式树构建一段高效的调用代码。
C# 8之后,模式匹配从“锦上添花”变成“常规武器”。is表达式、switch表达式、属性模式、位置模式、列表模式组合起来,代码可以写得非常紧凑又安全:
public static string Describe(object obj) { return obj switch { int i when i > 0 => $"正整数 {i}", int i when i < 0 => $"负整数 {i}", string { Length: > 0 } s => $"非空字符串: {s}", null => "空对象", _ => "其他类型" }; }这种写法把“类型判断+条件判断+取值+返回”压缩在了一个表达式里。有人觉得可读性差,但我认为只要是团队统一规范且模式复杂度不高,它比一长串if-else清楚得多。尤其是配合record类型做解构匹配,处理各种业务分支非常顺手。
4. 实际开发中的常见问题与排查经验
4.1 空引用与可空值类型:NullReferenceException的一生之敌
空引用异常是C#开发中出现频率最高的运行时错误之一。往往一个查询返回null,你直接访问它的属性,程序就崩了。防御手段有几种层级:最基础的是判空,然后是用?.和??:
string name = order?.Customer?.Name ?? "匿名用户";可以用的一行,就尽量不要写三层嵌套if判空。另外C# 8引入了可空引用类型,就是在项目里开启Nullable上下文后,编译器会把引用类型分成“可空”和“不可空”两类:
public string Name { get; set; } // 不可空,编译器提醒你可能赋值null public string? NickName { get; set; } // 可空,使用时必须处理null这个特性我强烈建议所有新项目都开启。它不是银弹,但能把大量潜在空引用问题从运行时提前到编译期。需要注意的是,老项目接入可空上下文会有海量的警告,建议分模块逐步开启,不要一刀切。
值类型的可空形态是Nullable ,声明方式就是int?。它本质上是个包含HasValue和Value两个字段的结构体。访问null可空值类型的Value会抛InvalidOperationException,所以取用之前用GetValueOrDefault()或?? 默认值会更安全。
4.2 集合修改异常与迭代器陷阱
foreach循环中修改集合是C#新手最高频的事故现场:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 }; foreach (var num in numbers) { if (num % 2 == 0) { numbers.Remove(num); // 运行时报 InvalidOperationException } }foreach依赖集合的版本号机制,每次迭代时检查集合有没有被修改,被修改过就抛异常。正确做法是先收集再删除:
var toRemove = numbers.Where(n => n % 2 == 0).ToList(); foreach (var num in toRemove) { numbers.Remove(num); }或者直接用RemoveAll这种支持谓词的方法一步到位:
numbers.RemoveAll(n => n % 2 == 0);另一个和迭代器相关的坑是:yield return返回的IEnumerable是延迟执行的,如果迭代体内部依赖的集合在调用方开始迭代之前就被改了,结果可能完全不是你预料的。调试这类问题时要记住一个检查方向:先把“集合什么时候被修改”和“查询什么时候真正执行”理清楚。
4.3 异步死锁与上下文问题
async/await写起来很优雅,但遇到阻塞式等待就翻车。经典的死锁场景是这样的:UI线程或ASP.NET请求上下文调用一个async方法,内部有未完成的await,随后这个调用方又用了.Result去等待。此时,await想要回去同步上下文完成后续代码,但同步上下文被.Result阻塞住,两边互等,就死锁了。
解决办法:
// 错误写法 string content = FetchDataAsync().Result; // 正确写法:一路async到底 string content = await FetchDataAsync();根治原则就一条:不要让async方法等待同步结果,从入口到最后都用await贯穿。如果实在遇到无法改成async的旧代码,就用ConfigureAwait(false)帮内部方法脱离上下文,减小死锁概率。把这条经验记在脑子里,能替你省大量排查时间。
还有一件事:async方法里用lock要特别小心,编写涉及多线程互斥的异步代码时优先考虑SemaphoreSlim,它对async场景是友好的,不会因为上下文切换而锁死。
4.4 几类隐蔽的性能与正确性坑
字符串拼接性能问题我在前面提过,这里再补充一个相似案例:用+拼接几千次的结果是内存碎片化,但用StringBuilder之后代码却可读性变差。我的建议非常简单:固定少量拼接用插值字符串,循环次数可能较大就用StringBuilder,不要盲目。
装箱拆箱是另一个隐蔽的性能杀手。在泛型出现前的旧集合里很常见,比如ArrayList里存int,取出来用就是拆箱。现在用List<int>就不会发生装箱。但有些地方还是会悄悄触发装箱,例如给接口类型赋值值类型、调用签名里带object参数的方法。严格来说装箱本身不伤正确性,需要大循环高并发时才明显,尽量用泛型避免它。
还有一个捕获循环变量的坑,早些版本C#的foreach循环变量在外层lambda里会被所有迭代共享。虽然新版语言行为已经修复,但如果你在维护老代码或者目标框架比较旧,还是要留意:
var handlers = new List<Action>(); for (int i = 0; i < 3; i++) { handlers.Add(() => Console.WriteLine(i)); } foreach (var handler in handlers) { handler(); // 老版本输出 3 3 3,新版本输出 0 1 2 }5. 学习路径与实战建议
5.1 从语言基础到工程能力的进阶顺序
我见过太多人花了很长时间去追新语法特性,基础的类型系统和面向对象设计反而没掌握。以我这些年带人的经验,路径应该是分阶段的。
第一阶段是语言基础。变量、类型、运算符、控制流、集合、字符串、异常处理,先把这些练到不用思考就能写。第二阶段是面向对象设计。封装、继承、多态、接口、抽象类,这一阶段要配合小项目去练,哪怕是一个控制台版本的简易订单系统都行,重点是理解设计原则如何在代码里落地。第三阶段是高级特性。泛型、委托、事件、LINQ、异步、反射,这类特性不用一次性学完,每做一个项目就往里引入两三个,反复练习直到变成直觉。
光看书是不行的。同一个语法点,你写十行代码和读十页文档的收获完全不是一个量级。学委托那一天就直接写一个事件订阅的小Demo,学LINQ的那一天就真的拿一组对象查询数据。动手量上来了,知识才会从“知道”变成“会”。
5.2 会看文档更要会用调试器
官方文档写得全面,但对你来说,更重要的是学会怎么用IDE的调试器。调试器的价值在于让你直接看到程序运行时的真相:变量此刻是什么值,类型是什么,调用栈从哪来,异常源头在哪。
我习惯的做法是三步走:
- 先读懂异常信息。异常类型、异常消息、堆栈跟踪这三样信息,能排除一半的问题。
- 在可疑代码前打断点,逐行观察变量变化。
- 用“即时窗口”或“监视窗口”验证自己的假设。
很多新手拿到报错不看消息和堆栈,直接把整段代码贴给搜索引擎或者问人。习惯改掉之后,解决问题的速度会快很多。调试不是“查错工具”,是理解代码运行逻辑最好的方式。
5.3 一个长期有用的习惯:阅读源码和重构
代码写到一定阶段,最大的瓶颈往往不是语法不熟,而是见过的“优秀设计”太少。拆开框架源码或者成熟类库源码,看别人怎么组织类、怎么处理边界条件、怎么设计API,这些经验比任何教程都来得实在。
再一个就是每天坚持重构自己写的代码。写完之后回来看看,哪里可以顺手删掉重复逻辑,哪个命名不达意,哪里有更好的表达方式。保持这个习惯,能力和代码质量是同步上升的。C#这套语言体系相当庞大,但真正的底子还是“清晰表达逻辑”和“合理组织代码”这两件事,万变不离其宗。