模块一 · 语言与类型
C# 基础:类型、相等性与可空
C# 的坑大多集中在"值还是引用""相等怎么判""空怎么处理"。这一节把值类型、相等性、可空与字符串讲透,并给出高频追问。
读完这一节,你能做到
- 说清值类型与引用类型在赋值、比较、装箱上的差异。
- 区分
==、Equals、ReferenceEquals与IEquatable<T>。 - 正确使用可空值类型与可空引用类型。
- 说清 string 的不可变与常用拼接方式。
一、值类型与引用类型
C# 的类型分两类:
- 值类型:struct、enum、内置数值、bool、char、DateTime、decimal。变量直接存值,赋值是拷贝。
- 引用类型:class、interface、delegate、string、数组。变量存引用,赋值是拷贝引用。
var a = new Point(1, 2);
var b = a; // 拷贝值
b.X = 99;
Console.WriteLine(a.X); // 1
var list1 = new List<int> { 1 };
var list2 = list1; // 拷贝引用
list2.Add(2);
Console.WriteLine(list1.Count); // 2
string 是引用类型,但表现像值:不可变 + 重载了 ==,所以 "a" == "a" 比较的是内容。
装箱:值类型转 object 或接口时,会在堆上分配一个包装对象;拆箱再拷回。频繁装箱会影响性能,泛型集合可以避免。
二、四种"相等"
| 方式 | 比较什么 | 备注 |
|---|---|---|
== | 值类型比值;引用类型默认比引用 | string 重载后比内容 |
Equals(object) | 默认引用比较,可重写 | 装箱开销 |
ReferenceEquals | 是否同一对象 | 永远比引用 |
IEquatable<T>.Equals | 强类型比较 | 避免装箱,推荐 |
重写相等性时要成对处理:
public sealed class User : IEquatable<User>
{
public int Id { get; init; }
public bool Equals(User? other) => other is not null && Id == other.Id;
public override bool Equals(object? obj) => Equals(obj as User);
public override int GetHashCode() => Id.GetHashCode();
}
只重写 Equals 不重写 GetHashCode,放进 HashSet/Dictionary 会找不到;只重写 GetHashCode 不重写 Equals,会出现"哈希相同但判不等"。
三、可空
可空值类型 int? 是 Nullable<int> 的语法糖,包含 HasValue 与 Value。直接访问 Value 而实际为 null 会抛 InvalidOperationException。
可空引用类型(C# 8+)是编译期提示,不是运行时保证:
string? name = null;
Console.WriteLine(name.Length); // 警告
Console.WriteLine(name!.Length); // 告诉编译器"我保证不为 null"
! 只是关闭警告,不改变运行时行为;真正的空值处理要用 ??、?.、??= 或显式判断。
四、字符串
string 不可变,每次修改都创建新对象。高频拼接用 StringBuilder,格式化用插值字符串,大量拼接还可以用 string.Create 或 ValueStringBuilder。
几个细节:
string.Empty与""行为一致,前者语义更明确。==比较内容,ReferenceEquals比较引用。- 字符串驻留(intern)只对字面量与显式
string.Intern生效。 StringComparison.Ordinal比ToLower()比较更快,也更安全。
五、集合与 LINQ
常用集合:List<T>(动态数组)、Dictionary<TKey,TValue>(哈希表)、HashSet<T>(去重)、Queue<T>/Stack<T>。
LINQ 是延迟执行:Where/Select 只构建查询,直到 ToList/foreach 才执行。这带来两个后果:
- 多次枚举会重复执行查询(可能重复查库)。
- 捕获的变量在枚举时才求值,容易出现意外结果。
需要即时结果时用 ToList();需要流式处理时保留 IEnumerable。
六、异常与资源释放
C# 的异常只应用于“异常情况”,不要用它做流程控制。三个原则:
- 能处理才捕获,不能处理就让它向上抛。
- 捕获后要么恢复,要么包装并保留内部异常(
InnerException)。 - 不要捕获
Exception后静默吞掉,至少记录日志。
非托管资源用 IDisposable + using,using 会在作用域结束时调用 Dispose,即使抛异常也会执行。异步场景用 await using。
using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
七、Span 与性能基础
Span<T>/ReadOnlySpan<T> 是对连续内存的轻量视图,不分配堆内存,适合字符串切片与缓冲区处理。
注意:Span<T> 是 ref struct,不能装箱、不能作为类的字段、不能跨 await。需要跨异步边界时用 Memory<T>。
八、迭代器与 yield
yield return 让方法变成迭代器:调用时不执行,foreach 时才逐个产出。
IEnumerable<int> Range(int max)
{
for (var i = 0; i < max; i++) yield return i;
}
三个要点:延迟执行、状态机实现、每次枚举都重新执行。需要多次遍历时先 ToList();需要提前结束用 yield break。
枚举与位标志
普通枚举默认从 0 递增;需要组合时用 [Flags] + 2 的幂,并用按位运算判断。注意 HasFlag 有装箱开销,热路径用位运算替代。
补充:常用 API 的坑
DateTime.Now依赖时区,跨时区用DateTimeOffset/UTC。decimal适合金额,double会有精度问题。string.Split不传参数会包含空项,必要时用StringSplitOptions.RemoveEmptyEntries。Enumerable.First无元素会抛异常,不确定时用FirstOrDefault。- 可变默认参数在编译期写死,修改默认值要重新编译调用方。
补充:怎么验证
为相等性写单测:覆盖 null、不同类型、相同内容、哈希一致四个用例。用 HashSet 与 Dictionary 实际跑一遍,比看代码可靠。验证:覆盖 null、不同类型与集合去重,测过才算懂。验证顺序:先写用例,再看结果;用例先行,别凭记忆,先跑一遍。先跑用例,再下结论;先跑,再断。
九、常见坑
- 用
==比较两个内容相同的对象,得到 false。 - 重写 Equals 不重写 GetHashCode。
- 对可空值直接访问
.Value,抛异常。 - 在循环里用
+=拼接字符串。 - 对 LINQ 查询多次枚举,重复执行。
十、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个 struct 与 class,验证赋值行为差异。
- 实现
IEquatable<T>,放进 HashSet 验证去重。 - 对比
+=与 StringBuilder 在 10 万次拼接下的耗时。
② 让 AI 写同一题
把第 2 题(相等性判断)交给 AI,补一句 .NET 8,启用可空引用类型,不引第三方库。
③ 对比要盯的点
- 它区分了
==、Equals与ReferenceEquals吗?string 的==被重载过。 - 可空引用类型开了吗?开了之后
string?与string是两种契约。 - 它有没有在循环里拼字符串?
StringBuilder才是对的做法。
④ 一句话取舍
它那版在两个值相等的对象上,能保证 Equals 与 == 得到同一结论吗?
十一、自测
- 值类型与引用类型的赋值差异?
- 四种相等比较分别比什么?
- 为什么重写 Equals 要重写 GetHashCode?
!能消除空引用异常吗?- LINQ 的延迟执行会带来什么问题?
参考答案要点:拷贝值/引用;值/内容/引用/强类型;哈希容器定位;不能,只是抑制警告;重复执行。
附录:高频追问
问:string 是值类型吗? 不是,是引用类型;但不可变且重载了 ==,所以表现像值。
问:装箱和拆箱的代价? 堆分配与拷贝;泛型集合与 IEquatable<T> 可以避免。
问:struct 能继承吗? 不能继承类,但可以实现接口;所有 struct 隐式继承 ValueType。
问:record 是什么? 默认值语义的引用类型,自动生成相等性、ToString、with 表达式,适合 DTO 与不可变模型。
问:const 与 static readonly 的区别? const 是编译期常量,只能用于基元/字符串;static readonly 是运行期初始化,可以是任意类型。
问:ref 与 out 的区别? ref 要求先初始化,out 由方法内赋值;都按引用传递。
问:params 有什么代价? 每次调用都会新建数组;热路径可以用重载或 Span 替代。
问:IDisposable 与 using 的关系? using 在作用域结束时调用 Dispose,确保非托管资源释放。
问:为什么推荐 StringComparison.Ordinal? 不依赖区域设置,速度快,行为稳定;需要语言规则时才用 CurrentCulture。
问:IEnumerable 与 IQueryable 的区别? 前者在内存中执行,后者把表达式树翻译成查询(如 SQL)。
补遗:反射、特性与源生成器
反射用 typeof(T)、GetCustomAttributes、Activator.CreateInstance、MethodInfo.Invoke 在运行时检查与调用。DI、序列化、ORM 都依赖反射;代价是启动慢、AOT 不友好,热路径要缓存 MethodInfo 或改用委托。
特性(Attribute)只是元数据,本身不执行逻辑,靠框架读取。自定义特性继承 Attribute,用 AttributeUsage 限制目标。
源生成器(Source Generator)在编译期生成代码,替代运行期反射,适合序列化、日志、映射;AOT 友好,但要注意增量生成与调试。
补遗:System.Text.Json
默认序列化公共属性、忽略字段;JsonSerializerOptions 要复用(每次 new 会重新生成元数据,慢)。常用配置:PropertyNamingPolicy = CamelCase、DefaultIgnoreCondition = WhenWritingNull、JsonStringEnumConverter。
多态序列化要显式配置 JsonDerivedType;循环引用用 ReferenceHandler.Preserve(慎用,输出会带 $id)。AOT 场景用 JsonSerializerContext 源生成。
补遗:IO 与序列化
小文件用 File.ReadAllText;大文件用 FileStream + Span<byte> 流式处理;异步用 ReadAsync/WriteAsync。
序列化选型:JSON 通用、Protobuf 紧凑、MessagePack 高性能;跨语言优先 JSON/Protobuf。不要用 BinaryFormatter(不安全,已废弃)。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解