C# 面试题库

收录真实面试常见问题,答案长度适合口头表达;拿不准时可返回相关主题复习。

64个问题 初级 高级
全部难度 初级 中级 高级
逐题查看 显示全部答案
报告错误

语言核心

14个问题
01 C# 中的值类型与引用类型有什么区别? 初级 常见 查看答案 ▾ 收起 ▴

值类型变量直接包含值,因此普通赋值与按值传参会复制该值。引用类型变量包含引用,复制后两个变量可能指向同一个可变对象。这描述的是可观察语义,并不表示结构体永远在栈上、类永远在堆上。值类型字段可以内联在堆对象中,装箱也会为值创建对象。结构体还可以包含引用字段,所以复制结构体只是逐字段复制,不会递归复制字段指向的对象。

延伸阅读 数据类型
讲清楚了吗?
02 T? 用于值类型和引用类型时有什么不同? 中级 常见 查看答案 ▾ 收起 ▴

对非可空值类型 T,T? 是 Nullable 的简写,它记录是否存在底层 T 值。对引用类型,T? 是同一个运行时引用类型上的可空注解,用于告诉编译器 null 属于预期状态,并参与空状态分析;它不会包装对象,也不会添加运行时检查。空包容运算符只会抑制警告。反序列化、反射、旧程序集或禁用可空上下文的代码仍可能违反注解表达的意图,因此边界处依然需要验证。

延伸阅读 数据类型
讲清楚了吗?
05 C# 事件与公开委托字段有什么区别? 中级 常见 查看答案 ▾ 收起 ▴

事件具有委托类型,但会限制外部代码使用该成员的方式。订阅者可以通过 += 和 -= 添加、移除处理器,却不能替换整个调用列表、将其清空或触发事件;这些操作留在声明类型内部,因此发布者控制通知时机。公开委托字段会暴露全部委托操作,任何调用方都能覆盖其他注册项,或使用任意参数执行回调。字段式事件由编译器提供存储与 add、remove 访问器;显式事件访问器则允许类型控制或转发订阅存储。

讲清楚了吗?
09 C# 如何选择 catch 子句,何时应该使用异常筛选器? 中级 常见 查看答案 ▾ 收起 ▴

运行时从抛出点沿调用栈向外搜索,并按源码顺序检查 catch 子句。抛出的对象与声明的异常类型赋值兼容时,子句才有资格处理;如果还有 when 筛选器,它也必须返回 true,返回 false 会继续搜索。派生类型要放在基类型之前,否则后面的具体处理器无法到达。筛选器适合检查决定能否恢复的稳定属性,例如参数名或状态码。它在对应的堆栈展开前运行,因此应保持快速且无副作用;筛选器抛出的异常会被视为 false。

延伸阅读 异常
讲清楚了吗?
13 表达式和语句与 C# 的静态类型系统有什么关系? 初级 常见 查看答案 ▾ 收起 ▴

表达式会被求值,具有编译时类型,并且通常产生一个值。语句执行声明变量、赋值、调用方法、选择分支或返回等动作,内部经常包含嵌套表达式。编译器先确定每个内层表达式的类型,再检查外层语句怎样使用结果。因此,double 目标变量不能反过来改变右侧的整数除法:赋值转换发生前,int 除法结果已经产生。审查时应标注中间表达式类型,不能只看最终变量声明。

延伸阅读 C# 语言基础
讲清楚了吗?
14 var 会让 C# 局部变量变成动态类型吗? 初级 常见 查看答案 ▾ 收起 ▴

不会。var 只是让编译器根据初始化表达式推断一个静态类型,该变量的类型随后保持不变。例如,var amount = 12.5m 会因为 m 后缀推断成 decimal,之后赋字符串会产生编译错误。var 改变的是源码写法,不会改变运行时表示或可用操作。构造式已清楚表明类型,或类型名称很难重复时,var 很合适;领域含义更重要或推断可能意外时应写出类型。生成代码在数值字面量附近使用 var 时,要检查初始化表达式。

延伸阅读 C# 语言基础
讲清楚了吗?
17 C# 属性与字段有什么区别,什么时候需要幕后字段? 初级 常见 查看答案 ▾ 收起 ▴

字段是存储位置,属性则是通过 get、set 或 init 访问器定义读取与赋值行为的成员。调用方虽然使用字段式语法,实际仍会执行这些访问器。只需简单保存值时,自动属性通常是默认选择,幕后字段由编译器生成。访问器需要协调状态、验证逻辑不适合 C# 14 的 field 写法,或存储还要被其他行为共享时,再显式声明字段。编译器生成的字段名属于实现细节,不应成为反射契约。

延伸阅读 属性
讲清楚了吗?
21 什么是 C# 特性,为什么应用特性不一定会改变程序行为? 初级 常见 查看答案 ▾ 收起 ▴

特性是附到程序集、类型、成员、参数、返回值或其他受支持实体上的结构化声明信息。编译器把它的构造函数与实参编码进程序集元数据,但这条记录本身不会执行任何行为。消费者必须解释它:C# 编译器认识 Obsolete 等特性,测试运行器、序列化器、Web 框架、分析器或应用扫描器则认识各自的类型。审查陌生特性时,要确认完整类型名、准确目标、消费 API 与执行阶段。如果没有消费者读取它,代码只有元数据,没有实现相应功能。

延伸阅读 特性
讲清楚了吗?
29 什么是 C# 委托,什么时候应自定义委托而不是使用 Func 或 Action? 中级 常见 查看答案 ▾ 收起 ▴

委托是一种引用类型,其签名决定它可以调用哪些方法。委托值可以保存静态方法、实例方法及其目标、lambda,也可以保存有序调用列表。只要签名足以解释契约,有返回值的回调通常用 Func,无返回值的回调用 Action。名称需要表达领域含义、参数需要 ref、in、out 或 params 修饰符,或者公开 API 需要独立文档契约时,我会自定义委托。两个自定义委托即使签名相同,仍是不同类型。

延伸阅读 委托与事件
讲清楚了吗?
33 表达式树与委托有什么区别,应该怎样选择? 中级 常见 查看答案 ▾ 收起 ▴

委托是有类型的可执行行为:调用方可以调用它,却无法从中恢复受支持、可移植的函数体表示。Expression 则是描述 lambda 的不可变、有类型对象图。消费者可以检查或改写这棵树,通过查询提供程序翻译它,也可以把它编译成委托。回调和进程内执行应选择委托;只有另一组件必须理解代码结构时才选择表达式树,并明确该消费者支持哪些节点,因为合法 C# 和合法表达式树仍可能无法翻译。

延伸阅读 表达式树
讲清楚了吗?
37 泛型 API 保留了哪些基于 object 的 API 会丢失的信息? 初级 常见 查看答案 ▾ 收起 ▴

泛型签名会保留实参、存储与结果类型之间的关系。例如,T Find(IEnumerable) 表明成功结果与序列元素类型相同;基于 object 的版本则要求调用方强制转换,并把类型不匹配推迟到运行时。泛型集合存储值类型时也不必让每个元素都经过 object,从而避开这条装箱路径。不过,约束仍要有意选择:无约束的 T 不保证比较或算术能力。相等判断应使用 EqualityComparer.Default 或传入的比较器,业务验证仍是独立的运行时责任。

延伸阅读 泛型
讲清楚了吗?
41 LINQ 的延迟执行是什么意思,什么时候应该物化查询? 中级 常见 查看答案 ▾ 收起 ▴

延迟执行表示返回序列的查询通常只记录操作,直到枚举时才读取数据源。每次重新枚举都可能再次执行筛选、投影、I/O 或提供程序查询,也可能看到已经变化的源数据。契约需要稳定成员快照、需要重复读取同一结果,或者数据必须离开资源作用域时,我会用 ToList 或 ToArray 物化。物化只复制外层容器,不复制其中引用的对象。需要继续组合或短路,而且数据源在消费期间保持有效时,则保留延迟查询。

延伸阅读 LINQ
讲清楚了吗?
42 怎样在 First、FirstOrDefault、Single 与 SingleOrDefault 之间选择? 中级 常见 查看答案 ▾ 收起 ▴

我会根据领域基数选择,而不是看哪个运算符能避免异常。First 要求至少一项,但允许更多项;Single 断言恰好一项,并把重复数据暴露为失败。两者的 OrDefault 版本允许没有结果,但 SingleOrDefault 仍会拒绝多项。默认值本身可能是合法数据,例如 int 的零,所以裸返回值未必能清楚表达是否存在。我会分别测试零项、一项与多项;必须区分缺失时,改用可空类型或明确的结果类型。

延伸阅读 LINQ
讲清楚了吗?
45 C# 怎样选择基于模式的 switch 表达式分支? 中级 常见 查看答案 ▾ 收起 ▴

C# 按文本顺序检查 switch 表达式分支,选择第一个模式匹配且可选 when 守卫为真的分支。因此,顺序就是契约的一部分:窄常量、具体类型和小范围通常位于宽模式之前。若前面的无守卫模式能够证明涵盖后续分支,编译器会拒绝代码,但它无法推断任意守卫方法之间的关系。我会让守卫保持无副作用,在边界允许未知值时把明确兜底放到最后,并用能同时满足多个模式的重叠输入验证优先级。

延伸阅读 模式匹配
讲清楚了吗?

数值与转换

1个问题
03 怎样让数值转换与算术运算安全失败? 中级 常见 查看答案 ▾ 收起 ▴

首先确定每个中间表达式的类型。更宽的目标变量不能阻止先前溢出:两个 int 操作数仍会先按 int 相乘,再赋给 long。需要拒绝溢出时,应在运算前扩大一个操作数,并让 checked 覆盖完整表达式。显式强制转换可能缩小范围,整数到浮点数的隐式转换也可能丢失精度。外部文本应使用带明确区域性与格式策略的 TryParse,而不是强制转换或异常驱动的解析。边界测试要覆盖格式错误、极值、组合最大值、符号以及小数位和舍入规则。

延伸阅读 数据类型
讲清楚了吗?

运行时行为

5个问题
04 什么是装箱与拆箱,为什么拆箱必须使用准确类型? 中级 偶尔 查看答案 ▾ 收起 ▴

装箱把值类型转换成 object 或它实现的接口,通常会创建一个包含值副本的对象。拆箱检查箱中值的运行时类型并提取该值,而且要求类型准确匹配:装箱为 object 的 int 不能直接拆成 long,尽管拆箱后的 int 可以扩大为 long。正确顺序是先拆成 int,再执行数值转换。重复装箱会增加分配与类型检查,因此 List 等泛型集合通常优于基于 object 的容器。泛型代码显式把 T 转成 object 或接口时仍可能装箱。

延伸阅读 数据类型
讲清楚了吗?
06 C# 多播事件的某个处理器抛出异常时会发生什么? 中级 常见 查看答案 ▾ 收起 ▴

默认情况下,处理器会按调用列表顺序同步运行。某个处理器抛出异常后,异常会返回触发事件的代码,列表中排在后面的处理器不会执行。在事件调用外层捕获异常只能接住失败,无法让已跳过的处理器恢复运行。如果 API 契约要求尝试每个处理器,发布者必须先取得调用列表快照,再逐个调用,并明确记录或聚合失败的方式。维护不变量的必做更新与清理应留在发布者内部,清理还要由 finally 保护,不能依赖最后一个订阅者。

讲清楚了吗?
30 多播委托怎样处理顺序、返回值与异常? 中级 常见 查看答案 ▾ 收起 ▴

直接调用会按调用列表顺序同步执行各项目。对于返回 void 的委托,正常完成的项目会把控制权交给下一项;对于有返回值的委托,所有执行到的项目都会运行,但调用表达式只得到最后一个正常完成项目的结果,前面结果会被丢弃。某个项目抛出异常后,调用立即停止,后续项目不会运行,也不会自动聚合错误。契约要求收集每个结果或尝试每个项目时,我会取得 GetInvocationList 快照,逐项调用,并明确规定顺序、部分副作用、取消与失败报告。

延伸阅读 委托与事件
讲清楚了吗?
40 .NET 运行时会保留哪些泛型类型信息? 高级 偶尔 查看答案 ▾ 收起 ▴

封闭构造类型会保留类型实参,因此反射能区分 List 与 List,也能取回 int 或 string;两者都指向 List<> 泛型类型定义。静态字段同样归每个封闭构造类型所有,这让按类型缓存成为可能,也可能成倍增加长期状态。运行时生成代码时可以让多个引用类型实参共享实现,并为值类型构造生成专门代码;这种实现细节不会擦除类型身份。反射工厂必须区分开放定义与封闭类型,并处理 MakeGenericType 的参数个数和约束失败。

延伸阅读 泛型
讲清楚了吗?
43 为什么重复枚举 IEnumerable<T> 有风险,应该怎样审查? 中级 常见 查看答案 ▾ 收起 ▴

IEnumerable 只承诺可以遍历,不承诺已经缓存数据,也不承诺能够重复消费。先调用 Any 或 Count 再遍历,可能让迭代器运行两遍、发起两次远程查询、重复副作用,或看到不同的数据源状态。我会标出 string.Join、聚合、序列化器和日志辅助方法等每个消费点,再确认具体源类型。操作需要一组稳定数据时,只物化一次并使用集合;刻意流式处理时,则设计单遍控制流,不预先探测是否为空。测试会使用统计枚举次数或拒绝第二次枚举的数据源。

延伸阅读 LINQ
讲清楚了吗?

对象生命周期

2个问题
07 事件订阅何时会让订阅者持续存活,怎样避免? 中级 常见 查看答案 ▾ 收起 ▴

实例方法委托会强引用其目标对象。只要可达的发布者仍把这个委托保存在事件调用列表中,订阅者也会保持可达。当静态事件、单例或其他长期发布者比页面、请求对象或临时服务活得更久时,这个问题尤其明显。应在创建订阅的同一所有权边界执行移除,常见位置是 Dispose 或框架卸载钩子,并移除准确的命名方法或已保存委托实例。重新写一个等价 lambda 无法标识原注册项。测试应在清理后再次触发,也可以用弱引用检查对象能否回收。

讲清楚了吗?
31 为什么看起来相同的 lambda 可能无法移除事件处理器? 中级 常见 查看答案 ▾ 收起 ▴

移除依据委托相等性,不依据源码是否相似。一个委托项目包含方法;对于实例方法或捕获型 lambda,它还包含目标对象。重新写一段 lambda 可能产生另一个编译器生成方法或捕获目标,因此不能可靠匹配原注册项。我会保存原委托实例,或者添加、移除同一个命名方法,并在创建订阅关系的所有权边界执行清理。测试应在清理后再次触发;-= 能通过编译,并不能证明处理器已经移除。

延伸阅读 委托与事件
讲清楚了吗?

并发

3个问题
08 为什么把异步 lambda 注册到 EventHandler 有风险,可等待设计应规定什么? 高级 偶尔 查看答案 ▾ 收起 ▴

EventHandler 返回 void,因此注册到它的异步 lambda 会变成 async void。发布者无法等待处理器完成,不能通过返回任务观察取消,也无法在触发调用中捕获发生在未完成 await 之后的异常。若发布者需要这些保证,应使用返回 Task 的明确委托,或者公开异步方法。契约必须说明处理器顺序执行还是并发执行、使用哪个 CancellationToken、一次失败是否停止或取消其他处理器,以及怎样报告多个错误。仅把触发方法命名为 RaiseAsync,或用 Task.Run 包装处理器,并不会产生这些语义。

讲清楚了吗?
12 异常与取消如何通过异步方法和 Task.WhenAll 传播? 高级 偶尔 查看答案 ▾ 收起 ▴

返回 Task 的 async 方法通常会把未处理异常存入任务,await 再在等待方的控制流中重新抛出。与操作令牌关联的 OperationCanceledException 表示取消,不能和普通故障混为一谈。Task.WhenAll 会等所有输入任务结束;只要任一任务失败,组合任务就进入 Faulted,其 AggregateException 保留全部输入失败。await 组合任务时只会抛出其中一个异常,因此需要全部失败时要保留 Task.WhenAll 的结果并检查 Exception。没有失败但至少一个任务取消时,组合任务会取消。事件处理器契约之外应避免 async void,因为调用方无法等待或检查它。

延伸阅读 异常
讲清楚了吗?
28 ConcurrentDictionary 保证哪些原子边界,为什么 GetOrAdd 仍可能重复工作? 高级 偶尔 查看答案 ▾ 收起 ▴

ConcurrentDictionary 保证文档列出的成员在并发调用下安全,并提供 TryAdd、TryUpdate 等针对单个键的条件操作。它不会把任意多次调用合并成事务,也不会保护跨多个键的不变量。GetOrAdd 与 AddOrUpdate 会在内部锁之外调用用户委托,因此竞争调用方可能多次执行工厂,尽管某个键最终只有一个值胜出。工厂应保持纯净或可安全重复执行。不可逆副作用与跨键规则需要另外使用锁、事务、幂等机制或专用协调对象,而且边界必须匹配业务操作。

延伸阅读 集合
讲清楚了吗?

诊断

1个问题
10 throw;、throw ex; 与包装异常有什么区别? 中级 常见 查看答案 ▾ 收起 ▴

在 catch 中,throw; 会继续传播当前异常并保留已有堆栈信息。throw ex; 则会重新抛出已捕获对象,把堆栈起点重置到当前位置,从而遮蔽原传播路径。包装异常用于表达当前抽象层的错误语义;应把已捕获异常作为 InnerException 传入,以保留底层类型、消息和堆栈。只有补充稳定上下文或改变错误契约时才包装,不要每个方法都包一层。若必须离开 catch 后再传播,可用 ExceptionDispatchInfo 连同传播信息一起捕获并在另一执行点抛出。

延伸阅读 异常
讲清楚了吗?

API 设计

7个问题
11 C# API 何时应返回结果而不是抛出异常? 中级 常见 查看答案 ▾ 收起 ▴

当某个结果属于预期状态,而且调用方会经常据此分支时,应返回值。TryParse 与 TryGetValue 就是典型例子:用户输入格式错误、字典键缺失都可能是正常状态,用异常表达反而让普通路径更难阅读和诊断。方法无法履行明确契约,而且失败需要向上传播时再抛出,例如公开参数无效或必需存储不可用。这个选择属于 API 契约,不是笼统的性能规则。返回类型要保留调用方必须区分的状态,但不要用字符串堆出一个只是重复异常层次的大型结果包装器。

延伸阅读 异常
讲清楚了吗?
16 C# 方法签名可以表达契约的哪些部分? 中级 常见 查看答案 ▾ 收起 ▴

方法签名能表达名称、参数类型与传递方式、返回类型、泛型参数和可访问性。编译器据此拒绝许多无效调用,调用方也能知道必须提供哪些值。方法需要具体值时,窄类型通常优于 object。签名一般无法表达所有范围、格式、参数间关系、副作用或失败策略,因此方法还要在边界验证剩余前置条件,并说明可观察行为。行为模式有名称时,应使用独立方法或枚举,而不是布尔开关;返回类型则要保留调用方必须区分的每种状态。

延伸阅读 C# 语言基础
讲清楚了吗?
20 属性公开可变对象或集合时应该审查什么? 中级 偶尔 查看答案 ▾ 收起 ▴

首先要区分属性赋值能力与对象本身的可变性。只有 get、private set 或 init 的属性仍可能返回 List、数组或可变子对象,调用方可以直接修改。应明确调用方需要实时视图、不可变值还是快照。把可变存储留在类型内部,对外提供不可变集合或只读包装,同时继续检查元素本身是否可变。还要检查相等性与哈希:对象进入 Dictionary 或 HashSet 后,如果参与身份的属性发生变化,集合可能无法再从原键或桶中找到它。

延伸阅读 属性
讲清楚了吗?
22 应该怎样设计自定义 C# 特性的契约? 中级 常见 查看答案 ▾ 收起 ▴

自定义特性类继承 System.Attribute,通常应声明为 sealed,并用 AttributeUsage 指定最小目标集合、能否出现多个实例以及是否参与继承。必需且有顺序的数据放在公开构造函数参数中,真正可选的数据放在公开可写字段或属性中作为命名实参,并给出明确默认值。实参必须是字符串、数值、枚举、Type 或允许的一维数组等可写入元数据的编译时值。构造函数与 setter 应保持确定且不执行 I/O。还要说明消费者如何处理重复、冲突、未知值与版本变化,因为特性类本质上是一份元数据协议。

延伸阅读 特性
讲清楚了吗?
27 只读集合视图与不可变快照有什么区别? 中级 常见 查看答案 ▾ 收起 ▴

只读接口或包装器只会阻止调用方通过这条引用修改集合,背后的集合仍可能可变。拥有者或其他别名可以修改数据,调用方也会观察到这些变化。快照复制某个时点的容器状态,因此源集合之后的结构变化不会出现其中。不可变集合还会把更新表达为返回新集合值的操作。这些选择都不会自动让可变元素深度不可变。API 应说明返回的是实时视图、浅快照还是不可变值,并单独定义跨线程可见性。

延伸阅读 集合
讲清楚了吗?
36 审查接收客户端输入的动态表达式构建器时要看什么? 高级 偶尔 查看答案 ▾ 收起 ▴

我会把构建器视为一种小型输入语言。外部名称必须映射到明确的属性和操作符白名单,而不是反射能够找到的所有成员。值要按已说明的区域、枚举与可空策略解析,再用准确的操作数类型表示。我会限制树深与集合大小,在提供程序执行前拒绝不支持的节点,并把值作为提供程序参数而不是目标语言文本传入。测试要覆盖禁止和缺失成员、无效与空值、提供程序翻译、授权和内存语义一致性。缓存还需要结构键、容量、失效与捕获对象生命周期检查。

延伸阅读 表达式树
讲清楚了吗?
38 怎样为公开泛型方法选择约束? 中级 常见 查看答案 ▾ 收起 ▴

我会从实现中的具体操作出发,把每项操作映射到最窄的协议。相等判断通常需要 IEqualityComparer,排序可以接收 IComparer,静态算术可使用运算符接口或 INumber。只有泛型代码负责无参创建,而且空实例本身有效时,我才添加 new()。每个未使用的约束都会排除合法类型,并成为 API 兼容性边界。约束描述类型能力,不会验证 null 值、范围、授权或领域不变量;这些检查仍应放在运行时边界。

延伸阅读 泛型
讲清楚了吗?

控制流

1个问题
15 怎样审查循环或分支中的控制流错误? 中级 常见 查看答案 ▾ 收起 ▴

不要只沿示例输入执行一次,而要枚举路径。对循环分别推演零次、一次和多次迭代,确认第一个与最后一个访问元素、终止条件,以及每条继续路径是否推进状态。对分支要覆盖所有 else、switch 兜底、提前 return、continue、break 和抛出异常。记录每个变量在哪里完成明确赋值,以及后续成立哪些不变量。然后为输入为空、单项、格式错误、边界值和未知情况编写测试。这能发现差一错误、遗漏更新、不可达处理和解析失败后继续使用值等问题。

延伸阅读 C# 语言基础
讲清楚了吗?

初始化

2个问题
18 只有 get、private set、init 与 required 属性有什么区别? 中级 常见 查看答案 ▾ 收起 ▴

只有 get 的属性可以在声明处或构造函数中初始化,但没有供之后赋值的 setter。private set 允许声明类型在构造后继续赋值,同时拒绝外部赋值。init 允许调用方在对象构造阶段赋值,之后拒绝普通赋值。required 回答的是另一个问题:除非所选构造函数声明已经完成初始化,否则创建代码必须设置该成员。它可以配合 set 或 init,既不执行领域验证,也不会让引用对象深度不可变。

延伸阅读 属性
讲清楚了吗?
19 为什么必需成员不能充当运行时验证边界? 中级 常见 查看答案 ▾ 收起 ▴

必需成员主要是面向 C# 对象创建表达式的编译时协议。显式写入 null 仍算已经设置成员,可空分析只会另外给出警告。反射、某些序列化器以及旧编译器生成的代码也不一定经过同一项创建检查。此外,SetsRequiredMembers 会让编译器信任构造函数,却不验证函数体。空值、格式、范围和跨字段规则仍要在可信模型边界执行运行时验证,并针对实际序列化器或框架构造路径做端到端测试。

延伸阅读 属性
讲清楚了吗?

反射

2个问题
23 消费者什么时候应使用 GetCustomAttributes,什么时候应使用 CustomAttributeData? 中级 常见 查看答案 ▾ 收起 ▴

GetCustomAttribute 与 GetCustomAttributes 会创建特性实例,适合可信应用代码需要强类型属性的场景。构造过程会调用特性构造函数,并执行命名字段或属性赋值,这些操作可能抛出异常或产生副作用。CustomAttributeData 则把构造函数、位置实参与命名实参作为元数据公开,不创建特性对象,适合分析器、文档工具、插件目录以及不应执行被检查代码的发现流程。多用特性要使用复数实例 API,继承策略要显式定义,所有值都要验证;发现结果会被重复使用时,应转换并缓存为普通不可变描述对象。

延伸阅读 特性
讲清楚了吗?
24 为什么特性目标与继承会让反射框架容易出错? 高级 偶尔 查看答案 ▾ 收起 ▴

一处源码声明可能对应多个元数据实体。自动属性同时涉及属性元数据、访问器方法和生成的幕后字段,因此 property:、method:、field:、param: 与 return: 会让扫描器到不同位置查找。继承是一项查询策略,不是复制后的元数据;反射入口、inherit 实参、特性的 Inherited 设置以及成员种类都会影响结果。接口实现也不属于类继承,需要显式处理接口映射。框架应说明具体扫描哪些实体,对多用特性使用复数读取,并分别测试直接声明、重写、接口、缺失与重复场景。

延伸阅读 特性
讲清楚了吗?

集合

1个问题
25 怎样在 List<T>、Dictionary<TKey,TValue> 与 HashSet<T> 之间选择? 初级 常见 查看答案 ▾ 收起 ▴

先看主要访问模式,不要只看元素类型。位置、顺序遍历和尾部追加最重要时使用 List;每个值都有唯一查找键,而且调用方按键取值时使用 Dictionary<TKey,TValue>;契约强调唯一性、成员测试或集合运算,而且值不需要关联数据时使用 HashSet。之后还要明确相等性与顺序要求。Dictionary 和 HashSet 都要求键的相等性稳定,也不应因为偶然的枚举顺序而被选中。容量、修改所有权与并发访问还可能改变最终选择。

延伸阅读 集合
讲清楚了吗?

相等性与哈希

2个问题
26 Dictionary 键必须满足怎样的相等性契约? 中级 常见 查看答案 ▾ 收起 ▴

字典通过 IEqualityComparer 计算键的哈希,并最终确认相等性。两个键相等时必须产生相同哈希码,但不相等的键可以发生碰撞。参与这两项操作的字段在键存入期间必须保持稳定;修改这些字段后,对象仍在旧哈希选择的桶中,查找和删除却可能失败。应优先使用不可变标识或值对象。领域身份不同于 EqualityComparer.Default 时,要传入明确比较器,例如对 SKU 使用 ordinal 的不区分大小写匹配。

延伸阅读 集合
讲清楚了吗?
51 集合成员会怎样影响生成的记录相等性与哈希键? 高级 常见 查看答案 ▾ 收起 ▴

生成的记录相等性会组合各成员自身的相等契约,不会递归比较任意集合内容。数组与常见可变列表类采用基于引用的相等性,因此元素相同但分别构造的列表可能让两个记录不相等。记录插入 Dictionary 或 HashSet 后修改可写成员,还可能改变生成哈希并破坏查找。若序列值包装按元素散列,修改元素同样危险。我会明确顺序、重复项、大小写与规范化是否参与相等,提供匹配的相等与哈希行为,并在键位于集合的整个期间保持所有哈希输入稳定。

延伸阅读 记录类型
讲清楚了吗?

异步契约

1个问题
32 调用方必须等待每个异步处理器时,应该怎样设计回调契约? 高级 偶尔 查看答案 ▾ 收起 ▴

我不会使用 Action 或 EventHandler,因为异步 lambda 转换到这两种类型后会成为 async void。Func<CancellationToken, Task> 等返回 Task 的委托能提供完成信号,但直接调用多播委托仍只返回最后一个处理器的 Task。发布方必须取得并转换调用列表快照,再选择顺序等待,或者收集全部任务交给 Task.WhenAll。契约还要说明取消来源、一次失败是否停止或取消其他处理器、怎样处理同步抛出,以及怎样报告多个失败。若一个结果会决定请求成败,普通异步方法往往更清楚。

延伸阅读 委托与事件
讲清楚了吗?

树改写

1个问题
34 怎样安全组合两个 `Expression<Func<T, bool>>` 谓词? 高级 常见 查看答案 ▾ 收起 ▴

参数绑定依据 ParameterExpression 对象标识,而不是显示名称。因此,两个独立谓词即使都把参数叫作 x,仍拥有不同参数对象。我会创建一个规范参数,遍历两个函数体,把各自原参数替换成该实例,再用 AndAlso 或 OrElse 连接函数体;最终 lambda 只绑定规范参数。除非目标提供程序明确支持 InvocationExpression,否则不使用 Expression.Invoke。通用访问器还必须尊重嵌套 lambda 的作用域,避免替换遮蔽外层参数的内部声明。

延伸阅读 表达式树
讲清楚了吗?

LINQ 提供程序

2个问题
35 `IQueryable<T>` 管道中的翻译边界在哪里? 高级 常见 查看答案 ▾ 收起 ▴

Queryable 运算符接收表达式树,并扩展由源 IQueryProvider 保存的查询描述。翻译与远程执行通常推迟到枚举或终结运算符要求结果时。Compile 会把谓词转成委托,AsEnumerable 则让后续运算符绑定到 Enumerable;两者都会使后续工作变为普通进程内执行。对内存序列调用 AsQueryable 不会重新产生数据库提供程序。我会检查每个物化和类型边界调用,再用真实提供程序验证支持节点、空值语义、参数化、结果与查询次数,而不是只测 List.AsQueryable()。

延伸阅读 表达式树
讲清楚了吗?
44 LINQ 管道从 IQueryable<T> 跨到 IEnumerable<T> 时会发生什么变化? 高级 常见 查看答案 ▾ 收起 ▴

Queryable 运算符为数据源提供程序构建表达式树描述,Enumerable 运算符则接收委托并执行普通 .NET 逻辑。AsEnumerable 本身不会取回数据,但会让后续扩展调用绑定到 Enumerable;ToList 会同时执行查询并物化结果。我会把可翻译的筛选、投影、排序和限制留在边界之前,让远程工作继续在远端完成。随后还要检查真实提供程序生成的查询、参数、空值与排序规则、结果规模和往返次数。只测试 List.AsQueryable 只能证明内存路径,不能证明生产环境的翻译。

延伸阅读 LINQ
讲清楚了吗?

类型关系

1个问题
39 为什么 IEnumerable<Dog> 可以赋给 IEnumerable<Animal>,而 List<Dog> 不能赋给 List<Animal>? 中级 常见 查看答案 ▾ 收起 ▴

IEnumerable 是协变接口,因为它的契约只产生 T,不接收 T 进行修改。因此,已有的 Dog 到 Animal 引用转换可以沿接口传播。List 同时读取和写入 T,所以保持不变。如果 List 能变成 List,调用方就能插入 Cat,破坏原来的狗列表。只有接口和委托的类型参数可以声明变体,而且转换要求引用类型实参。因此,IEnumerable 不会变成 IEnumerable;int 到 object 是装箱,不是隐式引用转换。

延伸阅读 泛型
讲清楚了吗?

可空性与控制流

1个问题
46 类型模式与属性模式遇到 null 时怎样工作? 中级 常见 查看答案 ▾ 收起 ▴

声明模式或类型模式不会匹配 null,因此 value is string text 同时证明运行时类型兼容,而且 text 非空。属性模式也要求输入非空;扩展属性路径中的中间接收者为空时,模式会失败,而不是抛出 NullReferenceException。我用 null 常量模式检查空值,用 is not null 直接检查非空,并记住 var other 仍会匹配 null。这些检查只收窄模式明确表达的事实,未出现的字段和外部业务不变量仍需单独验证。

延伸阅读 模式匹配
讲清楚了吗?

穷尽性

1个问题
47 switch 表达式的穷尽性是什么意思,仍有哪些失败边界? 高级 常见 查看答案 ▾ 收起 ▴

某个分支能处理每种可能输入时,switch 表达式才是穷尽的。最后的弃元模式能提供语法兜底,但若它把新输入静默当成成功,业务含义仍然错误。没有分支匹配时,现代 .NET 会抛出 SwitchExpressionException,编译器通常也会警告覆盖不完整。我仍会测试 null、未知派生类型,以及由未定义底层数值强制转换出的枚举值。列表模式还需额外小心,因为编译器不会警告序列长度或形状覆盖不完整。失败策略应由契约明确规定,不能只依赖警告。

延伸阅读 模式匹配
讲清楚了吗?

序列模式

1个问题
48 C# 列表模式能匹配什么输入,切片会怎样改变要求? 高级 偶尔 查看答案 ▾ 收起 ▴

列表模式按结构判断兼容性。输入的静态类型必须能通过可访问的 Length 或 Count 计数,并能通过 Index 或 int 索引器读取;只有 IEnumerable 不够。单独的 .. 只会调整长度与其他子模式使用的位置。用 .. var middle 捕获或检查中间部分时,还需要 Range 索引器或合适的 Slice 方法;数组和字符串由语言规定处理方式。我会把列表模式用于短小的位置形状,而不是任意流搜索;需要物化流时,先限制其大小。

延伸阅读 模式匹配
讲清楚了吗?

类型设计

1个问题
49 怎样在类、记录类与记录结构体之间选择? 中级 常见 查看答案 ▾ 收起 ▴

我会先确定身份与复制语义。若实体在属性变化后仍保持同一身份,或需要封装可变生命周期,普通类更合适。若稳定数据由所有选定成员值定义相等,而普通赋值应复制引用,可选择记录类;它还支持记录继承。若值很小且自包含,并且可以接受逐字段复制与全零默认值,可选择记录结构体。需要阻止顶层普通修改时使用 readonly record struct,但仍要检查嵌套引用。只有代表性基准测量了真实工作负载中的分配、复制、装箱与相等成本后,才让性能参与决定。

延伸阅读 记录类型
讲清楚了吗?

复制语义

1个问题
50 with 表达式究竟复制什么,又可能漏掉哪些不变量? 中级 常见 查看答案 ▾ 收起 ▴

with 表达式会创建新的外层值,再应用点名的成员初始化器。默认复制是浅层的,因此列表等引用类型成员仍指向同一嵌套对象,除非初始化器将其替换。对记录类,复制行为创建新对象;记录结构体则按值复制。该操作不等于用所有最终值调用公开构造函数,所以只放在构造函数中的跨成员验证可能不会重新执行。根据其他成员初始化的存储属性,也可能在输入改变后保留旧计算结果。我会在每种允许的复制更新后测试引用共享关系与所有派生属性。

延伸阅读 记录类型
讲清楚了吗?

继承

1个问题
52 记录类的继承、相等性与 with 如何相互作用? 高级 偶尔 查看答案 ▾ 收起 ▴

记录类只能在记录类体系中继承。生成的相等性通过相等契约包含运行时记录类型,因此基记录对象与派生记录对象不会仅因共有成员相同而相等,这能防止派生状态让相等关系失去对称性。对静态类型为基类的变量使用 with 时,记录类的虚拟复制行为会保留操作数的派生运行时类型,但初始化器只能点名接收者编译时类型可见的成员。多态记录跨越复制边界时,我会测试两个方向的相等比较、消费者对未知派生类型的处理,以及派生成员是否保留。

延伸阅读 记录类型
讲清楚了吗?

运行时类型系统

1个问题
53 `typeof(T)`、`obj.GetType()` 与按名称查找类型有什么区别? 中级 常见 查看答案 ▾ 收起 ▴

typeof(T) 使用编译代码已知的类型,不需要实例,也能表示接口、数组和开放泛型定义。obj.GetType() 要求对象非空,返回实际运行时类型;该类型可能比变量的声明类型更具体。按名称查找还增加了程序集解析和失败策略:Type.GetType 不会搜索所有已加载程序集,类型缺失时可以返回 null,也可以通过所选重载抛错。类型静态已知时我优先用 typeof;确实需要动态查找时,则明确程序集身份。

延伸阅读 反射
讲清楚了吗?

成员解析

1个问题
54 怎样让反射成员查找具有确定性? 高级 常见 查看答案 ▾ 收起 ▴

我先确定契约针对哪个准确 Type,再按成员类别查找,而不是扫描所有 MemberInfo。对方法,我会给出名称、形参类型、泛型元数或构造状态,以及 ref、out 形态。BindingFlags 明确可见性、实例或静态成员,并说明是否包含继承成员。除非协议另有规定,零个和多个匹配项都属于配置错误。我不会用枚举顺序作为选择策略。外部名称必须先经过应用白名单,只有验证后的结果才能缓存,而且缓存键要包含完整选择条件。

延伸阅读 反射
讲清楚了吗?

动态调用

1个问题
55 应怎样诊断 `MethodInfo.Invoke` 抛出的异常? 高级 常见 查看答案 ▾ 收起 ▴

我会区分解析、实参绑定和目标执行三个阶段。成员缺失或歧义在解析阶段失败;目标对象、实参数量或类型错误可能在目标方法开始前失败。若目标方法自身抛错,反射会用 TargetInvocationException 包装,并通过 InnerException 暴露原始异常。应用策略应检查或重新抛出原始异常,同时保留堆栈,不能使用 throw inner。诊断应记录阶段、声明类型和稳定签名,但不要记录敏感实参。测试至少覆盖一次绑定失败和一次目标内部已知异常。

延伸阅读 反射
讲清楚了吗?

部署

1个问题
56 为什么反射代码可能在裁剪或 Native AOT 发布后失败? 高级 偶尔 查看答案 ▾ 收起 ▴

裁剪器依据静态可见的使用关系工作,只通过不可分析字符串访问的代码或元数据可能被移除,因此开发构建能运行并不能证明发布产物正确。我会在真实项目中启用裁剪或 AOT 分析器,把警告当作设计证据,并运行目标 RID 产物。若 Type 值可跟踪,DynamicallyAccessedMembers 可以传播精确的成员保留要求。真正动态的协议可能需要 RequiresUnreferencedCode,但抑制警告不等于保留成员。对于宽泛扫描或映射,显式注册或源生成器通常能给链接器更清楚、更小的契约。

延伸阅读 反射
讲清楚了吗?

内存 API

1个问题
57 设计 API 时怎样在 Span<T> 与 Memory<T> 之间选择? 中级 常见 查看答案 ▾ 收起 ▴

我依据所需生命周期与访问权限选择,而不是套用笼统的性能规则。只在同步调用期间消费数据的方法接受 ReadOnlySpan;确实要写入时接受 Span。这样,数组等连续来源无需被迫复制就能调用。若实现会保存区域、在消费结束前返回,或跨越 await,就需要 ReadOnlyMemory 或 Memory。Memory 可以存储,却仍不拥有底层存储。契约必须说明保留期限、修改权限、有效长度,以及由谁保证所有者存活。

讲清楚了吗?

别名与可变性

1个问题
58 ReadOnlySpan<T> 或 ReadOnlyMemory<T> 会让底层数据不可变吗? 中级 常见 查看答案 ▾ 收起 ▴

不会。ReadOnly 只禁止通过当前视图写入,不约束其他别名或所有者。指向字符串的只读 span 之所以有不可变来源,是因为字符串不可变,并非 Span 提供了该保证。指向数组的只读视图仍会观察到原数组或另一个可写 span 的修改;池化存储归还后还可能被下一位租用者覆盖。若消费者需要稳定快照,我会复制到自己拥有的存储,或建立同步与独占租期,并在 API 中明确承诺,而不把 ReadOnly 当成所有权保证。

讲清楚了吗?

所有权与池化

1个问题
59 怎样安全返回由 ArrayPool<T> 或 IMemoryOwner<T> 支持的数据? 高级 常见 查看答案 ▾ 收起 ▴

单独的 Memory 不携带所有权,因此我不会在释放 IMemoryOwner 或把数组归还到池后再返回它。可行做法有三种:在租期内完成消费;把有效区域复制到调用方拥有的存储;返回可释放的所有者,让租用一直存活到调用方完成。对外暴露的 Memory 必须切到已写长度,不能使用租用容量。我还会验证生产者返回的长度,确保每次租用只归还一次,并按策略清除敏感区域,因为池化数组可能保留前一位租用者的数据。

讲清楚了吗?

引用安全

1个问题
60 当前在异步代码中使用 Span<T> 有哪些规则? 高级 偶尔 查看答案 ▾ 收起 ▴

Span 是 ref struct,因此不能跨越 await 或 yield 保持存活,不能被异步延续捕获,也不能存进普通类字段。「span 绝不能出现在 async 方法中」已经过时:从 C# 13 开始,可以在不含 await 或 yield 的代码块中使用 ref struct。我仍倾向于让 Memory 跨越 await,再把 memory.Span 传给同步辅助方法。这个形状让生命周期更直观,对较旧语言设置也更可预测,并把异步所有权与短生命周期的 span 访问分开。

讲清楚了吗?

编译器工具

2个问题
61 C# 源生成器在编译期间可以读取和改变什么? 中级 常见 查看答案 ▾ 收起 ▴

源生成器可以检查输入编译、语法与语义信息、解析选项、引用、分析器配置和已声明的额外文件。它可以添加新源码并报告诊断,但不能编辑或删除用户语法树,因此扩展已有类型通常依靠 partial 声明,或生成独立类型。普通生成器之间也没有可靠顺序,不能消费彼此的普通输出。我会把生成成员与诊断视为带版本的 API:测试既检查生成文本,也编译更新后的编译,并确认错误落在用户源码中的可修正位置。

延伸阅读 源生成器
讲清楚了吗?
62 什么因素让 `IIncrementalGenerator` 真正具备增量性? 高级 常见 查看答案 ▾ 收起 ▴

仅实现这个接口并不会自动获得增量性。管道需要细粒度提供者和转换,而且每一步输出都要具有稳定的值相等性。我会用便宜的语法谓词筛选候选,只对候选执行语义工作,再把符号与语法投影成小型 record 后发射。除非输出确实依赖全集,否则不把每项与完整 Compilation 组合,也不过早 Collect。集合还要有明确的逐项相等与确定顺序。失效测试会连续运行两次驱动,确认无关编辑不会改变或重新计算未受影响的输出。

延伸阅读 源生成器
讲清楚了吗?

Roslyn 语义

1个问题
63 为什么特性驱动的源生成器不能依赖语法文本匹配? 高级 常见 查看答案 ▾ 收起 ▴

源码文本无法确定符号身份。同一个特性可以写成短名、带 Attribute 后缀的名称、别名或限定名,另一个命名空间也可能定义相同短名。因此,字符串比较既会漏掉合法目标,也可能接受无关目标。ForAttributeWithMetadataName 根据完整元数据名执行语义匹配,并向转换提供目标符号与匹配的特性数据。它的语法谓词仍应保持便宜,随后只提取受支持生成契约所需的名称、选项与位置。

延伸阅读 源生成器
讲清楚了吗?

测试

1个问题
64 除了对生成输出做快照,还应怎样测试源生成器? 高级 偶尔 查看答案 ▾ 收起 ▴

我会为受支持与拒绝的声明形态建立输入矩阵,通过 GeneratorDriver 运行生成器,并检查提示名称、生成树与诊断。随后编译更新后的编译,遇到任何意外错误就让测试失败,因为看似合理的快照仍可能包含无法解析或不兼容的代码。负面测试要断言稳定的诊断 id、严重级别、消息和源码位置。我还会用不变输入及一次局部编辑各运行两轮,检查确定性与失效范围。包测试则在每个受支持编译器宿主中加载分析器,并覆盖真实发布配置。

延伸阅读 源生成器
讲清楚了吗?