metodos metodo investigacion genericos generico constructores c# .net generics

c# - metodo - ¿Dónde se almacenan los métodos genéricos?



metodos genericos c# (3)

Leí información sobre genéricos en .ΝΕΤ y noté una cosa interesante.

Por ejemplo, si tengo una clase genérica:

class Foo<T> { public static int Counter; } Console.WriteLine(++Foo<int>.Counter); //1 Console.WriteLine(++Foo<string>.Counter); //1

Dos clases Foo<int> y Foo<string> son diferentes en tiempo de ejecución. Pero, ¿qué pasa con el caso cuando la clase no genérica tiene un método genérico?

class Foo { public void Bar<T>() { } }

Es obvio que solo hay una clase Foo . ¿Pero qué hay del método Bar ? Todas las clases y métodos genéricos se cierran en tiempo de ejecución con los parámetros que usaron. ¿Significa que la clase Foo tiene muchas implementaciones de Bar y dónde se almacena la información sobre este método en la memoria?


En IL, solo hay una "copia" del código, al igual que en C #. Los genéricos son totalmente compatibles con IL, y el compilador de C # no necesita hacer ningún truco. Encontrará que cada reificación de un tipo genérico (por ejemplo, List<int> ) tiene un tipo separado, pero aún conservan una referencia al tipo genérico abierto original (por ejemplo, List<> ); sin embargo, al mismo tiempo, según el contrato, deben comportarse como si hubiera métodos o tipos separados para cada genérico cerrado. Entonces, la solución más simple es que cada método genérico cerrado sea un método separado.

Ahora para los detalles de implementación :) En la práctica, esto rara vez es necesario y puede ser costoso. Entonces, lo que realmente sucede es que si un solo método puede manejar múltiples argumentos de tipo, lo hará. Esto significa que todos los tipos de referencia pueden usar el mismo método (el tipo de seguridad ya está determinado en tiempo de compilación, por lo que no es necesario tenerlo nuevamente en tiempo de ejecución), y con un pequeño truco con campos estáticos, puede usar el mismo " escriba "también. Por ejemplo:

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

Solo hay un "método" de IsOk para IsOk , y puede ser utilizado por Foo<string> y Foo<object> (lo que, por supuesto, también significa que las llamadas a ese método pueden ser las mismas). Pero sus campos estáticos todavía están separados, como lo requiere la especificación CLI, lo que también significa que DoCount debe referirse a dos campos separados para Foo<string> y Foo<object> . Y, sin embargo, cuando hago el desmontaje (en mi computadora, tenga en cuenta que estos son detalles de implementación y pueden variar bastante; además, se necesita un poco de esfuerzo para evitar la DoCount de DoCount ), solo hay un método DoCount . ¿Cómo? La "referencia" a Counter es indirecta:

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()

Y el método DoCount se ve más o menos así (excluyendo el prólogo y el relleno "No quiero en línea este método"):

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

Entonces, el código básicamente "inyectó" la dependencia Foo<string> / Foo<object> , por lo que si bien las llamadas son diferentes, el método que se llama es en realidad el mismo, solo que con un poco más de indirección. Por supuesto, para nuestro método original ( () => Counter++ ), esto no será una llamada en absoluto, y no tendrá la indirección adicional, solo estará en línea en el sitio de llamadas.

Es un poco más complicado para los tipos de valor. Los campos de los tipos de referencia son siempre del mismo tamaño: el tamaño de la referencia. Por otro lado, los campos de tipos de valores pueden tener diferentes tamaños, por ejemplo, int vs. long o decimal . La indexación de una matriz de enteros requiere un ensamblaje diferente que la indexación de una matriz de s decimal . Y dado que las estructuras también pueden ser genéricas, el tamaño de la estructura puede depender del tamaño de los argumentos de tipo:

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

Si agregamos tipos de valor a nuestro ejemplo anterior

Foo<int>.DoCount(); Foo<double>.DoCount(); Foo<int>.DoCount();

Obtenemos este código:

000007FE940D04BB call 000007FE940D00F0 ; Foo<int>.DoCount() 000007FE940D04C0 call 000007FE940D0118 ; Foo<double>.DoCount() 000007FE940D04C5 call 000007FE940D00F0 ; Foo<int>.DoCount()

Como puede ver, si bien no obtenemos la indirección adicional para los campos estáticos, a diferencia de los tipos de referencia, cada método está completamente separado. El código en el método es más corto (y más rápido), pero no se puede reutilizar (esto es para Foo<int>.DoCount() :

000007FE940D058B mov eax,dword ptr [000007FE93FC60D0h] ; Foo<int>.Counter 000007FE940D0594 lea edx,[rax+1] 000007FE940D0597 mov dword ptr [7FE93FC60D0h],edx

Solo un simple acceso de campo estático como si el tipo no fuera genérico en absoluto, como si acabamos de definir la class FooOfInt y la class FooOfDouble .

La mayoría de las veces, esto no es realmente importante para ti. Los genéricos bien diseñados por lo general son más que suficientes para pagar sus costos, y no puede simplemente hacer una declaración clara sobre el rendimiento de los genéricos. Usar una List<int> casi siempre será una mejor idea que usar ArrayList de ints: usted paga el costo de memoria adicional de tener múltiples métodos List<> , pero a menos que tenga muchas List<> s de tipo de valor diferente sin elementos, los ahorros probablemente superarán el costo tanto en memoria como en tiempo. Si solo tiene una reificación de un tipo genérico dado (o todas las reificaciones están cerradas en los tipos de referencia), generalmente no pagará más; puede haber un poco de indirección adicional si no es posible la inserción.

Hay algunas pautas para usar genéricos de manera eficiente. Lo más relevante aquí es mantener solo las partes genéricas realmente genéricas. Tan pronto como el tipo que contiene es genérico, todo lo que contiene también puede ser genérico, por lo que si tiene 100 kiB de campos estáticos en un tipo genérico, cada reificación deberá duplicar eso. Esto puede ser lo que quieres, pero puede ser un error. El enfoque habitual es colocar las partes no genéricas en una clase estática no genérica. Lo mismo se aplica a las clases anidadas: la class Foo<T> { class Bar { } } significa que Bar también es una clase genérica ("hereda" el argumento de tipo de la clase que lo contiene).

En mi computadora, incluso si mantengo el método DoCount libre de cualquier cosa genérica (reemplace Counter++ con solo 42 ), el código sigue siendo el mismo: los compiladores no intentan eliminar la "genéricaidad" innecesaria. Si necesita utilizar muchas reificaciones diferentes de un tipo genérico, esto puede sumar rápidamente, así que considere mantener esos métodos separados; Puede valer la pena ponerlos en una clase base no genérica o en un método de extensión estática. Pero como siempre con el rendimiento - perfil. Probablemente no sea un problema.


En primer lugar, aclaremos dos cosas. Esta es una definición de método genérico:

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

Esta es una definición de tipo genérico:

class C<T> { }

Lo más probable es que si le pregunto qué es M , dirá que es un método genérico que toma una T y devuelve una T Eso es absolutamente correcto, pero propongo una forma diferente de pensarlo: aquí hay dos conjuntos de parámetros. Uno es el tipo T , el otro es el objeto x . Si los combinamos, sabemos que colectivamente este método toma dos parámetros en total.

El concepto de curry nos dice que una función que toma dos parámetros puede transformarse en una función que toma un parámetro y devuelve otra función que toma el otro parámetro (y viceversa). Por ejemplo, aquí hay una función que toma dos enteros y produce su suma:

Func<int, int, int> uncurry = (x, y) => x + y; int sum = uncurry(1, 3);

Y aquí hay una forma equivalente, donde tenemos una función que toma un entero y produce una función que toma otro entero y devuelve la suma de esos enteros antes mencionados:

Func<int, Func<int, int>> curry = x => y => x + y; int sum = curry(1)(3);

Pasamos de tener una función que toma dos enteros a tener una función que toma un entero y crea funciones . Obviamente, estos dos no son literalmente lo mismo en C #, pero son dos formas diferentes de decir lo mismo, porque pasar la misma información eventualmente lo llevará al mismo resultado final.

El curry nos permite razonar sobre las funciones más fácilmente (es más fácil razonar sobre un parámetro que sobre dos) y nos permite saber que nuestras conclusiones siguen siendo relevantes para cualquier número de parámetros.

Considere por un momento que, en un nivel abstracto, esto es lo que ocurre aquí. Digamos que M es una "superfunción" que toma un tipo T y devuelve un método regular. Ese método devuelto toma un valor T y devuelve un valor T

Por ejemplo, si llamamos a la superfunción M con el argumento int , obtenemos un método regular de int a int :

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

Y si llamamos a ese método regular con el argumento 5 , obtenemos un 5 , como esperábamos:

int v = e(5);

Entonces, considere la siguiente expresión:

int v = M<int>(5);

¿Ves ahora por qué esto podría considerarse como dos llamadas separadas? Puede reconocer la llamada a la superfunción porque sus argumentos se pasan en <> . Luego sigue la llamada al método devuelto, donde se pasan los argumentos en () . Es análogo al ejemplo anterior:

curry(1)(3);

Y de manera similar, una definición de tipo genérico también es una superfunción que toma un tipo y devuelve otro tipo. Por ejemplo, List<int> es una llamada a la List con un argumento int que devuelve un tipo que es una lista de enteros.

Ahora, cuando el compilador de C # cumple con un método regular, lo compila como un método regular. No intenta crear diferentes definiciones para diferentes argumentos posibles. Así que esto:

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

se compila como es. No se compila como:

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

En otras palabras, el compilador de C # no evalúa todos los argumentos posibles para este método para incrustarlos en el ejecutable final, sino que deja el método en su forma parametrizada y confía en que el resultado se evaluará en tiempo de ejecución.

De manera similar, cuando el compilador de C # cumple con una superfunción (un método genérico o definición de tipo), la compila como una superfunción. No intenta crear diferentes definiciones para diferentes argumentos posibles. Así que esto:

T M<T>(T x) => x;

se compila como es. No se compila como:

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

Nuevamente, el compilador de C # confía en que cuando se llama a esta superfunción, se evaluará en tiempo de ejecución, y esa evaluación generará el método o tipo normal.

Esta es una de las razones por las cuales C # se beneficia al tener un compilador JIT como parte de su tiempo de ejecución. Cuando se evalúa una superfunción, ¡produce un método completamente nuevo o un tipo que no estaba allí en el momento de la compilación! Llamamos a ese proceso reification . Posteriormente, el tiempo de ejecución recuerda ese resultado para que no tenga que volver a crearlo. Esa parte se llama memoization .

Compare con C ++ que no requiere un compilador JIT como parte de su tiempo de ejecución. El compilador de C ++ realmente necesita evaluar las superfunciones (llamadas "plantillas") en tiempo de compilación. Esa es una opción factible porque los argumentos de las superfunciones están restringidos a cosas que pueden evaluarse en tiempo de compilación.

Entonces, para responder a su pregunta:

class Foo { public void Bar() { } }

Foo es un tipo regular y solo hay uno de ellos. Bar es un método regular dentro de Foo y solo hay uno de ellos.

class Foo<T> { public void Bar() { } }

Foo<T> es una superfunción que crea tipos en tiempo de ejecución. Cada uno de esos tipos resultantes tiene su propio método regular llamado Bar y solo hay uno (para cada tipo).

class Foo { public void Bar<T>() { } }

Foo es un tipo regular y solo hay uno de ellos. Bar<T> es una superfunción que crea métodos regulares en tiempo de ejecución. Cada uno de esos métodos resultantes se considerará parte del tipo regular Foo .

class Foo<Τ1> { public void Bar<T2>() { } }

Foo<T1> es una superfunción que crea tipos en tiempo de ejecución. Cada uno de esos tipos resultantes tiene su propia superfunción llamada Bar<T2> que crea métodos regulares en tiempo de ejecución (en un momento posterior). Cada uno de esos métodos resultantes se considera parte del tipo que creó la superfunción correspondiente.

Lo anterior es la explicación conceptual. Más allá de eso, se pueden implementar ciertas optimizaciones para reducir el número de implementaciones distintas en la memoria, por ejemplo, dos métodos construidos pueden compartir una implementación de código de máquina única bajo ciertas circunstancias. Vea la respuesta de Luaan acerca de por qué el CLR puede hacer esto y cuándo realmente lo hace.


A diferencia de las plantillas C ++ , los genéricos .NET se evalúan en tiempo de ejecución, no en tiempo de compilación. Semánticamente, si crea una instancia de la clase genérica con diferentes parámetros de tipo, estos se comportarán como si fueran dos clases diferentes, pero en el fondo, solo hay una clase en el código IL (lenguaje intermedio) compilado.

Tipos genéricos

La diferencia entre diferentes instancias del mismo tipo genérico se hace evidente cuando usa Reflection : typeof(YourClass<int>) no será lo mismo que typeof(YourClass<string>) . Estos se llaman tipos genéricos construidos . También existe un typeof(YourClass<>) que representa la definición de tipo genérico . Aquí hay algunos consejos adicionales sobre cómo lidiar con los genéricos a través de Reflection.

Cuando crea una instancia de una clase genérica construida , el tiempo de ejecución genera una clase especializada sobre la marcha. Hay diferencias sutiles entre cómo funciona con valores y tipos de referencia.

  • El compilador solo generará un único tipo genérico en el ensamblaje.
  • El tiempo de ejecución crea una versión separada de su clase genérica para cada tipo de valor con el que lo usa.
  • El tiempo de ejecución asigna un conjunto separado de campos estáticos para cada parámetro de tipo de la clase genérica.
  • Debido a que los tipos de referencia tienen el mismo tamaño, el tiempo de ejecución puede reutilizar la versión especializada que generó la primera vez que lo usó con un tipo de referencia.

Métodos genéricos

Para los métodos genéricos , los principios son los mismos.

  • El compilador solo genera un método genérico, que es la definición del método genérico .
  • En tiempo de ejecución, cada especialización diferente del método se trata como un método diferente de la misma clase.