Где хранятся универсальные методы?


Я прочитал некоторую информацию о дженериках .ΝΕΤ и заметил одну интересную вещь.

например, если у меня есть универсальный класс:

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 а где информация об этом методе хранится в памяти?

3 54

3 ответа:

в отличие от шаблонов C++, .NET generics вычисляются во время выполнения, а не во время компиляции. Семантически, если вы создаете экземпляр универсального класса с разными параметрами типа, они будут вести себя так, как если бы это были два разных класса, но под капотом есть только один класс в скомпилированном коде IL (промежуточный язык).

универсального типа

разница между различными экземплярами одного и того же универсального типа становится очевидной, когда вы использовать отражение:typeof(YourClass<int>) не будет таким же, как typeof(YourClass<string>). Они называются сконструированного универсального типа. Там также существует typeof(YourClass<>) представляет определение универсального типа. Вот некоторые дополнительные советы о работе с дженериками через отражение.

когда вы создать экземпляр сконструированного универсального класса, среда выполнения создает специализированный класс на лету. Есть тонкие различия между тем, как это работает со значениями и ссылочными типами.

  • компилятор будет генерировать только один универсальный тип в сборку.
  • среда выполнения создает отдельную версию вашего универсального класса для каждого типа значения, с которым вы его используете.
  • среда выполнения выделяет отдельный набор статических полей для каждого параметра типа универсального класса.
  • поскольку ссылочные типы имеют одинаковый размер, среда выполнения может повторно использовать созданную ею специализированную версию первый раз, когда вы использовали его со ссылочным типом.

универсальные методы

на универсальные методы, принципы те же.

  • компилятор генерирует только один универсальный метод, который является определение универсального метода.
  • во время выполнения каждая различная специализация метода рассматривается как другой метод того же класса.

во-первых, давайте проясним две вещи. Это же определение универсального метода:

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

это определение универсального типа:

class C<T>
{
}

скорее всего, если я спрошу, чем M это, вы скажете, что это общий метод, который принимает T и возвращает a T. Это абсолютно верно, но я предлагаю другой способ мышления об этом-здесь есть два набора параметров. Один-это тип T, другой-это объект x. Если мы объединим их, мы знаем, что в совокупности этот метод принимает два параметра в общей сложности.


концепция Карри говорит нам, что функция, которая принимает два параметра могут быть преобразованы в функцию, которая принимает один параметр и возвращает другую функцию, которая принимает другой параметр (и наоборот). Например, вот функция, которая принимает два целых числа и возвращает их сумму:

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 значение и возвращает a 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-компилятора как части его выполнения. Когда супер-функция оценивается, она создает совершенно новый метод или тип, которого не было во время компиляции! Мы называем этот процесс овеществления. Впоследствии среда выполнения запоминает этот результат, поэтому ему не придется повторно создавать его снова. Эта часть называется memoization.

сравните с C++ , который не требует JIT-компилятора как часть его выполнения. Компилятор 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 метод выглядит примерно так (исключая пролог и" я не хочу встроить этот метод " наполнитель):

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++), это не будет вызов вообще, и не будет иметь дополнительной косвенности - он будет просто встроен в callsite.

это немного сложнее для типов значений. Поля ссылочных типов всегда имеют одинаковый размер-размер ссылки. С другой стороны, поля типов значений могут иметь разные размеры, например int и long или decimal. Индексирование массив целых чисел требует другой сборки, чем индексирование массива 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

если мы добавим типы значений в наш предыдущий пример

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 ints-вы платите дополнительную стоимость памяти, имея несколько List<> методы, но если у вас нет много разных значений типа List<>С без деталей, сбережения вероятно хорошо перевешивают цену как в памяти, так и во времени. Если у вас есть только одно овеществление данного универсального типа (или все овеществления закрыты для ссылочных типов), вы обычно не собираетесь платить дополнительно - может быть немного дополнительной косвенности, если встраивание не является вероятный.

есть несколько рекомендаций по эффективному использованию дженериков. Наиболее актуальным здесь является только сохранение фактически общих частей generic. Как только содержащий тип является универсальным, все внутри также может быть универсальным - поэтому, если у вас есть 100 кб статических полей в универсальном типе, каждое овеществление должно будет дублировать это. Это может быть то, что вы хотите, но это может быть ошибкой. Обычный подход заключается в том, чтобы поместить неродовые части в неродовой статический класс. Тот же применяется к вложенным классам - class Foo<T> { class Bar { } } означает, что Bar и и универсальный класс (он "наследует" аргумент типа содержащего его класса).

на моем компьютере, даже если я держу DoCount метод, свободный от чего-либо общего (заменить Counter++ С 42), код все тот же-компиляторы не пытаются устранить ненужную "обобщенность". Если вам нужно использовать много разных повторений одного универсального типа, это может быстро сложиться - так и сделайте рассмотрите возможность разделения этих методов; возможно, стоит поместить их в неродовой базовый класс или статический метод расширения. Но как всегда с профиля. Вероятно, это не проблема.