通用方法存储在哪里?

rom*_*man 55 .net c# generics

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

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

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

Console.WriteLine(++Foo<int>.Counter); //1
Console.WriteLine(++Foo<string>.Counter); //1
Run Code Online (Sandbox Code Playgroud)

两类Foo<int>,并Foo<string>在运行时不同.但是具有泛型方法的非泛型类呢?

class Foo 
{
    public void Bar<T>()
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

很明显,只有一个Foo班级.但是方法Bar怎么样?所有泛型类和方法都在运行时使用它们使用的参数关闭.这是否意味着类Foo有许多实现,Bar并且有关此方法的信息存储在内存中?

Ven*_*emo 51

与C++模板相反,.NET泛型在运行时进行评估,而不是在编译时进行评估.从语义上讲,如果使用不同的类型参数实例化泛型类,那些行为就好像它是两个不同的类,但在引擎盖下,编译的IL(中间语言)代码中只有一个类.

通用类型

当您使用Reflection时,相同泛型类型的不同实例之间的差异变得明显:typeof(YourClass<int>)将不同于typeof(YourClass<string>).这些被称为构造泛型类型.还存在typeof(YourClass<>)表示泛型类型定义的.以下是通过Reflection处理泛型的一些进一步提示.

当你实例构造泛型类,运行时产生的飞一个专门的类.它与值和引用类型的工作方式之间存在细微差别.

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

通用方法

对于通用方法,原则是相同的.

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

  • @OwenPauling啊,好的.我的意思是*编译的IL代码中只有一个类*.我希望现在很清楚.:) (3认同)

The*_*kis 30

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

T M<T>(T x) 
{
    return x;
}
Run Code Online (Sandbox Code Playgroud)

这是泛型类型定义:

class C<T>
{
}
Run Code Online (Sandbox Code Playgroud)

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


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

Func<int, int, int> uncurry = (x, y) => x + y;
int sum = uncurry(1, 3);
Run Code Online (Sandbox Code Playgroud)

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

Func<int, Func<int, int>> curry = x => y => x + y;
int sum = curry(1)(3);
Run Code Online (Sandbox Code Playgroud)

我们从一个带有两个整数的函数变成了一个带整数并创建函数的函数.显然,这两个在C#中并不是完全相同的东西,但它们是两种不同的说法,因为传递相同的信息最终会让你得到相同的最终结果.

Currying允许我们更容易推理函数(更容易推理一个参数而不是两个),它让我们知道我们的结论仍然与任意数量的参数相关.


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

例如,如果我们称之为超级功能M的说法int,我们从得到一个普通的方法intint:

Func<int, int> e = M<int>;
Run Code Online (Sandbox Code Playgroud)

如果我们用参数调用那个常规方法5,我们会得到一个5回复,正如我们所期望的那样:

int v = e(5);
Run Code Online (Sandbox Code Playgroud)

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

int v = M<int>(5);
Run Code Online (Sandbox Code Playgroud)

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

curry(1)(3);
Run Code Online (Sandbox Code Playgroud)

类似地,泛型类型定义也是一个超类函数,它接受一个类型并返回另一个类型.例如,List<int>调用List带有参数的超级函数,该参数int返回一个整数列表的类型.

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

int Square(int x) => x * x;
Run Code Online (Sandbox Code Playgroud)

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

int Square__0() => 0;
int Square__1() => 1;
int Square__2() => 4;
// and so on
Run Code Online (Sandbox Code Playgroud)

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

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

T M<T>(T x) => x;
Run Code Online (Sandbox Code Playgroud)

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

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
Run Code Online (Sandbox Code Playgroud)

同样,C#编译器相信,当调用此超级函数时,它将在运行时进行评估,并且该评估将生成常规方法或类型.

这是C#受益于将JIT编译器作为其运行时的一部分而受益的原因之一.当评估超级函数时,它会生成一个全新的方法或在编译时不存在的类型!我们称之为流程具体化.随后,运行时会记住该结果,因此不必再次重新创建它.那部分称为memoization.

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


那么,回答你的问题:

class Foo 
{
    public void Bar()
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

Foo是一种常规类型,只有一种.Bar是一个常规的方法Foo,只有一个.

class Foo<T>
{
    public void Bar()
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

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

class Foo
{
    public void Bar<T>()
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

Foo是一种常规类型,只有一种.Bar<T>是一个在运行时创建常规方法的超级函数.然后,这些结果方法中的每一个都将被视为常规类型的一部分Foo.

class Foo<?1>
{
    public void Bar<T2>()
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

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


以上是概念性解释.除此之外,可以实现某些优化以减少内存中不同实现的数量 - 例如,在某些情况下,两个构造的方法可以共享单个机器代码实现.请参阅Luaan关于CLR为何能够实现此目的以及何时实际执行此操作的答案.


Lua*_*aan 15

在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
Run Code Online (Sandbox Code Playgroud)

只有一个装配"方法"为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()
Run Code Online (Sandbox Code Playgroud)

并且该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  
Run Code Online (Sandbox Code Playgroud)

所以代码基本上"注入"了Foo<string>/ Foo<object>dependency,所以当调用不同时,被调用的方法实际上是相同的 - 只是更多的间接.当然,对于我们的原始方法(() => Counter++),这根本不是一个调用,并且不会有额外的间接 - 它只是在调用点内联.

这对于价值类型来说有点棘手.引用类型的字段总是相同的大小 - 引用的大小.另一方面,值类型的字段可能具有不同的大小,例如intvs. longdecimal.索引整数数组需要不同于索引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
Run Code Online (Sandbox Code Playgroud)

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

Foo<int>.DoCount();
Foo<double>.DoCount();
Foo<int>.DoCount();
Run Code Online (Sandbox Code Playgroud)

我们得到这个代码:

000007FE940D04BB  call        000007FE940D00F0  ; Foo<int>.DoCount()
000007FE940D04C0  call        000007FE940D0118  ; Foo<double>.DoCount()
000007FE940D04C5  call        000007FE940D00F0  ; Foo<int>.DoCount()
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,虽然与引用类型不同,我们没有获得静态字段的额外间接,但每个方法实际上是完全独立的.方法中的代码更短(更快),但不能重复使用(这适用于Foo<int>.DoCount():

000007FE940D058B  mov         eax,dword ptr [000007FE93FC60D0h]  ; Foo<int>.Counter
000007FE940D0594  lea         edx,[rax+1]
000007FE940D0597  mov         dword ptr [7FE93FC60D0h],edx  
Run Code Online (Sandbox Code Playgroud)

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

大多数时候,这对你来说并不重要.精心设计的仿制药通常不仅仅是为了支付成本,而且你不能仅仅对仿制药的性能做出平坦的陈述.使用一个List<int>遗嘱几乎总是比使用ArrayList整数更好的想法- 你支付多种List<>方法的额外内存成本,但除非你有许多不同的值类型List<>s没有项目,节省可能会超过内存中的成本和时间.如果您只有一个给定泛型类型的具体化(或者所有的引用都在引用类型上关闭),那么您通常不会支付额外的费用 - 如果无法进行内联,可能会有一些额外的间接性.

有效使用泛型有一些指导原则.这里最相关的是仅保持实际通用部分的通用性.一旦包含类型是通用的,内部的所有内容也可能是通用的 - 因此如果您在泛型类型中有100 kiB的静态字段,则每个具体化都需要复制它.这可能是你想要的,但可能是一个错误.通常的方法是将非泛型部分放在非泛型静态类中.这同样适用于嵌套类- class Foo<T> { class Bar { } }意味着Bar一个通用类(它"继承"其包含类的类型参数).

在我的计算机上,即使我保持DoCount方法没有任何通用(Counter++仅替换42),代码仍然是相同的 - 编译器不会尝试消除不必要的"通用性".如果你需要使用一种通用类型的许多不同的通知,这可以快速加起来 - 所以要考虑保持这些方法分开; 将它们放在非泛型基类或静态扩展方法中可能是值得的.但一如既往的表现 - 个人资料.这可能不是问题.