引用传递在 C# 中的工作原理
How pass by Reference works in C#
我正在尝试编写一些代码来创建链表,但我对按引用传递在 C# 中的工作方式感到困惑。下面是我的 AddNodeToEnd 方法代码,它将 LinkedList 的头部和要添加的数据元素作为输入。
public LinkedList AddNodeToEnd(LinkedList head, string data)
{
var node = new LinkedList() { Data = data };
if (head == null)
return node;
while (head.Next != null)
{
head = head.Next;
}
head.Next = node;
return head;
}
下面是我向列表添加元素的代码。
var linkedList = new LinkedListDriver();
var head = linkedList.AddNodeToEnd(null, "1");
linkedList.AddNodeToEnd(head, "2");
linkedList.AddNodeToEnd(head, "3");
linkedList.AddNodeToEnd(head, "4");
linkedList.AddNodeToEnd(head, "5");
Console.Write(linkedList.PrintList(head));
这是将输出打印为 1 => 2 => 3 => 4 => 5
(如预期)。
我的问题是如何在 AddNodeToEnd 方法中更改 head 元素?新节点被正确添加到 LinkedList,但在相同的方法中,我遍历 Head 节点本身而不将其分配给 different/temporary 变量,但在我的 Main 方法中,Head 仍然保持在 1,为什么?基于上面的代码,我预计 Head 会移动到 5 因为 head = head.Next;因为头部作为引用传递(默认情况下在 C# 中)。
感谢 C# 中对此行为的解释。
public class LinkedList
{
public string Data { get; set; }
public LinkedList Next { get; set; }
public LinkedList Previous { get; set; }
}
当您将 head
传递给 AddNodeToEnd
时,您传递的是引用的副本。该副本最初指向 head
,但在 AddNodeToEnd
中,您更改了 copy 指向的引用。你没有改变原来的 head
.
如果 AddNodeToEnd
的签名是 public LinkedList AddNodeToEnd(ref LinkedList head, string data)
那么您的代码将按照您认为应该的方式运行。在这种情况下,您不会传递引用 的 副本,您会传递 引用本身.
当您将引用类型(即自定义 type/class,而不是像 int
这样的 type/struct 中的构建)作为参数传递给函数时,您实际上得到的是一个指针到现有对象的内存位置。除非你传递额外的 ref
参数,否则你不能替换整个对象,但你 是 允许修改其内部值。
在这种情况下会发生一些有趣的事情:通过传递 head
给你一个引用类型,你可以修改它。因此,您修改了现有对象。但是,当您分配给 head
时,您替换了对该对象的本地复制引用。那不会修改现有对象。
您混淆了两个不同的概念。
我指的概念如下:
- 值类型和引用类型的区别
- 按值传递参数和按引用传递参数的区别
让我们从值类型 VS 引用类型开始。
值类型是值是数据本身的类型。当我说“数据本身”时,我指的是值类型的实际实例。值类型的一个示例是名为 System.Int32
的结构,它是一个 32 位有符号整数。考虑以下变量声明:
int number = 13;
在这种情况下,number
变量包含实际值 13
,它是 System.Int32
类型的一个实例。换句话说,您通过 number
标识符访问的计算机内存中的内存位置直接包含整数 13
.
根据我上面的解释,以下代码行创建 a
变量中包含的值的副本,并将该副本分配给 b
变量。代码执行后,这两个变量包含相同整数值 (13
) 的独立副本。换句话说,RAM 中有两个独立的内存位置,其中包含整数 13
:
的两个独立副本
int a = 13;
int b = a;
引用类型是其值是对数据本身的引用的类型。当我说“对数据本身的引用”时,我指的是对该类型实例的引用。引用类型的一个示例是 class System.String
,它用于表示字符串。
引用类型实例的生命周期由垃圾回收器处理,垃圾回收器负责回收引用类型实例占用的内存。这是在这些实例不再被任何人引用时完成的,因此它们可以安全地从内存中删除。
考虑以下代码行:
string name = "Enrico";
此处变量 name
不包含字符串 "Enrico"
,而是包含对字符串 "Enrico"
的引用。这意味着在计算机内存中的某处有一个包含实际数据(组成字符串 "Enrico"
的字符序列)的内存地址,并且您通过 name
标识符访问的内存位置包含对包含实际字符串数据的内存位置。您可以将我称为 引用 的东西想象成一个虚构的箭头(指针),它指向另一个内存位置,该位置实际上包含组成字符串 [=27 的字符序列=].
考虑以下代码:
string a = "Hello";
string b = a;
这是这里发生的事情:
- 分配了一些内存来包含组成字符串“Hello”的字符序列。在这个内存位置有真正的数据,字符串本身,
System.String
类型的实际实例。
- 变量
a
包含一个指向实际数据的指针,即指向第 1 步中描述的内存位置的指针。
- 变量
b
包含变量 a
内容的副本。这意味着变量 b
包含一个指向第 1 步中描述的内存位置的指针。现在有 2 个独立的指针指向同一内存位置,其中包含实际数据,即组成字符串的字符序列"Hello"
.
请注意,此时,您可以通过使用两个不同的指针访问同一个实例(字符串 "Hello"
,它是 System.String
类型的实例):a
和 b
变量引用存储在计算机内存中某处的字符串数据。 这里很重要的一点是内存中只有一个字符串实例.
我们现在可以讨论按值传递和按引用传递。 简而言之,默认情况下,在 C# 中,所有方法参数都是按值传递的。这是我的回答中最重要的部分,我多次注意到对此有些困惑。但这真的就是这么简单:除非您指定想要按引用传递行为,否则 C# 的默认行为是按值传递方法参数。您可以使用 ref
或 out
关键字通过此默认行为选择退出:这样您就可以决定 do 想要通过引用传递行为,当您将参数传递给方法。
按值传递意味着将值的副本作为参数传递给方法.
真正重要的是理解“值的副本”的实际含义。但你已经知道答案了:
- 值类型值的副本,表示代表该类型实例的实际数据的副本
- 引用类型的值的副本,意味着对实际数据的引用的副本。你是不是创建存储在内存中的实际对象的副本,而是创建指向该对象的指针的副本。
现在我们可以考虑最后一个例子,我希望它能澄清你的疑问。
我需要一个 class (这是一个引用类型)和几个方法。
public class Person
{
public string Name { get; set; }
}
public static void DoSomething(Person person)
{
person = new Person
{
Name = "Bob"
};
Console.Writeline(person.Name); // this prints Bob
}
public static void Main(string[] args)
{
Person alice = new Person
{
Name = "Alice"
};
DoSomething(alice);
Console.Writeline(alice.Name); // this prints Alice
}
事情是这样的:
Main
方法在计算机内存中创建Person
class的实例,其名称为"Alice"
。名为 alice
的变量被分配给该实例,因此变量 alice
包含指向 Person
class 实例的指针。 内存中有1个变量和1个对象。变量指向对象。
-
DoSomething
方法被调用,变量 alice
作为参数传递给 person
参数。变量person
是变量alice
的副本:这两个副本独立并且都指向同一个内存位置,其中包含在点 1 创建的对象(名称为 "Alice"
的 Person
class 实例)。
- 在方法
DoSomething
内,在内存中创建了一个新对象,该对象是 Person
class 的实例,名称为 "Bob"
。方法参数 person
被分配给新创建的对象。 现在内存中有两个对象,它们都是 Person
class 的实例。 参数 person
包含对其中之一的引用对象(名称为 "Bob"
的对象),而 Main
方法的变量 alice
包含对另一个对象 的引用 (具有名称 "Alice"
)。这很好,因为参数 person
和变量 alice
之间没有界限:它们是完全独立的,可以自由引用不同的对象。
- 当
DoSomething
的执行结束时,方法参数 person
超出范围并且无法通过代码访问。我们回到了 Main
方法,变量 alice
仍在范围内,可以通过代码访问。这个变量 没有被 DoSomething
的执行修改 并且一直指向在点 1 创建的 Person
class 的实例(那个有名字 "Alice"
).
我正在尝试编写一些代码来创建链表,但我对按引用传递在 C# 中的工作方式感到困惑。下面是我的 AddNodeToEnd 方法代码,它将 LinkedList 的头部和要添加的数据元素作为输入。
public LinkedList AddNodeToEnd(LinkedList head, string data)
{
var node = new LinkedList() { Data = data };
if (head == null)
return node;
while (head.Next != null)
{
head = head.Next;
}
head.Next = node;
return head;
}
下面是我向列表添加元素的代码。
var linkedList = new LinkedListDriver();
var head = linkedList.AddNodeToEnd(null, "1");
linkedList.AddNodeToEnd(head, "2");
linkedList.AddNodeToEnd(head, "3");
linkedList.AddNodeToEnd(head, "4");
linkedList.AddNodeToEnd(head, "5");
Console.Write(linkedList.PrintList(head));
这是将输出打印为 1 => 2 => 3 => 4 => 5
(如预期)。
我的问题是如何在 AddNodeToEnd 方法中更改 head 元素?新节点被正确添加到 LinkedList,但在相同的方法中,我遍历 Head 节点本身而不将其分配给 different/temporary 变量,但在我的 Main 方法中,Head 仍然保持在 1,为什么?基于上面的代码,我预计 Head 会移动到 5 因为 head = head.Next;因为头部作为引用传递(默认情况下在 C# 中)。
感谢 C# 中对此行为的解释。
public class LinkedList
{
public string Data { get; set; }
public LinkedList Next { get; set; }
public LinkedList Previous { get; set; }
}
当您将 head
传递给 AddNodeToEnd
时,您传递的是引用的副本。该副本最初指向 head
,但在 AddNodeToEnd
中,您更改了 copy 指向的引用。你没有改变原来的 head
.
如果 AddNodeToEnd
的签名是 public LinkedList AddNodeToEnd(ref LinkedList head, string data)
那么您的代码将按照您认为应该的方式运行。在这种情况下,您不会传递引用 的 副本,您会传递 引用本身.
当您将引用类型(即自定义 type/class,而不是像 int
这样的 type/struct 中的构建)作为参数传递给函数时,您实际上得到的是一个指针到现有对象的内存位置。除非你传递额外的 ref
参数,否则你不能替换整个对象,但你 是 允许修改其内部值。
在这种情况下会发生一些有趣的事情:通过传递 head
给你一个引用类型,你可以修改它。因此,您修改了现有对象。但是,当您分配给 head
时,您替换了对该对象的本地复制引用。那不会修改现有对象。
您混淆了两个不同的概念。 我指的概念如下:
- 值类型和引用类型的区别
- 按值传递参数和按引用传递参数的区别
让我们从值类型 VS 引用类型开始。
值类型是值是数据本身的类型。当我说“数据本身”时,我指的是值类型的实际实例。值类型的一个示例是名为 System.Int32
的结构,它是一个 32 位有符号整数。考虑以下变量声明:
int number = 13;
在这种情况下,number
变量包含实际值 13
,它是 System.Int32
类型的一个实例。换句话说,您通过 number
标识符访问的计算机内存中的内存位置直接包含整数 13
.
根据我上面的解释,以下代码行创建 a
变量中包含的值的副本,并将该副本分配给 b
变量。代码执行后,这两个变量包含相同整数值 (13
) 的独立副本。换句话说,RAM 中有两个独立的内存位置,其中包含整数 13
:
int a = 13;
int b = a;
引用类型是其值是对数据本身的引用的类型。当我说“对数据本身的引用”时,我指的是对该类型实例的引用。引用类型的一个示例是 class System.String
,它用于表示字符串。
引用类型实例的生命周期由垃圾回收器处理,垃圾回收器负责回收引用类型实例占用的内存。这是在这些实例不再被任何人引用时完成的,因此它们可以安全地从内存中删除。
考虑以下代码行:
string name = "Enrico";
此处变量 name
不包含字符串 "Enrico"
,而是包含对字符串 "Enrico"
的引用。这意味着在计算机内存中的某处有一个包含实际数据(组成字符串 "Enrico"
的字符序列)的内存地址,并且您通过 name
标识符访问的内存位置包含对包含实际字符串数据的内存位置。您可以将我称为 引用 的东西想象成一个虚构的箭头(指针),它指向另一个内存位置,该位置实际上包含组成字符串 [=27 的字符序列=].
考虑以下代码:
string a = "Hello";
string b = a;
这是这里发生的事情:
- 分配了一些内存来包含组成字符串“Hello”的字符序列。在这个内存位置有真正的数据,字符串本身,
System.String
类型的实际实例。 - 变量
a
包含一个指向实际数据的指针,即指向第 1 步中描述的内存位置的指针。 - 变量
b
包含变量a
内容的副本。这意味着变量b
包含一个指向第 1 步中描述的内存位置的指针。现在有 2 个独立的指针指向同一内存位置,其中包含实际数据,即组成字符串的字符序列"Hello"
.
请注意,此时,您可以通过使用两个不同的指针访问同一个实例(字符串 "Hello"
,它是 System.String
类型的实例):a
和 b
变量引用存储在计算机内存中某处的字符串数据。 这里很重要的一点是内存中只有一个字符串实例.
我们现在可以讨论按值传递和按引用传递。 简而言之,默认情况下,在 C# 中,所有方法参数都是按值传递的。这是我的回答中最重要的部分,我多次注意到对此有些困惑。但这真的就是这么简单:除非您指定想要按引用传递行为,否则 C# 的默认行为是按值传递方法参数。您可以使用 ref
或 out
关键字通过此默认行为选择退出:这样您就可以决定 do 想要通过引用传递行为,当您将参数传递给方法。
按值传递意味着将值的副本作为参数传递给方法.
真正重要的是理解“值的副本”的实际含义。但你已经知道答案了:
- 值类型值的副本,表示代表该类型实例的实际数据的副本
- 引用类型的值的副本,意味着对实际数据的引用的副本。你是不是创建存储在内存中的实际对象的副本,而是创建指向该对象的指针的副本。
现在我们可以考虑最后一个例子,我希望它能澄清你的疑问。 我需要一个 class (这是一个引用类型)和几个方法。
public class Person
{
public string Name { get; set; }
}
public static void DoSomething(Person person)
{
person = new Person
{
Name = "Bob"
};
Console.Writeline(person.Name); // this prints Bob
}
public static void Main(string[] args)
{
Person alice = new Person
{
Name = "Alice"
};
DoSomething(alice);
Console.Writeline(alice.Name); // this prints Alice
}
事情是这样的:
Main
方法在计算机内存中创建Person
class的实例,其名称为"Alice"
。名为alice
的变量被分配给该实例,因此变量alice
包含指向Person
class 实例的指针。 内存中有1个变量和1个对象。变量指向对象。-
DoSomething
方法被调用,变量alice
作为参数传递给person
参数。变量person
是变量alice
的副本:这两个副本独立并且都指向同一个内存位置,其中包含在点 1 创建的对象(名称为"Alice"
的Person
class 实例)。 - 在方法
DoSomething
内,在内存中创建了一个新对象,该对象是Person
class 的实例,名称为"Bob"
。方法参数person
被分配给新创建的对象。 现在内存中有两个对象,它们都是Person
class 的实例。 参数person
包含对其中之一的引用对象(名称为"Bob"
的对象),而Main
方法的变量alice
包含对另一个对象 的引用 (具有名称"Alice"
)。这很好,因为参数person
和变量alice
之间没有界限:它们是完全独立的,可以自由引用不同的对象。 - 当
DoSomething
的执行结束时,方法参数person
超出范围并且无法通过代码访问。我们回到了Main
方法,变量alice
仍在范围内,可以通过代码访问。这个变量 没有被DoSomething
的执行修改 并且一直指向在点 1 创建的Person
class 的实例(那个有名字"Alice"
).