【问题标题】:Does a generic function implicitly cast value types to objects when checking for null?检查 null 时,泛型函数是否将值类型隐式转换为对象?
【发布时间】:2014-01-24 22:13:22
【问题描述】:

例如下面的代码展示了我的思路:

class Program
{
    static void Main(string[] args)
    {
        int i = 0;
        IsNull(i);  // Works fine

        string s = null;
        IsNull(s);  // Blows up
    }

    static void IsNull<T>(T obj)
    {
        if (obj == null)
            throw new NullReferenceException();
    }

}

还有以下代码:

int i = 0;
bool b = i == null;  // Always false

是否正在进行隐式对象转换?这样:

int i = 0;
bool b = (object)i == null;

【问题讨论】:

  • 仅供参考,你永远不应该抛出NullReferenceException。我也不同意你对IsNull 的命名。它应该类似于ThrowIfNull

标签: c# reference null value-type


【解决方案1】:

xxbbcc 的回答假设 OP 是在问“为什么 0 不等于 null”,这很可能就是问题的全部内容。另一方面,在泛型类型的上下文中,有关装箱的问题通常与泛型类型通过避免装箱而提供的性能优势有关。

在考虑这个问题时,IL 可能会产生误导。它包括一个装箱指令,但这并不意味着值类型的装箱实例实际上将在堆上分配。 IL 将值“装箱”,因为 IL 代码也是通用的;用类型参数替换类型参数是 JIT 编译器的责任。对于不可为空的值类型,JIT 编译器优化了用于装箱和检查结果的 IL 指令,因为它知道结果将始终为非空。

我在示例代码中添加了一个 Thread.Sleep 调用,以便有时间附加调试器。 (如果在 Visual Studio 中使用 F5 启动调试器,某些优化将被禁用,即使它是发布版本)。这是发布版本中的机器代码:

            Thread.Sleep(20000);
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 83 EC 0C             sub         esp,0Ch 
00000006 89 4D FC             mov         dword ptr [ebp-4],ecx 
00000009 83 3D 04 0B 4E 00 00 cmp         dword ptr ds:[004E0B04h],0 
00000010 74 05                je          00000017 
00000012 E8 AD 4B 6A 71       call        716A4BC4 
00000017 33 D2                xor         edx,edx 
00000019 89 55 F4             mov         dword ptr [ebp-0Ch],edx 
0000001c 33 D2                xor         edx,edx 
0000001e 89 55 F8             mov         dword ptr [ebp-8],edx 
00000021 B9 C8 00 00 00       mov         ecx,0C8h 
00000026 E8 45 0E 63 70       call        70630E70 

            int i = 0;
0000002b 33 D2                xor         edx,edx 
0000002d 89 55 F8             mov         dword ptr [ebp-8],edx 
            IsNull(i);  // Works fine
00000030 8B 4D F8             mov         ecx,dword ptr [ebp-8] 
00000033 FF 15 E4 1B 4E 00    call        dword ptr ds:[004E1BE4h] 

            string s = null;
00000039 33 D2                xor         edx,edx 
0000003b 89 55 F4             mov         dword ptr [ebp-0Ch],edx 
            IsNull(s);  // Blows up
0000003e 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
00000041 BA 50 1C 4E 00       mov         edx,4E1C50h 
00000046 FF 15 24 1C 4E 00    call        dword ptr ds:[004E1C24h] 
        }
0000004c 90                   nop 
0000004d 8B E5                mov         esp,ebp 
0000004f 5D                   pop         ebp 
00000050 C3                   ret 

请注意,调用指令对 int 和 string 有不同的目标。他们在这里:

            if (obj == null)
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 83 EC 0C             sub         esp,0Ch 
00000006 33 C0                xor         eax,eax 
00000008 89 45 F8             mov         dword ptr [ebp-8],eax 
0000000b 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
0000000e 89 4D FC             mov         dword ptr [ebp-4],ecx 
00000011 83 3D 04 0B 32 00 00 cmp         dword ptr ds:[00320B04h],0 
00000018 74 05                je          0000001F 
0000001a E8 ED 49 6E 71       call        716E4A0C 
0000001f B9 70 C7 A4 70       mov         ecx,70A4C770h 
00000024 E8 2F FA E9 FF       call        FFE9FA58 
00000029 89 45 F8             mov         dword ptr [ebp-8],eax 
0000002c 8B 45 F8             mov         eax,dword ptr [ebp-8] 
0000002f 8B 55 FC             mov         edx,dword ptr [ebp-4] 
00000032 89 50 04             mov         dword ptr [eax+4],edx 
00000035 8B 45 F8             mov         eax,dword ptr [ebp-8] 
00000038 85 C0                test        eax,eax 
0000003a 75 1D                jne         00000059 
                throw new NullReferenceException();
0000003c B9 98 33 A4 70       mov         ecx,70A43398h 
00000041 E8 12 FA E9 FF       call        FFE9FA58 
00000046 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
00000049 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
0000004c E8 DF 22 65 70       call        70652330 
00000051 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
00000054 E8 BF 2A 57 71       call        71572B18 
        }
00000059 90                   nop 
0000005a 8B E5                mov         esp,ebp 
0000005c 5D                   pop         ebp 
0000005d C3                   ret 

            if (obj == null)
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 83 EC 0C             sub         esp,0Ch 
00000006 33 C0                xor         eax,eax 
00000008 89 45 F8             mov         dword ptr [ebp-8],eax 
0000000b 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
0000000e 89 4D FC             mov         dword ptr [ebp-4],ecx 
00000011 83 3D 04 0B 32 00 00 cmp         dword ptr ds:[00320B04h],0 
00000018 74 05                je          0000001F 
0000001a E8 ED 49 6E 71       call        716E4A0C 
0000001f B9 70 C7 A4 70       mov         ecx,70A4C770h 
00000024 E8 2F FA E9 FF       call        FFE9FA58 
00000029 89 45 F8             mov         dword ptr [ebp-8],eax 
0000002c 8B 45 F8             mov         eax,dword ptr [ebp-8] 
0000002f 8B 55 FC             mov         edx,dword ptr [ebp-4] 
00000032 89 50 04             mov         dword ptr [eax+4],edx 
00000035 8B 45 F8             mov         eax,dword ptr [ebp-8] 
00000038 85 C0                test        eax,eax 
0000003a 75 1D                jne         00000059 
                throw new NullReferenceException();
0000003c B9 98 33 A4 70       mov         ecx,70A43398h 
00000041 E8 12 FA E9 FF       call        FFE9FA58 
00000046 89 45 F4             mov         dword ptr [ebp-0Ch],eax 
00000049 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
0000004c E8 DF 22 65 70       call        70652330 
00000051 8B 4D F4             mov         ecx,dword ptr [ebp-0Ch] 
00000054 E8 BF 2A 57 71       call        71572B18 
        }
00000059 90                   nop 
0000005a 8B E5                mov         esp,ebp 
0000005c 5D                   pop         ebp 
0000005d C3                   ret 

看起来差不多,对吧?但是,如果您先启动进程然后附加调试器,您会得到以下结果:

            Thread.Sleep(20000);
00000000 55                   push        ebp 
00000001 8B EC                mov         ebp,esp 
00000003 50                   push        eax 
00000004 B9 20 4E 00 00       mov         ecx,4E20h 
00000009 E8 6A 0C 67 71       call        71670C78 
            IsNull(s);  // Blows up
0000000e B9 98 33 A4 70       mov         ecx,70A43398h 
00000013 E8 6C 20 F9 FF       call        FFF92084 
00000018 89 45 FC             mov         dword ptr [ebp-4],eax 
0000001b 8B C8                mov         ecx,eax 
0000001d E8 66 49 6C 70       call        706C4988 
00000022 8B 4D FC             mov         ecx,dword ptr [ebp-4] 
00000025 E8 46 51 5E 71       call        715E5170 
0000002a CC                   int         3 

优化器不仅移除了值类型的装箱,而且通过完全移除了对值类型的 IsNull 方法的调用,内联了它。从上面的机器代码中看不出来,但是引用类型对 IsNull 的调用也是内联的。 call 706C4988 指令似乎是 NullReferenceException 构造函数,call 715E5170 似乎是 throw

【讨论】:

  • 我不知道你为什么说我的回答不正确。问题是是否存在隐式对象转换? 答案显然是正确的——box 指令就是证明。在 x86 汇编中没有类型的概念,所以显然那里没有“强制转换”逻辑。消除对象实例是对box 实现的优化。您的答案是正确的,但不是问题的答案。
  • @xxbbcc 我将“隐式转换为对象”表示“在运行时将值的副本物理地放在堆上”。这是人们在讨论装箱时通常关心的问题,也是 .NET 实现泛型类型的主要原因之一。在这种情况下,我的答案是正确的,而你的答案不是。如果问题是关于 C# 和 CLR 类型系统的逻辑,纯粹是抽象的,那么你的答案是正确的,而我的没有抓住重点。也许 user1515024 可以告诉我们打算使用哪个上下文。
  • 嗯,源语言是 C#,语言规则几乎需要装箱操作。您认为人们在这种特殊情况下依靠底层优化 JIT-ter 消除盒装对象实例是否明智?我的意思是,根据您的论点,null0 是一回事,因为 JIT-ter 删除了所有否则的代码。
  • 聪明吗?我不知道。但是鉴于有时在不能限制为引用类型的代码中需要空值检查(我想这就是允许它们的原因),人们应该知道这样的检查不会对值类型产生性能损失。在T Min&lt;T&gt;(T x, T y) where T : IComparable&lt;T&gt; { return y == null ? x == null ? 0 : 1 : y.CompareTo(x) &lt; 0 ? y : x; } 中,您还能如何防止空引用异常?
  • 或者考虑 IDictionary,它不允许空键。如果 null 检查导致值类型键的装箱惩罚,它宁愿破坏类型的目的。
【解决方案2】:

是的,obj 被编译器装箱。这是为您的 IsNull 函数生成的 IL:

.maxstack 8

IL_0000: ldarg.0
IL_0001: box !!T
IL_0006: brtrue.s IL_000e

IL_0008: newobj instance void [mscorlib]System.NullReferenceException::.ctor()
IL_000d: throw

IL_000e: ret

box 指令是进行强制转换的地方。

编译器不知道任何关于T 的具体信息,因此它必须假定它必须是object - .NET 中所有内容的基本类型;这就是为什么它将obj 框起来以确保可以执行空检查。如果您使用type constraint,您可以向编译器提供有关T 的更多信息。

例如,如果您使用 where T : struct,则您的 IsNull 函数将不再编译,因为编译器知道 T 是值类型,而 null 不是值类型的值。

装箱一个值类型实例总是返回一个有效的(非空)对象实例*,所以IsNull 函数永远不会抛出一个值类型。仔细想想,这实际上是正确的行为:数值 0 不是 null - 值类型值不可能是 null

在上面的代码中brtrue.sif(objref!=0) 非常相似——它不检查对象的值(装箱前的值类型值),因为在检查时,它不是打开的值堆栈顶部:它是位于顶部的装箱对象实例。由于该值(它实际上是一个指针)不为空,因此对 null 的检查永远不会返回为真。

*Jon Hanna 在评论中指出,此声明对于 default(Nullable&lt;T&gt;) 不正确,这是正确的 - 对任何 T 装箱此值返回 null

【讨论】:

  • 哦,我希望它会被优化。
  • @MarcinJuraszek 我猜编译器不知道T 的类型是什么,所以它必须将值装箱以确保可以执行空检查。对T 使用类型约束可以解决它,但由于null 不是值类型的有效值,因此代码无法编译。
  • 这是否意味着如果您在泛型上使用 == 运算符,将调用基础对象 equals?即 obj == null 变为 => ((object)obj).equals(null),使方法签名 L bool Equals(object, object)。因此,如果您比较两个覆盖了 Equals 方法的 ,您很容易得到错误的 equals 方法?这样调用 base.Equals,而不是强类型 bool Equals(T a, T b)???
  • @user1515024 如果你看上面的代码,Equals 没有被调用来进行空检查——编译器只是调用brtrue.s 来查看值是否为非空。对于非空的情况,它只是返回,否则它会落入异常。
  • @user1515024 在更一般的情况下,您将调用具有某个值的Equals(),被调用的函数可能是T 基类型上的Equals() 重载。在您的情况下,这将是 object 但您可以使用类型约束来使您的 T 更具体。
【解决方案3】:

这里的两个答案都有其价值,我会给 phoog 打分,因为他们回答了大多数人在询问这个问题时所关心的实际问题(它的变体之前已经出现过)。但也有不完整的地方。

有四种方法可以查看相关代码,所有这四种方法都很重要,而答案只查看了两种(尽管 phoog 对彼此有很多暗示)。

我将从到目前为止被忽略的问题部分开始:

还有以下代码:

int i = 0;`
bool b = i == null;  // Always false`

是否正在进行隐式对象转换?这样:

int i = 0;
bool b = (object)i == null;

嗯,是的,也不是。这取决于我们看的层次,实际上我们要在不同的时间从不同的层次看,所以说这不是学究。

C#有四样东西:

  1. 它本身就是一种计算机语言。我们可以对其进行推理并对其进行推理,并检查某事物是否遵循其规则,以及根据这些规则它意味着什么。
  2. 这是一种生成 CIL 的方式,CIL 本身就是另一种语言,适用于上述内容。
  3. 通过 CIL,它是一种在运行时或通过 Ngen 生成机器代码的方式,Ngen 本身也是一种语言。
  4. 这是一种告诉计算机做某事的方式,这通常是练习的重点。

到目前为止,答案只关注第 2 点和第 3 点,但完整的情况是看所有四个。

而最重要的点实际上是第 1 点和第 4 点。

第 1 点很重要,因为 C# 毕竟是我们正在研究的语言,也是同事最有可能看到的语言。由于编程部分是在指示计算机做某事,部分是在表达自己的意图(中高级编程语言首先是为人服务,其次是计算机),因此实际的源代码很重要。

第 4 点很重要,因为这毕竟是我们的最终目标。这与查看机器代码的汇编不同(正如 phoog 的回答所做的那样),因为机器代码并不是完成哪些更改和优化的最终答案:

  1. CPU 会自行进行优化。这在出现分支时尤其重要。
  2. 当纯粹作为一种理论语言考虑时,两个程序集在处理 CPU 缓存方面可能会有所不同。
  3. 当纯粹作为一种理论语言考虑时,两个程序集可能是等效的,因为其中一个执行未对齐的读取,这会导致性能问题、不正确的结果、异常或死屏。
  4. 当纯粹作为一种理论语言考虑时,两段程序集在性能上是等效的,因为其中一个使用 CPU 恰好比另一个逻辑等效指令执行得更快的指令。
  5. 等等……

现在,说了这么多,在我们现在看到的情况下,机器代码大约是我们需要寻找的关于机器行为的推理。但总的来说,机器代码并不是每次的最终答案。尽管如此,phoog 的回答并不是暗示而不是说明这里的影响。我之所以提到它,是因为我的目标是写关于 phoog 和 xxbbcc 以不同方式正确的不同概念级别。

回到我们的代码bool b = i == null,其中i 的类型是int

在 C# 中,null 被定义为一个文字值,它是所有引用类型和可为空值类型的默认值。它可以与引用相等的任何值进行比较 - 也就是说,可以使用null 作为 X 的值来询问“X 和 Y 是否是同一个实例”的问题,如果 Y 不是实例,则答案为真,并且否则为假。

要与值类型进行比较,我们必须将值类型装箱,就像我们必须将值类型视为引用类型的任何情况一样。

如果值类型是可为空的值类型,并且为空(HasValue 返回 false),则装箱会产生空引用。在所有其他情况下,装箱值类型会创建对堆上新对象的引用,该类型为 object,它引用相同的值并且可以取消装箱返回。

因此,在 C# 概念级别的答案是“是的,i 被隐式装箱以创建一个新对象,然后将其与 null 进行比较 [因此将始终返回 false]”。

在下一个级别,我们有 CIL。

在 CIL 中,null 是一个自然字长(在 32 位进程中为 32 位,在 64 位进程中为 64 位)位模式全零的值(因此 brfalse, @ 987654332@ 和 brnull 都只是同一字节码的别名),这是托管指针、指针、自然整数和任何其他提供地址的方法的有效值。

同样在 CIL 中,装箱是对等效的装箱类型进行的;不仅仅是object,还有boxed type of intboxed type of float等。这对C#是隐藏的,因为它不是很有用(除了可以在object和unbox 回到等效的未装箱类型),但在 CIL 中更精确地定义,因为它需要执行“如何对许多不同类型进行装箱?”。

CIL 中的等效代码至少为:

ldc.i4.0                   // push the value 0 onto the stack.
box [mscorlib]System.Int32 // pop the top value from the stack, box it as boxed Int32,
                           // and push that boxed value onto the stack.
ldnull                     // push null (all zeros) onto the stack
ceq                        // pop the top two values onto the stack, if they are equal
                           // push 1 onto the stack, otherwise push 0 onto the stack.
//Instructions that actually act on "b" here, probably a stloc so it can be loaded as needed later.

我说“至少”,因为可能会为相关方法从本地数组加载和存储一些内容。

因此,在 CIL 级别,答案也是“是的,i 被隐式装箱以创建一个新对象,然后将其与 null 进行比较 [因此将始终返回 false]”。

但是,这实际上并不是要生成的 CIL。在发布版本中,它将是:

ldc.i4.0                   // push the value 0 onto the stack.
//Instructions that actually act on "b" here, probably a stloc so it can be loaded as needed later.

也就是说,它会将总是产生假的代码优化为只产生假的代码。即使在调试版本中,我们也可能会进行一些优化。

但是当我说在 CIL 中将整数与 null 进行比较的代码涉及装箱时,我并没有说谎。确实如此,但 C# 编译器可以看到这段代码是在浪费时间,只需将其替换为将 false 加载到 b 的代码即可。事实上,如果以后不使用b,它可能会删掉整个内容。 (相反,如果稍后使用i,它仍然会在某个时候将0 加载到其中,而不是像上面的示例中那样将其删除)。

这是我们第一次在这里遇到编译器优化问题,是时候研究一下这意味着什么。

编译器优化归结为一个简单的观察;如果一段代码可以重写为另一段代码,其效果与从外部看到的相同,但速度更快和/或使用更少的内存和/或导致更小的可执行文件,那么只有白痴会抱怨如果你生产了更快/更小/更轻的版本。

这个简单的观察因两件事而变得复杂。首先是在更快的版本和更轻的版本之间进行选择时该怎么做。一些编译器提供了权衡这些选择的选项(大多数 C++ 编译器都有),但 C# 没有。另一个是“从外面看”是什么意思?它曾经很简单“产生的任何输出、与其他进程的交互或对 volatile* 变量的操作”。当您有多个线程时,它会变得有点复杂,其中一个正在执行垃圾收集,所有这些当然是彼此“外部”的,因为这使得优化的情况(尤其是如果它涉及重新排序)可能会影响观察到的内容。不过,这些都不适用于这里。

C# 编译器并没有做很多优化,因为抖动无论如何都会做很多,所以优化的缺点(1.所有工作都有可能出现错误,所以如果你不做特定的优化你不会有与该优化相关的错误。2. 你优化的东西越多,你就越容易混淆开发人员) 如果给定的优化将由下一层完成,那么它变得更重要。

不过,它确实做了那个优化。

确实,它会优化掉整个部分。拿下代码:

public static void Main(string[] args)
{
  int i = 0;
  if(i == null)
  {
    Console.WriteLine("wow");
    Console.WriteLine("didn't expect that");
  }
  else
  {
    Console.WriteLine("ok");
    Console.WriteLine("expected");
  }
}

编译它,然后将它反编译回 C#,你会得到:

public static void Main(string[] args)
{
  Console.WriteLine("ok");
  Console.WriteLine("expected");
}

因为编译器可以删除它知道永远不会被命中的整个代码段。

因此,虽然在 C# 和 IL 中,将值类型与 null 进行比较涉及装箱,但 C# 编译器将删除这种毫无意义的垃圾,实际上不会发生装箱。它还会发出警告 CS0472,因为如果您在代码中添加了明显毫无意义的杂乱无章的内容,您的想法可能有问题,您应该查看它并弄清楚您真正想要做什么。

此时也值得看看如果iint? 类型会发生什么;可以装箱为空。仍然进行了优化:

  1. 大多数情况下,装箱和比较被调用HasValue 字段所取代。这比拳击更有效率。
  2. 有时编译器甚至可以优化(由于知道相关值)。

(在这个阶段组装的事情已经无关紧要,因为拳击和比较已经被删除了)。

现在,如果我们有一个泛型方法(或泛型类的方法)同时接受值和引用类型参数的情况,C# 编译器无法完成这种优化,因为泛型方法没有实例化到它们的编译时特定的特殊形式(与其他类似的 C++ 模板不同),但在 jitting 时。

因此,生成的 IL 将始终包含装箱操作(除非有其他原因导致即使在引用类型的情况下也可以将其优化掉)。

不过,抖动与 C# 编译器在我们的第一个示例中所做的相同,即装箱一个不可为空的值类型永远不会产生空值这一事实。它在优化方面也比 C# 编译器更积极。

这是我们得到 phoog 在他们的回答中描述的行为的地方:在为值类型类型参数生成的代码中,装箱操作被完全删除(对于引用类型参数,装箱操作本质上是禁止的)操作,也被删除)。检查被删除,因为答案是已知的,并且实际上只有在检查返回 true 时才会执行的整个代码部分也被删除。

phoog 未检查的案例是可空值类型。在这里,装箱和比较至少将被替换为对HasValue 的调用,而这又将被内联到结构中内部字段的读取。可能(如果知道该值永远不会为 null,或者如果知道它始终为 null)将被删除,以及无论如何都不会执行的一整段代码。

总结

您的问题后面还有两个更具体的问题,您可能对其中一个或两个感兴趣。

问题 1:我对 C# 作为一种语言的功能感兴趣,我想知道就 C# 而言,是否将不可为空的值类型与该值类型的空框进行比较。

答案 1:是的,与 null 的比较只能使用引用类型(包括装箱值类型)进行,因此始终存在装箱操作。

问题 2:我有将值与 null 进行比较的通用代码,因为我只想在它是引用类型或可空值类型时执行某些操作,并且如果值等于 null。在比较的类型是值类型的情况下,我的代码会支付装箱操作的性能损失吗?

答案 2:不。在 C# 编译器无法优化其生成的 IL 中的代码的情况下,抖动仍然可以。对于不可为空的值类型,整个装箱操作、比较和代码路径仅在与 null 的比较返回 true 时才采用,都将从生成的机器代码中删除,从而从计算机所做的工作中删除。此外,如果它是一个可以为空的值类型,则装箱和比较将被替换为检查值中指示HasValue 是否为真的字段。

*请注意,volatile 的定义与 .NET 中的定义相关,但并不相同,原因也与对多线程执行的更大支持如何使事情变得复杂有关。 1960 年代。

【讨论】:

  • +1 用于对各种场景的详细分析。但是请注意,我的答案中的 IL 来自具有所有优化的发布版本。 C# 编译器根本无法优化输出中的 box,因为它不知道 T 将是什么(因为它不会生成泛型函数的所有特化)。
  • @xxbbcc 确实,这就是我从“现在,如果我们有泛型方法的情况……”开始的内容,IL 无法优化它,因为它还不是不会产生影响(根据您的回答),但抖动可以(根据 phoog 的回答)。对两者的理解很重要,就像 IL 之上的级别和机器代码之下的级别一样;因此我说你和 phoog 的答案都是正确的。
  • 啊,是的,你是对的。我通读了你的答案,但我一定跳过了那段。
  • @xxbbcc 写长答案但相对较快的问题(而不是写、离开、返回、重新起草一篇更彻底的文章)很容易有一个失败的流程确保所有重要的部分都突出,所以这个缺陷很可能是我的。
  • +1 表示查看 C# 的 4 种方式,这是一个很好的框架讨论方式。但是,如果您查看可空值类型的历史(例如,请参阅blogs.msdn.com/b/somasegar/archive/2005/08/11/450640.aspx),您会发现使用属性来确定它们的空性并不是一种无需对值进行装箱的优化。相反,如果((object)(int?)null) 产生了int? 的(非空)盒装实例,装箱行为会导致挫败感,就像在第一个实现中那样。
猜你喜欢
  • 1970-01-01
  • 2013-05-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多