面向对象
4 / 11
继承
自在学
分类课程AI导师创意工坊价格
分类课程AI导师创意工坊价格
编程C#引用与类型

引用与类型

在计算机程序设计中,对象的存储与访问方式分为引用(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" string s3 = s2.Replace("HEL", "YEL"); // Replace 也会返回新字符串对象 Console.WriteLine(s2); // 仍然是 "HELLO" Console.WriteLine(s3); // 是 "YELLO" // 频繁拼接的性能提醒 string text = ""; // 空字符串 for (int i = 0; i < 3; i++) // 做个小回环 { text += i; // 每次 += 都会产生“新字符串”,老的会变为垃圾等待回收 } Console.WriteLine(text); // 输出 "012" } }

我们先用 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 = 2 }; // p 是“纸条”,对象在堆上 MoveRight(p); // 传入的是“纸条副本”,两张纸条都指向同一对象 Console.WriteLine(p.X); // 输出 2 // 但如果在方法里“重新指向新对象”,外部并不会跟着变(因为只是改了纸条副本的指向) ResetPoint(p); // 在方法内部把纸条副本换成一张新纸条 Console.WriteLine(p.X); // 仍然输出 2(外面的纸条没变) } static void AddOne(int value) { value = value + 1; // 改的是“副本” } static void MoveRight(Point pt) { pt.X += 1; // 虽然 pt 是“纸条副本”,但它指向的对象和外面是同一个 } static void ResetPoint(Point pt) { pt = new Point { X = 0, Y = 0 }; // 把“纸条副本”改指向新的对象,不影响外部的纸条 } } class Point { public int X { get; set; } public int Y { get; set; } }

你可以把这段话凝练成一句心法:默认传参总是“按值”,只是值类型的“值”就是内容本体,而引用类型的“值”是那张“纸条”。


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.WriteLine(text); // 输出 "Hello" // in 的演示:我们创建一个只读传入的大结构(这里只做语义演示) HugeStruct data = new HugeStruct { A = 1, B = 2, C = 3 }; PrintData(in data); // 传入时标注 in,方法内部不能改 data Console.WriteLine(data.A); // 仍然是 1 } static void DoubleInPlace(ref int value) { value = value * 2; // 直接修改调用者的变量本身 } static void EnsureGreeting(out string result) { result = "Hello"; // 必须在方法内对 out 参数赋值 } static void PrintData(in HugeStruct hs) { Console.WriteLine($"{hs.A}, {hs.B}, {hs.C}"); // 只能读,不能改 // hs.A = 10; // 编译错误:in 参数是只读的 } } struct HugeStruct { public int A; public int B; public int C; }

小心常见误区:ref 不是“引用类型”的简称,它同样可以用在值类型与引用类型上。ref 的意义在于“按引用传递”,即把变量本身当作可被修改的目标交给方法。


可空性与 null

null 的直觉就是“没有纸条”、“纸条上没有位置”。一旦我们拿着 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 时使用右边 Console.WriteLine(name); // 输出 "匿名" int? ageMaybe = TryGetAge(); // 返回可空值类型 int age = ageMaybe ?? 0; // 没有年龄就用 0 代替 Console.WriteLine(age); // ??= 在变量为 null 时才赋值 string title = null; title ??= "同学"; // 若为 null 则赋默认 Console.WriteLine(title); // 输出 "同学" } static int? TryGetAge() { return null; // 暂时没有 } } class Person { public string Name { get; set; } }

可空性不是为了增加麻烦,而是为了让“可能为空”的事实在代码层面被看见、被照顾。把“是否可能为空”说清楚,本质上就是在写对同事、对未来的自己更友善的程序。


装箱与拆箱

装箱(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 的效果) Console.WriteLine(m); // 123 // 性能提醒:频繁装箱/拆箱会带来分配和复制成本 object[] arr = new object[3]; for (int i = 0; i < 3; i++) { arr[i] = i; // i 是 int,会发生装箱 } // 更好的做法是使用泛型集合 List<int> 避免装箱 } }

理解装箱/拆箱之后,我们在设计 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" }; var p2 = new Person { Name = "A" }; Console.WriteLine(p1 == p2); // False(不同对象) Console.WriteLine(object.ReferenceEquals(p1, p2)); // False // string 特例:== 被重载为按内容比较 string s1 = "hi"; string s2 = new string(new[] { 'h', 'i' }); Console.WriteLine(s1 == s2); // True(内容相同) Console.WriteLine(object.ReferenceEquals(s1, s2)); // 可能是 False(不同实例) // 自定义相等性:重写 Equals 与 GetHashCode(示意) var v1 = new Vector2(1, 2); var v2 = new Vector2(1, 2); Console.WriteLine(v1.Equals(v2)); // True(按值相等) } } class Person { public string Name { get; set; } } struct Vector2 { public int X { get; } public int Y { get; } public Vector2(int x, int y) { X = x; Y = y; } public override bool Equals(object obj) { if (obj is Vector2 other) { return X == other.X && Y == other.Y; } return false; } public override int GetHashCode() { return HashCode.Combine(X, Y); } }

这段代码的重点:对值类型来说,两个独立的 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 d = "hello"; // 动态变量,调用成员在运行时解析 Console.WriteLine(d.ToUpper()); // OK:运行期找到 string.ToUpper // Console.WriteLine(d.DoesNotExist()); // 运行期才报错:不存在这个方法 } }

用 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 = "hello"; string s = o as string; // 转成 string,成功返回 string,失败返回 null if (o is string s2) // 模式匹配:若是 string,就把它绑定到 s2 { Console.WriteLine(s2.ToUpper()); } // TryParse 避免异常 string text = "123"; if (int.TryParse(text, out int value)) { Console.WriteLine(value + 1); // 124 } else { Console.WriteLine("无法解析为整数"); } } }

数组、元组与记录

有时候,我们会遇到这样的问题:想把一组相关的数据“打包”在一起,方便一起传递、存储或者处理。在 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] = 88; scores[2] = 76; Console.WriteLine(scores[1]); // 88 // 元组:轻量组合,无需自定义类型 (string Name, int Age) student = ("小明", 16); Console.WriteLine(student.Name); // 记录:默认值相等按内容、适合不可变数据模型 PersonRecord r1 = new("A", 20); PersonRecord r2 = new("A", 20); Console.WriteLine(r1 == r2); // True(记录默认按值语义比较) // 列表(泛型集合)与避免装箱 List<int> list = new List<int> { 1, 2, 3 }; list.Add(4); Console.WriteLine(list.Count); // 4 } } public record PersonRecord(string Name, int Age);

这里我们看三个层次的“打包”:数组是同类型、固定长度的队列式容器;元组是临时又轻便的“捆绑”,很适合函数返回多个值;记录类型则是一种“以数据为中心”的不可变类,天然支持按值相等,非常适合表示领域里的“数据模型”。


自定义值类型(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" } public override string ToString() { return $"{Amount} {Currency}"; } } class Program { static void Main() { Money m1 = new Money(100m, "CNY"); Money m2 = m1; // 值拷贝,得到一个独立副本 Console.WriteLine(m1); // 100 CNY Console.WriteLine(m2); // 100 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); // 方法里把纸条副本改指向新对象 b.Value = 3; // 此时 b 指向的是最初那个对象还是新对象? Console.WriteLine(a.Value); // ? Console.WriteLine(b.Value); // ? } static void Change(Box x) { x = new Box { Value = 2 }; // 换了一张纸条,仅在方法内生效 } }

推演: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, int score) { Subject = subject; // 设定科目 Score = score; // 设定分数 } public void UpdateScore(int newScore) { Score = newScore; // 更新分数 } public override string ToString() => $"{Subject}:{Score}"; } // 学生:包含名字与一组成绩 class Student { public string Name { get; } // 学生姓名 private readonly List<Grade> _grades; // 使用泛型 List<Grade>,避免装箱 public Student(string name) { Name = name ?? throw new ArgumentNullException(nameof(name)); // 保护性编程:名字不可为 null _grades = new List<Grade>(); // 初始化空列表 } public void AddGrade(string subject, int score) { // subject 是 string,不可变;score 是 int,值类型 _grades.Add(new Grade(subject, score)); } public bool TryGetGrade(string subject, out Grade grade) { // 返回是否找到,并通过 out 返回找到的成绩纸条 foreach (var g in _grades) { if (string.Equals(g.Subject, subject, StringComparison.OrdinalIgnoreCase)) { grade = g; // 把同一张纸条交出去,外面能看到后续改动 return true; } } grade = null; return false; } public override string ToString() { return $"{Name}: [" + string.Join(", ", _grades) + "]"; } } class Program { static void Main() { // 创建学生并添加成绩 var s = new Student("小李"); // var 推断为 Student s.AddGrade("语文", 88); s.AddGrade("数学", 92); Console.WriteLine(s); // 通过 out 获取某科成绩,并修改它 if (s.TryGetGrade("数学", out Grade math)) { math.UpdateScore(95); // 修改的是同一条成绩纸条指向的对象 } Console.WriteLine(s); // 学生对象里的成绩也随之更新 // 尝试获取不存在的科目 if (!s.TryGetGrade("物理", out Grade physics)) { Console.WriteLine("暂无物理成绩"); } // null 安全演示 Student maybeNull = null; // 没有纸条 Console.WriteLine(maybeNull?.ToString() ?? "没有这个学生"); } }

在上面这个小项目中:我们把 Grade 设计为引用类型,这样当我们从 Student 中拿到一条成绩记录的纸条时,修改它会反映到 Student 本身的数据里, 符合“一个成绩记录被多个地方共享”的直觉。string 的不可变性让我们不用担心科目名被悄悄改掉。

List<Grade> 作为泛型集合避免了装箱问题,存取高效。 TryGetGrade 用 out 让调用者一眼就知道“要返回一个结果对象,而且可能失败”,与 bool 结合形成经典的“尝试式” API。?? 与 ?. 则确保了我们在面对 null 时有体贴的默认行为。


小练习

  1. 在C#中,struct定义的是?
  1. 在C#中,string是?
  1. C#中用于改变参数传递方式的修饰符包括?

4. 值与引用的复制感觉练习

分析以下代码,理解值类型和引用类型的复制行为:

  • Point 是 struct(值类型),Box 是 class(引用类型)
  • 当 p2 = p1 时,值类型会复制值本身,所以修改 p2 不会影响 p1
  • 当 b2 = 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 }; var p2 = p1; // 值类型:复制值本身 p2.X = 9; // 修改p2不影响p1 Console.WriteLine($"{p1.X},{p1.Y}"); // 输出: 1,1 var b1 = new Box { P = new Point { X = 1, Y = 1 } }; var b2 = b1; // 引用类型:复制引用(纸条) b2.P.X = 8; // 修改b2指向的对象,b1也看到变化 Console.WriteLine($"{b1.P.X},{b1.P.Y}"); // 输出: 8,1 b2 = new Box { P = new Point { X = 0, Y = 0 } }; // b2指向新对象 Console.WriteLine($"{b1.P.X},{b2.P.X}"); // 输出: 8,0 } }
|
1,1 8,1 8,0

说明:

  • Point 是值类型,p2 = p1 复制值,修改 p2 不影响 p1
  • Box 是引用类型,b2 = b1 复制引用,两者指向同一对象
  • b2.P.X = 8 修改的是同一个对象,所以 b1.P.X 也变成 8
  • b2 = new Box {...} 让 b2 指向新对象,b1 仍指向原对象

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("!", "!!"); // Replace创建新字符串"go!!",t不变 Console.WriteLine(s); // 输出: go Console.WriteLine(t); // 输出: go! Console.WriteLine(u); // 输出: go!! var sb = new StringBuilder("go"); var sb2 = sb; // sb2和sb指向同一个StringBuilder对象 sb2.Append("!"); // 在原对象上修改,sb也看到变化 Console.WriteLine(sb.ToString()); // 输出: go! } }
|
go go! go!! go!

说明:

  • string 不可变:t += "!" 创建新对象,s 仍为 "go"
  • Replace() 返回新字符串,原字符串不变
  • StringBuilder 可变:Append() 在原对象上修改
  • sb2 = sb 复制引用,两者指向同一对象,修改会互相影响

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 void AddOne(Counter c) { c.Value++; } // 引用类型:修改对象内容,外部能看到 static void Replace(ref Counter c) { c = new Counter { Value = 200 }; } // ref:改变外部变量的引用 static void Main() { int n = 1; Inc(n); // n的副本被修改,n本身不变 Console.WriteLine(n); // 输出: 1 var c = new Counter { Value = 1 }; AddOne(c); // 修改c指向的对象,Value变成2 Console.WriteLine(c.Value); // 输出: 2 SetNew(c); // 只改变参数c的引用,外部c不变 Console.WriteLine(c.Value); // 输出: 2(仍然是原来的对象) Replace(ref c); // ref:改变外部变量c的引用 Console.WriteLine(c.Value); // 输出: 200(现在指向新对象) } }
|
1 2 2 200

说明:

  • Inc(n) 传递值副本,修改不影响外部 n
  • AddOne(c) 传递引用副本,可以修改对象内容
  • SetNew(c) 只改变参数引用,外部变量不变
  • Replace(ref c) 使用 ref,可以改变外部变量的引用

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:只读,不能修改 static void Main() { int a = 10; Double(ref a); // ref:修改a本身 Console.WriteLine(a); // 输出: 20 int b; // 未赋值 Ensure(out b); // out:方法内会赋值 Console.WriteLine(b); // 输出: 42 TryRead(in a); // in:只读传递,不能修改 // 输出: 20 } }
|
20 42 20

说明:

  • ref 允许方法修改外部变量本身,调用前必须赋值
  • out 用于输出参数,方法内必须赋值,调用前不必赋值
  • 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 ?? "匿名"); // 输出: 匿名 var q = new Person(); // q.Name 的默认值是 null,访问 null.Length 会抛出 NullReferenceException // 正确写法: Console.WriteLine(q.Name?.Length ?? 0); // 输出: 0 // 或者: // Console.WriteLine((q.Name ?? "").Length); // 输出: 0 } }
|
匿名 0

说明:

  • p?.Name 如果 p 为 null,返回 null,不抛出异常
  • ?? "匿名" 如果左边为 null,使用 "匿名"
  • q.Name 默认值为 null,直接访问 .Length 会抛出异常
  • 使用 q.Name?.Length ?? 0 安全访问,如果为 null 则返回 0

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; // 再次装箱 Console.WriteLine(o3); // 输出: 3 object ox = "5"; // int k = (int)ox; // 错误:ox是string类型,不能直接拆箱为int // 正确做法:先解析字符串 if (int.TryParse(ox.ToString(), out int k)) { Console.WriteLine(k); // 输出: 5 } } }
|
3 5

说明:

  • 装箱:值类型转换为 object,值被复制到堆上
  • 拆箱:从 object 取出值类型,需要显式转换和类型匹配
  • (int)ox 会失败,因为 ox 是 string 类型,不是装箱的 int
  • 正确做法:使用 TryParse 解析字符串为整数
  • 装箱/拆箱有性能开销,应避免频繁使用

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 = "A" }; // == 对于class默认比较引用(是否同一对象) Console.WriteLine(p1 == p2); // 输出: False // ReferenceEquals 只比较引用 Console.WriteLine(object.ReferenceEquals(p1, p2)); // 输出: False // Equals 默认比较引用(除非重写) Console.WriteLine(p1.Equals(p2)); // 输出: False string s1 = "hi"; string s2 = new string(new[]{'h','i'}); // string 重载了 ==,比较内容 Console.WriteLine(s1 == s2); // 输出: True // 但它们是不同的对象实例 Console.WriteLine(object.ReferenceEquals(s1, s2)); // 输出: False var r1 = new R("A"); var r2 = new R("A"); // record 默认按值相等性比较 Console.WriteLine(r1 == r2); // 输出: True } }
|
False False False True False True

说明:

  • class 的 == 默认比较引用,p1 和 p2 是不同的对象,所以为 False
  • string 重载了 ==,比较内容是否相同,所以 s1 == s2 为 True
  • ReferenceEquals 只比较是否为同一对象实例
  • record 默认按值相等性比较,字段值相同则相等
  • 理解这三种比较方式的区别对于正确使用类型很重要
  • 值类型与引用类型
  • 变量、对象与内存
  • 字符串为何“改不动”
  • 参数传递
  • `ref`、`out`、`in`
  • 可空性与 `null`
  • 装箱与拆箱
  • 比较:`==`、`Equals`、`ReferenceEquals` 到底有何不同
  • 类型推断与三兄弟:`var`、`object`、`dynamic`
  • 类型转换
  • 数组、元组与记录
  • 自定义值类型(`struct`)与何时选它
  • 猜一猜它会输出什么?
  • 迷你项目:学生成绩册
  • 小练习

目录

  • 值类型与引用类型
  • 变量、对象与内存
  • 字符串为何“改不动”
  • 参数传递
  • `ref`、`out`、`in`
  • 可空性与 `null`
  • 装箱与拆箱
  • 比较:`==`、`Equals`、`ReferenceEquals` 到底有何不同
  • 类型推断与三兄弟:`var`、`object`、`dynamic`
  • 类型转换
  • 数组、元组与记录
  • 自定义值类型(`struct`)与何时选它
  • 猜一猜它会输出什么?
  • 迷你项目:学生成绩册
  • 小练习
自在学

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 自在学,保留所有权利。

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1