我几年前第一次看到这个标题的时候,第一反应是:还能有一直用错这回事?结果越往下看越上头。我在 C# 上写了十来年,从 WinForms 到 WPF,从 Unity 到服务端,什么妖魔鬼怪都见过,但有些“看起来没问题”的写法确实是错的,只是错得很隐蔽,平时不炸,一炸就是大半夜排查。
这篇文章不打算做那种“13 个冷门技巧收藏”的标题党,而是实打实把 C# 里最容易被误解、用错、甚至用了很多年都没意识到的 13 个特性拆开来讲。里面不光有语法层面,还有运行时的内存行为、LINQ 延迟执行、异常处理、反射、委托和事件,以及你在用 Dapper、EF Core、Unity、上位机通信这些实际场景里会踩到的坑。不管你是刚入门的 C# 新手,还是写过几年项目的老手,我保证里面至少有 3 到 5 个点会让你拍大腿:原来我一直在这么写。
1. 最容易误用的“基础语法”习惯,藏了哪些坑
基础语法给人的错觉是“简单到不可能出错”,但恰恰是这些高频写法,在特定场景下会把你坑得最惨。这里挑三个最常见的:var、数组与集合的取舍、以及 == 和 Equals 的默认行为。
1.1 var 用多了,不等于类型推断万能
先说 var。很多新手以为 var 是“弱类型”,觉得用 var 就是偷懒。其实 var 在 C# 里是严格强类型的,它在编译期就已经确定类型,IDE 里鼠标悬停就能看到推断出来的真实类型。问题不在这里,问题在于你有没有意识到:var 适合用在类型一目了然的地方,而不是用来掩盖类型。
比如下面的代码:
var result = GetData();如果 GetData() 返回的是List<User>,那还好。但如果它是一个接口类型、一个基类类型,或者更坑的是匿名类型和动态类型,那么你写在后面的代码就只能在运行时才可能暴露问题。我曾经在一个 WCF 服务里见过有人把各种返回值全部定义成var,后续维护的人完全不知道 result 是什么,只能打断点看运行时类型。这不算语法错误,但它是可读性和可维护性的灾难。
实操上的建议是:当右侧的方法名已经明确表达类型时,var 可以用;当右侧是new[]或 LINQ 结果且类型很长时,var 能省事;但如果右侧是SomeMethod(),而这个方法的签名又不直观,请直接写显式类型。还有一点,var 无法在字段声明中使用,只能用于局部变量,这是一个很多人不知道的细节。
1.2 数组和集合:不是随便选一个就行
“C# 中数组和集合分别是怎么定义的?使用上有什么区别?”这是搜索热词里被问烂的问题,但能真正答清楚的人不多。数组定义是int[] arr = new int[10];,集合是指List<T>、Dictionary<TKey, TValue>、HashSet<T>这些。数组创建后长度固定,集合可以动态增删,这话没错,但更关键的是使用场景和性能特征。
数组在内存里是连续分配的,遍历时 CPU 缓存友好,性能上限很高,但它的长度不可变,插入删除都要自己搬数据。List<T>内部是数组实现的,扩容时会申请新数组并拷贝,这个开销在小数据量下无所谓,大数据量下就可能有性能峰值。所以在“需要频繁按索引访问但不会改变长度”的场景,数组更合适;在“需要不断 Add/Remove”的场景,List 更合适。
还有一个很多人会忽略的点:数组是协变的。什么意思呢?string[]可以赋给object[],这在编译期是允许的,但如果你在运行时往这个数组里塞一个 int,就会抛 ArrayTypeMismatchException。集合类型如List<T>是不协变的,这从根源上避免了这类错误。因此在新代码里,默认用集合会更安全,数组主要用于性能敏感或固定长度的数据。另外,IEnumerable<T>是很多方法的参数类型,它在语义上是“只读遍历”,而数组和 List 都实现了它,所以方法签名上用 IEnumerable 会更灵活,代价是内部不能随便索引访问。
1.3 == 和 Equals 的默认行为,和你想的不一样
这个坑我在面试里反复见到,也在代码评审里反复提:C# 的 == 和 Equals 并不总是等价的。对于值类型,它们的行为一般是按值比较。对于引用类型,== 默认比较引用,除非该类型重载了 operator ==;而 Equals 默认也是比较引用,除非类型重写了 Equals 方法。string 就比较特殊,它既重载了 ==,也重写了 Equals,所以字符串用 == 比较的是内容。
但真正的坑在下面这种写法:
object a = new string("hello".ToCharArray()); object b = new string("hello".ToCharArray()); Console.WriteLine(a == b); // False Console.WriteLine(a.Equals(b)); // True因为 a 和 b 被声明为 object,所以编译器不会调用 string 的 == 重载,而是按引用比较,结果为 False;Equals 则因为动态分派调用了 string 的 Equals 重写,所以为 True。这种“编译期类型决定绑定哪个 ==”的机制,在泛型方法里也容易出问题。如果泛型参数是 T,用t1 == t2,编译器默认执行的是 object 的引用比较(除非有约束),这很可能不是你想要的。
实操建议:如果是业务领域对象,应该用 Equals 或实现 IEquatable ,并且注意重写 GetHashCode;如果是需要精确控制值语义的自定义结构体,一定要同时重载 ==、!= 和 Equals,否则默认行为会让人惊讶。另一个容易踩的坑是float.NaN == float.NaN为 False,判断 NaN 要用 float.IsNaN()。
2. 类型系统与内存分配的细节,决定程序上限
基础语法只是热身,接下来这部分涉及类型系统的边界行为,很多问题只在写库、写框架、做性能优化或者处理大数据时才会暴露,一旦踩中,往往是全局性的。
2.1 const 和 readonly:一个编译期,一个运行期
不少人对const和readonly的理解就是“一个是常量,一个是只读字段”,但它们在编译和运行时的行为差异非常关键。const 的值在编译期就被硬编码到使用它的程序集里了,而 readonly(static readonly)是运行期初始化,通过引用访问。
举个例子:你在程序集 A 里定义了:
public const int Version = 1;然后在程序集 B 里使用 Version。编译 B 的时候,编译器会把 Version 直接替换成 1 这个字面量。如果后来你修改 A 里的 Version 为 2,只重新编译 A,而不重新编译 B,那么 B 里仍然是 1。这个 bug 在大型项目里排查起来非常痛苦,因为你明明看到 A 已经更新了,但运行结果还是旧的。
readonly 就不一样,static readonly 是字段访问,运行时去那个程序集里读取字段值,所以只要部署了新版本的 A,哪怕 B 不重编译,值也会变。当然,这不意味着你该把所有 const 都改成 static readonly。const 能用是好事,编译器可以做常量折叠、内联,性能和安全性都更好,关键是你要明确:const 是编译期契约,适合用于真正永不改变的数值,如数学常量;而配置类值,如版本号、连接串模板、默认端口,请用 static readonly,或者干脆走配置文件。另外,readonly 不能用于局部变量,只能修饰字段,而 C# 7.2 之后的in参数和ref readonly返回也在某些场景下和 readonly 结构体配合使用,这一点很多做底层库的人会忽略。
2.2 值类型参数传递:ref、out、in 的区别,很多人其实没搞懂
参数传递大概是 C# 面试题里出场率最高的考点,但实际代码里依然能看到大量误用。核心问题在于:C# 的默认传参方式是值传递,但这里的“值”指的是变量本身的复制。对于值类型,复制的是整个变量内容;对于引用类型,复制的是引用(相当于指针值),所以你在方法内部改引用对象的内容,外部能看见,但如果你给参数重新赋一个新对象,外部引用变量并不会变。
很多人以为“引用类型就是按引用传递”,这是完全错误的。真正按引用传递需要 ref 关键字:
void ChangeReference(ref StringBuilder sb) { sb = new StringBuilder("new"); }调用时传ref sb,外面的变量才会指向新对象。out 和 ref 的区别在于,out 不要求在进入方法时初始化,但必须在方法返回前完成赋值,语义上更适合“TryGet”模式。in 则是 C# 7.2 引入的只读引用传参,主要在性能敏感场景用于传递比较大的只读结构体,避免拷贝。
实际代码里最常见的误用是:方法里根本不需要改变引用本身,却加了一个 ref,导致调用时必须加 ref,代码看起来非常啰嗦,还会误导维护者以为参数一定被改了。反过来,如果方法确实要替换传入的对象(比如规范化字符串、重置缓冲区),又不加 ref,那外部对象可能没变,bug 就会很隐蔽。我的经验是:默认值传递,只有确实需要“替换外部变量”时才用 ref/out;in 只用在每秒调用几万次以上的大结构体场景,普通业务代码用不上就别用,可读性更重要。
2.3 可空值类型:不是简单地加个问号
int?、bool?这类可空值类型,很多人只知道它是“可以存 null 的 int”,却不知道底层是 Nullable ,它是一个结构体,有 HasValue 和 Value 两个核心属性。当你写int? x = null;时,x 实际上是一个 HasValue 为 false 的结构体实例。
常见的误用在于滥用可空类型,或者在可空类型与普通类型之间来回转换时丢失了语义。比如数据库里一个字段可能是 null,你用 int? 接收,这没问题。但如果你在拿到值后直接.Value,而 HasValue 为 false,就会抛 InvalidOperationException。正确写法是x.GetValueOrDefault()或者x ?? 0。
更隐蔽的坑是:Nullable 的装箱行为。如果你把有值的 int? 装箱,会得到装箱的 int;如果把没有值的 int? 装箱,会得到 null。这会导致一个诡异现象:一个“结构体”装箱后是 null。在写泛型或反射代码时,这个行为容易造成误判。此外,bool?在三态逻辑(true/false/null)下和if (flag.HasValue && flag.Value)的写法配合,经常有人写成if (flag == true),这个写法其实没问题,因为 C# 把bool? == true编译成了flag.HasValue && flag.Value,但没有意识到这一点的读者容易误解。我的建议是,可空类型只用于“本来就可能缺失”的数据,不要为了让某个字段“可以赋 null”而随意加问号,否则你只是把编译期错误推到了运行期。
3. 字符串、LINQ 和委托的实战误区
字符串拼接慢、LINQ 延迟执行、foreach 闭包捕获,这三块业务代码里天天见,出错率极高。很多时候不是原理多深,而是大家背了结论却不理解背后的机制,换个场景就翻车。
3.1 字符串拼接和比较:你写的“优化”可能更慢了
字符串是不可变的,这点都知道。但很多人对“用 + 拼接慢,用 StringBuilder 快”的理解过于片面,导致写出性能反而更差的代码。字符串是不可变类型,每次 + 操作都会生成新对象,这没错,但编译器对常量字符串的 += 有优化,而且在循环次数很少时,使用 StringBuilder 需要创建对象、扩容数组,反而可能更慢。
比如这个经典写法:
string s = ""; for (int i = 0; i < 1000; i++) { s += i.ToString(); }这里每一次循环都创建一个新字符串,复杂度是 O(n^2),1000 次可能还感觉不到,但 10 万次就会非常明显。正确做法是:
var sb = new StringBuilder(); for (int i = 0; i < 100000; i++) { sb.Append(i); } string s = sb.ToString();但对只有几次拼接的场景,直接"a" + "b" + "c"甚至string.Concat都更好。另一个被忽略的点是字符串比较:string.Equals(a, b, StringComparison.OrdinalIgnoreCase)和a.ToLower() == b.ToLower()差别很大。后者会创建两个新的小写字符串,前者直接在原字符串上按规则比较,性能高且不产生垃圾。如果要判断字符串为空,用string.IsNullOrEmpty,很多老代码喜欢写s == ""或s.Length == 0,但 IsNullOrEmpty 语义更清晰,还能顺便处理 null。
至于“判断不带 BOM 的文本文件编码模式”这类问题,本质上也涉及字符串和字节流处理,我建议不要在业务代码里用正则去猜,而是拿前几个字节做特征判断,具体方案后面专题再聊。
3.2 LINQ 的延迟执行:一个经典查询变量陷阱
LINQ 的查询大多是延迟执行的,也就是说,当你写下var query = list.Where(x => x.Age > 18);时,查询并没有真正执行,它只是构建了一个表达式树或迭代器。真正执行是在你遍历 query,或者调用 ToList()、Count()、First() 等触发方法时。这个机制带来很大的灵活性,但也带来一个经典陷阱:在循环里捕获同一个可枚举变量,然后多次迭代,取到的结果不是你想要的。
最典型的误用:
var list = new List<int> { 1, 2, 3, 4, 5 }; var query = list.Where(x => x > 3); list.Add(6); foreach (var item in query) { Console.WriteLine(item); // 4, 5, 6 }很多人以为查询在定义时就已经“拍快照”了,所以在后续修改集合后,query 里应该还是原来的结果。但因为延迟执行,query 会读取当前 list 的最新状态,所以新增的 6 也被查了出来。这种问题在缓存、批处理、并发修改场景中很容易导致数据不一致。如果你想要快照,请立刻调用 ToList() 或 ToArray()。
另外一个常见的坑是 IQueryable 和 IEnumerable 的差别:IQueryable 是表达式树,在 EF Core 里会翻译成 SQL;IEnumerable 是内存中迭代。如果你对一个 IQueryable 调用了某方法导致它被转换成 IEnumerable,后面再做的 Where 就是内存过滤而不是 SQL 过滤,性能差异可能是天上地下。诊断方法就是看变量类型,以及用query.ToString()看最终生成的 SQL。
3.3 foreach 与委托闭包:取变量还是取值?
C# 5.0 之后,foreach 的迭代变量在每次迭代中都是一个新的变量,因此下面这种写法:
var actions = new List<Action>(); foreach (var i in Enumerable.Range(0, 5)) { actions.Add(() => Console.WriteLine(i)); } foreach (var action in actions) { action(); // 输出 0 1 2 3 4 }输出是 0 到 4,没问题。但在 C# 5.0 之前,foreach 迭代变量在循环内是同一个变量,上面的代码会输出 5 个 5。许多老项目或 .NET Framework 4.0 时代的代码里,这类闭包捕获问题曾经是经典 bug。即便在今天,如果你在循环里捕获的是迭代变量本身且延迟执行,仍然容易出错:
var tasks = new List<Task>(); for (int i = 0; i < 5; i++) { tasks.Add(Task.Run(() => Console.WriteLine(i))); } await Task.WhenAll(tasks);这里用 for 循环,i 在整个循环里是同一个变量,所以打印结果不确定,可能都是 5。修复方式是局部变量拷贝:int j = i;。在 Unity 的协程、异步回调里,这类问题尤其常见,很多人不知道 Async 方法捕获的变量在 await 之后是否已经改变,所以代码表现会很诡异。最保险的原则是:让闭包捕获一个循环体内新创建的局部变量,而不是捕获循环变量本身。
4. 工程化场景下的高级误用:反射、异常与特性
这部分更贴近框架级、中间件级和大型系统的开发。也许你平时业务代码不写反射,但只要你在集成第三方库、写通用组件、做数据序列化或者处理上位机通信,就一定逃不开这几个知识点。
4.1 委托、事件和多播:从函数指针到观察者模式
C# 里委托(delegate)本质是一个类型安全的函数指针,很多人把它当成“存方法的变量”来用,但只有一个方法时这么理解没问题,一旦涉及多播和事件,就容易出问题。委托是引用类型,可以用 += 把多个方法挂到同一个委托实例上,调用时会按添加顺序依次执行。但如果你在订阅事件时忘记用 -= 取消订阅,那么被订阅的对象和订阅者之间就形成了强引用,导致内存泄漏。
具体到我见过的一个 WPF 项目里,某个页面在 Loaded 事件里订阅了全局消息中心的事件,却没有在 Unloaded 或 Dispose 里取消订阅。结果是每次打开页面,都会多一份订阅,页面关闭后依然被全局消息中心引用着,内存不断增加,最后程序越来越卡。这种 bug 用内存分析器一查,能看到事件源挂着大量已经不可见页面的实例。
另一个容易被忽略的细节是事件和委托的异常传播。多播委托在调用过程中如果其中一个方法抛出异常,后面的方法不会继续执行。如果你希望所有订阅者都能执行,哪怕有个别抛异常,就要手动 GetInvocationList 逐个去调并用 try-catch 包住。在发布/订阅模式的框架里,这个尤其重要。事件发布者要避免直接暴露委托字段,应该使用 event 关键字封装,防止外部随意覆盖事件订阅。
4.2 异常处理:throw ex 和 throw 是两码事
异常处理看起来简单,但很多代码里的错误用法会直接毁掉排查效率。最经典的就是捕获异常后throw ex;,这会重置异常的堆栈跟踪,把原始错误信息里的调用链清掉,你看到的新堆栈起点变成了 catch 块所在的方法,之前的调用关系全丢了。正确做法是用throw;来重新抛出,保留原始堆栈。
另一个常见的误用是“捕获一切”:
try { // 业务逻辑 } catch (Exception ex) { // 什么都做不了,只能 log 一下 }然后代码继续往下执行,但此时系统可能已经处于不一致状态。尤其在上位机通信、CAN 通讯、数据库连接这些场景,一个异常没处理好,后续所有操作都可能连锁失败。正确的做法是:能处理的异常才捕获,并且处理完要考虑状态是否需要回滚;不能处理的异常就让上层统一处理,或者使用全局异常处理器。.NET 异常处理的约定是“捕获具体异常,越具体越好”,捕获 Exception 是最后的手段。
C# 6.0 之后的异常过滤器when是一个很好用的特性,很多人还不知道:
catch (IOException ex) when (ex.Message.Contains("端口被占用")) { // 专门处理端口占用 }用 when 可以在不改变异常类型的情况下按条件分流,而没有匹配条件的异常会自动跳过这个 catch,继续向上抛,这在复杂网络通信中非常实用。还有一个细节:finally 块里的代码如果抛出异常,会覆盖掉 try 块里的原始异常,导致排查困难,所以 finally 里尽量别做可能抛异常的操作,或者也要 try-catch。
4.3 反射与 Attribute:特性不是“标签”那么简单
Attribute(特性)在 C# 里是一个很强大的扩展点,你可以在类型、方法、属性上标注特性,然后通过反射在运行时读取。但很多人只用到了“给代码打个标签”这层,没理解特性本身是类型实例,它的构造时机和读取方式决定了性能和使用模式。
常见的误用是频繁通过GetCustomAttribute读取同一个特性。反射本身性能就不高,如果你在循环里或高并发路径上反复读取,垃圾回收和 JIT 的开销还会放大。正确的做法是缓存读取结果:
private static readonly ConcurrentDictionary<Type, MyAttribute> Cache = new(); public static MyAttribute GetAttr(Type type) { return Cache.GetOrAdd(type, t => t.GetCustomAttribute<MyAttribute>()); }在用 Dapper 一类的 ORM 时,特性通常映射到数据库字段名、主键标记等。但如果你写一个高性能内部框架,我要提醒你:Attribute 构造函数传参在反射读取时会有较重的成本,能用缓存就用缓存。另一个常见坑是修改了特性定义后没有重新编译引用程序集,导致运行期读到的仍旧是老版特性。由于特性和 const 类似,编译后是固定元数据,有些场景下比 const 还隐蔽,需要重新编译所有引用项目。
在 Unity 和 .NET 上位机开发中,很多人都喜欢自定义 Attribute 来实现自动注册、序列化控制或状态机定义。这个方向没问题,但需要注意:特性本身不能包含运行时逻辑,它只是数据。如果需要根据特性动态创建或调用方法,必须在代码里显式写反射逻辑,不会因为打了特性就自动生效。这个认知非常重要,否则写出来的代码就像给空屋子贴了很多门牌,但门后面没有房间。
5. 常见问题与排查技巧实录
最后这部分,我直接整理一份问题速查表和排查手段,都是这些年现场踩坑留下的经验。方便你以后遇到类似现象时快速定位,而不是一个个去猜。
5.1 问题速查表
我把前面提到的最典型误用,以及对应的症状、原因、解决方案汇总如下:
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| 修改 const 不生效 | 引用程序集没有重新编译,const 被内联为字面量 | 使用 static readonly,或统一重新编译所有引用方 |
| 对象状态没被方法修改 | 参数没有用 ref,方法内部仅修改了引用变量本身 | 传入参数时明确是否需要 ref/out,或返回新对象 |
| 循环内创建的事件订阅越加越多 | 委托订阅后没有取消订阅,对象被事件源强引用 | 在 Dispose/Unloaded 中 -= 取消订阅 |
| LINQ 查询结果被后续集合修改影响 | 延迟执行导致每次迭代都读取集合最新状态 | 需要快照时立即 ToList()/ToArray() |
| 日志/堆栈看不到原始调用链 | 用了 throw ex 而不是 throw | 用throw;保留原始堆栈 |
| 使用反射读取 Attribute 耗时高 | 每次都调用 GetCustomAttribute | 使用 ConcurrentDictionary 缓存结果 |
| 捕获了 i 但最终运行时全是一样的值 | for 循环捕获循环变量本身,闭包延迟执行 | 循环体内int j = i;复制新变量 |
| int? 有值时操作正常,null 时抛异常 | 直接访问 .Value 没有判断 HasValue | 使用 GetValueOrDefault()、?? 或模式匹配 |
这里特别说一下“委托订阅不去订阅”的问题,这是内存泄漏里最隐蔽的一类。现象上看,程序没有明显报错,只是长期运行后内存直线上升,CPU 使用率也不低。你用 dotMemory 或 PerfView 抓一次内存快照,能看到某个全局对象下面挂着大量重复的页面对象,基本就是这个原因。
5.2 排查思路与工具建议
遇到这类“特性误用”引发的 bug,我的排查顺序一般是这样:
第一步,先复现和缩小范围。比如在 Unity 里遇到表现异常,先看是不是闭包捕获了循环变量,写成局部变量拷贝再试;在上位机通讯里遇到数据不对,先看是不是 LINQ 延迟读取了最新集合,加个 ToList 快照。
第二步,用诊断工具看运行时行为。Visual Studio 自带的调试器对 var、匿名类型和闭包非常友好,直接把鼠标悬停在变量上就能看到捕获值。如果怀疑内存泄漏,用 dotMemory 或 System.Diagnostics.PerformanceCounter 监控托管堆大小。怀疑数据库查询性能,用 EF Core 的 LogTo 或 SQL Server Profiler 抓实际执行 SQL。
第三步,开编译器警告和分析器。.NET 6+ 的 SDK 自带了大量内置分析器,比如 CA1822、CA2000,很多潜在误用其实 IDE 会给出提示。你只需要在项目文件里开启 EnforceCodeStyleInBuild,让警告在 CI 阶段直接暴露。
第四步,也是最重要的一步,做代码评审时针对这些高频误用检查。我总结了一个很简单的小清单:看循环体是否捕获了迭代变量,看 const 是否被外部程序集引用,看事件订阅是否成对出现,看 catch 里是否有 throw ex,看 LINQ 查询是否在延迟执行后被集合修改影响。这几条每次评审都过一遍,能拦下大部分隐患。
最后再分享一个小技巧:如果你在一个新的代码库里接手别人写的 C#,别急着重构,先用几分钟搜索一下throw ex;、= null的事件赋值、以及循环内的Task.Run(() => ...)这几个模式,基本能判断出代码里有没有上面这些坑。省下的排查时间,都是自己的。
这些年我在 C# 上花掉的调试时间,有一大半都耗在这些“看似正确”的写法上。语言本身是宽容的,但运行时不会骗人。你多用一点时间搞清楚每个特性背后的 CLR 行为,就能少熬几个通宵去追那些根本没报错的 bug。