C#奇怪的对象行为

5

在处理自定义对象时,我注意到了C#中的一些奇怪之处。我相信这只是我理解不足,也许有人可以启发我。

如果我创建一个自定义对象,然后将该对象分配给另一个对象的属性,并且第二个对象修改了分配给它的对象,则即使没有返回任何内容,这些更改也会反映在执行分配操作的同一类中。

需要英文示例?以下是一个例子:

class MyProgram
{
    static void Main()
    {
        var myList = new List<string>();
        myList.Add("I was added from MyProgram.Main().");
        var myObject = new SomeObject();
        myObject.MyList = myList;
        myObject.DoSomething();

        foreach (string s in myList)
            Console.WriteLine(s); // This displays both strings.
    }
}

public class SomeObject
{
    public List<string> MyList { get; set; }

    public void DoSomething()
    {
        this.MyList.Add("I was added from SomeObject.DoSomething()");
    }
}

在上面的示例中,我本以为由于SomeObject.DoSomething()返回void,因此此程序将仅显示"I was added from MyProgram.Main()."。然而,实际上List<string>包含了该行和"I was added from SomeObject.DoSomething()"
以下是另一个示例。在这个示例中,字符串保持不变。有何区别,我漏掉了什么?
class MyProgram
{
    static void Main()
    {
        var myString = "I was set in MyProgram.Main()";
        var myObject = new SomeObject();
        myObject.MyString = myString;
        myObject.DoSomething();

        Console.WriteLine(myString); // Displays original string.
    }
}

public class SomeObject
{
    public string MyString { get; set; }

    public void DoSomething()
    {
        this.MyString = "I was set in SomeObject.DoSomething().";
    }
}

这个程序示例最终显示"我在MyProgram.Main()中设置"。在看到第一个示例的结果后,我本以为第二个程序会用"我在SomeObject.DoSomething()中设置"覆盖原有字符串。我认为我肯定是有什么误解。

8个回答

15

这并不奇怪。当你创建一个类时,你创建了一个引用类型。当你传递对象的引用时,对它们所指向的对象进行的修改将对所有持有该对象引用的人可见。

var myList = new List<string>();
myList.Add("I was added from MyProgram.Main().");
var myObject = new SomeObject();
myObject.MyList = myList;
myObject.DoSomething();
所以在这段代码中,你实例化了一个新的>实例,并将该实例的引用分配给变量myList。然后,你向由myList引用的列表中添加“我是从MyProgram.Main()添加的”。接下来,你将同样的列表引用分配给myObject.MyList(明确一下,myList和myObject.MyList都引用同一个List!)。然后你调用myObject.DoSomething(),它会向myObject.MyList添加“I was added from SomeObject.DoSomething()”。由于myList和myObject.MyList都引用同一个List,因此它们都会看到这个修改。
让我们通过类比来理解。我有一张写有电话号码的纸条。我复印了那张纸并把它给了你。我们两个都有一张写着相同电话号码的纸条。现在我拨打那个号码并告诉电话另一端的人在他们的房子外面挂上一块标语,上面写着“我是从MyProgram.Main()添加的”。你也给那个电话号码打电话,告诉对方在他们的房子外面挂上一块标语,上面写着“我是从SomeObject.DoSomething()添加的”。好吧,现在住在那个电话号码所代表的房子里的人会有两个标语。其中一个标语是:
I was added from MyProgram.Main().

还有另一个说

I was added from SomeObject.DoSomething()

明白了吗?

现在,在你的第二个例子中,情况就有点棘手了。

var myString = "I was set in MyProgram.Main()";
var myObject = new SomeObject();
myObject.MyString = myString;
myObject.DoSomething();

首先创建一个新的string,其值为"I was set in MyProgram.Main()",并将该字符串的引用分配给myString。然后将同一个字符串的引用分配给myObject.MyString。现在,myStringmyObject.MyString都指向那个值为"I was set in MyProgram.Main()"string。但是,当您调用myObject.DoSomething时,会出现这个有趣的行:

this.MyString = "I was set in SomeObject.DoSomething().";

现在,你已经创建了一个新的string,其值为"I was set in SomeObject.DoSomething()"并将对该string的引用赋值给myObject.MyString。请注意,您从未更改myString所持有的引用。因此,现在,myStringmyObject.MyString指向不同的字符串!

让我们再次通过类比来理解。我有一张写有网址的纸条。我复印了这张纸,并将其给了你。我们两个都有一张写有相同网址的纸条。你划掉了那个网址,并写下了另一个网址。这不会影响到我在我的纸条上看到的内容!

最后,在这个帖子中,很多人都在谈论string的不可变性。这里发生的事情与string的不可变性无关。


谢谢!这是迄今为止最清晰、最易理解的答案。那很有道理。 - Chev
有道理。所以如果 DoSomething() 做了 this.MyList = new List<string> { "Added from SomeObject" };,那么我将创建一个新对象,而 Main() 没有引用它。 - Chev
@Alex Ford:没错。在这种情况下,myListmyObject.MyList并不是指向List<string>的不同实例。 - jason
1
+1 对于这个好的解释。我不禁觉得字符串的不可变性在这里起了作用。虽然不是在这个具体的例子中,但如果SomeObject.DoSomething的代码是这样的:this.MyString += "I was updated in SomeObject.DoSomething()."; ,那么你就需要解释新的字符串是通过"拼接"创建的,而第一个字符串并没有被更新。 - tsimbalar

2

这是绝对正确的:

myObject.MyList = myList; 

这一行代码将myList的引用分配给myObject的属性。
为了证明这一点,可以在myListmyObject.MyList上调用GetHashCode()

如果你愿意,我们可以说这是指向同一内存位置的不同指针。


1
你会发现哈希码相等,这意味着我们正在讨论完全相同的对象。不正确。 - jason
为了证明这一点,请在myListmyObject.MyList上调用GetHashCode()。这并不能证明什么,除了它们有相同的哈希码。 - jason
@Jason:如果它没有被覆盖,那就证明你在谈论同一个“对象”(内存位置),随便怎么称呼它。 - Tigran
@Jason:请注意我写的内容:“如果没有被覆盖”。你进行了覆盖!在问题中,我们谈论的是未被覆盖的List<T> BCL工件,因此具有其默认行为。 - Tigran
“ReferenceEquals” 基本上就是 “==”。不,Object.ReferenceEquals 是引用相等。 “==” 可能会被覆盖。 “具有相同ID的”哈希码并不是对象ID。 - jason
显示剩余2条评论

1

一个方法是否返回值,与其内部发生的事情无关。
你似乎对赋值的实际含义感到困惑。

让我们从头开始。

var myList = new List<string>();

在内存中分配一个新的List<string>对象,并将其引用放入myList变量中。
目前,您的代码只创建了一个List<string>实例,但您可以将引用存储在不同的位置。

var theSameList = myList; 
var sameOldList = myList;
someObject.MyList = myList;

现在myListtheSameListsameOldListsomeObject.MyList(它又保存在编译器自动生成的SomeObject的私有字段中)都指向同一个对象

看一下这些:

var bob = new Person();
var guyIMetInTheBar = bob;
alice.Daddy = bob;
harry.Uncle = bob;
itDepartment.Head = bob;

只有一个 Person 实例,但有许多对它的引用。
如果我们的 Bob 变老了一岁,每个实例的 Age 就会增加。
它是同一个对象。

如果一个城市改名了,你会期望所有地图都印上它的新名字。

你觉得奇怪的是:

这些更改反映在执行赋值的同一类中

— 但等等,这些更改并没有被“反映”。底层没有复制。它们存在,因为它是同一个对象,如果你改变它,无论你从哪里访问它,你都访问到它的当前状态。

所以,在你添加一个项目到列表时,不重要的是你在哪里:只要你引用同一个列表,你就会看到添加的项目。

至于你的第二个例子,我看到 Jason 已经为你提供了 比我能给你提供的好得多的解释,所以我不会深入探讨。

如果我说:

  1. .NET 中的字符串是不可变的,由于多种原因,您无法修改 string 实例。
  2. 即使它们是可变的(例如像 List<T> 那样具有通过方法可修改其内部状态),在您的第二个示例中,您没有更改对象,而是更改了引用

    var goodGuy = jack;
    alice.Lover = jack;
    alice.Lover = mike;
    

alice 的情绪变化会让 jack 变成坏人吗?当然不会。
同样地,改变 myObject.MyString 不会影响局部变量 myString。你并没有对字符串本身做任何事情(实际上,你也不能这样做)。


0

你混淆了两种对象类型。 List 是一种字符串类型的列表,这意味着它可以接受字符串 :)

当你调用 Add 方法时,它会将字符串文字添加到其字符串集合中。

在调用 DoSomething() 方法时,与 Main 中使用的相同列表引用也可用于它。因此,当你在控制台打印时,你可以看到两个字符串。


0

别忘了,你的变量也是对象。在第一个例子中,你创建了一个List<>对象并将其分配给你的新对象。你只持有对列表的引用,在这种情况下,你现在持有两个对同一列表的引用。

在第二个例子中,你将一个特定的字符串对象分配给你的实例。


好的,是的,变量就是变量 :) - IanNorton

0

这就是引用类型的行为和预期。myList 和 myObject.MyList 是指向堆内存中同一个 List 对象的引用

在第二个例子中,字符串是不可变的,并且按值传递,因此在该行上:

myObject.MyString = myString;

myString的内容被复制到myObject.MyString中(即通过值传递而非引用传递)。

String类型有些特殊,它既是引用类型又是值类型,并具有不可变性的特殊属性(一旦创建了字符串,就无法更改它,只能创建一个新的字符串。但这在实现中已经对您隐藏了一部分细节)。


“myString”的内容被复制到myObject.MyString(即按值传递而不是按引用传递)。这是完全错误的。 String是引用类型;仅仅因为它们是不可变的并不改变它们是引用类型的基本事实。此外,这里发生的事情与不可变性无关。 - jason
从你的回答中,现在你已经创建了一个新的字符串,其值为“我是在SomeObject.DoSomething()中设置的”。如果这不是不可变性在起作用,那我就不知道什么是了。 - hollystyles
将第一个例子中,在DoSomething方法中的this.MyList.Add("I was added from SomeObject.DoSomething()"); 改为 this.MyList = new List<string> { "I was added from SomeObject.DoSomething()" };,此时 myList 中只包含 "I was added from MyProgram.Main()."myObject.MyList 包含了 "I was added from SomeObject.DoSomething()"。这就是在 string 示例中所发生的事情。请注意,List<string> 是可变的,这与不可变性无关。 - jason
好的,但现在你正在明确地使用“new”关键字。我会认为字符串是引用类型“底层”,但语义始终是字符串按值传递,像值类型一样进行操作(我只在这里谈论.NET)。也许我是错的。 - hollystyles

0
Alex - 你写到 -
在上面的示例中,我本以为由于SomeObject.DoSomething()返回了void,所以这个程序只会显示“I was added from MyProgram.Main()”。然而,实际上List包含了那一行和“I was added from SomeObject.DoSomething()”。
但事实并非如此。函数的void只是意味着该函数不返回任何值,这与你在DoSomething()方法中调用的this.MyList.Add方法无关。你确实有两个指向同一个对象的引用——myList和SomeObject中的MyList。

0
在第一个例子中...你正在使用可变对象,并且它总是通过引用访问。所有对不同对象中MyList的引用都指向同一件事情
在另一种情况下,字符串的行为有些不同。声明一个字符串字面量(即引号之间的文本)会创建一个新的String实例,与原始版本完全分离。您无法修改字符串,只能创建一个新的字符串更新 Jason是正确的,这与字符串的不可变性无关...但是... 我不禁想到字符串的不可变性在这里发挥了作用。不是在这个具体的例子中,但是如果SomeObject.DoSomething的代码是这样的:this.MyString +="I was updated in SomeObject.DoSomething().";,那么你必须解释“连接”创建了新的字符串,而第一个字符串没有被更新。

1
这与string的不可变性无关。 - jason

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