generics方法存储在哪里?

我已经阅读了一些有关generics的信息,并注意到一件有趣的事情。

例如,如果我有一个generics类:

class Foo<T> { public static int Counter; } Console.WriteLine(++Foo<int>.Counter); //1 Console.WriteLine(++Foo<string>.Counter); //1 

在运行时,两个类Foo<int>Foo<string>是不同的。 但是具有generics方法的非generics类的情况呢?

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

很显然,只有一个Foo类。 但是方法Bar呢? 所有通用的类和方法都在运行时用它们使用的参数closures。 这是否意味着Foo类有很多Bar实现,并且这个方法的信息存储在内存中?

与C ++模板相反 ,.NETgenerics是在运行时进行评估的,而不是在编译时。 从语义上讲,如果你用不同的types参数实例化generics类,那么它们的行为就好像是两个不同的类,但是在编译的IL(中间语言)代码中只有一个类。

genericstypes

当使用Reflection时,相同genericstypes的不同实例之间的区别变得明显: typeof(YourClass<int>)将不会与typeof(YourClass<string>) 。 这些被称为构造genericstypes 。 还有一个typestypeof(YourClass<>) ,它代表genericstypes定义 。 这里有一些关于通过reflection来处理generics的更多提示 。

当你实例化一个构造的generics类时 ,运行时会生成一个专门的类。 它如何与值和引用types一起工作有细微的差异。

  • 编译器只会在程序集中生成一个通用types。
  • 运行时为您使用的每个值types创build一个单独的通用类版本。
  • 运行时为generics类的每个types参数分配一组单独的静态字段。
  • 因为引用types具有相同的大小,所以运行时可以在首次使用引用types时重用它生成的专用版本。

通用方法

对于通用方法 ,原理是一样的。

  • 编译器只生成一个generics方法,这是generics方法定义
  • 在运行时,方法的每个不同的专业化被视为同一个类的不同的方法。

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

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

这是一个genericstypes定义:

 class C<T> { } 

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


currying的概念告诉我们,一个带有两个参数的函数可以被转换成一个函数,该函数接受一个参数,并返回另一个函数,而另一个函数接受另一个参数(反之亦然)。 例如,这是一个函数,它接受两个整数并产生它们的总和:

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

这里是一个等价的forms,我们有一个函数,它接受一个整数并产生一个函数,它接受另一个整数并返回上述整数的和:

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

我们从具有两个整数的函数转到具有一个整数并创build函数的函数 。 显然,这两个在C#中并不是一回事,但是它们是用同样的方式说两种不同的方式,因为传递相同的信息最终会得到相同的最终结果。

柯里让我们可以更容易地推理一些函数(比二更容易推理一个参数),这让我们知道我们的结论对于任何参数都是相关的。


想一想,在抽象层面上,这是发生在这里的事情。 假设M是一个“超级函数”,它接受一个typesT并返回一个常规方法。 返回的方法接受一个T值并返回一个T值。

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

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

如果我们用5参数来调用这个常规方法,那么我们会得到一个5 ,就像我们预期的那样:

 int v = e(5); 

所以,考虑下面的expression式:

 int v = M<int>(5); 

你现在看到为什么这可以被认为是两个单独的电话? 您可以识别对超级函数的调用,因为它的参数在<>中传递。 然后调用返回的方法,参数在()中传递。 这与前面的例子类似:

 curry(1)(3); 

同样,genericstypes定义也是一个超types的函数,它接受一个types并返回另一个types。 例如, List<int>是一个调用超级函数List的参数int ,返回一个整数列表。

现在,当C#编译器遇到一个常规方法时,它将它编译为常规方法。 它并不试图为不同的可能论点创build不同的定义。 所以这:

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

得到编译原样。 它不会被编译为:

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

换句话说,C#编译器不会评估此方法的所有可能参数,以便将它们embedded到最终的可执行文件中 – 而是将该方法留在其参数化forms中,并相信结果将在运行时进行评估。

同样,当C#编译器遇到超级函数(generics方法或types定义)时,它将其编译为超级函数。 它并不试图为不同的可能论点创build不同的定义。 所以这:

 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#编译器相信,当这个超级函数被调用时,它将在运行时进行评估,并且常规的方法或types将由该评估产生。

这是为什么C#受益于JIT编译器作为其运行时的一部分的原因之一。 当一个超级函数被评估的时候,它会产生一个全新的方法或者在编译时就没有的types! 我们称之为stream程的物化 。 随后,运行时会记住结果,因此不必再次重新创build。 这部分被称为memoization 。

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


所以,要回答你的问题:

 class Foo { public void Bar() { } } 

Foo是一个普通的types,只有一个。 BarFoo里的常规方法,只有一个。

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

Foo<T>是一个在运行时创buildtypes的超级函数。 这些结果types中的每一个都有自己的常规方法Bar并且只有一个(对于每个types)。

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

Foo是一个普通的types,只有一个。 Bar<T>是一个在运行时创build常规方法的超级函数。 这些结果方法中的每一个都将被视为常规typesFoo

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

Foo<T1>是一个在运行时创buildtypes的超级函数。 每种结果types都有自己的一个名为Bar<T2>的超级函数,它在运行时(稍后)创build常规方法。 这些结果方法中的每一个都被认为是创build相应超级函数的types的一部分。


以上是概念性的解释。 除此之外,可以实现某些优化以减less内存中不同实现的数量 – 例如,在某些情况下,两种构build的方法可以共享一个机器代码实现。 请参阅Luaan的回答 :为什么CLR可以做到这一点,什么时候才能做到这一点。

在IL本身中,就像在C#中一样,只有一个代码的“副本”。 generics完全由IL支持,C#编译器不需要做任何技巧。 你会发现genericstypes(例如List<int> )的每个实例都有一个单独的types,但是仍然保留对原来的开放genericstypes(例如List<> )的引用。 然而,同时,按照合同,它们必须performance得好像每个封闭的通用都有单独的方法或types。 所以最简单的解决scheme确实是每个封闭的generics方法是一个单独的方法。

现在的实施细节:)实际上,这是很less有必要的,可能是昂贵的。 所以实际发生的是,如果一个方法可以处理多个types的参数,它将会。 这意味着所有的引用types都可以使用相同的方法(types安全性已经在编译时确定了,所以不需要在运行时重新使用),而且对于静态字段有一些小小的诡计,你可以使用相同的“键入“以及。 例如:

 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<string>Foo<object> (当然这也意味着对该方法的调用可以是相同的)。 但是它们的静态字段仍然是独立的,正如CLI规范所要求的那样,这也意味着DoCount 必须Foo<string>Foo<object>引用两个单独的字段。 但是,当我进行反汇编时(在我的电脑上,请注意,这些是实现细节,可能会有很大的差别;同样, DoCount的内联操作也需要一些努力),只有一个DoCount方法。 怎么样? 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方法看起来像这样(不包括prolog和“我不想内联这个方法”填充):

 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<string> / Foo<object>依赖项,所以在调用方式不同的情况下,被调用的方法实际上是相同的 – 只是间接一点。 当然,对于我们原来的方法( () => Counter++ )来说,这根本不会是一个调用,也不会有额外的间接性 – 它只会内联在调用点上。

值types有点棘手。 引用types的字段总是相同的大小 – 引用的大小。 另一方面,值types的字段可能具有不同的大小,例如intlongdecimal 。 对整数数组进行索引需要与对decimal数组build立索引不同的程序集。 由于结构也可以是通用的,结构的大小可能取决于types参数的大小:

 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 

如果我们将值types添加到前面的示例中

 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() 

正如你所看到的,虽然我们没有像引用types那样获得静态字段的额外间接,但是每个方法实际上是完全分离的。 该方法中的代码更短(也更快),但不能重用(这是为Foo<int>.DoCount()

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

只是一个普通的静态字段访问,就好像types不是generics一样 – 就好像我们刚刚定义了class FooOfIntclass FooOfDouble

大多数时候,这对你来说并不重要。 精心devise的generics通常不仅仅是为了支付成本,而且你不能只是对generics的性能做一个平坦的表述。 使用List<int>几乎总是比使用int的ArrayList更好 – 您支付多个List<>方法的额外内存开销,但除非您有许多不同的值typesList<> ,在储存和时间上的节省将可能大大超过成本。 如果你只有一个给定的genericstypes(或者所有的引用types都是closures的),那么你通常不会付出额外的代价 – 如果内联是不可能的,那么可能会有一点额外的间接性。

有几条指导方针可以有效地使用generics。 这里最重要的是只保留通用的通用部分。 一旦包含types是通用的,那么里面的所有东西都可能是通用的 – 所以如果在genericstypes中有100kB的静态字段,每个通用都需要重复。 这可能是你想要的,但可能是一个错误。 通常的方法是将非通用部分放在非generics的静态类中。 同样适用于嵌套类 – class Foo<T> { class Bar { } }意味着Bar 也是一个generics类(它“inheritance”它的包含类的types参数)。

在我的计算机上,即使我保持DoCount方法没有任何generics(用42代替Counter++ ),代码仍然是相同的 – 编译器不会试图消除不必要的“通用性”。 如果你需要使用一个genericstypes的很多不同的修饰,这可以很快加起来 – 所以考虑保持这些方法的分开; 把它们放在非generics基类或静态扩展方法中可能是值得的。 但是一如既往的performance – 轮廓。 这可能不是一个问题。