Подтвердить что ты не робот

Где хранятся общие методы?

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

Например, если у меня есть общий класс:

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

4b9b3361

Ответ 1

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

Общие типы

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

Когда вы создаете созданный общий класс, среда выполнения генерирует специализированный класс "на лету". Существуют тонкие различия между тем, как он работает со значениями и ссылочными типами.

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

Общие методы

Для общих методов принципы одинаковы.

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

Ответ 2

Прежде всего, давайте проясним две вещи. Это определение общего метода:

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

Это определение общего типа:

class C<T>
{
}

Скорее всего, если я спрошу вас, что M, вы скажете, что это общий метод, который принимает T и возвращает a 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);

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

Currying позволяет нам легче рассуждать о функциях (проще рассуждать об одном параметре, чем два), и это позволяет нам знать, что наши выводы по-прежнему актуальны для любого количества параметров.


Рассмотрим на мгновение, что на абстрактном уровне это то, что здесь происходит. Скажем, M является "суперфункцией", которая берет тип T и возвращает обычный метод. Этот возвращаемый метод принимает значение T и возвращает значение T.

Например, если мы вызываем суперфункцию M с аргументом int, мы получаем регулярный метод от int до int:

Func<int, int> e = M<int>;

И если мы будем называть этот обычный метод аргументом 5, мы получим a 5 назад, как и ожидалось:

int v = e(5);

Итак, рассмотрим следующее выражение:

int v = M<int>(5);

Теперь вы видите, почему это можно рассматривать как два отдельных вызова? Вы можете распознать вызов суперфункции, потому что его аргументы передаются в <>. Затем следует вызов возвращаемого метода, где аргументы передаются в (). Это аналогично предыдущему примеру:

curry(1)(3);

И аналогичным образом определение общего типа также является суперфункцией, которая принимает тип и возвращает другой тип. Например, List<int> - это вызов суперфункции List с аргументом int, который возвращает тип, содержащий список целых чисел.

Теперь, когда компилятор С# встречается с обычным методом, он компилирует его как обычный метод. Он не пытается создавать разные определения для разных возможных аргументов. Итак, это:

int Square(int x) => x * x;

компилируется как есть. Он не компилируется как:

int Square__0() => 0;
int Square__1() => 1;
int Square__2() => 4;
// and so on

Другими словами, компилятор С# не оценивает все возможные аргументы для этого метода, чтобы вставлять их в окончательный exacutable - скорее, он оставляет метод в его параметризованной форме и надеется, что результат будет оцениваться во время выполнения.

Аналогично, когда компилятор С# встречает суперфункцию (общий метод или определение типа), он компилирует ее как суперфункцию. Он не пытается создавать разные определения для разных возможных аргументов. Итак, это:

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

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

Это одна из причин, по которой С# получает выгоду от наличия JIT-компилятора как части его времени выполнения. Когда суперфункция оценивается, она производит совершенно новый метод или тип, которого не было во время компиляции! Мы называем этот процесс reification. Впоследствии среда выполнения запоминает этот результат, поэтому ему не придется повторно создавать его снова. Эта часть называется memoization.

Сравните с С++, который не требует JIT-компилятора как часть его времени выполнения. Компилятор С++ действительно должен оценивать суперфункции (называемые "шаблоны" ) во время компиляции. Это возможный вариант, поскольку аргументы суперфункций ограничены вещами, которые можно оценить во время компиляции.


Итак, чтобы ответить на ваш вопрос:

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 answer о том, почему CLR может это сделать и когда это действительно делает.

Ответ 3

В самом ИЛ есть только одна "копия" кода, как в С#. Generics полностью поддерживаются IL, а компилятору С# не нужно делать никаких трюков. Вы обнаружите, что каждое переопределение общего типа (например, 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>. И все же, когда я делаю разборку (на моем компьютере, заметьте), это детали реализации и могут сильно варьироваться, а также требуется немного усилий, чтобы предотвратить inlining 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 выглядит примерно так (исключая пролог и "Я не хочу встроить этот метод" filler):

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

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

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

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