ZetCode

C# 值复制与引用复制

最后修改于 2025 年 5 月 16 日

本教程解释了 C# 如何处理值复制和引用复制,从而影响变量赋值和参数传递。 理解这些概念对于编写正确且高效的 C# 代码至关重要,因为它决定了数据在变量和方法之间如何共享或隔离。

值类型与引用类型

C# 类型分为 值类型引用类型,它们决定了数据在内存中如何复制、存储和管理。

特征 值类型 引用类型
存储 栈(直接数据) 堆(对数据的引用)
复制行为 值复制(独立副本) 引用复制(共享对象)
示例 int, double, struct class, array, string

当您将一个值类型变量赋值给另一个变量时,会创建一个新的数据副本。 对于引用类型,仅复制引用,因此两个变量都指向内存中的同一对象。

按值复制示例

值类型在赋值时会创建一个独立的副本,因此对一个变量的更改不会影响另一个变量。 对于内置类型和用户定义的结构体都是如此。

Program.cs
int a = 10;
int b = a; // Copy by value

Console.WriteLine($"Original: a = {a}, b = {b}");
b = 20; // Doesn't affect a
Console.WriteLine($"After change: a = {a}, b = {b}");

// Structs are value types
Point p1 = new (1, 2);
Point p2 = p1; // Copy by value

p2.X = 10; // Doesn't affect p1
Console.WriteLine($"p1 = ({p1.X}, {p1.Y}), p2 = ({p2.X}, {p2.Y})");

struct Point(int x, int y)
{
    public int X = x;
    public int Y = y;
}

该示例展示了值类型(intstruct)如何创建独立的副本。修改一个不会影响另一个。

$ dotnet run
Original: a = 10, b = 10
After change: a = 10, b = 20
p1 = (1, 2), p2 = (10, 2)

复制值类型数组

数组本身是引用类型,即使它们的元素是值类型。 将一个数组分配给另一个数组会复制引用,而不是元素。

Program.cs
int[] arr1 = { 1, 2, 3 };
int[] arr2 = arr1; // arr2 references the same array as arr1

arr2[0] = 99;
Console.WriteLine($"arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}");

这表明修改 arr2 也会影响 arr1,因为它们引用的是同一个数组。 输出将会是

$ dotnet run
arr1[0] = 99, arr2[0] = 99

要创建数组的真实副本,请使用 Array.CopyClone

Program.cs
int[] arr1 = { 1, 2, 3 };    
int[] arr2 = (int[])arr1.Clone();

arr2[0] = 123;
Console.WriteLine($"arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}");

这将创建一个与 arr1 具有相同元素的新数组。 对 arr2 的更改不会影响 arr1Clone 方法创建数组的浅表副本,这意味着它复制元素,但不复制它们引用的对象。 对于引用类型,需要深度复制才能创建数组中对象的新实例。

字符串:具有不可变行为的引用类型

C# 中的字符串是引用类型,但它们是不可变的。 这意味着一旦创建了字符串对象,就无法更改它。 任何看起来修改字符串的操作实际上都会在内存中创建一个新的字符串对象。 因此,将一个字符串变量分配给另一个变量会复制引用,但更改一个变量不会影响另一个变量。

Program.cs
string s1 = "hello";
string s2 = s1; // Copy by reference (but strings are immutable)

Console.WriteLine($"Original: s1 = {s1}, s2 = {s2}");
s2 = "world"; // s1 is not affected
Console.WriteLine($"After change: s1 = {s1}, s2 = {s2}");

在此示例中,s1s2 最初引用同一个字符串对象。 当 s2 被赋予一个新值时,它指向一个新的字符串对象,而 s1 保持不变。 这证明了 C# 中字符串的不可变性。

$ dotnet run
Original: s1 = hello, s2 = hello
After change: s1 = hello, s2 = world

由于不可变性,字符串可以安全地在变量和方法之间共享,而不会有意外修改的风险。 但是,频繁的字符串修改可能会由于创建许多临时字符串对象而导致性能问题。 对于需要多次更改的情况,请考虑使用 StringBuilder

引用复制示例

C# 中的引用类型存储内存地址而不是实际值。 将引用类型变量分配给另一个变量时,会复制内存地址,这意味着两个变量都指向相同的数据。 这种行为允许对一个变量的修改反映在原始对象中。

与赋值时创建单独副本的值类型不同,引用类型在多个变量之间维护共享数据。 这在使用复杂对象(如数组和类实例)时尤其重要,在这些对象中,多个引用可以与相同的底层内存进行交互。

Program.cs
int[] arr1 = { 1, 2, 3 };
int[] arr2 = arr1; // Copy by reference

Console.WriteLine($"Original: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}");

arr2[0] = 100; // Affects arr1
Console.WriteLine($"After change: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}");

在上面的示例中,arr1arr2 都指向同一个数组。 修改 arr2[0] 时,更改也会应用于 arr1。 由于数组是引用类型,因此在赋值期间不会复制它们的内容。

类似地,从类实例化的对象以相同的方式运行。 当一个对象引用分配给另一个变量时,两个变量共享相同的内存地址。 修改一个会影响另一个。

Program.cs
Person person1 = new("Alice");
Person person2 = person1; // Copy by reference

person2.Name = "Bob"; // Affects person1
Console.WriteLine($"person1.Name = {person1.Name}, person2.Name = {person2.Name}");

class Person(string name)
{
    public string Name { get; set; } = name;
}

该示例演示了类实例如何表现为引用类型。 最初,person1 包含名称“Alice”。 当分配给 person2 时,两个变量都引用同一个对象。 更改 person2.Name 会更新 person1.Name,因为它们共享相同的内存。

引用类型的这一特性可以有效地利用内存,但也需要小心处理。 对共享对象的意外修改可能会引入意想不到的副作用,因此必须了解变量如何引用数据。

$ dotnet run
Original: arr1[0] = 1, arr2[0] = 1
After change: arr1[0] = 100, arr2[0] = 100
person1.Name = Bob, person2.Name = Bob

为了防止意外修改,开发人员通常使用克隆对象或使用不可变类型等技术来确保数据完整性。 了解引用类型如何工作对于编写高效且可预测的 C# 程序至关重要。

参数传递:值、引用、refout

C# 以不同的方式处理参数传递,具体取决于变量是值类型还是引用类型。 默认情况下,值类型参数会被复制,这意味着方法内部的修改不会影响原始变量。 另一方面,引用类型参数传递引用,允许方法内部的更改反映到外部。 此外,C# 提供了 refout 关键字来显式地按引用传递变量,使方法能够修改调用者的变量。

默认行为:值类型 vs 引用类型

传递值类型参数时,值的副本会发送到方法,这意味着修改不会影响原始变量。

Program.cs
int number = 10;
ModifyValue(number);
Console.WriteLine($"After ModifyValue: {number}"); // Output: 10

void ModifyValue(int x)
{
    x = 20; // Changes the local copy, not the original
}

在此示例中,ModifyValue 方法不会影响原始变量 number。 方法调用后,number 的值保持为 10。 这是因为 number 是一个值类型,当传递给方法时,它的值会被复制。

引用类型参数将引用传递给原始对象,这意味着方法内部的修改会影响原始数据。

Program.cs
int[] numbers = { 1, 2, 3 };
ModifyReference(numbers);
Console.WriteLine($"After ModifyReference: {string.Join(", ", numbers)}");

void ModifyReference(int[] arr)
{
    arr[0] = 100; // Changes the actual array
}

在此示例中,ModifyReference 方法修改了数组 numbers 的第一个元素。 更改会反映到方法外部,因为数组是引用类型。 该方法接收对原始数组的引用,允许它直接修改数据。 结果,输出显示数组的第一个元素已更改为 100

使用 ref 显式地按引用传递

ref 关键字允许方法直接在调用者的范围内修改变量的值。 与标准值传递不同,方法内部的更改会保留到外部。

Program.cs
int y = 5;
SetToTen(ref y);
Console.WriteLine($"y = {y}"); // Output: y = 10

void SetToTen(ref int n)
{
    n = 10; // Modifies the original variable
}

必须在方法签名和方法调用中使用 ref 关键字。 这表示变量按引用传递,允许该方法直接修改其值。 当您需要返回多个值或想要避免复制大型数据结构时,这尤其有用。

使用 out 进行强制输出

out 关键字的工作方式类似于 ref,但它表示必须在退出之前在方法内部分配参数。 它主要用于返回多个值。

Program.cs
int z;
Initialize(out z);
Console.WriteLine($"z = {z}"); // Output: z = 42

void Initialize(out int n)
{
    n = 42; // Must be assigned before the method returns
}

out 关键字允许方法通过将值分配给输出参数来返回多个值。 与 ref 不同,变量在传递给方法之前不需要初始化。 该方法负责在返回之前为 out 参数分配一个值。 这对于需要返回多个值或在方法执行之前输出值未知的那些方法很有用。

refout 之间的主要区别

ref 关键字要求变量在传递到方法之前进行初始化,因为它保留了其原始值,同时允许修改。 但是,out 关键字不需要事先初始化——该方法负责在返回之前分配一个值。

当使用复杂的数据结构或需要多个返回值的情况时,策略性地使用这些技术可以优化内存使用并增强功能。

总结与最佳实践

本教程涵盖了 C# 中的值复制和引用复制,包括内存分配、赋值、参数传递和最佳实践。

来源

C# 值类型文档

C# 引用类型文档

C# 参数传递

作者

我叫 Jan Bodnar,是一位充满热情的程序员,拥有丰富的编程经验。 我自 2007 年以来一直在撰写编程文章。 迄今为止,我已经撰写了 1,400 多篇文章和 8 本电子书。 我在编程教学方面拥有超过十年的经验。

列出所有 C# 教程