C# 7.0 及更高版本中的 C# 引用重述






5.00/5 (53投票s)
回顾 C# 7 前后关于类型系统(特别是引用类型和类引用行为)我们需要了解的内容,并纠正过程中常见的误解。
热身练习
以下代码会输出什么?提示:数组是引用类型。
private static void Main()
{
var intArray = new int[] { 10, 20 };
One(intArray);
Console.WriteLine(string.Join(", ", intArray));
intArray = new int[] { 10, 20 };
Two(intArray);
Console.WriteLine(string.Join(", ", intArray));
intArray = new int[] { 10, 20 };
Three(ref intArray);
Console.WriteLine(string.Join(", ", intArray));
}
private static void One(int[] intArray)
{
intArray[0] = 30;
}
private static void Two(int[] intArray)
{
intArray = new int[] { 40, 50 };
}
private static void Three(ref int[] intArray)
{
intArray = new int[] { 60, 70 };
}
C# 类型:引用、值和原始类型
简单来说,类型就是描述变量可以容纳什么。
误解:new
关键字意味着我们正在创建一个引用类型。错误!这或许是由于原始类型别名提供的语法(而且许多开发人员不经常使用 struct
,因此不会接触到使用 new 来处理它们)。
原始类型是编译器直接支持的类型,以及一系列运算符和它们之间的转换。它们映射到框架类型,例如:
int
映射到System.Int32
float
映射到System.Single
C# 编译器将为以下行生成相同的 IL
System.int32 a = new System.int32();
int a = 0;
后者是原始类型提供的别名,它掩盖了这些值类型对 new
关键字的使用。
下面是 C# 中类型的简要概述。框架类库 (FCL) 当然还有更多内容,我将不尝试全部涵盖。
值类型
如果您编辑了文件的副本,所做的更改不会影响原始文件。
这就是值类型在 C# 中的传递方式——作为数据的副本。给定
int originalInt = 0;
originalInt
的值是 0
,这是我们打算存储在该变量中的数据。
当我们把 originalInt
作为值传递给方法(稍后会详细介绍)或将其赋值给新变量时,我们会创建一个副本。副本中对值的任何更改都不会改变原始值,例如:
int originalInt = 0;
int copiedInt = originalInt;
// Output: original=0,copied=0C
onsole.WriteLine($"original={originalInt},copied={copiedInt}");
copiedInt += 500; // Add 500 onto the copied int
// Output: original=0,copied=500
Console.WriteLine($"original={originalInt},copied={copiedInt}");
500
只被加到了副本上。originalInt
仍然是 0
。
关于继承的说明
为了增加混淆,所有类型,包括 System.ValueType
,都继承自 System.Object
。别想多了——这是为了通过装箱使它们能够像引用类型一样工作。开发人员实际上无法在我们的代码中继承值类型。
总结
- 值类型中存储的“值”就是实际数据。
- 默认情况下,传递它的行为是我们正在创建该值的副本。
- 它们支持接口,但没有继承。
引用类型
我们从文件链接的比喻开始。
这个比喻中的“链接”在 C# 中就是引用。
引用类型仍然有值——只是这个“值”是实际数据所在位置的内存地址。
默认情况下,我们不直接操作该值。每当我们访问变量时,我们都会获取该值引用的内存位置中存储的数据(Mads Torgersen 用指针世界的例子来比喻——可以看作是自动解引用)。
因此,当您在代码中传递这些类型时,它会复制这个引用,而不是数据。考虑以下代码
var original = new SimpleObject() { Number = 100 };
var copied = original;
// Outputs: Original=100,Copied=100
Console.WriteLine($"Original={originalExample},Copied={copied}");
// Add 500 onto the Number property in the copied object reference
copied.Number += 500;
// Outputs: Original=600,Copied=600
Console.WriteLine($"Original={original},Copied={copied}");
我们在内存中创建了一个新的 SimpleObject
,并将其内存地址/位置存储在 original 的值中。
当我们创建它的副本时,我们复制的仍然是值类型的值,就像我们处理值类型一样。
var copied = original;
但正在复制的值是这个内存位置。
现在 copied
和 original
都引用同一个内存位置。当我们修改 copied
引用的数据(其中包含的属性 Number
)时,我们也在改变 original
。
现在变得有趣了,这让我们离理解热身练习中代码的行为又近了一步。
var original = new SimpleObject() { Number = 100 };
SimpleObject copied = original;
// Outputs: Original=100,Copied=100
Console.WriteLine($"Original={original.Number.ToString()}," +
$"Copied={copied.Number.ToString()}");
// This time, replace the original with a new object
original = new SimpleObject() { Number = 300 };
// Outputs: Original=300,Copied=100
Console.WriteLine($"Original={original.Number.ToString()}," +
$"Copied={copied.Number.ToString()}");
请记住——引用类型中存储的“值”是对对象内存地址的引用。现在,在创建副本后,我们又创建了一个 SimpleObject
,new
运算符返回其内存地址,我们将其存储在 original
中。
copied
仍然指向 original
曾经指向的对象。感到困惑吗?让我们回到我们的比喻。
Tom 改变了他拥有的链接的副本,这不会影响 Kate 的副本。因此,现在他们的链接指向不同的文件。
总结 / 需要了解
- 引用类型中的“值”是实际数据的内存位置。
- 每当我们访问引用类型变量时,我们都会获取其作为值的内存位置中存储的数据。
- 默认情况下,当我们传递它时,我们只是复制这个引用值。对对象内部数据的更改对原始值和任何副本都可见。对实际内存位置值的更改不会在该值的副本之间共享。
按值传递给方法
默认情况下,不带额外关键字的传递方式如下:
private static void One(int[] intArray)
您可能 99% 的时间都在这样做,无需思考。
没有新的知识点,因此不需要代码示例。这将展示上面已经涵盖的所有行为。
- 值类型会传递其值的副本,并且对该副本的更改不会被调用者看到。
- 引用类型会将其对象的引用作为值传递,对对象的更改会被调用者看到;但如果它在方法内部被赋值为一个全新的对象,调用者不会看到这种变化。
按引用传递给方法
我们有 ref
、out
和 C# 7 中的 in
关键字用于按引用传递。
当我们了解按引用传递的含义时,我们只看 ref
。
请注意,编译器会坚持要求关键字同时出现在调用和方法中,这样我们的意图在两端都很清晰。
SomeMethod(ref myObjectInstance);
//…
private static void SomeMethod(ref MyObject myObjectInstance)
{
//…
值类型行为
如果您按引用传递值类型,则不会发生复制,被调用的方法可以更改数据,这些更改对调用者可见。
private static void ChangeNumb(ref int i)
{
i = 2;
}
static void Main(string[] args)
{
int i = 1;
ChangeNumb(ref i);
// Output: i = 2
Console.WriteLine($"i = {i.ToString()}");
}
误解:按引用传递值类型会导致装箱。错误!装箱发生在将值类型转换为引用类型时。按引用传递值类型只是创建了一个别名,这意味着我们会有两个变量表示同一个内存位置。
引用类型行为
我在热身测试中偷懒了——我按引用传递了一个引用类型,这并不常见。
误解:传递引用类型与按引用传递相同。错误!通过同时尝试这两种方式更容易解释,并观察它与将引用类型作为值传递有何不同。
回到文件链接的比喻,看看当我们按引用将引用类型传递给方法时会发生什么。
我没有给 Tom 和 Kate 我链接的副本,而是给了他们对链接本身的访问权限。所以,和以前一样,他们都看到了文件的更改;但现在,如果他们中的任何一个人将链接更改为新文件,他们都会看到这个变化。
因此,使用 ref
关键字就像告诉编译器不要解引用/从内存位置获取数据,而是传递地址本身,这类似于创建别名。
从上面代码生成的 IL 中我们可以看到,使用了 stind
操作码将结果存储回按地址传递的 int32
的地址中(注意 &
)。
.method /*06000006*/ private hidebysig static void
ChangeNumb(/*08000004*/ int32& i) cil managed
{
// ...
IL_0006: stind.i4
// ...
}
总结 / 需要了解
ref
修饰符允许传递并修改一个值——调用者可以看到更改。- 当与引用类型一起使用时,“值”是实际的内存位置,因此它可以改变调用者的变量在内存中的指向。
当引用类型遇到 Ref Locals 时
在 C# 7 中,我们有了 ref
locals。它们与 ref returns 一起引入,以支持对更高效、更安全代码的推动。
我想将它们与引用类型一起使用,以便我们有机会再次体会当我们将引用类型按引用传递时会发生什么。
完整的代码示例
var original = new SimpleObject() { Number = 100 };
ref SimpleObject copied = ref original;
// Outputs: Original=100,Copied=100
Console.WriteLine($"Original={original.Number.ToString()}," +
$"Copied={copied.Number.ToString()}");
// This time, replace the copied with a new object
copied = new SimpleObject() { Number = 300 };
// Outputs: Original=300,Copied=300
Console.WriteLine($"Original={original.Number.ToString()}," +
$"Copied={copied.Number.ToString()}");
注意原始对象现在如何被副本替换。在 IL 中,我们可以看到 ref
locals 使用 ldloca
(对于值类型和引用类型)——我们复制的是存储值的实际地址(记住,引用类型的值是对象所在内存地址)。
SimpleObject copied = original;
// Emits the following IL:
ldloc.0 // Load value of local variable, original onto stack
stloc.2 // Pop the value we just put on the stack into copied
ref SimpleObject copied = ref original;
// Emits the following IL:
ldloca.s // Load the address of local variable, original onto the stack
stloc.1 // Pop the value we just put on the stack into copied
通过使用 ref
,我们实际上是创建了一个包含该地址的值的别名——两者中的任何一个的变化,包括将引用指向新对象,都会影响两者。
Ref Returns
试想一下,我有一个大型 struct
数组,而不是我下面使用的 int
。
我现在可以直接返回对 int
数组中某个元素的引用,而无需任何复制。
private static ref int ElementAt(int[] array, int position)
{
return ref array[position];
}
static void Main(string[] args)
{
int[] intArray = { 2, 2, 2 };
ref int arrayElement = ref ElementAt(intArray, 2);
arrayElement = 4;
Console.WriteLine("[{0}]", string.Join(", ", intArray));
}
返回 ref
的一个陷阱是作用域。快速浏览一下,您会看到我简要地介绍了一下堆栈和堆栈帧,如果您在这方面遇到困难。最终,当一个方法返回时,您将失去堆栈上的任何内容,并丢失对堆上任何内容的引用(GC 会回收它)。考虑到这一点,您只能 ref
返回在调用者作用域中可见的内容。您可以看到上面我正在返回对调用站点上数组中某个索引的引用。
Ref Locals & Returns - 对引用类型有用吗?
真正的价值在于避免复制大型值类型——它们补充了现有的按引用传递功能,增加了我们已经从引用类型中获得的(缺失的)类引用行为。
我们可以开始使用 ref
returns 和 ref
locals,但如果您在更高级别的堆栈上工作,预期用例将有限。许多我们使用的库已经或将要利用这些以及新的 Span<T>
工作,因此理解它们如何协同工作是有用的。
对于引用类型,与按 ref
传递给方法一样,您将给调用者访问实际内存位置的权限,并让他们更改它。如果有人遇到了一些实际场景,请分享,以便我添加到这里。
堆栈、堆和寄存器在哪里发挥作用?
误解:值类型总是分配在堆栈上。错误!如果我们开始讨论分配发生在哪里,那么更正确的说法是,意图更像是
- 短暂生命周期的对象被分配在寄存器或堆栈上(任何在方法内部声明的对象都是如此)。
- 而长生命周期的对象则分配在堆上。
编辑:Eric Lippert 建议,我们应该考虑“短期分配池和长期分配池……无论该变量包含一个 int
还是一个指向对象的引用”。
大多数情况下,我们不应过多关注特定 JIT 如何分配,而应确保我们了解这两种类型传递方式的差异。不过,.NET 路线图一直专注于“与您的月度账单直接相关的低效率”,并推出了 Span<T>
和 ref struct
,它们是仅限堆栈的。
出于兴趣,以下是一些我们可以预期值类型会被堆分配的场景:
- 声明为类的字段
- 在数组中
- 已装箱
- 静态
- 在 yield return 块中的局部变量
- 在 lambda / 匿名方法中
堆栈(或堆)分配到底意味着什么?
这个堆栈的东西……实际上就是由帧组成的那同一个调用堆栈,负责执行您的代码。我现在不打算教您堆栈数据结构是什么。
堆栈帧代表一个方法调用,包括任何局部变量。这些变量存储了我们上面已经详细讨论过的,值类型或引用类型的值。
一个帧只在方法的生命周期内存在;因此,帧中的任何变量也只存在直到方法返回。
堆栈和堆之间的一个大区别是,堆上的对象可以在我们退出函数后继续存在,如果它有来自其他地方的引用。因此,考虑到传递对象引用可能会让它们永远存活,我们可以安全地说,所有引用类型都可以被认为是长期的,对象/数据将被分配在堆上。
误解:整数数组 int[]
中的整数将被分配到堆栈上。错误。值类型嵌入在它们的容器中,因此它们将与堆上的数组一起存储。
强制不可变性,现在我们正在传递更多引用
out
和 ref
生成几乎相同的 IL,唯一的区别是,编译器强制执行正确的代码,负责初始化被引用的对象。
out
– 调用者不必初始化值。如果他们初始化了,则在调用方法时会被丢弃。被调用的方法必须写入它。ref
– 调用者必须初始化值。
这对于避免复制值类型非常有用,但如何防止被调用的方法进行不必要的修改?C# 7 引入了 in
修饰符。它之所以得名,是因为它是 out
的反义词(因为它使引用(别名)变为只读;调用者必须初始化值)。
void DoWork(in BigStruct bigStruct)
{
//...
反之亦然的等效项,即 return ref
,是新修饰符:ref readonly
。
这是来自 readonly ref 提案中的不可变数组示例。
struct ImmutableArray<T>
{
private readonly T[] array;
public ref readonly T ItemRef(int i)
{
// returning a readonly reference to an array element
return ref this.array[i];
}
}
现在我们仍然可以获得数组元素的引用而无需复制,但又没有完全访问该位置的危险。
ImmutableArray<int> immutableArray = new ImmutableArray<int>();
ref readonly int elementRef = ref immutableArray.ItemRef(2);
简要介绍装箱
您可以从值类型转换为引用类型,然后再转换回来。它可以是隐式或显式的,通常在将值类型传递给接受 object 类型的参数时看到。
{
int i = 4;
object o;
o = i; // implicit boxing
o = (object)i; // explicit boxing by casting
MyMethod(i);
}
// Implicit box
static void MyMethod(object o) {}
以及拆箱
int unboxedInt = (int) o;
隐式装箱的一个有趣案例是处理实现接口的 struct
。请记住,接口是引用类型。
Stuct Thing : IThing {
//...
IThing myThing = new Thing()
这将导致装箱。
误解:当值类型被装箱时,对装箱的引用的更改会影响值类型本身。错误!您可能会想到当我们使用 ref local 或按引用传递来创建别名时。堆上装箱副本的更改不会影响值类型实例,反之亦然。
当 C# 编译器发现任何隐式或显式装箱时,它将发出特定的 IL。
IL_007c: box
当 JIT 编译器看到此指令时,它将分配堆存储,并将值类型的内容包装在一个“框”中,该框指向堆上的对象。
如果您小心,装箱不会影响性能。问题出现在大量数据集的迭代过程中。装箱本身需要额外的 CPU 时间,并且增加了垃圾收集器的压力。
误解:在热身练习中,数组被放在堆上,其中的 int
对象也被放在堆上。因此,int
对象必须被装箱。错误!
请记住,我们反驳了所有值类型都放在堆栈上的误解。考虑到这一点,这并不意味着放在堆上的 int
对象就被装箱了。看代码
int[] intArray = {10, 20};
如果这段代码在方法内部,那么堆上会分配一个新的数组对象,并有一个指向它的引用存储在堆栈上。int
对象 10
和 20
也会被分配到堆上,并与数组一起。
热身答案
30, 20
10, 20
60, 70
摘要
- 值类型中的“值”就是实际数据。
- 默认情况下,当我们传递值类型时,我们是在复制实际的值。
- 引用类型中存储的“值”,是对内存中数据所在位置的引用。
- 每当我们访问引用类型变量时,我们都会获取其作为值的内存位置中存储的数据。
- 默认情况下,当我们传递引用类型时,我们只是复制这个引用值。对对象内部数据的更改对原始值和任何副本都可见。对实际内存位置值的更改不会在该值的副本之间共享。
ref
修饰符允许传递并修改一个值——调用者可以看到更改。当与引用类型一起使用时,“值”是实际的内存位置,因此它可以改变调用者的变量在内存中的指向。- 除本文讨论的内容外,C# 7 还引入了通过
ref
返回的方式。它还提供了readonly
关键字和in
修饰符来帮助强制不可变性。
留一些作业,因为我没地方写了
- 正确处理引用类型和值类型的质量
- 何时使用
struct
而非类 string
的区别- 扩展方法
ref
- 只读
struct
- 可空值类型,并展望可空引用类型
来源
谁知道呢?我经常研究内部机制并阅读大量文档,所以不确定所有信息都来自哪里。现在都在我的脑子里了。可能来自
- 我看过的 Mads 或 Skeet 的任何讲座
- Eric Lippert 的著作
- Ben Watson 的《Writing High Performance .NET Code》
- Jeffrey Richter 的《CLR Via C#》
- Sasha Goldshetin 的《Pro .NET Performance》
- 可能还有很多来自 MS 博客和 MS 在 github.com 上的存储库。
也发布在我的博客上 http://benhall.io/a-reintroduction-to-csharp-references/。