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 动态绑定性能与适用场景

把这六个点串起来看,本质是同一件事:清楚每个值在内存中的位置、生命周期和比较方式。面试时先给结论,再用一段可运行代码或一个具体场景佐证,比背诵定义更有说服力。