Skip to content

类型系统

值类型和引用类型有什么区别?

C# 里值类型保存的是数据本身,引用类型保存的是对象地址的引用

csharp-value-reference-types

值类型

  • 常见:intfloatboolcharstructenum
  • 变量里直接存值
  • 赋值时通常会复制一份数据
  • 修改副本不会影响原来的值
  • 一般分配在栈上,作为对象字段时会跟随对象存在堆上
c
int a = 10;
int b = a;
b = 20;
// a 还是 10,b 是 20

引用类型

  • 常见:classstring、数组、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。 需要注意值类型也不一定永远在栈上,比如作为类字段时会随对象一起在堆上。

structclass 的区别是什么?

struct 是值类型,赋值或传参默认复制的是“值本身”;

class 是引用类型,赋值或传参默认复制的是“对象引用”。

struct-vs-class

核心区别

对比点structclass
类型分类值类型引用类型
赋值行为复制整个值复制引用,多个变量可指向同一对象
默认值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-reference-immutable

string引用类型,不是值类型。它其实是 System.Stringstring 只是 C# 里的关键字别名。

但它用起来很像“值”,因为它有两个特殊点:

  1. 不可变 字符串对象一旦创建,内容不能被原地修改。任何拼接、替换、大小写转换,都会创建一个新的字符串对象。
c
string a = "hello";
string b = a;

a += " world";

Console.WriteLine(a); // hello world
Console.WriteLine(b); // hello

这里不是把原来的 "hello" 改成了 "hello world",而是创建了新对象,然后让 a 指向新字符串。b 仍然指向原来的 "hello"

  1. == 比较内容 普通引用类型默认比较引用地址,但 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

装箱和拆箱是什么?什么时候会发生?

boxing-unboxing

装箱是把值类型包装成 objectValueType 或接口引用;拆箱是把这个引用再显式取回成具体的值类型。

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); // 42

o 里不是引用原来的 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 类型有什么作用?

objectSystem.Object 的别名,是 C# 里所有类型的共同基类

object-type-role

一句话版

object 的作用是提供一个“所有类型都能转成它”的统一抽象,让代码可以接收任意类型;

但使用时会损失具体类型信息,值类型转成 object 还会发生装箱。

主要作用

  1. 统一接收任意类型
c
void Print(object value)
{
    Console.WriteLine(value);
}

Print(123);
Print("hello");
Print(DateTime.Now);

因为 intstringDateTime、自定义类,最终都可以看成 object

  1. 提供所有类型都有的基础方法

几乎所有对象都有这些方法:

c
ToString()
Equals()
GetHashCode()
GetType()

例如:

c
object o = "hello";

Console.WriteLine(o.ToString()); // hello
Console.WriteLine(o.GetType());  // System.String
  1. 用于通用场景

比如早期非泛型集合、反射、日志、通用参数、动态数据容器等:

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>TIEnumerable<T>,通常优先不用 object

vardynamic 区别是什么?

var-vs-dynamic

vardynamic 最大区别是:var 是编译期确定类型,dynamic 是运行期才解析类型和成员。

c
var x = 10;       // 编译器推断 x 是 int
dynamic y = 10;   // y 的操作延迟到运行时处理

var 只是让编译器帮你推断类型,本质仍然是强类型:

c
var x = 10;
x = "abc"; // 编译错误,因为 x 已经是 int

dynamic 会绕过很多编译期检查,运行时才判断成员或方法是否存在:

c
dynamic d = "hello";

Console.WriteLine(d.Length); // OK
d.NotExists();               // 编译能过,运行时报错

面试一句话:

var 是编译期类型推断,推断后类型固定,仍然安全;dynamic 是运行期动态绑定,灵活但失去编译期类型检查,可能带来运行时错误和性能成本。

常见使用建议:平时优先用 var 或明确类型;dynamic 只在和动态对象、COM、反射、JSON 动态结构等场景交互时谨慎使用。

nullable 类型是什么?int? 本质是什么?

nullable-int-essence

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-underlying-type

enum值类型,底层是整数类型,默认底层类型是 int

c
enum Status
{
    Pending = 0,
    Done = 1
}

上面这个枚举,本质上可以理解为:Status.Pending 底层存的是 0Status.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,也可以指定其他整数类型。它提供的是“有名字的整数值”。

readonlyconst 区别是什么?

readonly-vs-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;
}

它可以在声明时赋值,也可以在构造函数里赋值。构造完成后就不能再改。它可以是运行时计算出来的值,也可以是复杂对象。

核心区别表:

对比点constreadonly
确定时间编译期运行期
赋值位置只能声明时赋值声明时或构造函数
是否属于实例隐式静态,属于类型默认属于实例,可配合 static
支持类型适合基础常量、字符串、枚举等几乎任意类型
是否可能被内联会被编译进调用方不会像 const 那样直接内联值

面试加分点:公共 API 里慎用 public const。因为调用方编译时可能把常量值直接写进自己的程序集,如果你把库里的 const100 改成 200,调用方不重新编译,可能还在用旧值。公共常量更常用:

c
public static readonly int MaxCount = 100;

一句话收尾:

const 适合真正永远不变的编译期常量;readonly 适合运行时确定、初始化后不再变化的值。

static 字段什么时候初始化?

static-field-initialization

static 字段会在类型初始化时初始化,不是创建每个对象时初始化。

c
class Demo
{
    static int X = Init();

    static Demo()
    {
        Console.WriteLine("static ctor");
    }
}

初始化顺序是:

  1. 先执行静态字段初始化器:
c
static int X = Init();
  1. 再执行静态构造函数:
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> 各有自己的静态字段。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0