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

Posted

技术标签:

【中文标题】C# 按值传递与按引用传递【英文标题】:C# pass by value vs. pass by reference 【发布时间】:2015-06-18 19:57:10 【问题描述】:

考虑以下代码(我有意将 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 上的相同操作在全局范围内将 a 替换为 b

示例:

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 指向内存中的相同引用...有人可以澄清我哪里出错了吗?

【问题讨论】:

您的示例都没有使用refout,因此它们都按值传递(引用)。 【参考方案1】:

回复:OP 的断言

众所周知(至少在 C# 中),当您通过引用传递时,该方法包含对被操作对象的引用,而当您通过值传递时,该方法复制被操作的值...

TL;DR

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

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

如果一个变量是通过value传递的:

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

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

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

详细说明

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

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

除非您通过引用显式传递(任何)变量,否则通过使用 outref 关键字,参数在 C# 中由 value 传递,无论变量是否为值类型或引用类型。

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

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

但是,当通过value 传递reference 类型(例如自定义类,如您的MyPoint 类)时,将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 - 相反,我们应该在返回值中传递返回数据,如果要返回多个变量,则使用Tuple 或自定义class 或@ 987654355@,其中包含所有此类返回变量。 在被调用的方法中更改(“变异”)共享值(甚至引用)变量是不受欢迎的,尤其是函数式编程社区,因为这会导致棘手的错误,尤其是在使用多个线程时。相反,优先考虑不可变变量,或者如果需要突变,则考虑更改变量的(可能很深)副本。您可能会发现有关“纯函数”和“常量正确性”的主题很有趣。

编辑

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

按值传递(引用类型):

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

    您的初始代码(我称之为main)在托管堆上分配两个MyPoint 对象(我称之为point1point2),然后分配两个局部变量引用@987654363 @ 和b,分别引用这些点(浅蓝色箭头):
MyPoint a = new MyPoint  x = 1, y = 2 ; // point1
MyPoint b = new MyPoint  x = 3, y = 4 ; // point2

    Replace&lt;Point&gt;(a, b) 的调用然后将两个引用的副本推入堆栈(红色箭头)。方法Replace 将这些视为两个参数,也称为ab,它们仍然分别指向point1point2(橙色箭头)。

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

通过引用传递:

如果我们将调用更改为Replace&lt;T&gt;(ref T a, T b),然后将main 更改为通过引用传递a,即Replace(ref a, b)

    和以前一样,在堆上分配两个点对象。

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

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

    这是调用函数,maina 变量引用现在更新为引用point2。现在mainReplace 都可以看到重新分配给a 所做的更改。现在没有对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;

现在,当执行返回到main 时,所有对point1point2 的引用,包括main's 变量ab,现在将在下次读取时“看到”更改点的xy 的值。您还会注意到变量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 关键字的作用类似,允许通过引用传递值类型变量,即调用者的值类型变量的“地址”被传递到堆栈上,现在可以直接对调用者的赋值变量进行赋值.

【讨论】:

作为一个初学者,我需要读几遍才能理解它。谢谢你的图表。 在 OP 的 MyPoint 类中,xy 的类型为 int,即 value type。如果我们想设计一个方法来修改 MyPoint 对象的 xy 值,例如 Transpose(Mypoint p),您需要像这样声明 prefTranspose(ref MyPoint p) 对吗?跨度> @MinhTran - 不,不需要特殊修饰 - 如果 MyPoint 是引用类型(类)并且它的属性/字段是可变的(即可以更改),那么 MyPoint 实例实际上由调用者和被调用者共享(在堆上),并且调用者或被调用者对字段的任何更改都将被共享此变量的每个人看到。您可能缺少的是对象(类实例)上的字段也将在堆上。这不同于将标量值类型作为参数传递给方法。 This might help @StuartLC 在您的最后一个代码 sn-p 中,您已在“调用范围”的堆上初始化 myRectangle,并将其传递给 SomeFunc(Rectangle),它为 Rectangle.X 分配值 @ 987654437@。我认为Rectangle.XSystem.Int32 或其他一些整数值类型。由于myRectangle.XmyRectangle 的可变字段,它也存在于堆上,因此SomeFunc() 内的赋值aRectangle.X = 99 应该在调用范围内可见。然而Assert.AreEqual(10, myRectangle.X) 表示调用范围没有观察到变化。 所以这就是为什么它如此复杂。 Rectangle 是一个值类型的结构。所以它被复制到堆栈上,这就是为什么没有反映变化的原因。我建议您使用类和结构尝试自己的示例。但我建议在实践中尽可能让类和结构不可变,避免改变共享实例的诱惑,避免通过引用传递。它会节省很多错误。【参考方案2】:

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

void Replace<T>(ref T a, T b)

    a = b;

这会得到你想要的结果,但实际上有点奇怪。

【讨论】:

【参考方案3】:

在 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 Pointx=5,y=5;

Func2(p);
// p.x = 4, p.y = 4

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

还有 ref 关键字拯救了一天:

public void Func3(ref Point p)
 p = new Pointx=5,y=5;

Func3(ref p);
// p.x = 5, p.y = 5

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

【讨论】:

【参考方案4】:

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

阅读 Jon Skeet 的 this great piece 以获得更深入的理解。

【讨论】:

【参考方案5】:

看一下 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();
    

输出:

【讨论】:

【参考方案6】:

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

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

【讨论】:

对象本身没有被复制,但是对它的引用被复制了。因此,如果您更改该类中的任何内容,则该更改将在您退出该函数时持续存在。【参考方案7】:

你说的不对。

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

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

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

【讨论】:

【参考方案8】:

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

【讨论】:

我认为很多初学者的困惑是即使引用也是按值传递的【参考方案9】:

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

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

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

【讨论】:

以上是关于C# 按值传递与按引用传递的主要内容,如果未能解决你的问题,请参考以下文章

C#在方法调用中,参数按值传递与按引用传递的区别是啥?

java的按值传递与按引用传递

哪个更快?按引用传递与按值传递 C++

Common Lisp:按值传递与按引用传递[重复]

Java:按值传递与按引用传递

cpp►引用变量,按值与按引用传递返回及销毁