【问题标题】:Where are generic methods stored?泛型方法存储在哪里?
【发布时间】:2017-01-04 10:29:37
【问题描述】:

我在 .ΝΕΤ 中阅读了一些关于泛型的信息,并注意到一件有趣的事情。

例如,如果我有一个泛型类:

class Foo<T> 
{ 
    public static int Counter; 
}

Console.WriteLine(++Foo<int>.Counter); //1
Console.WriteLine(++Foo<string>.Counter); //1

Foo&lt;int&gt;Foo&lt;string&gt; 两个类在运行时是不同的。但是当非泛型类具有泛型方法的情况下呢?

class Foo 
{
    public void Bar<T>()
    {
    }
}

很明显只有一个Foo 类。但是Bar 方法呢?所有泛型类和方法在运行时都使用它们使用的参数关闭。这是否意味着Foo 类有许多Bar 的实现,以及有关此方法的信息存储在内存中的什么位置?

【问题讨论】:

  • 您所说的“有关此方法的信息”究竟是什么意思?
  • 泛型是编译器的东西。实际上,您将获得多个类/方法。
  • @Sinatr,我认为情况并非如此。泛型类/方法作为泛型编译为 IL。在运行时(通过反射),您可以选择任何类型的参数。
  • @Sinatr:不是真的。编译器只会发出一个类/方法。运行时将为每个构造类型分配一组静态字段;至于方法,JIT 编译器可以做任何它认为合适的事情。我相信当有不同的类型参数是 reference 类型时,它们可以共享代码 - 但当它们是值类型时则不能。
  • @Sinatr:不,说实话,这并不是更好。如果它只是“编译器的东西”,您不会期望在编译器输出中看到它。目前尚不清楚您要说什么 - 我怀疑如果您进一步澄清您要说的内容,至少有一部分可能是错误的。

标签: c# .net generics


【解决方案1】:

As opposed to C++ templates,.NET 泛型是在运行时计算的,而不是在编译时。从语义上讲,如果你用不同的类型参数实例化泛型类,它们的行为就好像它是两个不同的类,但实际上,编译的 IL(中间语言)代码中只有一个类。

泛型类型

当你use Reflection 时,相同泛型类型的不同实例之间的差异变得明显:typeof(YourClass&lt;int&gt;)typeof(YourClass&lt;string&gt;) 不同。这些被称为构造的泛型类型。还有一个typeof(YourClass&lt;&gt;) 代表泛型类型定义。这里有一些 further tips 关于通过反射处理泛型的内容。

当您instantiate a constructed generic class 时,运行时会动态生成一个专门的类。它与值类型和引用类型的工作方式之间存在细微差别。

  • 编译器只会在程序集中生成一个泛型类型。
  • 运行时会为您使用的每个值类型创建一个单独的泛型类版本。
  • 运行时为泛型类的每个类型参数分配一组单独的静态字段。
  • 由于引用类型具有相同的大小,运行时可以重用它在您第一次将其与引用类型一起使用时生成的专用版本。

通用方法

对于generic methods,原理相同。

  • 编译器只生成一个泛型方法,即泛型方法定义
  • 在运行时,方法的每个不同特化都被视为同一类的不同方法。

【讨论】:

  • 链接的文章似乎与您的回答相矛盾。
  • @OwenPauling 我编辑了答案以澄清。有什么矛盾?
  • 您之前的回答指出“在后台,只有一个 [class]”,而文章指出“运行时生成 Stack 类的专门版本”。
  • @OwenPauling 啊,好的。我的意思是编译的 IL 代码中只有一个类。我希望现在很清楚。 :)
  • 非常感谢您的回答!现在对我来说更清楚了。
【解决方案2】:

首先,让我们澄清两件事。这是一个泛型方法定义:

T M<T>(T x) 
{
    return x;
}

这是一个泛型类型定义:

class C<T>
{
}

如果我问你M 是什么,你很可能会说它是一个泛型方法,它接受T 并返回T。这是绝对正确的,但我提出了一种不同的思考方式——这里有两组参数。一个是类型T,另一个是对象x。如果我们把它们结合起来,我们知道这个方法总共需要两个参数。


The concept of currying 告诉我们,一个接受两个参数的函数可以转换为一个接受一个参数并返回另一个接受另一个参数的函数(反之亦然)。例如,这里有一个函数,它接受两个整数并产生它们的和:

Func<int, int, int> uncurry = (x, y) => x + y;
int sum = uncurry(1, 3);

这是一个等价的形式,我们有一个函数,它接受一个整数并生成一个函数,该函数接受另一个整数并返回上述整数的总和:

Func<int, Func<int, int>> curry = x => y => x + y;
int sum = curry(1)(3);

我们从一个接受两个整数的函数变成了一个接受一个整数的函数并创建函数。显然,这两者在 C# 中并不是完全一样的东西,但它们是同一件事的两种不同表达方式,因为传递相同的信息最终会得到相同的最终结果。

柯里化让我们更容易推理函数(推理一个参数比推理两个参数更容易),它让我们知道我们的结论仍然适用于任意数量的参数。


请考虑一下,在抽象层面上,这就是这里发生的事情。假设M 是一个“超级函数”,它采用T 类型并返回一个常规方法。返回的方法接受 T 值并返回 T 值。

例如,如果我们使用参数int 调用超函数M,我们会得到一个从intint 的常规方法:

Func<int, int> e = M<int>;

如果我们使用参数5 调用该常规方法,我们会得到一个5,正如我们预期的那样:

int v = e(5);

因此,请考虑以下表达式:

int v = M<int>(5);

您现在明白为什么这可以被视为两个单独的调用了吗?您可以识别对超级函数的调用,因为它的参数是在&lt;&gt; 中传递的。然后调用返回的方法,参数在() 中传递。类似于前面的例子:

curry(1)(3);

同样,泛型类型定义也是一个超函数,它接受一个类型并返回另一个类型。例如,List&lt;int&gt; 是对超函数 List 的调用,其参数 int 返回一个整数列表类型。

现在当 C# 编译器遇到常规方法时,它会将其编译为常规方法。它不会尝试为不同的可能参数创建不同的定义。所以,这个:

int Square(int x) => x * x;

按原样编译。它不会被编译为:

int Square__0() => 0;
int Square__1() => 1;
int Square__2() => 4;
// and so on

换句话说,C# 编译器不会评估此方法的所有可能参数,以便将它们嵌入到最终的可执行文件中——相反,它将方法保留为其参数化形式并相信结果将在运行时进行评估.

同样,当 C# 编译器遇到超函数(泛型方法或类型定义)时,它会将其编译为超函数。它不会尝试为不同的可能参数创建不同的定义。所以,这个:

T M<T>(T x) => x;

按原样编译。它不会被编译为:

int M(int x) => x;
int[] M(int[] x) => x;
int[][] M(int[][] x) => x;
// and so on
float M(float x) => x;
float[] M(float[] x) => x;
float[][] M(float[][] x) => x;
// and so on

同样,C# 编译器相信,当这个超级函数被调用时,它会在运行时被求值,而常规的方法或类型将由该求值产生。

这是 C# 受益于将 JIT 编译器作为其运行时的一部分的原因之一。当一个超级函数被求值时,它会产生一个全新的方法或编译时不存在的类型!我们称这个过程为reification。随后,运行时会记住该结果,因此不必再次重新创建它。那部分叫做memoization

与不需要 JIT 编译器作为其运行时一部分的 C++ 相比。 C++ 编译器实际上需要在编译时评估超级函数(称为“模板”)。这是一个可行的选择,因为超级函数的参数仅限于在编译时可以评估的东西。


所以,回答你的问题:

class Foo 
{
    public void Bar()
    {
    }
}

Foo 是一个常规类型,并且只有一个。 BarFoo 中的常规方法,只有一个。

class Foo<T>
{
    public void Bar()
    {
    }
}

Foo&lt;T&gt; 是一个在运行时创建类型的超级函数。这些结果类型中的每一个都有自己的常规方法,名为Bar,并且只有一个(对于每种类型)。

class Foo
{
    public void Bar<T>()
    {
    }
}

Foo 是一个常规类型,并且只有一个。 Bar&lt;T&gt; 是一个在运行时创建常规方法的超级函数。然后,这些结果方法中的每一个都将被视为常规类型 Foo 的一部分。

class Foo<Τ1>
{
    public void Bar<T2>()
    {
    }
}

Foo&lt;T1&gt; 是一个在运行时创建类型的超级函数。这些结果类型中的每一个都有自己的一个名为Bar&lt;T2&gt; 的超级函数,它在运行时(稍后)创建常规方法。这些结果方法中的每一个都被认为是创建相应超函数的类型的一部分。


以上是概念性的解释。除此之外,可以实现某些优化以减少内存中不同实现的数量——例如在某些情况下,两个构造方法可以共享一个机器代码实现。请参阅Luaan's answer,了解 CLR 为何可以执行此操作以及何时执行此操作。

【讨论】:

    【解决方案3】:

    在 IL 本身中,代码只有一个“副本”,就像在 C# 中一样。 IL 完全支持泛型,C# 编译器不需要做任何技巧。你会发现泛型类型的每个具体化(例如List&lt;int&gt;)都有一个单独的类型,但它们仍然保留对原始开放泛型类型的引用(例如List&lt;&gt;);但是,与此同时,根据合同,它们必须表现得好像每个封闭的泛型都有单独的方法或类型。所以最简单的解决方案确实是让每个封闭的泛型方法都是一个单独的方法。

    现在了解实施细节:) 在实践中,这很少是必要的,而且可能很昂贵。所以实际发生的情况是,如果一个方法可以处理多个类型参数,它会的。这意味着所有引用类型都可以使用相同的方法(类型安全性在编译时已经确定,因此无需在运行时再次使用),并且使用静态字段的一些技巧,您可以使用相同的“类型”也是如此。例如:

    class Foo<T>
    {
      private static int Counter;
    
      public static int DoCount() => Counter++;
      public static bool IsOk() => true;
    }
    
    Foo<string>.DoCount(); // 0
    Foo<string>.DoCount(); // 1
    Foo<object>.DoCount(); // 0
    

    IsOk 只有一个程序集“方法”,Foo&lt;string&gt;Foo&lt;object&gt; 都可以使用它(这当然也意味着对该方法的调用可以相同)。但按照 CLI 规范的要求,它们的静态字段仍然是独立的,这也意味着 DoCount 必须 引用 Foo&lt;string&gt;Foo&lt;object&gt; 的两个独立字段。然而,当我进行反汇编时(在我的计算机上,请注意 - 这些是实现细节,可能会有很大差异;而且,要防止 DoCount 的内联需要一些努力),只有一个 @987654331 @ 方法。如何?对Counter 的“引用”是间接的:

    000007FE940D048E  mov         rcx, 7FE93FC5C18h  ; Foo<string>
    000007FE940D0498  call        000007FE940D00C8   ; Foo<>.DoCount()
    000007FE940D049D  mov         rcx, 7FE93FC5C18h  ; Foo<string>
    000007FE940D04A7  call        000007FE940D00C8   ; Foo<>.DoCount()
    000007FE940D04AC  mov         rcx, 7FE93FC5D28h  ; Foo<object>
    000007FE940D04B6  call        000007FE940D00C8   ; Foo<>.DoCount()
    

    DoCount 方法看起来像这样(不包括序言和“我不想内联这个方法”填充物):

    000007FE940D0514  mov         rcx,rsi                ; RCX was stored in RSI in the prolog
    000007FE940D0517  call        000007FEF3BC9050       ; Load Foo<actual> address
    000007FE940D051C  mov         edx,dword ptr [rax+8]  ; EDX = Foo<actual>.Counter
    000007FE940D051F  lea         ecx,[rdx+1]            ; ECX = RDX + 1
    000007FE940D0522  mov         dword ptr [rax+8],ecx  ; Foo<actual>.Counter = ECX
    000007FE940D0525  mov         eax,edx  
    000007FE940D0527  add         rsp,30h  
    000007FE940D052B  pop         rsi  
    000007FE940D052C  ret  
    

    所以代码基本上“注入”了Foo&lt;string&gt;/Foo&lt;object&gt; 依赖项,所以虽然调用不同,但被调用的方法实际上是相同的——只是间接性增加了一点。当然,对于我们原来的方法 (() =&gt; Counter++),这根本不是一个调用,也不会有额外的间接性——它只是内联在调用站点中。

    值类型有点棘手。引用类型的字段总是相同的大小——引用的大小。另一方面,值类型的字段可能具有不同的大小,例如intlongdecimal。索引整数数组与索引decimals 数组需要不同的汇编。而且由于结构也可以是通用的,结构的大小可能取决于类型参数的大小:

    struct Container<T>
    {
      public T Value;
    }
    
    default(Container<double>); // Can be as small as 8 bytes
    default(Container<decimal>); // Can never be smaller than 16 bytes
    

    如果我们在前面的示例中添加值类型

    Foo<int>.DoCount();
    Foo<double>.DoCount();
    Foo<int>.DoCount();
    

    我们得到这个代码:

    000007FE940D04BB  call        000007FE940D00F0  ; Foo<int>.DoCount()
    000007FE940D04C0  call        000007FE940D0118  ; Foo<double>.DoCount()
    000007FE940D04C5  call        000007FE940D00F0  ; Foo<int>.DoCount()
    

    如您所见,虽然我们没有获得与引用类型不同的静态字段的额外间接性,但每个方法实际上是完全独立的。方法中的代码更短(更快),但不能重复使用(这是给Foo&lt;int&gt;.DoCount()

    000007FE940D058B  mov         eax,dword ptr [000007FE93FC60D0h]  ; Foo<int>.Counter
    000007FE940D0594  lea         edx,[rax+1]
    000007FE940D0597  mov         dword ptr [7FE93FC60D0h],edx  
    

    只是一个普通的静态字段访问,就好像该类型根本不是通用的 - 就好像我们刚刚定义了 class FooOfIntclass FooOfDouble

    大多数时候,这对你来说并不重要。精心设计的仿制药通常会支付其成本,而且您不能对仿制药的性能做出简单的陈述。使用 List&lt;int&gt; 几乎总是比使用整数的 ArrayList 更好的主意 - 您需要为拥有多个 List&lt;&gt; 方法支付额外的内存成本,但除非您有许多不同的值类型 List&lt;&gt;s 而没有项目,节省的成本可能远远超过内存和时间的成本。如果您只有一个给定泛型类型的具体化(或所有具体化都对引用类型关闭),您通常不会支付额外费用 - 如果无法进行内联,可能会有一些额外的间接性。

    有一些指南可以有效地使用泛型。这里最相关的是只保留实际通用的部分通用。一旦包含的类型是泛型的,里面的所有东西也可能是泛型的——所以如果你在泛型类型中有 100 kiB 的静态字段,那么每个具体化都需要复制它。这可能是你想要的,但它可能是一个错误。通常的方法是将非泛型部分放在非泛型静态类中。这同样适用于嵌套类 - class Foo&lt;T&gt; { class Bar { } } 意味着 Bar也是一个泛型类(它“继承”其包含类的类型参数)。

    在我的计算机上,即使我使 DoCount 方法没有任何泛型(仅将 Counter++ 替换为 42),代码仍然相同 - 编译器不会尝试消除不必要的“泛型” ”。如果您需要对一种泛型类型使用许多不同的具体化,这可以很快加起来 - 所以请考虑将这些方法分开;将它们放在非泛型基类或静态扩展方法中可能是值得的。但一如既往的性能 - 配置文件。这可能不是问题。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2014-01-04
      • 2015-01-25
      • 1970-01-01
      • 2014-01-26
      • 1970-01-01
      • 1970-01-01
      • 2015-05-05
      相关资源
      最近更新 更多