2. C#语言核心机制
这一章讨论的六个知识点,几乎每一场C#后端面试都会以某种形式出现。它们看似零散,实则围绕同一个核心问题:数据在内存中如何存放、如何复制、如何比较、如何被编译器处理。理解这条主线,比死记结论更有效。
一、值类型与引用类型:先看内存,再看行为
值类型(struct、enum、int、double、bool、DateTime、decimal等)的实例通常存放在栈上(作为字段时则内嵌在宿主对象中),赋值时复制整个数据;引用类型(class、interface、delegate、string、数组等)的变量存放的是指向堆上对象的引用,赋值时只复制引用地址,两个变量指向同一对象。
面试中常被追问的是“struct一定在栈上吗”。准确回答是:不一定。当struct作为类的字段、被装箱、被闭包捕获时,它会位于堆上。栈/堆只是实现细节,真正稳定的语义差异是复制语义与引用语义。看下面这段代码:
struct Point { public int X; }
class PointRef { public int X; }
var a = new Point { X = 1 };
var b = a; // 复制值
b.X = 99;
Console.WriteLine(a.X); // 输出 1
var c = new PointRef { X = 1 };
var d = c; // 复制引用
d.X = 99;
Console.WriteLine(c.X); // 输出 99
判断依据很直接:修改副本后原对象是否变化。若变化,是引用语义;不变,是值语义。实际选型时,小且不可变的数据(坐标、金额、时间区间)适合struct;需要继承、多态、生命周期共享的实体适合class。注意struct默认的Equals基于反射逐字段比较,性能较差,自定义struct应重写Equals和GetHashCode。
二、装箱与拆箱:性能陷阱的常见来源
装箱是把值类型转换成object或接口类型,运行时在堆上分配一个包装对象并复制值;拆箱是把包装对象中的值复制回值类型。装箱产生堆分配和GC压力,拆箱需要类型检查,二者都是高频操作下的性能隐患。
int n = 42;
object boxed = n; // 装箱:堆分配
int back = (int)boxed; // 拆箱:类型检查 + 复制
// 隐蔽的装箱
var list = new ArrayList();
list.Add(n); // 装箱
Console.WriteLine($"n={n}"); // 早期版本会装箱,现代编译器已优化
面试中常问“如何避免装箱”。实用手段包括:使用泛型集合List<int>替代ArrayList;值类型实现IEquatable<T>避免默认比较器装箱;字符串拼接优先用StringBuilder或插值(现代编译器已优化);比较时用EqualityComparer<T>.Default。判断某处是否装箱,可用IL查看是否出现box/unbox指令,或用BenchmarkDotNet对比分配字节数。
三、字符串不可变性:设计取舍与正确用法
string是引用类型,但一旦创建内容不可更改。所有看似“修改”的操作(Replace、Substring、+)都返回新字符串。这一设计带来线程安全、可安全作为字典键、可缓存哈希值等好处,代价是频繁拼接会产生大量临时对象。
string s = "hello";
string t = s.Replace('h', 'H');
Console.WriteLine(s); // 仍是 hello
Console.WriteLine(t); // Hello
// 循环拼接的反例
string result = "";
for (int i = 0; i < 1000; i++) result += i; // 产生约1000个中间字符串
// 正确做法
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++) sb.Append(i);
string ok = sb.ToString();
面试延伸问题:string.Intern把字符串放入驻留池以复用;ReadOnlySpan<char>可在不分配新字符串的前提下做切片和解析,适合高频文本处理。判断是否需要优化,可观察GC Gen0回收频率和分配速率。
四、Equals与GetHashCode:必须成对重写
默认Equals对引用类型比较引用地址,对值类型逐字段比较;GetHashCode默认基于对象标识。当两个对象“逻辑相等”时,必须保证Equals返回true且GetHashCode返回相同值,否则哈希容器(Dictionary、HashSet)会失效。
public sealed class UserId : IEquatable<UserId>
{
public string Value { get; }
public UserId(string value) => Value = value;
public bool Equals(UserId other) =>
other is not null && Value == other.Value;
public override bool Equals(object obj) => Equals(obj as UserId);
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
}
验证方法:构造两个逻辑相等的对象放入HashSet,若Count为1则正确;若为2,说明GetHashCode未同步重写。注意GetHashCode不应基于可变字段,否则对象入桶后修改字段会导致再也找不到。
五、const与readonly:编译期常量与运行期只读
const是编译期常量,值在编译时确定并直接嵌入调用方程序集;readonly是运行期只读字段,可在声明处或构造函数中赋值。关键差异在于跨程序集版本控制:若A程序集引用B程序集的const,B修改常量值后A不重新编译,A仍使用旧值;readonly则每次运行时读取最新值。
public const double Pi = 3.14159; // 编译期嵌入
public static readonly DateTime Start = DateTime.Now; // 运行期确定
public readonly int Id; // 构造函数中赋值
选型建议:真正永不变化的数学常量用const;对外暴露、可能调整的配置值用static readonly;实例级不可变字段用readonly。注意readonly只保证引用不可变,引用指向的对象内容仍可修改。
六、var与dynamic:编译期推断与运行期绑定
var是编译期类型推断的语法糖,变量类型在编译时确定,具备完整静态检查和IntelliSense;dynamic把类型检查推迟到运行时,通过DLR调用,编译期不做成员校验,错误在运行期抛出RuntimeBinderException。
var list = new List<int>(); // 编译期即 List<int>
// list.Add("x"); // 编译错误
dynamic d = 1;
d = "text"; // 合法,类型可变
Console.WriteLine(d.Length); // 运行期解析
// d.NotExist(); // 运行期抛异常
面试常问“var是否影响性能”——不影响,编译后类型完全相同。“dynamic是否等于object”——不等同,object需要显式转换,dynamic自动绑定。实际项目中dynamic应限于COM互操作、动态JSON解析等场景,业务代码优先用强类型或泛型。
面试速查表
| 知识点 | 核心结论 | 常见追问 |
|---|---|---|
| 值/引用类型 | 复制语义 vs 引用语义 | struct一定在栈上吗 |
| 装箱拆箱 | 堆分配 + 类型检查 | 如何用IL验证 |
| 字符串不可变 | 每次操作产生新对象 | StringBuilder与Span |
| Equals/GetHashCode | 必须成对重写 | 可变字段作哈希键的后果 |
| const/readonly | 编译期嵌入 vs 运行期读取 | 跨程序集版本问题 |
| var/dynamic | 静态推断 vs 动态绑定 | 性能与适用场景 |
把这六个点串起来看,本质是同一件事:清楚每个值在内存中的位置、生命周期和比较方式。面试时先给结论,再用一段可运行代码或一个具体场景佐证,比背诵定义更有说服力。