CodeGym /课程 /C# SELF /引用类型和值类型

引用类型和值类型

C# SELF
第 12 级, 课程 3
可用

1. 入门

想象一下你有个盒子。如果这个盒子里直接装着东西(比如里面有个苹果),那就像值类型。数据本身就放在这个“盒子”(变量)里。

另一种情况,假如你有一张写着地址的名片。名片本身不是房子,只是告诉你房子在哪。这就像引用类型。变量里不是数据本身,而是“名片”——内存里的地址,数据就放在那。

最简单的例子:

  • int x = 5; // 值类型:变量x直接“存”着数字5。
  • string name = "瓦夏"; // 引用类型:变量name“存”的是字符串"瓦夏"在内存里的地址。

谁是值类型,谁是引用类型?

为了让你不迷糊,这里有个常见数据类型的分类表:

值类型(Value Types):
  • 基本类型: int, double, float, bool, char, byte, short, long, decimal 等等。
  • 结构体(struct): 你用struct关键字声明的所有自定义结构体。
  • 枚举(enum): 可以定义一组命名常量的类型。
引用类型(Reference Types):
  • 字符串(string): 虽然string有点特殊(不可变),但它就是引用类型。
  • 所有数组: 比如int[]、string[]、YourCustomClass[]。
  • 所有类(class): 你用class关键字声明的所有自定义类。
  • 委托(delegate): 表示方法引用的类型。
  • 接口(interface): 虽然接口本身不是对象,但接口类型的变量可以存放实现了这个接口的对象的引用。
  • 列表、字典和其他集合: 比如List<T>、Dictionary<TKey, TValue>。
  • 总之,除了struct和enum,其他在C#里默认都是引用类型。

2. 变量复制

这里就是实际操作时最重要的区别。当你把一个变量赋值给另一个变量时,实际复制了什么?

A)复制值类型(Value Type)

复制值类型时,会完整地独立复制数据本身。就像复印一份文件:你改一份,另一份不会变。


    int a = 10;
int b = a; 					// b现在也是10,但这是“自己的副本”
Console.WriteLine($"初始值: a = {a}, b = {b}"); // 初始值: a = 10, b = 10

b = 15; 					// 改b
Console.WriteLine($"改完b后: a = {a}, b = {b}"); // 改完b后: a = 10, b = 15

解释: 变量b拿到的是10的独立副本。你把b改成15,a还是10。它们完全互不影响。

B)复制引用类型(Reference Type)

复制引用类型时,只复制引用,也就是“地址”。两个变量现在都指向同一个对象。就像你给两个人同一张名片:他们都能找到同一个房子。一个人刷墙,另一个人去也能看到新颜色。

用数组举例最直观(字符串有点特殊):


int[] arr1 = {1, 2, 3};
int[] arr2 = arr1; // arr2和arr1指向内存里的同一个数组!

// 初始值: arr1[0] = 1, arr2[0] = 1
Console.WriteLine($"初始值: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

arr2[0] = 42; // 通过arr2改数组元素

// 改完arr2[0]后: arr1[0] = 42, arr2[0] = 42
Console.WriteLine($"改完arr2[0]后: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

解释: 两个变量(arr1和arr2)都指向同一个数组。你改arr2[0],其实就是改了那块内存,arr1[0]也跟着变。

字符串的特殊点(string)

C#里的字符串是引用类型,但因为不可变(immutability),行为有点不一样。字符串一旦创建就不能改。任何“修改”操作(比如拼接、Replace())其实都是新建一个字符串对象。


string str1 = "Hello";
string str2 = str1; // str2和str1都指向"Hello"这个对象

// str1 = "Hello", str2 = "Hello"
Console.WriteLine($"初始值: str1 = \"{str1}\", str2 = \"{str2}\""); 

str2 = "Bye"; // 这里创建了新对象"Bye",str2指向它

// str1 = "Hello", str2 = "Bye"
Console.WriteLine($"改完str2后: str1 = \"{str1}\", str2 = \"{str2}\""); 

解释: 一开始str1和str2都指向"Hello"。你给str2赋值"Bye",C#会新建一个"Bye"对象,str2指向它,str1还是"Hello"。这点经常让新手懵圈。

3. 主要区别表

特性 值类型
(比如struct、int)
引用类型
(比如class、string、数组)
复制的内容 值本身(数据的“复印件”) 对象的引用(内存“地址”)
副本间的关系 没有,副本完全独立。改一个不会影响另一个。 有,所有引用都指向同一个对象。通过一个引用改对象,其他引用也能看到。
能不能是null? 不能(除了Nullable类型,比如int?)。总有值。 能。可以指向“啥都没有”(null)。访问null对象会抛NullReferenceException。
声明方式 struct,还有所有基本类型(int、bool等)、enum class、interface、delegate、array、string、object
清理机制 离开作用域后自动从栈里移除。 垃圾回收器(Garbage Collector)清理,没有引用时才回收。

4. 应用里的例子

假设我们要写个简单的用户问卷控制台程序。有个结构体存考试分数,还有个类存用户资料。

// 值类型:存分数的结构体
struct Score
{
    public int Points;
    public string Grade; // 为了演示加个字段
}

// 引用类型:用户资料的类
class User
{
    public string Name;
    public Score ExamScore; // 嵌套结构体
}

结构体复制(Score)

Score score1 = new Score { Points = 100, Grade = "A" };
Score score2 = score1; // 把score1的内容全复制到score2
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=100, Grade=A

score2.Points = 88;
score2.Grade = "B";

Console.WriteLine("--- 改完score2后 ---");
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A(没变!)
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=88, Grade=B

结果: score1没变。因为Score score2 = score1;时,score1的内容(所有字段)被复制到score2。两个变量现在各有自己的数据。

类的复制(User)

User u1 = new User 
{ 
    Name = "安娜", 
    ExamScore = new Score { Points = 95, Grade = "A" } 
};
User u2 = u1; // u2和u1现在指向内存里的同一个用户!
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}"); // u1: Name=安娜, Score=95
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}"); // u2: Name=安娜, Score=95

u2.Name = "伊万"; // 通过u2改名字
u2.ExamScore.Points = 60; // 通过u2改分数
u2.ExamScore.Grade = "C";

Console.WriteLine("--- 改完u2后 ---");
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}, Grade={u1.ExamScore.Grade}"); // u1: Name=伊万, Score=60, Grade=C
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}, Grade={u2.ExamScore.Grade}"); // u2: Name=伊万, Score=60, Grade=C

结果: u1也变了!因为u1和u2一开始就指向同一个User对象。你通过u2改属性(比如u2.Name = "伊万";),其实就是改了对象本身。u1.Name也就变了。连嵌套的结构体ExamScore也跟着变,因为它是User对象的一部分,两者都指向同一个对象。

5. 传递到方法时会发生什么

理解类型怎么传到方法里,对你写出可预期的程序很重要。

把值类型(Value Type)传到方法

默认情况下,传值类型到方法是值传递。也就是说,方法拿到的是变量的副本。你在方法里怎么改都不会影响外面的原变量。

void AddTen(int x)
{
    Console.WriteLine($"方法里(改前): x = {x}"); // 方法里(改前): x = 5
    x = x + 10; // x现在是15,但只是本地副本
    Console.WriteLine($"方法里(改后): x = {x}"); // 方法里(改后): x = 15
    // 这个本地x出了方法就没了。
}

int num = 5;
Console.WriteLine($"调用方法前: num = {num}"); // 调用方法前: num = 5
AddTen(num);
Console.WriteLine($"调用方法后: num = {num}"); // 调用方法后: num = 5(没变!)

结果: 变量num还是5。x只是AddTen里的一个副本,初始化时复制了num的值。

把引用类型(Reference Type)传到方法

默认情况下,传引用类型到方法也是值传递,但复制的是引用的值,不是对象本身。也就是说,方法里拿到的是“名片”的副本(地址),但两张名片都能找到同一个对象。

void RenameUser(User u)
{
    // 方法里(改前): u.Name = "奥尔加"
    Console.WriteLine($"方法里(改前): u.Name = \"{u.Name}\""); 
    u.Name = "新名字"; // 改对象的属性
    // 方法里(改后): u.Name = "新名字"
    Console.WriteLine($"方法里(改后): u.Name = \"{u.Name}\""); 
}

User user = new User { Name = "奥尔加" };
// 调用方法前: user.Name = "奥尔加"
Console.WriteLine($"调用方法前: user.Name = \"{user.Name}\""); 
RenameUser(user); 
// 调用方法后: user.Name = "新名字"
Console.WriteLine($"调用方法后: user.Name = \"{user.Name}\""); 

结果: 用户名变成了“新名字”。RenameUser方法拿到的是user的引用副本。通过这个副本,方法能改堆里的原对象的Name属性。

注意: 如果你在方法里给引用类型变量赋新对象会怎样?

void ReassignUser(User u)
{
    u = new User { Name = "全新用户" }; // u现在指向新对象
    Console.WriteLine($"方法里(重新赋值后): u.Name = \"{u.Name}\"");
}

User originalUser = new User { Name = "原始用户" };
Console.WriteLine($"调用ReassignUser前: originalUser.Name = \"{originalUser.Name}\"");
ReassignUser(originalUser); 
Console.WriteLine($"调用ReassignUser后: originalUser.Name = \"{originalUser.Name}\""); // "原始用户"——没变!

结果: originalUser没变!因为ReassignUser拿到的是引用的副本。你在方法里u = new User(...),只是让本地变量u指向新对象,外面的originalUser还是原来的对象。这点很重要!

6. “新手泪点”:常见错误和原因

理解引用类型和值类型对新手来说确实有点烧脑。下面是常见的坑:

数组复制搞混: 很多新手以为arr2 = arr1;会生成独立的数组副本。其实只是两个引用指向同一个数组。就像两个手柄控制同一台游戏机:你按哪个都会影响游戏,另一个手柄也能看到变化。要想独立复制数组,得显式克隆(比如int[] arr2 = (int[])arr1.Clone();或者用Copy方法)。

以为字符串能“引用改”: 因为string是引用类型,有人以为它能像数组那样改。其实因为不可变,任何“改动”其实都是新建字符串对象。这样经常会有意外结果,而且在循环里频繁操作字符串会很慢(这种情况建议用StringBuilder)。

忘了null: 除了nullable类型(int?、bool?),值类型总有值,永远不会是null。引用类型可以是null,也就是啥都不指。你要是访问null对象的成员,就会遇到著名的NullReferenceException。只要有可能为null,用前一定要检查。

小数据用类而不是结构体: 有些人习惯啥都用类。其实对于小而简单、代表一个整体的数据(比如点Point { X, Y }、颜色Color { R, G, B }),用结构体更高效,因为它们在栈里存、复制快、不会增加垃圾回收压力。但结构体最好是不可变的、字段少、别包含引用类型字段(否则还是可能有null或状态变化)。

2
任务
C# SELF, 第 12 级, 课程 3
已锁定
数组作为引用类型的行为
数组作为引用类型的行为
2
任务
C# SELF, 第 12 级, 课程 3
已锁定
与值类型和引用类型的方法交互
与值类型和引用类型的方法交互
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION