Asignar null o cero a referencias o manejadores, que no apuntan a ningún objeto válido, es una forma eficaz de evitar errores difíciles de trazear. En este artículo presento por qué es una buena idea “anular” referencias no utilizadas y como anulamos referencias para que a primera vista no parece existir un valor nulo.

Qué son referencias

En este artículo usamos el término “referencia” para varios tipos datos. Pueden ser punteros a memoria dinámica o manejadores (handles en inglés) de recursos. También pueden ser variables de objetos como en Java. Lo que todas estas entidades tienen en común es que no son un recurso sino lo representan.

Cuando se copia una referencia, hay un apuntador más al mismo recurso, pero no se copia el recurso en si. Cuando se libera un recurso, entonces todas las referencias a él se vuelven inválidos. Los problemas que pueden surgir cuando usamos referencias inválidas discutimos a continuación.

Punteros

Antes de hablar de referencias en general, quiero explicar unos conceptos básicos con el caso estrella de liarse: los punteros de C y C++. Punteros son variables que guardan una dirección en la memoria. Cuando se reserva memoria, entonces se guarda la dirección de este memoria en un puntero.

Memoria dinámica en C++

Un escenario típico para la creación de memoria dinámica es el siguiente.

// Reserva memoria - en C se haría con malloc
MiObjeto* puntero_a_objeto = new MiObjeto();

// Haz algo con los datos del puntero

// Libera la memoria
delete puntero_a_objeto;

¿Por qué el código arriba está mal? Pues, realmente no es. La memoria se reserva con new (o malloc en C) y se libera correctamente con delete (con free en C). No obstante, no se asigna NULL al puntero_a_objeto. Y esto puede causar problemas.

Por ser completos, también quiero decir, cuando no da problemas: cuando se pierde el acceso a la variable puntero_a_objeto porque se sale del bloque donde se definió. Este caso se da a menudo en destructores, donde no anular un puntero no suele causar problemas.

Apuntar al vacío

En muchas ocasiones se usa la comparación puntero == NULL para comprobar, si un puntero apunta a nada. Si no es NULL se lee y escribe en la memoria a que apunta. A veces un programa espera hasta que se haya liberado una memoria. Un puntero con el valor NULL representa esta liberación. Por lo tanto, olvidarse a anular un puntero inválido puede causar un bucle infinito. El programa espera a la liberación de la memoria, pero no se entera porque no se anuló el puntero correspondiente.

Aunque sea liberada, la memoria no desaparece. (Esto sólo pasa si sacas la memoria RAM de tu ordenador.) Como todavía existe, se puede leer de ella. Por eso, un objeto liberado puede parecer vivo y se puede emplear al menos hasta que se reserve la memoria de nuevo.

Apuntar mal

Cuando se reserva la memoria liberada de nuevo, se suele sobrescribirla con otros valores en general. Entonces pueden producirse dos casos:

Los nuevos valores no son útiles para el objeto anteriormente liberado. Por eso se lee valores malos con el puntero “olvidado” (olvidado a asignarle NULL). Un valor inútil puede ser, por ejemplo, índices de array más allá del tamaño del array. En este caso suele producirse un error en el programa en tiempo de ejecución.

El problema es encontrar este error: en el objeto anteriormente liberado parece cambiarse un índice de array así de repente y sin que exista algún instrucción correspondiente. Y ahora a adivinar por qué. El nuevo objeto que se guardó en la memoria liberada puede estar definido en una biblioteca de que ni siquiera tienes el código. Y la función en que deberías haber anulado el puntero puede estar en otro fichero.

El segundo caso es aún peor: el nuevo objeto cambia el contenido de la memoria pero los valores todavía resultan “correctos”. Por ejemplo, un índice de array vale uno en lugar de cinco. Con esto el programa puede seguir funcionando aunque más tarde o temprano algo parece estar mal.

Más tarde puede ser días o meses. ¡Imagínate que el índice apunta a un array de datos personales y de repente lees los datos de otra persona! Se envía una multa de tráfico a alguien, aunque el policía nunca lo editó así. Y todo esto por usar un puntero inválido por haberse olvidado de asignarle NULL cuando se liberó.

La regla de oro de punteros liberados

En conclusión: Si liberas memoria, asigna NULL al puntero. Sin pensar. Y aunque sea la última instrucción en un destructor. Por 99 veces que lo haces en vano, esta una que te salva de un error complicado lo vale.

Diferencias entre compilación debug y release

Para evitar desastres mayores con la memoria liberada, algunos compiladores incluyen código en modo debug, que sobrescribe la memoria liberada con algún valor tonto como 0xCCCCCCCC. Con suerte es un valor lo suficientemente inútil, para que se genere un error al usarlo de forma inmediata.

No obstante, sobrescribir cuesta tiempo y esto se quiere ahorrar. Por eso, en un programa compilado en modo release, la memoria se queda inalterada tras liberarla. Y por lo tanto se puede seguir leyendo de ella como si fuera válida. Es una de las razones porque un programa puede comportarse de forma diferente en modo debug y en modo release.

Referencias en general

La física de una referencia

Los punteros son un tipo de referencias, pero todavía representan algo físico: una dirección de memoria. Una referencia en general no está atada a una posición específica en la memoria. Sólo representa “algo” que suele estar encapsulado en una biblioteca.

Por ejemplo, cuando se crea una ventana con la biblioteca X, la función de creación devuelve un identificador que representa esta ventana. Hay que pasar este identificador como parámetro cuando se llama a una función de la biblioteca X. Dentro de la biblioteca se usa el identificador para buscar, en qué dirección de memoria se guarda la ventana y se modifica según la función llamada.

La imensa mayoría de referencias son números enteros aunque no aparecen como tales. Una variable objeto en Java o C# se comporta como un objeto de este tipo, pero internamente es un número entero que se el intérprete usa como índice en una lista de objetos.

De hecho, instancias de objetos y referencias tienen características en común. Una referencia o manejador representa una instancia de algo igual como una variable de objeto. Por ejemplo, cuando se usan sockets en en lenguaje C, se representa un socket específico por un socket id – que es un número entero. En un lenguaje orientado a objetos como Java existe una clase Socket. Se crea una conexión de socket al crear una instancia de esta clase.

Analogía entre referencias y punteros

Lo que equivale a “reservar memoria” para un puntero, es la creación de un objeto y el acceso a un recurso para una referencia o un manejador. Se llama a una función Create, que devuelve un identificador para el nuevo recurso creado, y al final se llama a una función Free para liberar este recurso.

Normalmente existe un valor que indica que una referencia o un manejador no referencia nada. En una referencia numérica suele ser cero, una variable objeto puede contener el valor null o nullptr. Tras liberar un objeto, se debería asignar este valor nulo a la referencia, ya que ahora ya no apunta a nada. Si no, las consecuencias puede ser parecidas a las de los punteros “olvidados” mencionados arriba.

Un caso real

Cuento un caso real. Con la biblioteca X Toolkit se pueden crear temporizadores. Cuando vence el tiempo establecido, la biblioteca llama a una función callback y tras esto, el temporizador ya no existe.

Una clase que usa uno de estos temporizadores guarda la referencia a ella en una variable miembro. Puede ofrecer el servicio de cancelar un temporizador. Si el valor de la variable no es cero, entonces se cancela.

Ahora nos imaginamos que el temporizador ya ha vencido y, en consecuencia ya no existe, pero no hemos anulado la referencia en la función callback. Más tarde cancelamos el temporizador. Como la variable miembro no vale cero, se cancela.

En un caso favorable, la biblioteca X Toolkit devuelve un error que este identificador ya no existe. En un caso menos favorable, el identificador, sí, existe, porque se ha vuelto a asignar para otro temporizador. Como existe, la cancelación elimina un temporizador totalmente distinto.

Como el temporizador ya no existe, ya no vencerá, y se asignará al programador correspondiente la tarea de corregir su error, cuando él no ha hecho nada mal. Y ahora imagínate que este programador eres tú y te toca tener la idea que este error puede ser el problema y, a continuación, rastrear mil ficheros de código para ver, en qué callback se olvidó anular la referencia al temporizador.

El valor NULL en cada caso

En fin, no pienses más. Si liberas un recursos, anula las referencias correspondientes. Siempre y sin pensar.

A veces no es muy obvio que es el valor nulo. Por ejemplo, ¿qué es el valor nulo para un iterador de la biblioteca estándar de C++? En este caso se le podría asignar el valor que devuelve el método end() del contenedor correspondiente.

El problema de adivinar cuál es el valor nulo suele aparecer cuando la referencia es un tipo opaco: algún tipo que devuelve una función de creación, pero que no permite asignarle el número entero cero.

Si una biblioteca no aparenta tener un valor nulo para estas referencias, entonces se puede hacer un truco. Se encapsula el identificador en una clase. Sólo es posible crear una instancia de esta clase creando una instancia del objeto que se quiere referenciar. El destructor de la clase libera el recurso. Es decir, la referencia no se anula sino desaparece con la clase que la encapsula.

Si el lenguaje no permite programación orientado a objetos, entonces se puede utilizar una variable booleana adicional, que indica si la referencia es válida o no.

Conclusión

Hemos visto, qué líos pueden surgir por referencias y punteros no anulados tras liberar el recurso a que apuntaban. Suelen causar errores que requieren mucha experiencia en fijarlos. Por eso hemos llegado a la conclusión, que siempre hay que evitar referencias inválidas asignándoles algún valor nulo. En pocos casos el valor nulo puede ser no obvio. Entonces se puede encapsular la referencia en una clase o se usa una variable booleana que indica la validez de la referencia.

Lectura adicional

Menos errores
Más estilo
Más conceptos