C#中的string是引用类型吗?

195

我知道在C#中 "string" 是引用类型。这是在MSDN上的描述。然而,以下代码却不能按照预期工作:

class Test
{
    public static void Main()
    {
        string test = "before passing";
        Console.WriteLine(test);
        TestI(test);
        Console.WriteLine(test);
    }

    public static void TestI(string test)
    {
        test = "after passing";
    }
}

输出应该是"before passing" "after passing",因为我将字符串作为参数传递,并且它是引用类型,第二个输出语句应该认识到TestI方法中的文本已更改。 但是,我得到"before passing" "before passing",使它看起来像是按值而不是按引用传递。我知道字符串是不可变的,但我不明白这如何解释这里发生的事情。我错过了什么?谢谢。


请查看Jon下面提到的文章。你提到的行为也可以通过C++指针来复现。 - Sesh
MSDN上也有非常好的解释。 - Dimi_Pel
11个回答

242

字符串的引用是按值传递的。通过值传递引用和通过引用传递对象之间有很大的区别。不幸的是,这两种情况下都使用了“引用”这个词。

如果你确实通过引用传递字符串的引用,它将按照预期工作:

using System;

class Test
{
    public static void Main()
    {
        string test = "before passing";
        Console.WriteLine(test);
        TestI(ref test);
        Console.WriteLine(test);
    }

    public static void TestI(ref string test)
    {
        test = "after passing";
    }
}

现在你需要区分对所引用对象进行更改和更改变量(例如参数)以使其引用不同对象之间的区别。我们无法更改字符串,因为字符串是不可变的,但我们可以使用StringBuilder来演示:

using System;
using System.Text;

class Test
{
    public static void Main()
    {
        StringBuilder test = new StringBuilder();
        Console.WriteLine(test);
        TestI(test);
        Console.WriteLine(test);
    }

    public static void TestI(StringBuilder test)
    {
        // Note that we're not changing the value
        // of the "test" parameter - we're changing
        // the data in the object it's referring to
        test.Append("changing");
    }
}

查看我的有关参数传递的文章以获取更多详细信息。


2
同意,只是想澄清使用 ref 修饰符也适用于非引用类型,即两者是相当独立的概念。 - eglasius
2
@Jon Skeet,我很喜欢你文章中的旁注。你应该将其作为你的回答“引用(referenced)”出来。 - Nithish Inpursuit Ofhappiness

41
如果我们需要回答这个问题:String是一个引用类型,并且它的行为像一个引用。我们传递的参数持有的是一个引用,而不是实际的字符串。问题出在函数中:
public static void TestI(string test)
{
    test = "after passing";
}

参数test持有字符串的引用,但实际上是一个副本。我们有两个指向这个字符串的变量。因为任何与字符串相关的操作都会创建一个新的对象,所以我们让我们的局部副本指向新的字符串。但原始的test变量没有改变。

建议的解决方案是在函数声明和调用中放置ref,因为这样我们不会传递test变量的值,而是只传递对它的引用。因此,在函数内部的任何更改都将反映在原始变量中。

最后我想重申一点:String是一个引用类型,但由于它是不可变的,因此test = "after passing";这行代码实际上创建了一个新的对象,我们复制的test变量被改变以指向新的字符串。


29

就像其他人所说的,.NET中的String类型是不可变的,并且它的引用是按值传递的。

在原始代码中,一旦执行了这行代码:

test = "after passing";

那么test不再是指向原始对象了。我们创建了一个新的String对象,并将test分配为引用托管堆上的该对象。

我觉得很多人在这里被绊倒了,因为没有看到明显的构造函数提醒他们。在这种情况下,它是在幕后发生的,因为String类型在构造时有语言支持。

因此,这就是为什么对test的更改在TestI(string)方法的作用域之外是不可见的——我们通过值传递了该引用,现在该值已经发生了变化!但如果String引用按引用传递,那么当引用发生变化时,我们将在TestI(string)方法的作用域之外看到它。

在这种情况下,需要使用refout关键字。我觉得out关键字可能更适合这种特定情况。

class Program
{
    static void Main(string[] args)
    {
        string test = "before passing";
        Console.WriteLine(test);
        TestI(out test);
        Console.WriteLine(test);
        Console.ReadLine();
    }

    public static void TestI(out string test)
    {
        test = "after passing";
    }
}

1
ref = 在函数外初始化,out = 在函数内初始化,或者说; ref 是双向的,out 是只输出的。所以应该使用 ref。 - Paul Zahra
@PaulZahra:out 需要在方法内被赋值才能编译通过。ref 没有这样的要求。此外,out 参数在方法外部被初始化 - 此答案中的代码就是反例。 - Derek W
应该澄清 - out 参数可以在方法外初始化,但不一定要这样做。在这种情况下,我们希望初始化 out 参数以展示 .NET 中 string 类型的性质。 - Derek W

28
"一图胜千言"。
我这里有一个简单的例子,类似于你的情况。
string s1 = "abc";
string s2 = s1;
s1 = "def";
Console.WriteLine(s2);
// Output: abc

以下是发生的事情:

enter image description here

  • 第一行和第二行: s1s2 变量引用同一个 "abc" 字符串对象。
  • 第三行: 因为 字符串是不可变的, 所以 "abc" 字符串对象不会修改自己(变成 "def"),而是创建了一个新的 "def" 字符串对象,然后 s1 引用它。
  • 第四行: s2 仍然引用 "abc" 字符串对象,所以输出就是那样。

9

实际上,对于任何对象来说都是一样的,即在C#中作为引用类型和按引用传递是两个不同的概念。

这将起作用,但无论类型如何都适用:

public static void TestI(ref string test)

关于字符串作为引用类型,它也是特殊的一种。它被设计为不可变,所以它的所有方法都不会修改实例(它们返回一个新实例)。它还有一些额外的东西用于提高性能。


8
以下是内容翻译:

以下是一种理解值类型、按值传递、引用类型和按引用传递之间差异的好方法:

变量是一个容器。

值类型变量包含一个实例,而引用类型变量包含指向其他地方存储的实例的指针。

修改值类型变量会改变它所包含的实例,而修改引用类型变量会改变它所指向的实例。

不同的引用类型变量可以指向相同的实例,因此通过任何指向该实例的变量都可以对其进行更改。

按值传递的参数是一个新的容器,其中包含了内容的副本。按引用传递的参数是原始容器及其原始内容。

当值类型参数按值传递时:

重新分配参数的内容不会影响外部范围,因为容器是唯一的。 修改参数不会影响外部范围,因为实例是独立的副本。

当引用类型参数按值传递时:

重新分配参数的内容不会影响外部范围,因为容器是唯一的。 修改参数的内容会影响外部范围,因为复制的指针指向共享实例。

当任何参数按引用传递时:

重新分配参数的内容会影响外部范围,因为容器是共享的。 修改参数的内容会影响外部范围,因为内容是共享的。

总之:

字符串变量是引用类型变量。因此,它包含指向其他地方存储实例的指针。 按值传递时,它的指针被复制,因此修改字符串参数应该会影响共享实例。 但是,一个字符串实例没有可变属性,因此不能更改字符串参数。 按引用传递时,指针的容器是共享的,因此重新分配仍然会影响外部范围。


6

针对好奇的读者并为了完整的讨论:

是的,String是引用类型。
unsafe
{
     string a = "Test";
     string b = a;
     fixed (char* p = a)
     {
          p[0] = 'B';
     }
     Console.WriteLine(a); // output: "Best"
     Console.WriteLine(b); // output: "Best"
}

但请注意,这个更改只在一个不安全块中起作用!因为字符串是不可变的(来自MSDN):
字符串对象的内容在创建后无法更改,尽管语法使其看起来好像可以这样做。例如,当您编写此代码时,编译器实际上会创建一个新的字符串对象来保存新的字符序列,并将该新对象分配给b。然后,字符串“h”就有资格进行垃圾回收。
string b = "h";  
b += "ello";  

请记住:

虽然字符串是引用类型,但相等性运算符(==!=)是定义为比较字符串对象的值而不是引用。


5
上面的答案很有帮助,我想举一个例子,清晰地展示了当我们传递没有 ref 关键字的参数时会发生什么,即使该参数是引用类型:
MyClass c = new MyClass(); c.MyProperty = "foo";

CNull(c); // only a copy of the reference is sent 
Console.WriteLine(c.MyProperty); // still foo, we only made the copy null
CPropertyChange(c); 
Console.WriteLine(c.MyProperty); // bar


private void CNull(MyClass c2)
        {          
            c2 = null;
        }
private void CPropertyChange(MyClass c2) 
        {
            c2.MyProperty = "bar"; // c2 is a copy, but it refers to the same object that c does (on heap) and modified property would appear on c.MyProperty as well.
        }

1
这个解释对我来说是最好的。所以基本上,尽管变量本身是值类型或引用类型,但我们仍然通过值传递所有内容,除非我们使用ref关键字(或out)。在我们日常编码中,这并不突出,因为我们通常不会在方法内部将对象设置为null或不同的实例,而是设置它们的属性或调用它们的方法。在“string”的情况下,将其设置为新实例总是发生,但新建不可见,这给未经训练的人带来了错误的解释。如果有错误请纠正我。 - Ε Г И І И О

0

尝试:


public static void TestI(ref string test)
    {
        test = "after passing";
    }

3
您的答案应该不仅包含代码,还应该解释为什么它有效。 - Charles Caldwell

0

绕过字符串行为的另一种方法是使用仅包含一个元素的字符串数组,并操纵该元素。

class Test
{
    public static void Main()
    {
        string[] test = new string[1] {"before passing"};
        Console.WriteLine(ref test);
        TestI(test);
        Console.WriteLine(ref test);
    }

    public static void TestI(ref string[] test)
    {
        test[0] = "after passing";
    }
}

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