知

模块一 · 语言与类型

第 1 / 11 节19 分钟阅读型

C# 基础:类型、相等性与可空

C# 的坑大多集中在"值还是引用""相等怎么判""空怎么处理"。这一节把值类型、相等性、可空与字符串讲透,并给出高频追问。

读完这一节,你能做到

  1. 说清值类型与引用类型在赋值、比较、装箱上的差异。
  2. 区分 ==、Equals、ReferenceEquals 与 IEquatable<T>。
  3. 正确使用可空值类型与可空引用类型。
  4. 说清 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、不同类型与集合去重,测过才算懂。验证顺序:先写用例,再看结果;用例先行,别凭记忆,先跑一遍。先跑用例,再下结论;先跑,再断。

九、常见坑

  1. 用 == 比较两个内容相同的对象,得到 false。
  2. 重写 Equals 不重写 GetHashCode。
  3. 对可空值直接访问 .Value,抛异常。
  4. 在循环里用 += 拼接字符串。
  5. 对 LINQ 查询多次枚举,重复执行。

十、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个 struct 与 class,验证赋值行为差异。
  2. 实现 IEquatable<T>,放进 HashSet 验证去重。
  3. 对比 += 与 StringBuilder 在 10 万次拼接下的耗时。

② 让 AI 写同一题

把第 2 题(相等性判断)交给 AI,补一句 .NET 8,启用可空引用类型,不引第三方库。

③ 对比要盯的点

  • 它区分了 ==、Equals 与 ReferenceEquals 吗?string 的 == 被重载过。
  • 可空引用类型开了吗?开了之后 string? 与 string 是两种契约。
  • 它有没有在循环里拼字符串?StringBuilder 才是对的做法。

④ 一句话取舍

它那版在两个值相等的对象上,能保证 Equals 与 == 得到同一结论吗?

十一、自测

  1. 值类型与引用类型的赋值差异?
  2. 四种相等比较分别比什么?
  3. 为什么重写 Equals 要重写 GetHashCode?
  4. ! 能消除空引用异常吗?
  5. 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(不安全,已废弃)。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解