Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 11 de julio de 2017
C#En C# siempre hemos podido enviar a métodos parámetros por referencia usando la palabra clave ref. Aunque su alcance era algo limitado, nos permitía coquetear con punteros para cubrir de forma eficiente algunos escenarios y permitirnos algunos usos avanzados, pero sin necesidad de abandonar la seguridad que nos ofrece el entorno de ejecución de .NET.

Un ejemplo clásico es el uso de ref para intercambiar dos valores desde un método:
int one = 1, two = 2;
Swap(ref one, ref two);
Console.WriteLine($"{one},{two}"); // 2,1
...

void Swap<T>(ref T a, ref T b)
{
    var temp = a;
    a = b;
    b = temp;
}
En C#7, el ámbito de uso de las referencias se ha ampliado bastante gracias a la introducción de dos nuevas características en el lenguaje:
  • El soporte para variables locales de tipo referencia, o ref locals.
  • La capacidad de un método o función de retornar referencias, también conocida como ref returns.
Ninguna de estas características son ideas nuevas. Ya Eric Lippert tanteó sobre la posibilidad de implementarlas en el lenguaje hace más de siete años, pero no fue hasta hace dos años cuando volvió a saltar a la palestra y, tras la propuesta oficial, convertirse finalmente en una realidad.

Veamos en qué consisten.

1. Variables locales de tipo referencia (ref locals)

En C#7 podemos utilizar la palabra clave ref para definir variables de tipo referencia, es decir, crear punteros hacia otras variables o miembros que sean del mismo tipo estén visibles en el momento de la definición. Veamos un código de dudosa utilidad, pero que ilustra su sintaxis y forma de utilización en este contexto:
int original = 0;
ref int alias = ref original; // O bien, ref var alias = ref original;

alias = 18; // Sobreescribimos el contenido del destino de la referencia
Console.WriteLine(original); // 18;
Observad que estamos creando una variable local llamada alias que es una referencia hacia otra variable de tipo int definida en el mismo ámbito, aspecto que indicamos insertando la palabra ref antes de su declaración y a la hora de asignarla.

Es importante tener en cuenta que las referencias locales sólo pueden ser asignadas en el momento de su declaración y, por supuesto, sólo podremos inicializarlas con referencias (en la práctica, expresiones que también van precedidas de la palabra clave ref).

2. Retorno de referencias (ref returns)

Los métodos, al igual que pueden recibir referencias (parámetros ref), en C#7 pueden también retornar referencias. Veamos primer un ejemplo parecido al intercambio de variables que hemos visto anteriormente:
public ref int Max(ref int first, ref int second)
{
    if (first >= second)
        return ref first;
    return ref second;
}
Fijaos que la firma del método contiene varias palabras ref indicando tanto los parámetros de entrada como los de salida son referencias a valores enteros. Lo interesante de este método es que su retorno no es una copia del valor máximo, como sería lo habitual, sino una referencia hacia la variable que contiene el valor máximo.

Existe alguna restricción a tener en cuenta a la hora de retornar referencias, puesto que hay que cumplir unos principios de seguridad básicos. Por ejemplo, no podemos retornar una referencia hacia una variable local porque ésta desaparecerá al finalizar el método; por ello, el retorno consistirá siempre en referencias recibidas como parámetros, o bien hacia miembros que continúen vivos tras su ejecución y no sean candidatos a ser eliminados por el recolector de basura, por ejemplo:
public class MyClass
{
    private int IntValue = 0;

    public ref int DoSomething(ref int first, ref int second)
    {
        return ref IntValue;
    }
}

3. Cómo se usan juntos

Realmente, estas nuevas capacidades cobran mayor sentido cuando se utilizan juntas. Por ejemplo, en el siguiente código veremos cómo podríamos consumir el método que vimos anteriormente para obtener una referencia al máximo de dos números, y los interesantes daños colaterales que podríamos causar cuando jugueteamos con referencias:
int one = 1, two = 2;
ref var max = ref Max(ref one, ref two);

Console.WriteLine(max++); // 2
Console.WriteLine(two);   // 3!!

public static ref int Max(ref int first, ref int second)
{
    if (first >= second)
        return ref first;
    return ref second;
}
Desde el punto de vista sintáctico, lo único que puede llamar la atención es el uso de la palabra clave ref en todo código que opera sobre una referencia. No es que resulte muy cómodo, pero entiendo que es necesario para facilitar la comprensión del código y evitar problemas.

Veamos otro ejemplo, donde se muestra cómo podemos utilizarlo para obtener referencias hacia el elemento de un array. Fijaos de nuevo que el contenido retornado desde el método GetMax() no es una copia del valor máximo, sino una referencia hacia la "caja" donde se guarda éste, por lo que puede ser manipulable con facilidad:
var arr = new int[] {1, 2, 3, 4, 5};
ref var max = ref GetMax(arr);
Console.WriteLine(max);                   // 5
max*=2;
Console.WriteLine(string.Join(",", arr)); // 1,2,3,4,10
...

ref int GetMax(int[] array)
{
    int max = int.MinValue, index = -1;
    for (int i = 0; i < array.Length; i++)
    {
        if (array[i] > max)
        {
            max = array[i];
            index = i;
        }
    }
    return ref array[index];
}
Otro aspecto bastante curioso es que, dado que los retornos de métodos o funciones ref son referencias, podemos operar sobre ellas de forma directa, sin necesidad de almacenarlas en una variable local, lo que puede dar lugar a un código tan sorprendente como el siguiente:
int one = 1, two = 2;
Max(ref one, ref two) = 99;  // WTF?
Console.WriteLine(two);      // 99!!
Pues sí, ¡estamos incluyendo una llamada a un método en el lado izquierdo de una asignación! Aunque algo extraño de leer, si lo pensamos un poco tiene su sentido: el método retorna una referencia, y simplemente estamos usándola para establecer el valor 99 en la variable a la que estamos apuntando.

Y esto es todo…

… al menos de momento, seguro que iremos descubriendo más usos y posibilidades ;D

En definitiva, se trata de un incremento del lenguaje que puede resultar interesante en algunos escenarios poco frecuentes y en cualquier caso muy enfocado al rendimiento, pues estas dos características permiten sustituir movimientos de datos, copias y allocations por simples referencias en memoria a información ya existente. Pero, eso sí, siempre en un entorno seguro y libre de los típicos fallos debidos a punteros desmadrados de lenguajes de menor nivel.

Seguro que no utilizaremos estas novedades todos los días, pero, como en otros casos, es interesante conocerlas y tenerlas en el cinturón de herramientas por si en algún momento necesitamos usarlas. Pero como siempre, antes de lanzaros como posesos a implementar métodos y variables referencia, tened en cuenta las implicaciones que puede tener también a nivel de legibilidad y complejidad en vuestro código; si existe una solución a vuestro problema sin usar ref, probablemente sea mejor optar por ella salvo que tengáis motivos de peso.

Por último, si queréis seguir profundizando en las novedades de C# 7, ahí van links a otros artículos la serie publicados hasta el momento:
Publicado en Variable not found.

9 Comentarios:

Rfog dijo...

Me acaban de salir dos sarpullidos y Dios está matando gatitos en cantidades industriales, y lo seguirá haciendo hasta que deje de ser posible devolver una referencia a un objeto...

Esto intenta solucionar una pega de C#: la inexistencia de punteros, pero clama al cielo la aberración de devolver una referencia. Entiendo que es "sugar syntax", pero aun así ronda la aberración más absoluta, sobre todo con tipos-valor, que tienen que ser movidos al montón y luego sacados de él: eficiencia y rendimiento. Eso o apuntar al objeto en la pila, pero ¿qué pasa entonces cuando devolvemos una referencia (un puntero) a un objeto que dejará de estar en la pila cuando el método retorne? ¿Se moverá al montón (y cómo, ¿copia profunda, movimiento de memoria tal cual? ¿Qué pasa con las referencias dentro de las referencias?)? En fin.

La aberración sigue cuando estamos obteniendo una referencia a un tipo-referencia (en oposición a un tipo-valor)... ¿Una referencia a una referencia (que viene a ser un puntero a puntero)? ¿Qué pasa con la copia profunda? ¿Si tengo una referencia a un array de referencias a tipos-valor, se me copiaría todo? ¿Solo el array? ¿Qué pasa si las cosas referenciadas en ese array son también tipos-valor y están en la pila?

Mmmmm... Asigno un bloque de código (blob binario, una estructura-valor con un byte detrás del otro, secuencial, que va en la pila). Obtengo una referencia a él. Me salgo de la función que ha declarado la estructura (que está en la pila) y devuelvo dicha referencia... pinneo dicho bloque (o lo pinneo antes de salir del método), lo asigno a una función y la ejecuto... ¿Buffer overflow? ¿Ejecución de código dentro de la pila (en Win32 no hay bit NX)?

Se me debe haber escapado algo, no creo que sean tan tontos.

José María Aguilar dijo...

Hola, Rafael!

Te salen sarpullidos porque eres un tío muy sensible... a mi ya se me ha formado costra y soy inmune a novedades extravagantes del lenguaje ;DDD

Creo que el tema es más simple tal y como lo han planteado. Esta feature no nos proporciona un C# con soporte completo a punteros, sino un pequeño adelanto pensado sobre todo -supongo- para evitar allocs en algunos escenarios frecuentes.

Por ejemplo, las referencias sólo se pueden obtener de objetos perdurables (var local en su ámbito o propiedades de una clase -que vive en el heap-), por lo que no se pueden dar muchos de esos problemas que comentas. El resto de casos extremos habría que probar, pero probablemente el comportamiento sea consistente con el entorno seguro que proporciona el CLR.

Muchas gracias por comentar!









Anónimo dijo...

Menudas perlas, Rafael. Que tal si aprendes un minimo de como funciona .NET y su gestion de memoria antes de hacer esas afirmaciones? Algunas de las dudas que expones son cosas con las que te topas en cuanto tocas por primera vez codigo unsafe. Por no hablar de la "inexistencia de punteros".

Hay vida mas alla de C++.

Rfog dijo...

Anónimo, ¿qué te parece escribir un artículo que demuestre todas las tonterías que he dicho en el comentario? Estoy seguro al 100% J. M. Aguilar te lo iba a publicar aquí a pies juntillas, y si no, te pongo yo en WinTablet o en mi propio blog. ¿Te imaginas? Demostrar lo tonto que soy, dejarme en evidencia ante todo el mundo... Uff, qué subidón, ¿eh, tío?

Pues nada, lo escribes y hablamos.

José María Aguilar dijo...

Hola!

Disculpad por no haber podido intervenir hasta ahora.

@anónimo si no estás de acuerdo con la opinión de alguien, creo que lo correcto es intentar exponer tu punto de vista en lugar decir simplemente "no tienes ni idea". Flaco favor hacemos a la comunidad si seguimos en esta línea.

Por supuesto, estás invitado a escribir aquí tu punto de vista, si quieres incluso manteniendo el anonimato, y profundizar en este tema tan interesante. Pero siempre con respeto y buscando el que todos podamos aprender :)

Saludos!

Joseba dijo...

Hola!
Siempre que salen nuevas funcionalidades de un lenguaje hay gente que brinca de su silla.
Me recuerda un poco a cuando la R.A.E. acepta nuevos palabros o definiciones y los incluye en el diccionario. Hay de todo... Al final es un asunto de si te gusta y le ves utilidad lo usas cuando llegue el caso, sino, lo ignoras y lo haces a la vieja usanza.

Nunca me he puesto a mirar ensamblados ni cosas por el estilo (soy más superficial) pero la línea Max(ref one, ref two) = 99; me recuerda a cuando pasas con new un objeto "anónimo" a una función, se queda por "ahí" una referencia fantasma.
Me pregunto como trabajará el recolector de basura en ambos casos.
Seguro que he dicho una tontería pero la ignorancia me puede.

Saludos.

Rfog dijo...

Se supone que el recolector en ambos casos tiene la referencia a lo instanciado y lo liberará en la siguiente pasada en el que haya salido de ámbito... De todos modos, y ahora hablo de oídas, no está muy claro cómo trata el CLR a eso. En C++ eso debería ser liberado antes de que el método padre salga de ámbito o tendrás una fuga de memoria. Hasta donde sé, el CLR suma uno al contador de referencias del objeto cuando entra y resta uno cuando sale, por que en teoría, ese temporal pasado debería tener dos conteos dentro de la función llamada, uno después de que la llamada se haya ejecutado, y cero cuando salga de la llamante, por lo que en la siguiente pasada del recolector sería liberado. Así al menos funcionaría con C++ y un autopuntero, que viene a ser lo mismo.

De todos modos tampoco te lo puedo asegurar. Hace mucho tiempo que solo uso C# para utilidades rápidas y me da igual si me dejo algo colgando, por lo que ahora hago como tu.

Respecto a lo de añadir elementos al lenguaje, tienes toda la razón del mundo, je je.

José María Aguilar dijo...

Hola!

Pues sí, sin duda es como dices. El tiempo nos irá diciendo cuáles de las nuevas funcionalidades pasarán al uso diario, y cuáles caen en el olvido :)

Respecto al comentario sobre la recolección de basura, no creo que haya mucha magia por detrás en estos casos. En los objetos anónimos, al fin y al cabo son objetos normales (aunque de una clase que no tiene nombre), y serán candidatos para recolección cuando salen de su ámbito, que será normalmente el método/bloque desde el que son instanciados.

En cuanto a Max(ref one, ref two)=99 no debe haber recolección alguna, puesto que sólo enviamos y recibimos referencias. Ese "99" se introducirá como valor en la "caja" apuntada por la referencia que se retorna, pero no hay instanciación de ningún tipo porque los objetos ya existen previamente.

Gracias por comentar y un saludo!

Rfog dijo...

Se me pasó comentar que en C++ si pasas un objeto anónimo instanciado con new... lo tienes que liberar dentro de la función llamada o tendrás una fuga de memoria, así que en general los compiladores de C++ suelen protestar cuando haces algo así. ¿Que por qué lo sé? Pues imagina, a veces se me olvida el "const" en una firma de método y el compilador se me pone a gritar como un loco sobre el uso de temporales...