我在.ΝΕΤ中读到了一些关于泛型的信息,并注意到一件有趣的事情。
例如,如果我有一个泛型类:
class Foo<T>
{
public static int Counter;
}
Console.WriteLine(++Foo<int>.Counter); //1
Console.WriteLine(++Foo<string>.Counter); //1
两个类Foo<int>
和Foo<string>
在运行时是不同的。但是具有泛型方法的非泛型类呢?
class Foo
{
public void Bar<T>()
{
}
}
很明显,只有一个Foo
类。但是方法Bar
呢?所有泛型类和方法都在运行时使用它们使用的参数关闭。这是否意味着类Foo
有许多Bar
的实现,并且有关此方法的信息存储在内存中?
答案 0 :(得分:51)
As opposed to C++ templates,.NET泛型在运行时进行评估,而不是在编译时进行评估。从语义上讲,如果使用不同的类型参数实例化泛型类,那些行为就好像它是两个不同的类,但在引擎盖下,编译的IL(中间语言)代码中只有一个类。
当您use Reflection typeof(YourClass<int>)
与typeof(YourClass<string>)
不同时,相同通用类型的不同实例之间的差异就会变得明显。这些被称为构造的泛型类型。还存在typeof(YourClass<>)
,表示泛型类型定义。以下是通过Reflection处理泛型的一些further tips。
当你instantiate a constructed generic class时,运行时会动态生成一个专门的类。它与值和引用类型的工作方式之间存在细微差别。
对于generic methods,原则是相同的。
答案 1 :(得分:30)
首先,让我们澄清两件事。这是泛型方法定义:
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#中并不是完全相同的东西,但它们是两种不同的说法,因为传递相同的信息最终会让你得到相同的最终结果。
Currying允许我们更容易推理函数(它更容易推理一个参数而不是两个),它让我们知道我们的结论仍然适用于任意数量的参数。
考虑一下,在抽象层面上,这就是这里发生的事情。让我们说M
是一个&#34;超级功能&#34;采用类型T
并返回常规方法。返回的方法采用T
值并返回T
值。
例如,如果我们使用参数M
调用超级函数int
,我们会从int
到int
获得常规方法:
Func<int, int> e = M<int>;
如果我们使用参数5
调用该常规方法,我们会按预期得到5
:
int v = e(5);
因此,请考虑以下表达式:
int v = M<int>(5);
你现在看到为什么这可以被视为两个单独的电话?您可以识别对超级函数的调用,因为它的参数在<>
中传递。然后调用返回的方法,其中参数在()
中传递。它类似于前面的例子:
curry(1)(3);
同样,泛型类型定义也是一个超类型函数,它接受一个类型并返回另一个类型。例如,List<int>
是对超级函数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#编译器不会为此方法计算所有可能的参数,以便将它们嵌入到最终的exacutable中 - 而是将方法保留为参数化形式,并相信结果将在运行时进行评估
类似地,当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。
与C ++比较,C ++不需要JIT编译器作为其运行时的一部分。 C ++编译器实际上需要在编译时评估超级函数(称为&#34; templates&#34;)。这是一个可行的选项,因为超级函数的参数仅限于在编译时可以进行评估的事物。
所以,回答你的问题:
class Foo
{
public void Bar()
{
}
}
Foo
是常规类型,并且只有其中一种。 Bar
是Foo
内的常规方法,并且只有其中一种方法。
class Foo<T>
{
public void Bar()
{
}
}
Foo<T>
是一个在运行时创建类型的超级函数。这些结果类型中的每一个都有自己的常规方法Bar
,其中只有一个(每种类型)。
class Foo
{
public void Bar<T>()
{
}
}
Foo
是常规类型,并且只有其中一种。 Bar<T>
是一个超级函数,可在运行时创建常规方法。然后,这些结果方法中的每一个都将被视为常规类型Foo
的一部分。
class Foo<Τ1>
{
public void Bar<T2>()
{
}
}
Foo<T1>
是一个在运行时创建类型的超级函数。这些结果类型中的每一个都有自己的一个名为Bar<T2>
的超级函数,它在运行时(稍后)创建常规方法。这些结果方法中的每一个都被认为是创建相应超函数的类型的一部分。
以上是概念性解释。除此之外,可以实现某些优化以减少存储器中不同实现的数量 - 例如在某些情况下,两种构造的方法可以共享单个机器代码实现。请参阅Luaan's answer,了解CLR为何可以执行此操作以及实际执行此操作的时间。
答案 2 :(得分:15)
在IL本身,只有一个&#34; copy&#34;代码,就像在C#中一样。 IL完全支持泛型,C#编译器不需要做任何技巧。您会发现泛型类型的每个具体化(例如List<int>
)都有一个单独的类型,但它们仍然保留对原始开放泛型类型的引用(例如List<>
);但是,同时,根据合同,它们必须表现得好像每个封闭的通用都有单独的方法或类型。因此,最简单的解决方案确实是将每个封闭的通用方法作为一个单独的方法。
现在为实现细节:) 在实践中,这很少是必要的,并且可能很昂贵。所以实际发生的是,如果一个方法可以处理多个类型的参数,它会。这意味着所有引用类型都可以使用相同的方法(类型安全性已经在编译时确定,因此不需要在运行时再次使用它),并且通过静态字段的一些小技巧,您可以使用相同的&#34;类型&#34;同样。例如:
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
只有一种装配&#34;方法&#34;对于IsOk
,它可以由Foo<string>
和Foo<object>
使用(这当然也意味着对该方法的调用可以是相同的)。但是,根据CLI规范的要求,它们的静态字段仍然是独立的,这也意味着DoCount
必须引用Foo<string>
和Foo<object>
的两个单独字段。然而,当我进行反汇编时(在我的计算机上,请注意 - 这些是实现细节,可能会有所不同;同样,需要花费一些精力来阻止DoCount
的内联),其中&#39 ;只有一个DoCount
方法。怎么样? &#34;参考&#34;到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和&#34;我不想内联这种方法&#34;填充):
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
所以代码基本上是#34;注入&#34; Foo<string>
/ Foo<object>
依赖关系,所以当调用不同时,被调用的方法实际上是相同的 - 只是更多的间接。当然,对于我们的原始方法(() => Counter++
),这根本不是一个调用,并且不会有额外的间接 - 它只会在调用点内联。
对于价值类型来说,这有点棘手。引用类型的字段总是相同的大小 - 引用的大小。另一方面,值类型的字段可以具有不同的大小,例如int
与long
或decimal
。索引整数数组需要不同的程序集,而不是索引decimal
的数组。并且由于结构也可以是通用的,结构的大小可能取决于类型参数的大小:
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<int>.DoCount()
:
000007FE940D058B mov eax,dword ptr [000007FE93FC60D0h] ; Foo<int>.Counter
000007FE940D0594 lea edx,[rax+1]
000007FE940D0597 mov dword ptr [7FE93FC60D0h],edx
只是一个简单的静态字段访问,就好像该类型根本不是通用的 - 好像我们刚刚定义了class FooOfInt
和class FooOfDouble
。
大多数时候,这对你来说并不重要。精心设计的仿制药通常不仅仅是为了支付成本,而且您不能仅仅对仿制药的性能做出平坦的陈述。使用List<int>
几乎总是比使用ArrayList
整数更好的想法 - 您需要支付多个List<>
方法的额外内存成本,但除非您有许多不同的值类型{没有项目的{1}},节省的费用可能会超过内存和时间的成本。如果您只有一个给定泛型类型的具体化(或者所有的引用都在引用类型上关闭),那么您通常不会支付额外费用 - 如果内联不是,则可能会有一些额外的间接性可能的。
有效使用泛型的一些指导原则。这里最相关的是仅保持实际通用部分的通用性。一旦包含类型是通用的,内部的所有内容也可以是通用的 - 因此如果您在泛型类型中有100 kiB的静态字段,则每个具体化都需要复制它。这可能是你想要的,但可能是一个错误。通常的方法是将非泛型部分放在非泛型静态类中。这同样适用于嵌套类 - List<>
意味着class Foo<T> { class Bar { } }
也是一个泛型类(&#34;继承&#34;其包含类的类型参数)。
在我的计算机上,即使我保持Bar
方法没有任何通用(仅用DoCount
替换Counter++
),代码仍然相同 - 编译器不会#39 ;尝试消除不必要的&#34;通用性&#34;。如果你需要使用一种通用类型的许多不同的通知,这可以快速加起来 - 所以要考虑保持这些方法分开;将它们放在非泛型基类或静态扩展方法中可能是值得的。但一如既往的表现 - 个人资料。这可能不是问题。