ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# 13个高频误用陷阱:从var、LINQ到反射的深度排雷指南

C# 13个高频误用陷阱:从var、LINQ到反射的深度排雷指南 我几年前第一次看到这个标题的时候第一反应是还能有一直用错这回事结果越往下看越上头。我在 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() 返回的是ListUser那还好。但如果它是一个接口类型、一个基类类型或者更坑的是匿名类型和动态类型那么你写在后面的代码就只能在运行时才可能暴露问题。我曾经在一个 WCF 服务里见过有人把各种返回值全部定义成var后续维护的人完全不知道 result 是什么只能打断点看运行时类型。这不算语法错误但它是可读性和可维护性的灾难。实操上的建议是当右侧的方法名已经明确表达类型时var 可以用当右侧是new[]或 LINQ 结果且类型很长时var 能省事但如果右侧是SomeMethod()而这个方法的签名又不直观请直接写显式类型。还有一点var 无法在字段声明中使用只能用于局部变量这是一个很多人不知道的细节。1.2 数组和集合不是随便选一个就行“C# 中数组和集合分别是怎么定义的使用上有什么区别”这是搜索热词里被问烂的问题但能真正答清楚的人不多。数组定义是int[] arr new int[10];集合是指ListT、DictionaryTKey, TValue、HashSetT这些。数组创建后长度固定集合可以动态增删这话没错但更关键的是使用场景和性能特征。数组在内存里是连续分配的遍历时 CPU 缓存友好性能上限很高但它的长度不可变插入删除都要自己搬数据。ListT内部是数组实现的扩容时会申请新数组并拷贝这个开销在小数据量下无所谓大数据量下就可能有性能峰值。所以在“需要频繁按索引访问但不会改变长度”的场景数组更合适在“需要不断 Add/Remove”的场景List 更合适。还有一个很多人会忽略的点数组是协变的。什么意思呢string[]可以赋给object[]这在编译期是允许的但如果你在运行时往这个数组里塞一个 int就会抛 ArrayTypeMismatchException。集合类型如ListT是不协变的这从根源上避免了这类错误。因此在新代码里默认用集合会更安全数组主要用于性能敏感或固定长度的数据。另外IEnumerableT是很多方法的参数类型它在语义上是“只读遍历”而数组和 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 的 重载而是按引用比较结果为 FalseEquals 则因为动态分派调用了 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 的值在编译期就被硬编码到使用它的程序集里了而 readonlystatic 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/outin 只用在每秒调用几万次以上的大结构体场景普通业务代码用不上就别用可读性更重要。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 Listint { 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 里会翻译成 SQLIEnumerable 是内存中迭代。如果你对一个 IQueryable 调用了某方法导致它被转换成 IEnumerable后面再做的 Where 就是内存过滤而不是 SQL 过滤性能差异可能是天上地下。诊断方法就是看变量类型以及用query.ToString()看最终生成的 SQL。3.3 foreach 与委托闭包取变量还是取值C# 5.0 之后foreach 的迭代变量在每次迭代中都是一个新的变量因此下面这种写法var actions new ListAction(); 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 ListTask(); 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 ConcurrentDictionaryType, MyAttribute Cache new(); public static MyAttribute GetAttr(Type type) { return Cache.GetOrAdd(type, t t.GetCustomAttributeMyAttribute()); }在用 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。
返回列表