C# 按值传递与按引用传递

C# pass by value vs. pass by reference

考虑下面的代码(我有目的地写了MyPoint作为这个例子的引用类型)

public class MyPoint
{
    public int x;
    public int y;
}

众所周知(至少在 C# 中),当您通过引用传递时,该方法包含对被操作对象的引用,而当您通过值传递时,该方法复制被操作的值,因此全局范围内的值不受影响。

示例:

void Replace<T>(T a, T b)
{
    a = b;
}

int a = 1;
int b = 2;

Replace<int>(a, b);

// a and b remain unaffected in global scope since a and b are value types.

这是我的问题; MyPoint 是引用类型,因此我希望在 Point 上执行相同的操作以在全局范围内用 b 替换 a

示例:

MyPoint a = new MyPoint { x = 1, y = 2 };
MyPoint b = new MyPoint { x = 3, y = 4 };

Replace<MyPoint>(a, b);

// a and b remain unaffected in global scope since a and b...ummm!?

我希望 ab 指向内存中的相同引用...有人可以澄清我哪里出错了吗?

C#其实就是按值传递。您会产生它是按引用传递的错觉,因为当您传递引用类型时,您会得到引用的副本(引用是按值传递的)。但是,由于您的替换方法正在用另一个引用替换该引用副本,因此它实际上什么都不做(复制的引用立即超出范围)。您实际上可以通过添加 ref 关键字来通过引用传递:

void Replace<T>(ref T a, T b)
{
    a = b;
}

这会得到你想要的结果,但在实践中有点奇怪。

C# 不是通过引用传递引用类型对象,而是通过值传递引用。这意味着你可以乱搞他们的内部,但你不能改变分配本身。

阅读 Jon Skeet 的 this great piece 以加深理解。

你没有理解引用传递的含义。您的 Replace 方法正在创建 Point 对象的副本——按值传递(这实际上是更好的方法)。

要通过引用传递,使a和b都引用内存中的同一个点,需要在签名中添加"ref"。

你没听懂。

它类似于Java - 一切都按值传递!但你必须知道,价值是什么。

在原始数据类型中,值就是数字本身。其他情况是参考。

但是,如果您将引用复制到另一个变量,它拥有相同的引用,但不引用该变量(因此它不是 C++ 中已知的通过引用传递)。

回复:OP 的断言

It is universally acknowledged (in C# at least) that when you pass by reference, the method contains a reference to the object being manipulated, whereas when you pass by value, the method copies the value being manipulated ...

TL;DR

远不止于此。除非使用 ref or out 关键字传递变量,否则 C# 会通过 value 将变量传递给方法,而不管变量是否为 value 类型 引用类型.

  • 如果通过 reference 传递,则被调用函数可能会更改变量在调用点的地址(即更改原始调用函数变量的赋值)。

  • 如果一个变量被值传递:

    • 如果被调用函数对变量重新赋值,这个变化只是被调用函数局部的,不会影响调用函数中的原变量
    • 但是,如果调用的函数对变量的字段或属性进行更改,则取决于变量是 value 类型还是 reference 类型以确定调用函数是否会观察对此变量所做的更改。

因为这一切都相当复杂,我建议尽可能避免通过引用传递(相反,如果您需要 return 来自一个函数的多个值,请使用作为 return 类型的复合 class、结构或元组,而不是在参数上使用 refout 关键字)

此外,当传递引用类型时,通过不更改(改变)传递给方法的对象的字段和属性(例如,使用 C# 的 immutable properties 来防止更改属性,并力求在构造期间仅分配一次属性。

详细

问题是有两个截然不同的概念:

  • 值类型(例如 int)与引用类型(例如字符串或自定义 classes)
  • 按值传递(默认行为)与按引用传递(ref,out)

除非您明确通过引用传递(任何)变量,否则通过使用 outref 关键字,参数在 C# 中通过 value 传递,不管变量是值类型还是引用类型。

按值传递 类型(例如 intfloatDateTime 之类的结构)时(即没有 outref),被调用函数得到一个 copy of the entire value type(通过堆栈)。

对值类型的任何更改,以及对副本的任何属性/字段的任何更改都将在退出被调用函数时丢失。

但是,当通过 value 传递 reference 类型(例如自定义 class 像你的 MyPoint class)时,它是复制并传递到堆栈上的同一个共享对象实例的 reference

这意味着:

  • 如果传递的对象具有可变(可设置)字段和属性,则对共享对象的这些字段或属性的任何更改都是永久性的(即,对 xy 的任何更改都可以看到任何观察物体的人)
  • 然而,在方法调用期间,引用本身仍然被复制(按值传递),所以如果参数变量被重新分配,这个改变只对引用的本地副本进行,所以改变不会被复制被调用者看到。 这就是您的代码无法按预期工作的原因

这里发生了什么:

void Replace<T>(T a, T b) // Both a and b are passed by value
{
    a = b;  // reassignment is localized to method `Replace`
}

对于引用类型T,意味着对对象a的局部变量(栈)引用被重新赋值给局部栈引用b。此重新分配仅适用于此函数 - 一旦作用域离开此函数,重新分配就会丢失。

如果你真的想替换调用者的引用,你需要像这样更改签名:

void Replace<T>(ref T a, T b) // a is passed by reference
{
    a = b;   // a is reassigned, and is also visible to the calling function
}

这将调用更改为 通过引用调用 - 实际上我们将调用者变量的地址传递给函数,然后允许 被调用的方法 更改 调用方法的 变量。

然而,如今:

  • 通过引用传递是 generally regarded as a bad idea - 相反,我们应该在 return 值中传递 return 数据,如果有多个变量是 returned,然后使用包含所有此类 return 变量的 Tuple 或自定义 classstruct
  • 更改 ('mutating') 被调用方法中的共享值(甚至引用)变量是不受欢迎的,尤其是函数式编程社区,因为这会导致棘手的错误,尤其是在使用多线程时.相反,优先考虑不可变变量,或者如果需要突变,则考虑更改变量的(可能很深的)副本。您可能会发现围绕 'pure functions' 和 'const correctness' 的主题值得进一步阅读。

编辑

这两张图可能有助于解释。

按值传递(引用类型):

在您的第一个实例 (Replace<T>(T a,T b)) 中,ab 是按值传递的。对于 reference types, this means the references 被复制到堆栈并传递给被调用的函数。

  1. 您的初始代码(我称之为 main)在托管堆上分配两个 MyPoint 对象(我称之为 point1point2) ,然后赋值两个局部变量引用ab,分别引用点(浅蓝色箭头):
MyPoint a = new MyPoint { x = 1, y = 2 }; // point1
MyPoint b = new MyPoint { x = 3, y = 4 }; // point2
  1. 然后调用 Replace<Point>(a, b) 将两个引用的副本压入堆栈(红色箭头)。方法 Replace 将这些视为两个参数,它们也分别命名为 ab,它们仍然分别指向 point1point2(橙色箭头)。

  2. 赋值,a = b; 然后更改 Replace 方法的 a 局部变量,使得 a 现在指向与引用相同的对象通过 b(即 point2)。但是请注意,此更改仅针对 Replace 的本地(堆栈)变量,并且此更改只会影响 Replace 中的后续代码(深蓝色线)。它不会以任何方式影响调用函数的变量引用,也不会改变堆上的 point1point2 对象。

按引用传递:

但是,如果我们将调用更改为 Replace<T>(ref T a, T b),然后更改 main 以通过引用传递 a,即 Replace(ref a, b):

  1. 和之前一样,在堆上分配了两个点对象。

  2. 现在,当 Replace(ref a, b) 被调用时,虽然 main 的引用 b(指向 point2)在调用期间仍然被复制, a 现在 通过引用传递 ,这意味着 main 的 a 变量的“地址”传递给了 Replace

  3. 现在当分配a = b时...

  4. 它是调用函数,maina 变量引用现在更新为引用 point2a 的重新分配所做的更改现在对 mainReplace 都可见。现在没有对 point1

    的引用

引用该对象的所有代码都可以看到对(堆分配的)对象实例的更改

在上述两种情况下,堆对象point1point2实际上没有发生任何变化,只是传递和重新分配了局部变量引用。

但是,如果实际上对堆对象 point1point2 进行了任何更改,那么对这些对象的所有变量引用都会看到这些更改。

因此,例如:

void main()
{
   MyPoint a = new MyPoint { x = 1, y = 2 }; // point1
   MyPoint b = new MyPoint { x = 3, y = 4 }; // point2

   // Passed by value, but the properties x and y are being changed
   DoSomething(a, b);

   // a and b have been changed!
   Assert.AreEqual(53, a.x);
   Assert.AreEqual(21, b.y);
}

public void DoSomething(MyPoint a, MyPoint b)
{
   a.x = 53;
   b.y = 21;
}

现在,当执行returns到main时,所有对point1point2的引用,包括main's变量ab,当他们下次读取点的 xy 的值时,现在将 'see' 发生变化。您还会注意到变量 ab 仍然按值传递给 DoSomething.

对值类型的更改仅影响本地副本

值类型(像System.Int32System.Double这样的原语)和结构(像System.DateTime,或者你自己的结构)分配在栈上,而不是堆上,并被复制传递给调用时逐字入栈。这导致了行为上的重大差异,因为被调用函数对值类型字段或 属性 所做的更改只会被被调用函数 在本地观察到 ,因为它只将改变值类型的本地副本。

例如考虑以下带有可变结构实例的代码,System.Drawing.Rectangle

public void SomeFunc(System.Drawing.Rectangle aRectangle)
{
    // Only the local SomeFunc copy of aRectangle is changed:
    aRectangle.X = 99;
    // Passes - the changes last for the scope of the copied variable
    Assert.AreEqual(99, aRectangle.X);
}  // The copy aRectangle will be lost when the stack is popped.

// Which when called:
var myRectangle = new System.Drawing.Rectangle(10, 10, 20, 20);
// A copy of `myRectangle` is passed on the stack
SomeFunc(myRectangle);
// Test passes - the caller's struct has NOT been modified
Assert.AreEqual(10, myRectangle.X);

以上内容可能非常令人困惑,并强调了为什么最好将自己的自定义结构创建为不可变结构。

关键字ref的作用类似,允许值类型变量通过引用传递,即调用者的值类型变量的'address'被传递到栈上,而调用者的赋值现在可以直接分配变量。

在 C# 中,传递给方法的所有参数都是按值传递的。
在你大喊大叫之前继续阅读:

值类型的值是被复制的数据,而引用类型的值实际上是一个引用。

因此,当您将对象引用传递给方法并更改该对象时,更改也会反映在方法外部,因为您正在操作分配对象的同一内存。

public void Func(Point p){p.x = 4;}
Point p = new Point {x=3,y=4};
Func(p);
// p.x = 4, p.y = 4

现在让我们看看这个方法:

public void Func2(Point p){
 p = new Point{x=5,y=5};
}
Func2(p);
// p.x = 4, p.y = 4

所以这里没有发生变化,为什么?您的方法只是创建了一个新点并更改了 p 的引用(按值传递),因此更改是本地的。你没有操纵点,你改变了参考,你在本地做了。

ref 关键字来挽救局面:

public void Func3(ref Point p){
 p = new Point{x=5,y=5};
}
Func3(ref p);
// p.x = 5, p.y = 5

同样的情况也发生在你的例子中。您使用新参考分配了一个点,但您是在本地完成的。

默认情况下,c# 按值传递 ALL 参数...这就是为什么 a 和 b 在您的示例中在全局范围内保持不受影响的原因。 Here's a reference 对于那些反对的选民。

通过一个简单的 C# 程序查看行为:

class Program
{
    static int intData = 0;
    static string stringData = string.Empty;

    public static void CallByValueForValueType(int data)
    {
        data = data + 5;
    }

    public static void CallByValueForRefrenceType(string data)
    {
        data = data + "Changes";
    }


    public static void CallByRefrenceForValueType(ref int data)
    {
        data = data + 5;
    }


    public static void CallByRefrenceForRefrenceType(ref string data)
    {
        data = data  +"Changes";
    }


    static void Main(string[] args)
    {
        intData = 0;
        CallByValueForValueType(intData);
        Console.WriteLine($"CallByValueForValueType : {intData}");

        stringData = string.Empty;
        CallByValueForRefrenceType(stringData);
        Console.WriteLine($"CallByValueForRefrenceType : {stringData}");

        intData = 0;
        CallByRefrenceForValueType(ref intData);
        Console.WriteLine($"CallByRefrenceForValueType : {intData}");

        stringData = string.Empty;
        CallByRefrenceForRefrenceType(ref stringData);
        Console.WriteLine($"CallByRefrenceForRefrenceType : {stringData}");

        Console.ReadLine();
    }
}

输出:

要添加更多详细信息...在 .NET 中,C# 方法使用分配给所有参数的默认“按值传递”,引用类型在两种情况下的行为不同。在使用 classes(System.Object 类型)的所有引用类型的情况下,将传入原始 class 或对象的“指针”(指向内存块)的副本,并且分配给方法的参数或变量名。该指针也是一个值,并复制到存储所有值类型的内存中的堆栈中。对象的值不只是存储其指针的副本,它指向原始的 cl;ass 对象。我相信这是一个 4 字节的值。这就是所有引用类型在方法中物理传递和存储的内容。因此,您现在有一个新的方法参数或变量,其分配给它的指针仍然指向方法外部的原始 class 对象。您现在可以使用复制的指针值对新变量做两件事:

  1. 您可以通过在方法内部更改其属性来更改方法外部的原始对象。如果“MyObject”是带有复制指针的变量,您将执行 MyObject.myproperty = 6;,这会更改方法外原始对象内的“myproperty”。您在传递指向原始对象的指针并将其分配给方法中的新变量时执行了此操作。请注意,这确实会更改方法外的引用对象。

  2. 或者,使用复制指针设置变量到一个新对象和新指针,如下所示:MyObject = new SomeObject();在这里,我们销毁了分配给上面变量的旧复制指针并将其分配给一个指向新对象的新指针!现在我们已经失去了与外部对象的连接,只改变了一个新对象。