在计算机程序设计中,对象的存储与访问方式分为引用(Reference)与类型(Type)的概念。可以将对象理解为存储于内存(通常为堆区)中的实体,而变量本身则保存着该对象的引用或地址,即对象在内存中的定位信息。
变量中的引用并不是对象实体本身,但通过引用可以间接操作和访问对象。如果变量的引用为 null,意味着该变量未指向任何实际对象,无法对对象进行操作。
当多个变量保存同一个对象的引用时,这些变量实际上指向同一个内存地址。任何一个变量对该对象内容的更改,都会被所有指向该对象的变量所感知。 理解引用与类型的区别是掌握 C# 等语言内存模型与对象操作机制的基础。

在 C# 里,类型大致分为两大类:值类型(Value Type)和引用类型(Reference Type)。
当我们用“值类型”时,变量更像是一个“装东西的小盒子”,盒子里放的是货物本体;当我们用“引用类型”时,变量更像是一张“纸条”,上面记的是行李箱的位置,真正的货物在仓库深处的行李箱里。
值类型常见代表:int、double、bool、char、struct、enum 等。
引用类型常见代表:class(大多数对象)、string、array(数组)、delegate、interface 等。
接下来我们用一段小程序,先把“复制”和“共享”的感觉建立起来:
|using System; class Program { static void Main() { // 值类型示例:int 是值类型 int a = 42; // 声明并初始化一个 int 变量 a,存放值 42(值本体就放在变量里) int b = a; // 把 a 的值复制一份给 b,此时 a 与 b 是两份独立的“货物” b = 100; // 修改 b 的值,不会影响 a Console.WriteLine(a); // 输出 42 Console.WriteLine(b); // 输出 100 // 引用类型示例:一个简单的 Box 类(引用类型) Box box1 = new Box { Name = "小箱子", Volume = 10 }; // box1 里放的是“纸条”(引用),真正的对象在托管堆(堆)上 Box box2 = box1; // 把纸条位置复制一份给 box2,二者指向同一个“行李箱” box2.Volume = 99; // 通过 box2 改动同一个对象的 Volume 字段 Console.WriteLine(box1.Volume); // 输出 99,因为箱子只有一个,改谁都是改箱子 } } class Box { public string Name { get; set; } // 箱子名称 public int Volume { get; set; } // 箱子容量 }
我们先看值类型部分,int a = 42; 就像一个小盒子里装了数字 42,当我们写 int b = a;,更像是把 42 这个货物“再拿一件”放到另一个盒子里,
两个盒子各有一份独立的 42。于是当 b = 100; 的时候,改变的是 b 盒子里的货物,不会影响 a。输出也印证了这一点。
再看引用类型部分,Box box1 = new Box {...}; 的 new 动作像是把行李箱放进了仓库(堆)里,然后 box1 拿到一张标着“这个箱子在哪儿”的纸条。
当我们 Box box2 = box1;,只是把纸条复印了一份,箱子还是那个箱子。于是当我们通过 box2 去改 Volume,box1 再看,自然也看到同样的变化。
很多初学者容易把“变量就是对象”混成一团。其实更准确的说法是:
变量只是个“名字”,在值类型里,它确实“装着值本体”;在引用类型里,它“装着地址纸条”,纸条指向真正的对象。 对象本身主要生活在托管堆(Managed Heap)中,由垃圾回收器(GC)负责回收;变量和方法的参数、临时值等往往生活在栈(Stack)上,生命周期短而快。
我们不用死记硬背“栈”与“堆”的所有细节,只需掌握形象感:值类型变量像“小盒子”,引用类型变量像“纸条”,对象在“仓库”。这样你在读和写代码时,就不容易被“看起来一样”的赋值语句迷惑。
string 在 C# 里是一种非常特别的引用类型——它是不可变(immutable)的。不可变并不是说它不能用,而是说“它一旦被创建,它就不再改变自己的内容”。
这听起来有点抽象,我们马上用一个例子验证。
|using System; class Program { static void Main() { string s1 = "hello"; // 创建一个字符串对象,内容为 "hello" string s2 = s1; // s2 拿到同一张“纸条”,指向同一个字符串对象 s2 = s2.ToUpper(); // ToUpper 返回的是“新字符串对象”,原来的不变 Console.WriteLine(s1); // 仍然是 "hello" Console.WriteLine(s2); // 是 "HELLO"
我们先用 s1 = "hello" 创建一个字符串对象,然后 s2 = s1 表示 s2 也拿到了同样的纸条,大家都指向“hello”这个行李箱。
当我们调用 ToUpper() 时,它并不会在原箱子里“直接把小写字母换成大写”,而是“新建了一个箱子”,放入 "HELLO",然后把新的纸条交给 s2。
于是 s1 还看着原箱子(hello),s2 则看着新箱子(HELLO)。Replace 同理。
至于 +=,它其实每次都会创建新箱子,这对性能不太友好,特别是在大量拼接时。现实开发中,我们常使用 StringBuilder 来避免大量中间字符串对象的创建。
在 C# 中,默认的参数传递是“按值传递”(pass-by-value)。这里“值”的含义要结合类型来理解:
当参数是值类型时,传递的是“值的副本”;当参数是引用类型时,传递的是“引用(纸条)的副本”。
这句话有点拗口,我们用两个紧挨着的小例子来体会:
|using System; class Program { static void Main() { // 值类型参数:对副本的修改不影响外部变量 int x = 10; // x 是值类型,变量里装着 10 这个值 AddOne(x); // 传入的是 x 的“值副本” Console.WriteLine(x); // 仍然输出 10 // 引用类型参数:对对象内部的修改会体现在外部(因为引用副本仍指向同一对象) Point p = new Point { X = 1, Y =
你可以把这段话凝练成一句心法:默认传参总是“按值”,只是值类型的“值”就是内容本体,而引用类型的“值”是那张“纸条”。
ref、out、in有些时候,我们不仅想在方法里修改对象的内容,还想让方法能直接“换掉外面的纸条”,或者强制方法一定要“给一张纸条回来”。这时候,ref、out、in 就登场了。
我们先记一个直觉用途:
ref:允许方法读写调用者的变量本身(可以读也可以改,调用前必须赋值)。
out:方法负责产出一个值(调用前不必赋值,方法内必须赋值)。
in:只读引用,保证方法不会改动传入的内容(C# 7.2+,多用在性能与只读意图表达)。
来看例子:
|using System; class Program { static void Main() { int number = 5; // 调用前已经有值 DoubleInPlace(ref number); // 传入时写上 ref,表示“我愿意让你改我的变量本身” Console.WriteLine(number); // 输出 10 string text; // 没赋值 EnsureGreeting(out text); // 用 out,方法会保证给它赋值 Console.
小心常见误区:ref 不是“引用类型”的简称,它同样可以用在值类型与引用类型上。ref 的意义在于“按引用传递”,即把变量本身当作可被修改的目标交给方法。
nullnull 的直觉就是“没有纸条”、“纸条上没有位置”。一旦我们拿着 null 去打开箱子,就会摔个大跤——NullReferenceException。
在现代 C#(启用了可空参考类型检查的项目)里,编译器会尽力提醒我们哪里可能是 null,并鼓励我们用更安全的写法,比如 ?.、??、??= 等。
同时,值类型也可以通过 T? 的形式变成“可空值类型”,比如 int?、bool?。
|using System; class Program { static void Main() { Person p = null; // p 没有纸条,指向“无” // Console.WriteLine(p.Name.Length); // 直接用会抛 NullReferenceException Console.WriteLine(p?.Name?.Length); // 使用 ?. 安全访问,如果任一处为 null,整体为 null string name = p?.Name ?? "匿名"; // ?? 提供默认值,当左边为 null 时使用右边
可空性不是为了增加麻烦,而是为了让“可能为空”的事实在代码层面被看见、被照顾。把“是否可能为空”说清楚,本质上就是在写对同事、对未来的自己更友善的程序。
装箱(boxing)与拆箱(unboxing)是 C# 类型系统里很重要的概念,尤其在性能敏感场景中更该知晓。
当一个值类型需要被当作 object 或某个接口类型时,运行时会将它“装箱”——把这个值放进一个新建的对象里,
这样它就可以以“对象”的身份四处走动了。拆箱则是把对象里的那个值再取出来,恢复到原本的值类型。
|using System; class Program { static void Main() { int n = 123; // 值类型 object obj = n; // 装箱:把 n 的值复制到一个新对象里,obj 引用这个对象 int m = (int)obj; // 拆箱:从对象里把 int 取出来,注意需要显式转换 Console.WriteLine(n); // 123 Console.WriteLine(obj); // 123(ToString 的效果)
理解装箱/拆箱之后,我们在设计 API 和选择集合类型时,就能更有意识地避免不必要的分配与复制。
==、Equals、ReferenceEquals 到底有何不同在 C# 里,“相等”有好几种语义,我们需要分清:
==:对于值类型,通常比较“值本身是否相等”;对于引用类型,默认比较“是否引用同一个对象”,但像 string、某些类型可能重载了 == 以比较内容。
Equals:实例方法,多用于比较“值是否相等”,很多类型会重写它来定义自己的“相等性”。
ReferenceEquals:静态方法,只比较“是不是同一个对象实例”。
我们写个小场景来感受差异:
|using System; class Program { static void Main() { // 值类型:== 比较值 int a = 10; int b = 10; Console.WriteLine(a == b); // True // 引用类型默认:== 比较引用(是否同一个对象) var p1 = new Person { Name = "A" };
这段代码的重点:对值类型来说,两个独立的 10 本就是“同样的货物”,自然相等;对引用类型来说,两个看起来内容一样的盒子,也仍然是两个不同的箱子,所以“不是同一个”。
而 string 是常见的特例,它重载了 == 让直觉更好用——比的是内容是否一致。至于我们自定义的 Vector2,通过重写 Equals 与 GetHashCode,我们把“相等”定义为坐标相等。
var、object、dynamic在日常代码里我们常会遇到这三个关键词,它们各自的性格不同:
var:编译期类型推断。变量的“静态类型”由右侧初始化表达式决定,之后类型固定不变。var 不是“无类型”,只是“把类型推断出来而不必写在左边”。
object:所有类型的最终基类。用作“通用容器”时,可能引发装箱/拆箱(对值类型),并且取出时需要转换。
dynamic:绕过编译期的静态类型检查,把成员解析推迟到运行期。使用不当容易出错,但在需要与动态系统交互、或快速拼原型时很方便。
|using System; class Program { static void Main() { var n = 123; // 编译器推断 n 是 int // n = "abc"; // 编译错误:n 已经被推断为 int,不能再换类型 object box = 456; // 值类型进入 object,会装箱 int unboxed = (int)box; // 取出时需要拆箱并显式转换 Console.WriteLine(unboxed); // 456 dynamic
用 var 可以让代码更简洁,前提是右侧表达式足够清晰;用 object 时要注意装箱拆箱与转换;用 dynamic 要非常有节制,明确你是在跟“运行期才知道类型”的系统对话。
类型转换也是初学者常遇到的坑。我们只需抓住几个常用招式:
隐式转换:安全、不会丢信息,比如 int 到 long。
显式转换:可能丢信息或失败,需要强制写出来,比如 double 到 int。
as:安全转换引用类型或可空类型,失败则得到 null,不会抛异常。
is:判断一个对象是否是某种类型,C# 的模式匹配还可以一边判断一边取出变量。
同时,解析文本到数字等,优先使用 TryParse 系列来避免异常。
|using System; class Program { static void Main() { // 数字之间 int small = 100; long big = small; // 隐式转换,安全 double pi = 3.14; int approx = (int)pi; // 显式转换,小数部分丢失 // as 与 is object o
有时候,我们会遇到这样的问题:想把一组相关的数据“打包”在一起,方便一起传递、存储或者处理。在 C# 里,最常用的三种“打包”方式分别是数组、元组和记录类型。
数组(T[])就像一排整齐的储物柜,柜子里只能放同一种类型的物品,而且柜子的数量一旦确定就不能随便增减。
元组((T1, T2, ...))则像是把几个不同的小物件临时用橡皮筋捆在一起,类型可以各不相同,非常适合用来快速返回多个结果。
到了 C# 9,我们又多了记录类型(record),它更像是专门为“数据模型”设计的不可变小箱子,内容一旦装好就不轻易改变,而且判断两个记录是否相等时,会自动比较里面的内容而不是箱子的编号。
我们用一个小例子串起来:
|using System; using System.Collections.Generic; class Program { static void Main() { // 数组:固定长度,元素类型相同 int[] scores = new int[3]; // 长度为 3,默认值为 0 scores[0] = 95; scores[1] =
这里我们看三个层次的“打包”:数组是同类型、固定长度的队列式容器;元组是临时又轻便的“捆绑”,很适合函数返回多个值;记录类型则是一种“以数据为中心”的不可变类,天然支持按值相等,非常适合表示领域里的“数据模型”。
struct)与何时选它struct 是值类型,适合表示“小而简单、语义像一个值”的东西,比如二维向量、颜色、货币金额(需谨慎考虑精度)。
相对 class 而言,struct 的实例通常分配在栈上(也可能被装箱进入堆),复制成本是“按字段拷贝”,因此不应该太大也不应频繁变动其内部状态。
|using System; struct Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { Amount = amount; // 金额 Currency = currency; // 货币代码,如 "CNY" }
选择 struct 的经验性标准:它代表一个逻辑上的“值”、体量很小(例如几个数值字段)、不可变或变化很少、以及复制它本身的成本很低。否则,使用 class 更合适。
我们来做一个温柔的小练习,巩固“纸条 vs 箱子”的感觉。先不要跑代码,先在脑海里推演,然后再验证。
|using System; class Box { public int Value { get; set; } } class Program { static void Main() { Box a = new Box { Value = 1 }; Box b = a; Change(a);
推演:a 和 b 最初都指向同一个箱子(Value=1)。调用 Change(a) 时,a 的纸条被复制给参数 x。在方法里,
我们让 x 指向一个新箱子(Value=2),但这只是“换了纸条副本”,外面的 a 不受影响。
回到 Main 后,b.Value = 3 改的是那个最初的箱子,于是最后 a.Value 和 b.Value 都是 3。你可以跑一跑,看看是不是这样。
我们做一个小而完整的控制台应用,模拟“学生成绩册”。 我们会定义几个类型,体会“按值 vs 按引用”、“不可变字符串”、“列表(避免装箱)”、“相等性”等等。
|using System; using System.Collections.Generic; // 学生成绩条目:值类型还是引用类型? // 这里我们把它设计为 class(引用类型),因为我们希望共享与修改同一条成绩记录 class Grade { public string Subject { get; } // 科目名(string 不可变,赋值后不变) public int Score { get; private set; } // 分数 public Grade(string subject,
在上面这个小项目中:我们把 Grade 设计为引用类型,这样当我们从 Student 中拿到一条成绩记录的纸条时,修改它会反映到 Student 本身的数据里,
符合“一个成绩记录被多个地方共享”的直觉。string 的不可变性让我们不用担心科目名被悄悄改掉。
List<Grade> 作为泛型集合避免了装箱问题,存取高效。
TryGetGrade 用 out 让调用者一眼就知道“要返回一个结果对象,而且可能失败”,与 bool 结合形成经典的“尝试式” API。?? 与 ?. 则确保了我们在面对 null 时有体贴的默认行为。
4. 值与引用的复制感觉练习
分析以下代码,理解值类型和引用类型的复制行为:
Point 是 struct(值类型),Box 是 class(引用类型)p2 = p1 时,值类型会复制值本身,所以修改 p2 不会影响 p1b2 = b1 时,引用类型会复制引用(纸条),所以 b1 和 b2 指向同一个对象Box 类包含一个 Point 类型的字段,这是值类型字段在引用类型对象中请分析并预测输出结果。
|using System; struct Point { public int X; public int Y; } class Box { public Point P; } class Program { static void Main() { var p1 = new Point { X = 1, Y = 1
5. 字符串不可变与 StringBuilder 可变练习
分析以下代码,理解字符串的不可变性和 StringBuilder 的可变性:
string 是不可变的,每次修改操作(如 +=、Replace)都会创建新对象StringBuilder 是可变的,可以在原对象上修改内容t = s 时,两者指向同一个字符串对象t += "!" 时,创建新字符串,s 仍指向原字符串请分析并预测输出结果。
|using System; using System.Text; class Program { static void Main() { string s = "go"; string t = s; // t和s指向同一个字符串对象 t += "!"; // 创建新字符串"go!",t指向新对象,s仍指向"go" var u = t.Replace("!"
6. 按值传参但值是"纸条"练习
分析以下代码,理解参数传递的机制:
ref 参数:传递变量本身的引用,可以改变外部变量的引用请分析并预测输出结果。
|using System; class Counter { public int Value; } class Program { static void Inc(int x) { x++; } // 值类型:修改副本,不影响外部 static void SetNew(Counter c) { c = new Counter { Value = 100 }; } // 引用类型:改变参数c的引用,不影响外部 static
7. ref/out/in 的语义练习
分析以下代码,理解 ref、out、in 三种参数修饰符的区别:
ref:引用传递,可读写,调用前必须赋值out:输出参数,方法内必须赋值,调用前不必赋值in:只读引用传递,不能修改,适合大型结构体避免拷贝请分析并预测输出结果。
|using System; class Program { static void Double(ref int v) { v *= 2; } // ref:可读写,修改外部变量 static void Ensure(out int v) { v = 42; } // out:方法内必须赋值 static void TryRead(in int v) { Console.WriteLine(v); } // in:只读,不能修改
8. null 安全与默认值练习
分析以下代码,理解 null 安全操作符和默认值的使用:
?. 空条件运算符:如果左边为 null,返回 null,不继续执行?? 空合并运算符:如果左边为 null,使用右边的值string 类型的默认值是 null,访问 null 的成员会抛出异常请分析并预测输出结果,并完成注释中的问题。
|using System; class Person { public string Name { get; set; } } class Program { static void Main() { Person p = null; // ?. 如果p为null,返回null,不继续执行 // ?? 如果左边为null,使用右边的值 Console.WriteLine(p?.Name ?? "匿名"); // 输出: 匿名
9. 装箱/拆箱与错误的拆箱练习
分析以下代码,理解装箱和拆箱的机制:
object 类型,值被复制到堆上的对象中object 中取出值类型,需要显式转换object 拆箱为不兼容的类型会抛出异常请分析并预测输出结果,并完成注释中的问题。
|using System; class Program { static void Main() { object o1 = 1; // 装箱:int值被复制到堆上的对象中 object o2 = 2; // 装箱 int sum = (int)o1 + (int)o2; // 拆箱两次,然后相加 object o3 = sum; // 再次装箱
10. 相等性的三个角度练习
分析以下代码,理解 ==、Equals、ReferenceEquals 三种相等性比较的区别:
==:对于引用类型默认比较引用,但 string 重载了 == 比较内容Equals:实例方法,通常比较值是否相等ReferenceEquals:静态方法,只比较是否为同一对象实例record 类型默认按值相等性比较请分析并预测输出结果。
|using System; class Person { public string Name; } record R(string Name); class Program { static void Main() { var p1 = new Person { Name = "A" }; var p2 = new Person { Name
|1,1 8,1 8,0
说明:
Point 是值类型,p2 = p1 复制值,修改 p2 不影响 p1Box 是引用类型,b2 = b1 复制引用,两者指向同一对象b2.P.X = 8 修改的是同一个对象,所以 b1.P.X 也变成 8b2 = new Box {...} 让 b2 指向新对象,b1 仍指向原对象|go go! go!! go!
说明:
string 不可变:t += "!" 创建新对象,s 仍为 "go"Replace() 返回新字符串,原字符串不变StringBuilder 可变:Append() 在原对象上修改sb2 = sb 复制引用,两者指向同一对象,修改会互相影响|1 2 2 200
说明:
Inc(n) 传递值副本,修改不影响外部 nAddOne(c) 传递引用副本,可以修改对象内容SetNew(c) 只改变参数引用,外部变量不变Replace(ref c) 使用 ref,可以改变外部变量的引用|20 42 20
说明:
ref 允许方法修改外部变量本身,调用前必须赋值out 用于输出参数,方法内必须赋值,调用前不必赋值in 用于只读引用传递,不能修改参数,适合大型结构体避免拷贝|匿名 0
说明:
p?.Name 如果 p 为 null,返回 null,不抛出异常?? "匿名" 如果左边为 null,使用 "匿名"q.Name 默认值为 null,直接访问 .Length 会抛出异常q.Name?.Length ?? 0 安全访问,如果为 null 则返回 0|3 5
说明:
object,值被复制到堆上object 取出值类型,需要显式转换和类型匹配(int)ox 会失败,因为 ox 是 string 类型,不是装箱的 intTryParse 解析字符串为整数|False False False True False True
说明:
class 的 == 默认比较引用,p1 和 p2 是不同的对象,所以为 Falsestring 重载了 ==,比较内容是否相同,所以 s1 == s2 为 TrueReferenceEquals 只比较是否为同一对象实例record 默认按值相等性比较,字段值相同则相等