你真的了解C#中的值和引用吗?(上)
术语解释
在阅读本文之前,你需要了解以下这几个术语是不同的:值、引用、值类型、引用类型。
- C#中有三种值(value),分别是值类型实例的值、引用类型实例的值和引用。
- 值类型表达式的值是数据本身。
- 引用类型表达式的值是引用。
- 引用的值是一个数据块,指向引用类型的实例。
注意,上面我说的都是值类型表达式和引用类型表达式,包括局部变量和成员(如字段、属性、索引器)等。现在,我们来考虑以下问题:
- 值类型总是存储在栈上吗?
- 值类型的局部变量总是存储在栈上吗?
- 值类型要么存储在栈上,要么存储在堆上,是这样吗?
- 引用的值是引用类型实例所在的地址吗?
对于上面这些问题,您的答案是什么呢?
误区:值类型到底存储在哪?
在谈到值类型和引用类型的区别时,很多初学者常说值类型分配在方法的调用栈(或线程栈)上,引用类型分配在托管堆上,这种说法是错误的,至少前半部分是错误的。实际上这根本不应该成为值类型和引用类型区别的答案,这是所答非所问。值类型和引用类型的区别在语义层面,与存储位置无关,并不是值类型和引用类型不同的分配方式导致了它们行为上的差异,而是因为值和引用这两种类型在语义上的差异,才导致了他们不同的分配方式。本文只讨论存储位置,不会深入介绍它们的区别。
有些朋友可能会说,详细的分配方式应该是这样的:
- 引用类型的实例总是分配在托管堆上(在栈上至只保留实例的引用)
- 值类型的实例总是分配在它声明的地方(声明为局部变量时被分配在栈上,声明为引用类型成员时则被分配在托管堆上)
具体来讲也就是说,当值类型作为引用类型的私有字段时,它将作为引用类型实例的一部分,也分配在托管堆上。而当引用类型作为值类型的成员变量时,栈上将保留该成员的引用,其实际数据还是保存在堆中。
在C# 2出现之前,这样的说法没有问题。但C# 2引入了匿名方法和迭代器块后,以上说法就过于笼统了,它只看到了代码层面的东西,而没有看到编译器层面的东西。值类型实例作为局部变量不都是分配在栈上。这是因为C#代码中的局部变量,很可能在编译为IL后就不再是局部变量了。比如,如果匿名方法使用了外部变量(外部方法中声明的局部变量),或者迭代器块中声明了变量,那么这些变量将被提升为隐藏类的字段,因此也将分配在堆上。
由此可见,虽然MSDN的文档上也说,“值类型分配在栈上”,但这显然是不合适的。因为
- 这是不正确的。正确的说法应该是:值类型可以存储在栈上。
- 这是无关的。在C#中,存储的种类是隐藏在后台的,是实现细节。
- 这是不完整的。比如引用是什么?它既不是值类型,也不是引用类型的实例。引用也是值,也需要存储在某个地方。
值类型的存储位置
这样,关于值类型的存储位置,正确、完整的说法应该是:对于值类型来说,在微软桌面CLR的C#实现中,如果值类型的实例是局部变量、Lambda表达式或匿名方法中封闭的临时变量,且方法体不是迭代器块,并且JIT不对该值进行寄存,那么这时该值类型将存储在栈上。
够啰嗦吧,其实每一句都必不可少:
- 其他厂商实现C#时,完全可以对临时变量采用不同的分配策略。C#并没有要求必须将局部的值类型变量存储在栈上。
- 微软提供了多个CLI版本,有的用于嵌入式系统,有的用于Web浏览器。这些CLI运行在不同的硬件设备上,其分配策略是未知的。可能这些硬件根本就没有栈,也可能每个线程包含多个栈,也可能所有的东西都分配在堆上。
- Lambda表达式和匿名方法会将局部变量提升为分配在堆上的字段。
- 现在桌面CLR的C#实现中,迭代器块也将局部变量提升为分配在堆上的字段。但这不是必须的。微软也可以选择其他的实现,将其存储在栈上。
- 除了栈和堆以外,还有其他的内存管理方式,但人们总是忽略这一点。比如寄存器,它既不在堆上,也不在栈上。如果寄存器的大小适当,值类型也完全可以位于一个寄存器中。如果有东西存储在栈上是重要的,那么为什么存储在寄存器中就不重要呢?相反,如果JIT编译器的寄存器规划算法是不重要的,那么为什么栈的分配策略就不能是不重要的呢?
存储位置与生存时间
之所以会有这样的误区,是因为人们总是错误地以为类型系统与存储分配策略有关。然而究竟是存储在栈上还是堆上,与要存储的类型没有任何关系。分配机制的选择只与存储所需的生存时间(lifetime)有关。
明确了这些之后,我们可以得出以下结论:
- 值共有三种:值类型实例、引用类型实例和引用。(C#代码不能直接操纵引用类型的实例,但可以通过引用来操纵。在不安全代码下,指针类型被视为值类型,以决定其值的存储需求)
- 值存储在存储位置(storage location)中。
- 程序所操作的所有值都存储在某个存储位置中。
- 所有引用(空引用除外)都指向一个存储位置。
- 所有存储位置都有一个生存时间(在这段时间内,存储位置中的内容是有效的)
- 从某个特定方法的开始,到方法返回或抛出异常为止,这段时间成为方法执行的活动期(activation period)。
- 方法中的代码可以请求一个存储位置(即声明一个局部变量)。如果该存储位置所需的生存时间大于当前方法执行的活动期,那么这个存储位置就称为是长期的(long lived)。否则为短期的(short lived)。(注意,当方法M调用方法N时,M会要求使用传入N的参数和N返回值的存储位置。)
现在我们来看一下实现细节。在微软CLR对C#的实现中:
- 共有三种存储位置:栈位置、堆位置和寄存器。
- 长期的存储位置通常是堆位置。
- 短期的存储位置通常是栈位置或寄存器。
- 在某些情况下,编译器或运行时很难决定某个特定的存储位置是短期的还是长期的。这时,会谨慎地认为它们是长期的。例如,引用类型实例的存储位置总是认为是长期的,尽管可能为短期的。因此,它们总是位于堆上。
这样就可以很自然地得出:
- 就存储来说,引用和值类型实例实质上是一回事,都存储在栈上、寄存器中或堆上,这取决于值的存储是短期的还是长期的。
- 数组元素、引用类型的字段、迭代器块中的局部变量以及Lambda或匿名方法中的非封闭局部变量,它们的生存期都必须比第一次请求这些存储的方法的活动期要长。即使少数情况下它们的生存时间要短于方法的活动期,也很难或根本没法通知编译器。因此不得不保守地对待:所有这些存储都将位于堆上。
- 局部变量和临时值通常可以通过编译时分析,认为在方法活动期之后就没用了,因此为短期的,可以存储在栈上或寄存器中。
一旦你摒弃值的类型与存储有关这个疯狂的想法,一切就会豁然开朗了。其实,你无需知道这些,除非要编写不安全代码或与非托管代码交互。你尽可以让编译器和运行时来管理存储位置的生存时间,这正是它们所擅长的。
误区:引用就是地址
下面我们来看一个关于引用的误区。虽然连《CLR via C#》中都有类似的描述:引用类型的变量保存的是对象的地址,但这是不正确的。引用类型的变量保存的是对象的引用。
引用是一个模糊的概念。指针与引用类似,可以通过跟踪其位置找到一些数据。但指针更智能,比如可以进行数学运算等。指针也更强大,引用能做的事,指针都能做,反之则不然。指针的缺点是对初学者来说太难理解了,很可能搬石头砸自己的脚。
指针是通过地址实现的。地址是一个数字,表示对进程的整个虚地址空间的一个偏移量(offset)。正因为地址是数字,所以才能对指针进行数学运算。
有些时候,指针是无法替代的;而大多数时候,又不需要这么复杂的概念。因此,C#中既包含指针,也包含引用。
C#语言规范中对引用的描述是十分模糊的:引用类型的变量存储了对某个对象的引用。同样,对指针的描述也是很模糊的:指针变量存储了对象的地址。不过,规范中从来没有说过引用就是地址。因此C#的引用是一个十分模糊的概念。你只能对一个引用进行解引用(dereference),或比较两个引用是否相等,除此之外不能进行任何操作。
实际上,在后台,对于托管对象的引用,CLR将其实现为GC所拥有的对象的地址。但这是实现细节。C#引用应该实现为只对GC有意义的不透明的句柄,只是这个句柄恰巧为运行时地址。这是实现细节,你既不应该知道,也不应该依赖于此。
所以,你不能说“引用即地址”这样的话。它并不是必须为地址,实现细节完全有可能改变。而且对初学者来说,你还要解释什么是地址,什么是偏移量。对了解指针的人来说,还会带来困扰:既然引用和指针都是地址,那么应该可以将引用转换为unsafe的指针。但这是不正确的。
综上所述,如果你不是要向别人解释C#的内存模型,请不要使用“引用即地址”这种论调。我们应该说:引用是一个小的数据块,它包含一些信息,CLR可以根据这些信息来找到引用所指向的对象。这很模糊,但却正确,并且没有多余的暗示。
结论
- 值类型可以存储在栈上,也可能不存储在栈上。即便存储在栈上,这也属于实现细节。微软完全可以不这么做。
- 引用在当今的CLR实现为地址,这也是实现细节。
你会发现,我们“无意中”从很多书籍和资料中了解到了CLR的实现细节,如果不是要深入研究这些细节,其实是没有必要知道的。我并不是说这些细节不重要,而是说它们会给我们带来误导,让我们误以为必须是这样。
参考资料
- The Stack Is An Implementation Detail
- The Truth About Value Types
- Memory in .NET - what goes where
- References are not addresses
相关文章
- 你真的了解C#中的值和引用吗?(下)