.NET参数传递 - 按引用传递与按值传递的区别

18

我试图验证自己对C#/.NET/CLR如何处理值类型和引用类型的理解。我已经读了很多矛盾的解释,仍然不太清楚。

这是我今天的理解,请纠正我如果我的假设是错误的。

像int等值类型存在于栈上,引用类型存在于托管堆上,但是如果一个引用类型有一个类型为double的实例变量,它将与其对象一起存在于堆上。

第二部分是我最困惑的。

考虑一个简单的名为Person的类。

Person有一个名为Name的属性。

假设我在另一个类中创建了Person的一个实例,我们称之为UselessUtilityClass。

考虑以下代码:

class UselessUtilityClass
{
   void AppendWithUnderScore(Person p)
   {
     p.Name = p.Name + "_";
   }
}

然后我们在某个地方执行:

Person p = new Person();
p.Name = "Priest";
UselessUtilityClass u = new UselessUtilityClass();
u.AppendWithUnderScore(p);

Person是一个引用类型,当传递给UselessUtilityClass时 - 这就是我发疯的地方... 实例化Person的变量p是按传递的,这意味着当我写p.Name时,我会看到"Priest_"。

然后,如果我写了:

Person p2 = p;

我执行以下操作:

p2.Name = "Not a Priest";

然后像下面这样写 p 的名称,就可以得到 "Not a Priest"。

Console.WriteLine(p.Name) // will print "Not a Priest"
这是因为它们是引用类型并指向内存中相同的地址。
我的理解正确吗?
我认为人们说“所有.NET对象都是按引用传递”的时候存在一些误解,这与我的想法不符。我可能错了,这就是为什么我来到Stackers的原因。
6个回答

30
值类型如int等存储在堆栈上。引用类型存储在托管堆上,但是如果引用类型例如具有double类型的实例变量,则它将与对象一起存储在堆上。
不,这是不正确的。一个正确的陈述是“Microsoft CLI实现和C#实现中,既不直接位于迭代器块中,也不是lambda或匿名方法的闭合外部变量的值类型的本地变量和形式参数分配在执行线程的系统堆栈上。”。
没有任何版本的C#或CLI使用系统堆栈进行任何操作的要求。当然,我们之所以这样做,是因为对于既不直接位于迭代器块中,也不是lambda或匿名方法的闭合外部变量的值类型的本地变量和形式参数来说,这是一种方便的数据结构。
请参阅我的文章,以了解关于(1)为什么这是一种实现细节,(2)我们从这种实现选择中获得的好处,以及(3)希望进行这种实现选择的限制驱动到语言设计中的讨论。

http://blogs.msdn.com/ericlippert/archive/2009/04/27/the-stack-is-an-implementation-detail.aspx

http://blogs.msdn.com/ericlippert/archive/2009/05/04/the-stack-is-an-implementation-detail-part-two.aspx

Person是一个引用类型,当传递给UselessUtilityClass时——这就是我疯狂的地方...

深呼吸。

变量是存储位置。每个存储位置都有关联的类型。

与引用类型相关联的存储位置可以包含对该类型对象的引用,也可以包含空引用。

与值类型相关联的存储位置始终包含该类型的对象。

变量的值是存储位置的内容。

变量p是Person引用的实例,按值传递,

变量p是一个存储位置。它包含对Person实例的引用。因此,变量的值是对Person的引用。该值——对实例的引用——被传递给被调用者。现在,另一个变量,您令人困惑地称之为“p”,包含相同的值——该值是对特定对象的引用。

现在,也可以传递对变量的引用,这让很多人感到困惑。更好的思考方式是当您说

void Foo(ref int x) { x = 10; }
...
int p = 3456;
Foo(ref p);

这意味着"x是变量p的别名"。也就是说,x和p是同一个变量的两个名称。因此,无论p的值是什么,x的值也是相同的,因为它们是同一存储位置的两个名称。

现在理解了吗?


8

值类型,如int等,存储在栈上,引用类型存储在托管堆上。但是,如果引用类型有一个double类型的实例变量,它将与其对象一起存储在堆上。

正确。

您还可以将其描述为实例变量是分配给堆上实例的内存区域的一部分。

变量p是Person引用的实例,按值传递

该变量实际上不是类的实例。该变量是指向类实例的引用。引用按值传递,这意味着您传递引用的副本。此副本仍然指向与原始引用相同的实例。

我认为人们说.NET中所有对象都是按引用传递时存在一些误解

是的,这绝对是一个误解。所有参数都是按值传递的(除非您使用refout关键字将它们作为引用传递)。传递引用并不等同于按引用传递

引用是值类型,这意味着您传递的所有内容都是值类型。您从未传递过对象实例本身,而是传递其引用。


关于堆栈和堆的说法并不完全正确。请看我的答案以获得解释。 - Rune FS
作为值类型与其存储位置无关,唯一的区别在于值类型具有复制语义。其余部分是实现定义的。 - wj32
@Guffa:“值类型(如int等)存储在堆栈上,引用类型存储在托管堆上,但是如果引用类型例如具有double类型的实例变量,则它将与其对象一起存储在堆上。正确。”首先,这取决于实现。其次,在Microsoft实现中并不是真的;例如,捕获的本地变量。 - jason
@Jason:是的,这取决于具体实现,但我想让你展示任何没有采用这种方式的实现。讨论任何不存在的理论和不切实际的实现都是毫无意义的。 - Guffa
你认为这个语句正确的主要原因是错误的,因为在值类型的本地变量被捕获的情况下,它并不成立。 - jason

1

也许这些例子可以向您展示引用类型和值类型之间的区别,以及按引用传递和按值传递之间的区别:

//Reference type
class Foo {
    public int I { get; set; }
}

//Value type
struct Boo {
    //I know, that mutable structures are evil, but it only an example
    public int I { get; set; }
}


class Program
{
    //Passing reference type by value
    //We can change reference object (Foo::I can changed), 
    //but not reference itself (f must be the same reference 
    //to the same object)
    static void ClassByValue1(Foo f) {
        //
        f.I++;
    }

    //Passing reference type by value
    //Here I try to change reference itself,
    //but it doesn't work!
    static void ClassByValue2(Foo f) {
        //But we can't change the reference itself
        f = new Foo { I = f.I + 1 };
    }

    //Passing reference typ by reference
    //Here we can change Foo object
    //and reference itself (f may reference to another object)
    static void ClassByReference(ref Foo f) {
        f = new Foo { I = -1 };
    }

    //Passing value type by value
    //We can't change Boo object
    static void StructByValue(Boo b) {
        b.I++;
    }

    //Passing value tye by reference
    //We can change Boo object
    static void StructByReference(ref Boo b) {
        b.I++;
    }

    static void Main(string[] args)
    {
        Foo f = new Foo { I = 1 };

        //Reference object passed by value.
        //We can change reference object itself, but we can't change reference
        ClassByValue1(f);
        Debug.Assert(f.I == 2);

        ClassByValue2(f);
        //"f" still referenced to the same object!
        Debug.Assert(f.I == 2);

        ClassByReference(ref f);
        //Now "f" referenced to newly created object.
        //Passing by references allow change referenced itself, 
        //not only referenced object
        Debug.Assert(f.I == -1);

        Boo b = new Boo { I = 1 };

        StructByValue(b);
        //Value type passes by value "b" can't changed!
        Debug.Assert(b.I == 1);

        StructByReference(ref b);
        //Value type passed by referenced.
        //We can change value type object!
        Debug.Assert(b.I == 2);

        Console.ReadKey();
    }

}

1

当你传递一个对象时,它会创建一个引用的副本 - 不要将其与对象的副本混淆。换句话说,它是创建了第二个引用,指向同一个对象,然后传递该引用。

当你通过 ref/out 关键字传递时,它会传递与调用者使用的相同引用,而不是创建引用的副本。


0

“按值传递”这个术语有点误导人。

你正在做两件事:

1)将引用类型(Person p)作为参数传递给方法

2)将引用类型变量(Person p2)设置为已经存在的变量(Person p)

让我们分别看一下每种情况。

情况1

你创建了一个指向内存中某个位置x的Person p。当你进入方法AppendWithUnderScore时,你运行以下代码:

p.Name = p.Name + "_"; 

方法调用会创建一个新的本地变量p,它指向内存中相同的位置:x。因此,如果您在方法内修改p,则更改p的状态。

但是,在此方法内部,如果您设置p = null,则不会将p外部的值设置为null。这种行为称为“按值传递”

情况2

这种情况与上述情况类似,但略有不同。当您创建一个新变量p2 = p时,您只是说p2引用了p位置处的对象。因此,如果您现在修改p2,则正在修改p,因为它们引用相同的对象。如果您现在说p2 = null,则p现在也将为null。请注意这种行为与方法调用内部的行为之间的差异。该行为差异概述了在调用方法时“按值传递”的工作原理。


0
规范没有说明在哪里分配值类型和对象。在C#中,将所有内容都分配到堆上是正确的实现方式,有时候除了你编写的情况之外,也会分配值到堆上。
例如:int i = 4; Func dele = ()=> (object)i; 这段代码会导致(i的副本)被分配到堆上,因为编译器会将其转换为类的成员,尽管它没有声明为这样。除此之外,你的观点基本上是正确的。并不是所有东西都作为引用传递。更接近事实的说法是每个参数都按值传递,但仍然不完全正确(例如ref或out)。

不,编写该代码不会将变量的值装箱。它会在闭包中放置一个对它的引用,但这完全是不同的事情。只有在使用委托时,值才会被装箱。 - Guffa
@Guffa,嗯,我没有写关于拳击的任何内容 :),由于引用堆栈分配的值是一个可怕的想法,因此存储对其的引用将导致被引用的值在堆上分配而不是在堆栈上分配。唯一需要引用堆栈分配的值的版本是来自另一个生命周期比被引用值更短的堆栈分配的值。 - Rune FS

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接