为什么结构体需要装箱?

25
在C#中,任何用户定义的struct都会自动成为System.StructSystem.ValueType的子类,而System.StructSystem.ValueTypeSystem.Object的子类。但当我们将一些struct分配给对象类型的引用时,它会被装箱。例如:
struct A
{
    public int i;
}

A a;
object obj = a;  // boxing takes place here

所以我的问题是:如果 ASystem.Object 的后代,编译器不能将其向上转型为对象类型而不是装箱吗?

6个回答

46
一个结构体是值类型,System.Object 是引用类型。值类型和引用类型在运行时被存储和处理的方式不同。要将值类型视为引用类型,需要对其进行装箱。从底层角度来看,这包括将值从原本所在的堆栈复制到新分配的堆上内存中,该内存还包含对象头。引用类型需要其他标头来解决其vtable以启用虚方法调度和其他引用类型相关功能(请记住,在堆栈上的结构体只是一个值,并且它没有任何类型信息;它不包含任何类似于vtable的内容,并且不能直接用于解析动态分派方法)。此外,要将某物视为引用类型,必须使用对它的引用(指针),而不是其原始值。

那么我的问题是 - 如果A是System.Object的子类,编译器不能将其向上转换为对象类型而不是装箱吗?

从更低的层面来看,一个值并不继承任何内容。实际上,正如我之前所说的那样,它并不是真正的对象。A从System.ValueType继承,后者又从System.Object继承,这是在您的编程语言(C#)的抽象级别上定义的。C#确实很好地隐藏了装箱操作,因此您没有明确提到装箱值,所以您可以认为编译器已经为您“向上转换”了结构体。它为值提供了继承和多态行为的幻觉,而它们直接提供多态行为所需的工具都没有被它们提供。

6
回答不错,但有几个小问题。首先,堆栈与堆对于装箱来说是无关紧要的;值类型不需要在堆栈上,即使它们在堆上,它们也会被装箱。其次,虚方法是无关紧要的;在结构体上分派虚方法从不需要装箱!由于所有结构体都是密封的,Jitter 在 JIT 时具有足够的信息来精确确定调用哪个方法。 - Eric Lippert
1
Eric:我就知道你会对那个发表评论。我提到堆栈比喻主要是为了指出你需要有某种指针来指向它。关于你的第二点,我想你指的是“约束”IL指令。但我的意思是在一个结构体上调用像ToString这样的东西,将其强制转换为System.Object或者说,在一个静态类型为IComparable的装箱整数上调用IComparable.CompareTo。我认为这里需要进行vtable查找,不是吗? - Mehrdad Afshari
对装箱值类型上方法的调用被视为“vtable”调用,是的;Jitter 没有理由认为它是特殊的。 (实际上,调用接口方法是否是 C++ 编译器编写者严格考虑的“vtable”调用是一个有趣的问题,但与此问题不太相关。)但许多人错误地认为,在未装箱的结构上调用接口方法实际上会将结构装箱,然后进行虚拟调用;当方法已在元数据中命名时,Jitter 为什么要费那么大劲呢? - Eric Lippert
没错。我曾经和一些人讨论过2.ToString()是否会将2装箱。顺便问一下,有没有可能仅通过C#代码来证明这个事实?我的意思是,除了反汇编或者深入挖掘WinDbg之外... System.Object没有提供一个可以改变装箱值的方法,我不知道如何证明这一点。 - Mehrdad Afshari
(是的,“.constrained”前缀指令有助于提示JIT编译器,表明特定调用可以跳过装箱操作。如果您对接口分派与虚方法调用实际上不同的工作方式感兴趣,这里有一篇旧文章解释了它:http://msdn.microsoft.com/en-us/magazine/cc163791.aspx#S12) - Eric Lippert
嗯,有趣的问题。我暂时想不到什么。 - Eric Lippert

19
这是我的理解方式。考虑一个包含32位整数的变量的实现方式。当作为值类型处理时,整个值适合32位存储。这就是值类型:存储只包含组成值的位,没有多余的内容。

现在考虑包含对象引用的变量的实现方式。变量包含一个“引用”,可以用许多方式实现。它可以是指向垃圾收集器结构的句柄,也可以是托管堆上的地址等等。但是它是一些允许你找到对象的东西。这就是引用类型:与引用类型变量相关联的存储包含一些位,允许你引用对象。

显然这两个东西完全不同。

现在假设你有一个object类型的变量,并希望将int类型变量的内容复制到其中。怎么办?组成整数的32位不是这些“引用”中的任何一个,它只是一个包含32位的桶。引用可能是指向托管堆的64位指针,或者是指向垃圾回收器数据结构的32位句柄,或者是你能想到的任何其他实现,但32位整数只能是32位整数。

所以,在这种情况下,你所要做的就是装箱(boxing)整数:你创建一个包含整数存储空间的新对象,然后存储对新对象的引用。

只有在想要(1)统一类型系统和(2)确保32位整数占用32位内存时才需要装箱。如果愿意放弃其中任何一个,就不需要装箱;但我们不愿放弃这些,所以装箱是我们被迫接受的方法。


Eric,像往常一样,解释得非常好!不过当你说“如果想要一个统一的类型系统,装箱是必需的”时,能否再详细解释一下?我不明白“装箱”如何使类型系统统一。谢谢! - SolutionYogi
再仔细考虑一下,您是在暗示C#更喜欢一种系统,开发人员可以将值类型类比于引用类型,而无需了解它们实际上是如何由.NET CLR实现的吗?为了实现这一点,“装箱”就成为了必要的恶?如果选择避免“装箱”,开发人员与值类型/引用类型的交互会是什么样子呢? - SolutionYogi
3
让我重新表述一下。三个期望的特点:(1)值类型仅包含其数据,因此与引用类型具有不同的表示形式,(2)所有值可以转换为统一的公共类型——对象,以及(3)值类型永远不需要“装箱”。这三个期望的特点是相互矛盾的;最多只能拥有其中的两个。我们选择了(1)和(2);不拥有(3)是你所付出的代价。 - Eric Lippert
1
同样地,你可能希望你的相机是(1)便宜、(2)轻便、(3)拍摄照片质量好。你只能选择其中两个,你选择哪两个取决于你,但你不能同时得到这三个条件。 - Eric Lippert
几个小问题:(1)装箱的必要性取决于是否希望将值类型与对象引用中的有效位数接近,以便作为对象传递。如果对象引用是64位,但永远不会需要超过2^48个不同的对象实例,如果需要的话,可以避免对所有预定义的32位及更小的值类型进行装箱,以及范围在+/- 2^512之间的所有双精度数、范围在+/- 2^55之间的所有Int64和范围在0..2^56-1之间的所有UInt64,以及可能还有其他一些类型。更大的数字仍然需要进行装箱。 - supercat
此外,(2) 虽然可以定义一个框架,使独立的堆对象可以提供值类型或引用类型语义,但 .net 框架使用了一种稍微简化的模型,其中所有堆对象都具有引用类型语义。这大大简化了某些操作(例如,它意味着结构分配或 MemberwiseClone 可以执行简单的按位复制),但迫使许多类在程序员所做的事情更适合值语义时也要使用引用类型语义。 - supercat

5
虽然.NET的设计者确实不需要包括装箱,但C#语言规范第4.3节非常好地解释了其背后的意图:
装箱和拆箱使类型系统具有统一视图,其中任何类型的值最终都可以被视为对象。
因为值类型不是引用类型(System.Object最终是引用类型),所以存在装箱的行为,以便拥有一个统一的类型系统,其中任何东西的值都可以表示为对象。
这与C++不同,因为类型系统不是统一的,没有所有类型的公共基类型。

1
严格来说,并非所有内容都源自对象。在类型方面,指针类型既不可转换为对象,也不派生自对象。类型参数类型和接口类型不派生自对象,但始终可转换为对象。非指针类型的总是派生自对象。除了引用类型的null值,它不派生自任何东西,也不是对象。引用指向空值时,它并未指向对象;这样的引用可以转换为对象,但并不派生自对象。 - Eric Lippert
@Eric Lippert:已修改答案以反映您的关注。 - casperOne
根据C#规范,所有值类型都是从引用类型(System.ValueTypeSystem.Enum)派生的,但它们不是引用类型。这是荒谬的,并让我怀疑C#规范的准确性。@EricLippert:我很想听听您对我的答案的看法。 - stakx - no longer contributing
2
@stakx:我认为你的回答更加混淆了问题,而不是解释清楚。当然,所有值类型都是从引用类型派生出来的;这里没有任何矛盾。派生关系并不要求表示中存在任何共性。是的,CLI规范关注的是表示,但对于C#程序员来说,这是一个无关紧要的实现细节。 - Eric Lippert
1
@EricLippert:如果 T 真正从 U 派生,那么从 TU 的转换是保持标识的事实不是一个“无关紧要的实现细节”。C# 规范可能使用“派生”的定义,其中包括其转换不是保持标识的类型,但这并不意味着这样的定义有所帮助。 - supercat

1

struct 是一种按值传递的类型,因此在转换为引用类型时需要进行装箱。 struct 派生自 System.ValueType,后者又派生自 System.Object

仅仅因为 struct 是对象的子类,并不意味着太多的东西... 因为 CLR 在运行时处理 structs 与引用类型有所不同。


0
在问题得到回答之后,我将介绍与该主题相关的一个小“技巧”:
结构体可以实现接口。如果你将一个值类型传递给一个期望该值类型实现的接口的函数,通常该值类型会被装箱。使用泛型可以避免装箱:
interface IFoo {...}
struct Bar : IFoo {...}

void boxing(IFoo x) { ... }
void byValue<T>(T x) : where T : IFoo { ... }

var bar = new Bar();
boxing(bar);
byValue(bar);

0
如果struct ASystem.Object的后代,编译器不能进行向上转型而不进行装箱,这是因为根据C#语言的定义,“向上转型”在这种情况下就是装箱。
C#语言规范(第13章)包含了所有可能的类型转换目录。所有这些转换都按特定方式分类(例如数字转换、引用转换等)。
  1. 类型 S 到其超类型 T 存在隐式类型转换,但这仅适用于模式 "从类类型 S 到引用类型 T"。因为你的 struct A 不是类类型,所以这些转换不能应用于你的示例中。

    也就是说,A (间接) 派生自 object 的事实(虽然正确)在这里根本不相关。相关的是 A 是一个结构体值类型。

  2. 唯一存在的匹配模式 "从值类型 A 到其引用超类型 object" 的转换被归类为装箱转换。因此,每个从 structobject 的转换都被定义为装箱。


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