我已经阅读了一些关于.ΝΕΤ中泛型的信息,并注意到一件有趣的事情。
例如,如果我有一个泛型类:
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
实现以及有关此方法的信息存储在内存中的位置?
与C++模板相反,.NET 泛型在运行时而不是在编译时计算。从语义上讲,如果使用不同的类型参数实例化泛型类,则这些参数的行为就像是两个不同的类一样,但在后台,编译的 IL(中间语言)代码中只有一个类。
泛型类型
当您使用 反射:typeof(YourClass<int>)
与typeof(YourClass<string>)
不同时,同一泛型类型的不同瞬时之间的差异变得明显。这些类型称为构造泛型类型。还存在一个表示泛型类型定义的typeof(YourClass<>)
。以下是通过反射处理泛型的一些进一步提示。
实例化构造的泛型类时,运行时会动态生成专用类。它与值和引用类型的工作方式之间存在细微差别。
- 编译器只会在程序集中生成单个泛型类型。
- 运行时会为使用它的每个值类型创建泛型类的单独版本。
- 运行时为泛型类的每个类型参数分配一组单独的静态字段。 由于引用类型
- 具有相同的大小,因此运行时可以重用首次将引用类型一起使用时生成的专用版本。
泛型方法
对于泛型方法,原理是相同的。
- 编译器仅生成一个泛型方法,即泛型方法定义。
- 在运行时,方法的每个不同专用化都被视为同一类的不同方法。
首先,让我们澄清两件事。这是一个泛型方法定义:
T M<T>(T x)
{
return x;
}
这是一个泛型类型定义:
class C<T>
{
}
最有可能的是,如果我问你M
是什么,你会说它是一个通用方法,它接受一个T
并返回一个T
。这是绝对正确的,但我提出了一种不同的思考方式——这里有两组参数。一个是T
类型,另一个是对象x
。如果我们将它们结合起来,我们知道这种方法总共需要两个参数。
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
值。
例如,如果我们用参数调用超级函数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# 编译器不会计算此方法的所有可能参数,以便将它们嵌入到最终的可解释性中 - 相反,它将方法保留为其参数化形式,并相信将在运行时计算结果。
同样,当 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 编译器作为其运行时一部分的原因之一。当一个超级函数被计算时,它会产生一个全新的方法或一个在编译时不存在的类型!我们称这一进程为化。随后,运行时会记住该结果,因此不必再次重新创建它。这部分称为记忆。
与不需要 JIT 编译器作为运行时一部分的C++进行比较。C++编译器实际上需要在编译时评估超级函数(称为"模板")。这是一个可行的选择,因为超级函数的参数仅限于可以在编译时评估的东西。
所以,回答你的问题:
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 关于 CLR 为什么可以执行此操作以及何时实际执行此操作的答案。
在 IL 本身中,只有一个代码"副本",就像在 C# 中一样。IL 完全支持泛型,C# 编译器不需要执行任何技巧。您会发现泛型类型的每个化(例如List<int>
)具有单独的类型,但它们仍然保留对原始开放泛型类型的引用(例如List<>
);但是,与此同时,根据合约,它们的行为必须像每个封闭泛型都有单独的方法或类型一样。因此,最简单的解决方案确实是让每个封闭的泛型方法成为单独的方法。
现在了解实现详细信息:) 在实践中,这很少是必要的,而且可能很昂贵。因此,实际发生的是,如果单个方法可以处理多个类型参数,那么它将处理。这意味着所有引用类型都可以使用相同的方法(类型安全性已经在编译时确定,因此无需在运行时再次使用它),并且对静态字段进行一些技巧,您也可以使用相同的"类型"。例如:
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++
),这根本不是调用,也不会有额外的间接寻址 - 它只会在调用站点中内联。
对于值类型来说,这有点棘手。引用类型的字段始终具有相同的大小 - 引用的大小。另一方面,值类型的字段可能具有不同的大小,例如int
vs.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>
几乎总是比使用 intArrayList
更好 - 您需要支付拥有多个List<>
方法的额外内存成本,但除非您有许多不同的值类型List<>
没有项目,否则节省的成本可能会远远超过内存和时间的成本。如果给定泛型类型只有一个重新化(或者所有化在引用类型上都关闭),则通常不会支付额外费用 - 如果无法内联,则可能会有一些额外的间接寻址。
有一些准则可以有效地使用泛型。这里最相关的是只保持实际通用部件的通用性。一旦包含类型是泛型的,里面的所有内容也可能是泛型的 - 因此,如果你在泛型类型中有 100 kiB 的静态字段,则每次重新调整都需要复制它。这可能是你想要的,但这可能是一个错误。通常的做法是将非泛型部件放在非泛型静态类中。这同样适用于嵌套类 -class Foo<T> { class Bar { } }
意味着Bar
也是一个泛型类(它"继承"了其包含类的类型参数)。
在我的计算机上,即使我保持DoCount
方法没有任何通用(仅用42
替换Counter++
),代码仍然相同 - 编译器不会试图消除不必要的"通用性"。如果您需要使用一种泛型类型的大量不同化,这可能会很快加起来 - 因此请考虑将这些方法分开;将它们放在非泛型基类或静态扩展方法中可能是值得的。但一如既往地与性能 - 配置文件。这可能不是问题。