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
,我们就得到一个从int
到int
的常规方法:
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,只有一个。 Bar
是Foo
里的常规方法,只有一个。
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的字段可能具有不同的大小,例如int
与long
或decimal
。 对整数数组进行索引需要与对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 FooOfInt
和class 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 – 轮廓。 这可能不是一个问题。