Regreso a las bases: Memoria (Parte 2)

Lo que sigue es una traducción de una sección del ebook gratuito Foundations of Programming de Karl Seguin.


Apuntadores


Para muchos desarrolladores, aprender sobre apuntadores en la escuela fue una experiencia dolorosa. Representan la verdaderamente real indirección que existe entre código y hardware. Muchos más desarrolladores nunca tuvieron la experiencia de aprender sobre ellos  - saltaron directamente a programar en un lenguaje que no los expone directamente. La verdad sin embargo es que cualquiera que diga que C# o Java son lenguajes sin apuntadores es simplemente un error. Como los apuntadores son el mecanismo con el cual todos los lenguajes almacenan valores en el heap, es más bien tonto no entender como son usados.


Los apuntadores representan el nexus del modelo de memoria de un sistema – esto es, los apuntadores son el mecanismo donde el stack y el heap trabajan juntos para proveer el subsistema de memoria requerido por su programa. Como discutimos anteriormente, cuando instancia un objeto new, .NET asigna un bloque de memoria al heap y regresa un apuntador al inicio de este bloque de memoria. Esto es todo lo que un apuntador es: la dirección de inicio para el bloque de memoria que contiene un objeto. La dirección no es nada más que un número único, generalmente representado en formato hexadecimal. Por lo tanto, un apuntador no es nada más que un número único que le dice a .NET donde está el objeto mismo en memoria. Esta indirección es transparente en Java o .NET, pero no en C o C++ donde se puede manipular la dirección de memoria directamente con un apuntador aritmético. En C o C++ se puede tomar un apuntador y agregar 1 a él, y así arbitrariamente cambiar a donde está apuntando (y seguramente hacer tronar el programa debido a esto).


Donde se pone interesante es donde el apuntador está realmente almacenado. Ellos en realidad siguen las mismas reglas descritas arriba: como enteros son almacenados en el stack – al menos, claro, que ellos formen parte de una referencia a un objeto y entonces estarán en el heap con el resto del objeto. Puede no ser claro aún, pero esto significa que ultimadamente, todos los objetos heap están enraizados al stack (posiblemente a través de numerosos niveles de referencias). Veamos primero este ejemplo simple:


static void Main(string[] args)

{

  int x = 5;

  string y = “codebetter.com“;

}


Del código de arriba, terminaremos con 2 valores en el stack, el entero 5 y el apuntador a nuestra cadena, así como también precisamente el valor en el heap. Aquí una representación gráfica:




Cuando salimos de nuestra function main (olvidémonos del hecho de que el programa se parará), nuestro stack liberará todos los valores locales, lo que significa que tanto el valor de x como de y se perderán. Esto es significativo porque la memoria asignada en el heap todavía contiene nuestra cadena, pero hemos perdido toda referencia a ella (no hay algún apuntador apuntándola). En C o C++ esto resulta en una fuga de memoria – sin una referencia a nuestra dirección en el heap no podemos liberarla de la memoria). En C# o Java, nuestro confiable recolector de basura detectará el objeto sin referencia y lo liberará.


Veremos ejemplos más complejos, que aparte de tener más flechas apuntando, es básicamente el mismo.


public class Empleado

{

  private int _empleadoId;

  private Empleado _gerente;

  public int EmpleadoId

  {

    get { return _empleadoId; }

    set { _empleadoId = value; }

  }

  public Empleado Gerente

  {

    get { return _gerente; }

    set { _gerente = value; }

  } 

  public Empleado(int empleadoId)

  {

    _empleadoId = empleadoId;

  }

}

public class Prueba

{

  private Empleado _subordinado;

  void HacerAlgo()

  {

    Empleado jefe = new Empleado(1);
    _subordinado = new Empleado(2);

    _subordinado.Gerente = _jefe;

  }

}





Interesantemente, cuando salimos de nuestro método, la variable jefe se liberará del stack, pero el subordinado, que está definido por el alcance padre, no. Esto significa que el recolector de basura no tendrá nada que limpiar porque los dos valores del heap seguirán siendo referenciados (uno directamente del stack, y el otro indirectamente del stack a través del objeto referenciado.


Como puede ver, los apuntadores definitivamente juegan una parte importante tanto en C# como en VB.NET. Como el apuntador aritmético no está disponible en ninguno de estos lenguajes, los apuntadores son grandemente simplificados y con suerte fácilmente entendidos.


 

Regreso a las bases: Memoria

Lo que sigue es una traducción de una sección del ebook gratuito Foundations of Programming de Karl Seguin.


Regreso a las bases: Memoria


Por más que se intente, los lenguajes modernos de programación no pueden abstraer completamente los aspectos fundamentales de los sistemas computacionales. Por ejemplo, podemos asumir que usted se ha encontrado con las siguientes excepciones .NET: NullReferneceException, OutOfMemoryException, StackOverflowException y ThreadAbortException. Tan importante como es para desarrolladores adopter varios patrones y técnicas de alto nivel, es igualmente importante comprender el ecosistema en el cual su programa se ejecuta. Mirando por encima de las capas proveídas por el compilar de C# (o VB.NET), el CLR y el sistema operativo, nos encontramos con la memoria. Todos los programas hacen uso extensivo de la memoria del sistema e interaccionan con ella en maravillas maneras, es difícil ser un buen programador sin comprender esta interacción fundamental.
Mucha de la confusión sobre la memoria nace del hecho de que tanto C# y VB.NET son lenguajes administrados y que el CLR provee la recolección automática de basura. Esto ha causado que muchos desarrolladores asuman erróneamente que no necesitan preocuparse por la memoria.


Asignación de Memoria


En .NET, como en muchos otros lenguajes, cada variable que se defina está almacenada en el stack  o en el heap . Estos son dos espacios separados asignados en la memoria de sistema que sirven un propósito distinto, aunque complementario. Lo que va donde está predeterminado: valores de tipos van en el stack, mientras que la referencia a tipos va en el heap. En otras palabras, todos los tipos de sistema, como char, int, long, byte, enum y cualquier estructura (ya sean definidas por.NET o por usted) van en el stack. La única excepción a esta regla son los valores de tipos que pertenecen a referencias de tipos – por ejemplo la propiedad Id de una clase User va en el heap junto con la instancia de la clase User misma.


El Stack


Aunque estamos acostumbrados al mágico colector de basura, los valores en el stack son automáticamente administrados aún en un mundo sin colector de basura (como en C). Esto es porque cuando sea que entramos a un nuevo alcance (como un método o una sentencia If) los valores son empujados al stack y cuando salen del stack los valores son liberados. Esta es la razón por la que un stack es sinónimo a LIFO – last-in first-out (último en entrar primero en salir). Puede pensarlo en este modo: cuando se crea un nuevo alcance, por ejemplo un método, un marcador es puesto en el stack y los valores son añadidos como se necesiten. Cuando se deja ese alcance, todos los valores son liberados incluyendo el marcador del método. Esto funciona en cualquier nivel de anidado.
Hasta que veamos la interacción entre el heap y el stack, la única manera real de meterse en problemas con el stack es con StackOverflowException. Esto significa que ha usado todo el espacio disponible del stack. 99.9% del tiempo, esto indica una llamada recursiva interminable (una función que se llama a sí misma ad infinitum). En teoría esto puede ser causado por un muy muy mal diseño de sistema, aunque nunca he visto una llamada  no recursiva usando todo el espacio del stack.


El Heap


La asignación de memoria en el heap no es tan simple como el stack. La mayoría de la asignación de memoria basada en el heap ocurre cuando creamos un objeto new. El compilador averigua cuanta memoria necesitaremos (lo cual no es tan difícil, aún con objetos con referencias anidadas), toma un apropiado montón de memoria y regresa el apuntador a la memoria asignada (más acerca de esto en un momento). El ejemplo más sencillo es una cadena, si cada carácter en una cadena toma 2 bytes, y creamos una nueva cadena con el valor de “Hola Mundo”, entonces el CLR necesitará asignar 22 bytes (11×2) más cualquier adicional necesitado.
Hablando de cadenas, seguramente ha oído que las cadenas son inmutables – esto es, una vez que ha sido declarada una cadena y asignado un valor, si se modifica esa cadena (cambiando el valor o concatenando otra cadena a ella), entonces una nueva cadena se crea. Esto realmente puede tener implicaciones de rendimiento negativas, y por ello la recomendación general es usar un StringBuilder para cualquier manipulación de cadenas significativa. La verdad es que cualquier objeto almacenado en el heap es inmutable con respecto a la asignación de tamaño, y cualquier cambio en el tamaño subyacente requerirá una nueva asignación. El StringBuilder, junto con algunas colecciones, parcialmente pueden sacar la vuelta a esto usando buffers internos. Una vez que el buffer se llena, la misma reasignación ocurre y algún tipo de algoritmo de crecimiento es usado para determinar el nuevo tamaño (el más simple siendo antiguoTamaño * 2). Siempre que sea posible es buena idea especificar la capacidad inicial de dichos objetos para evitar este tipo de reasignación (el constructor para tanto el StringBuilder y el  ArrayList (entre muchas otras colecciones) le permiten especificar capacidad inicial).
Recolectar basura del heap es una tarea no trivial. A diferencia del stack donde el último alcance puede simplemente liberarlo, los objetos del heap no son locales a un determinado alcance. En lugar de ello, la mayoría son referencias profundamente anidadas de otros objetos referenciados. En lenguajes como en C, cuando un programador causa que la memoria sea asignada al heap, debe asegurarse también de remover del heap cuando ha terminado con él. En lenguajes administrados, el motor en tiempo de ejecución se encarga de limpiar los recursos (.NET usa un Recolector de Basura Generacional que está brevemente descrito en la Wikipedia).
Hay muchos incidentes horribles que pueden molestar a los desarrolladores mientras trabajan con el heap. Fugas de memoria no solo son posibles sino muy comunes, la fragmentación de memoria puede causar todo tipo de caos, y varios problemas de rendimiento pueden generarse gracias a comportamiento de asignación extraño o interacción con código sin administrar (lo cual .NET hace mucho debajo del agua).