Appearance
类型系统
值类型和引用类型有什么区别?
C# 里值类型保存的是数据本身,引用类型保存的是对象地址的引用。
值类型
- 常见:
int、float、bool、char、struct、enum - 变量里直接存值
- 赋值时通常会复制一份数据
- 修改副本不会影响原来的值
- 一般分配在栈上,作为对象字段时会跟随对象存在堆上
c
int a = 10;
int b = a;
b = 20;
// a 还是 10,b 是 20引用类型
- 常见:
class、string、数组、List<T>、Dictionary<TKey, TValue>、委托 - 变量里存的是对象引用,可以理解成“地址”
- 赋值时复制的是引用,不是对象本身
- 多个变量可能指向同一个对象
- 修改对象内容,会影响所有指向它的引用
c
class Player
{
public int hp;
}
Player p1 = new Player();
p1.hp = 100;
Player p2 = p1;
p2.hp = 50;
// p1.hp 也是 50,因为 p1 和 p2 指向同一个对象面试可以这样答:
NOTE
值类型变量直接保存数据,赋值时会复制值;引用类型变量保存对象引用,赋值时复制引用,多个变量可能指向同一个堆对象。 值类型常见有 int、float、struct、enum,引用类型常见有 class、数组、string、List、Dictionary。 需要注意值类型也不一定永远在栈上,比如作为类字段时会随对象一起在堆上。
struct 和 class 的区别是什么?
struct 是值类型,赋值或传参默认复制的是“值本身”;
class 是引用类型,赋值或传参默认复制的是“对象引用”。
核心区别
| 对比点 | struct | class |
|---|---|---|
| 类型分类 | 值类型 | 引用类型 |
| 赋值行为 | 复制整个值 | 复制引用,多个变量可指向同一对象 |
| 默认值 | default(T),字段为默认值 | null |
| null | 不能为 null,除非 Nullable<T> / T? | 可以为 null |
| 继承 | 不能继承其他 struct/class,但可实现接口 | 支持继承、多态、接口 |
| 性能 | 小型数据可减少堆分配,但大 struct 复制成本高 | 对象由 GC 管理,引用传递成本低 |
| 适用场景 | 小、简单、通常不可变的“值” | 有身份、生命周期、复杂行为的“对象” |
加分点
WARNING
不要简单说“struct 在栈上,class 在堆上”。
更严谨的说法是:class 实例通常在托管堆上;
struct 的存储位置取决于上下文,可能作为局部变量、对象字段、数组元素存在,也可能在装箱时进入堆。
面试收尾可以说:值语义、体积小、最好不可变的数据适合用 struct;有身份、有共享状态、有继承关系或复杂生命周期的模型适合用 class。
小例子
c
struct Point
{
public int X;
}
class Person
{
public string Name = "";
}
var p1 = new Point { X = 1 };
var p2 = p1;
p2.X = 99;
// p1.X 仍然是 1,因为 struct 赋值复制值
var a = new Person { Name = "Ada" };
var b = a;
b.Name = "Grace";
// a.Name 也变成 "Grace",因为 class 赋值复制引用string 是值类型还是引用类型?为什么表现得像不可变?
string 是引用类型,不是值类型。它其实是 System.String,string 只是 C# 里的关键字别名。
但它用起来很像“值”,因为它有两个特殊点:
- 不可变 字符串对象一旦创建,内容不能被原地修改。任何拼接、替换、大小写转换,都会创建一个新的字符串对象。
c
string a = "hello";
string b = a;
a += " world";
Console.WriteLine(a); // hello world
Console.WriteLine(b); // hello这里不是把原来的 "hello" 改成了 "hello world",而是创建了新对象,然后让 a 指向新字符串。b 仍然指向原来的 "hello"。
==比较内容 普通引用类型默认比较引用地址,但string重载了==,所以通常比较的是字符串内容。
c
string x = "abc";
string y = new string(new[] { 'a', 'b', 'c' });
Console.WriteLine(x == y); // true,内容相同
Console.WriteLine(object.ReferenceEquals(x, y)); // false,不是同一个对象面试可以这样答:
string是引用类型,但它是不可变对象,并且重载了相等比较,所以很多时候表现得像值类型。对字符串的“修改”本质上是创建新字符串,而不是修改原对象。
加分点:不可变让 string 更容易线程安全、可共享、适合做哈希键,也方便字符串驻留池优化。频繁拼接时用 StringBuilder。
装箱和拆箱是什么?什么时候会发生?
装箱是把值类型包装成 object、ValueType 或接口引用;拆箱是把这个引用再显式取回成具体的值类型。
c
int x = 42;
object o = x; // 装箱:int -> object
int y = (int)o; // 拆箱:object -> int装箱发生了什么?
c
int x = 42;
object o = x;
x = 100;
Console.WriteLine(o); // 42o 里不是引用原来的 x,而是在托管堆上创建了一个“盒子对象”,把 42 复制进去。所以后来 x 变成 100,盒子里的值还是 42。
什么时候会装箱?
常见场景是:值类型被当成引用类型使用。
c
object o = 123; // int 装箱成 object
IComparable c = 123; // int 装箱成接口
void Print(object value) {}
Print(123); // 传给 object 参数,装箱
ArrayList list = new ArrayList();
list.Add(123); // 非泛型集合内部按 object 存,装箱还有类似 string.Format("{0}", 123) 这种 params object[] 场景,也经常会触发装箱。
什么时候会拆箱?
当你把装箱后的对象强转回具体值类型时:
c
object o = 123;
int n = (int)o; // 正确:盒子里确实是 int
long m = (long)o; // 错误:盒子里不是 long,会抛 InvalidCastException注意这个坑:拆箱必须先拆成“盒子里真实的值类型”。如果想要 long,要这样:
c
long m = (long)(int)o;面试加分点
装箱和拆箱有性能成本:装箱会分配对象并复制值,拆箱会做类型检查并复制值。所以高频场景应尽量用泛型集合,比如 List<int>,而不是 ArrayList。泛型的一个重要价值,就是避免大量不必要的装箱拆箱。
object 类型有什么作用?
object 是 System.Object 的别名,是 C# 里所有类型的共同基类。
一句话版
object的作用是提供一个“所有类型都能转成它”的统一抽象,让代码可以接收任意类型;但使用时会损失具体类型信息,值类型转成
object还会发生装箱。
主要作用
- 统一接收任意类型
c
void Print(object value)
{
Console.WriteLine(value);
}
Print(123);
Print("hello");
Print(DateTime.Now);因为 int、string、DateTime、自定义类,最终都可以看成 object。
- 提供所有类型都有的基础方法
几乎所有对象都有这些方法:
c
ToString()
Equals()
GetHashCode()
GetType()例如:
c
object o = "hello";
Console.WriteLine(o.ToString()); // hello
Console.WriteLine(o.GetType()); // System.String- 用于通用场景
比如早期非泛型集合、反射、日志、通用参数、动态数据容器等:
c
ArrayList list = new ArrayList();
list.Add(1);
list.Add("abc");
list.Add(DateTime.Now);注意点
object 虽然灵活,但不要滥用。因为它会让编译器不知道具体类型,你取出来时经常要判断或强转:
c
object o = 123;
int n = (int)o; // 需要强转如果值类型转成 object,还会发生装箱:
c
int x = 10;
object o = x; // 装箱面试加分回答:
object是所有类型的根类型,提供统一抽象和基础方法。但它牺牲了类型安全和性能,尤其值类型转object会装箱。所以现代 C# 中,如果能用具体类型、接口或泛型,比如List<int>、T、IEnumerable<T>,通常优先不用object。
var 和 dynamic 区别是什么?
var 和 dynamic 最大区别是:var 是编译期确定类型,dynamic 是运行期才解析类型和成员。
c
var x = 10; // 编译器推断 x 是 int
dynamic y = 10; // y 的操作延迟到运行时处理var 只是让编译器帮你推断类型,本质仍然是强类型:
c
var x = 10;
x = "abc"; // 编译错误,因为 x 已经是 intdynamic 会绕过很多编译期检查,运行时才判断成员或方法是否存在:
c
dynamic d = "hello";
Console.WriteLine(d.Length); // OK
d.NotExists(); // 编译能过,运行时报错面试一句话:
var是编译期类型推断,推断后类型固定,仍然安全;dynamic是运行期动态绑定,灵活但失去编译期类型检查,可能带来运行时错误和性能成本。
常见使用建议:平时优先用 var 或明确类型;dynamic 只在和动态对象、COM、反射、JSON 动态结构等场景交互时谨慎使用。
nullable 类型是什么?int? 本质是什么?
nullable 类型的作用是:让值类型也能表示“没有值”。
c
int? age = null;int? 的本质是:
c
System.Nullable<int>也就是下面这种写法的简写:
c
Nullable<int> age = null;Nullable<T> 本身是一个结构体,大致可以理解成里面有两个核心东西:
c
bool HasValue; // 有没有值
T Value; // 真正的值例子:
c
int? x = null;
Console.WriteLine(x.HasValue); // false
// Console.WriteLine(x.Value); // 会抛异常
int? y = 10;
Console.WriteLine(y.HasValue); // true
Console.WriteLine(y.Value); // 10面试要特别说清楚这一句:
int?不是引用类型,它本质还是值类型,是Nullable<int>的语法糖。它通过HasValue表示有没有值,通过Value保存真正的int。
常用写法:
c
int? score = null;
int realScore = score ?? 0; // 如果是 null,就用 0
int value = score.GetValueOrDefault(); // null 时返回 int 默认值 0加分点:Nullable<T> 只能用于值类型,因为它的定义约束是 where T : struct,所以 string? 在现代 C# 里不是 Nullable<string>,而是“可空引用类型”的编译期注解。
enum 底层是什么?
enum 是值类型,底层是整数类型,默认底层类型是 int。
c
enum Status
{
Pending = 0,
Done = 1
}上面这个枚举,本质上可以理解为:Status.Pending 底层存的是 0,Status.Done 底层存的是 1。
也可以手动指定底层类型:
c
enum Color : byte
{
Red = 1,
Blue = 2
}允许的底层类型有:
c
byte, sbyte, short, ushort, int, uint, long, ulong面试加分点:
c
Status s = (Status)999;
Console.WriteLine(s); // 999这段代码是可以成立的。也就是说,enum 底层是整数,所以可以把未定义的整数强转成枚举值。需要判断是否合法时,可以用:
c
Enum.IsDefined(typeof(Status), s)一句话收尾:
enum是继承自System.Enum的值类型,底层由整数存储,默认是int,也可以指定其他整数类型。它提供的是“有名字的整数值”。
readonly 和 const 区别是什么?
const 是编译期常量,readonly 是运行期赋值后只读字段。
c
public const int MaxCount = 100;
public readonly int Id;
public User(int id)
{
Id = id;
}const 的特点:
c
public const double Pi = 3.14;它必须在声明时赋值,而且值必须在编译期就能确定。const 天然属于类型本身,类似静态成员,不需要创建对象。
readonly 的特点:
c
public readonly DateTime CreatedAt;
public Order()
{
CreatedAt = DateTime.Now;
}它可以在声明时赋值,也可以在构造函数里赋值。构造完成后就不能再改。它可以是运行时计算出来的值,也可以是复杂对象。
核心区别表:
| 对比点 | const | readonly |
|---|---|---|
| 确定时间 | 编译期 | 运行期 |
| 赋值位置 | 只能声明时赋值 | 声明时或构造函数 |
| 是否属于实例 | 隐式静态,属于类型 | 默认属于实例,可配合 static |
| 支持类型 | 适合基础常量、字符串、枚举等 | 几乎任意类型 |
| 是否可能被内联 | 会被编译进调用方 | 不会像 const 那样直接内联值 |
面试加分点:公共 API 里慎用 public const。因为调用方编译时可能把常量值直接写进自己的程序集,如果你把库里的 const 从 100 改成 200,调用方不重新编译,可能还在用旧值。公共常量更常用:
c
public static readonly int MaxCount = 100;一句话收尾:
const适合真正永远不变的编译期常量;readonly适合运行时确定、初始化后不再变化的值。
static 字段什么时候初始化?
static 字段会在类型初始化时初始化,不是创建每个对象时初始化。
c
class Demo
{
static int X = Init();
static Demo()
{
Console.WriteLine("static ctor");
}
}初始化顺序是:
- 先执行静态字段初始化器:
c
static int X = Init();- 再执行静态构造函数:
c
static Demo()
{
}触发时机通常是:
c
Demo.X; // 第一次访问静态成员
new Demo(); // 第一次创建实例之前面试要补这个细节:如果类里有显式 static 构造函数,类型初始化会更精确地发生在首次使用类型之前。
c
class A
{
static int X = Init();
static A()
{
}
}但如果没有显式 static 构造函数,编译器可能给类型加上 beforefieldinit,运行时允许更早初始化:
c
class B
{
static int X = Init();
}不过无论如何,静态字段一定会在它被实际使用前初始化完成。
加分点:
static字段每个类型只初始化一次,而且 CLR 会保证类型初始化过程是线程安全的。泛型类型的静态字段按封闭泛型类型分别存一份,比如Cache<int>和Cache<string>各有自己的静态字段。