You are currently browsing the tag archive for the ‘estilo de programación’ tag.

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

Introducción

Con funcioncitas me refiero a funciones de una o dos líneas, muchas veces con un cálculo muy simple, pero con un valor semántico muy potente.

Escribir una función adicional fracasa a menudo en una cierta pereza, porque hay que buscar un hueco en el fichero fuente, escribirla, en C y C++ hay que añadirlo en la cabecera, eventualmente serás capaz de escribir algún comentario de qué esta función debe hacer y todo esto para una línea de código bastante trivial. Pero esto se puede ver también de otra forma: escribes el código una vez, lo modificas diez veces y lo lees cien veces. Si tardas 99 segundos (1 minuto y 39 segundos) para escribir una función que te ahorra un segundo en la lectura, todavía ahorras tiempo.

El valor de las funciones pequeñas está en su claridad semántica, que mejora mucho la legibilidad del código – sobre todo cuando se trata de una combinación de varias funciones pequeñas. Para ilustrar esto, presento algunos ejemplos.

Copiar con punteros

Si p y q son dos punteros, entonces ¿qué hace el código de C siguiente?

while (*p++ = *q++) {}

Lo que hace es copiar el valor a que q apunta a la memoria referenciada por p. A continuación pasa ambos punteros al próximo y elemento y sigue copiando hasta que haya copiado un cero. (El valor de una asignación es el valor asignado, el cero se interpreta como un false y el bucle while termina.)

Tenemos una expresión booleana basada en cualquier tipo arbitrario que el compilador sepa amablemente interpretar como false o true y que, además, modifica las variables dentro de la expresión. A parte de tener un buen ejemplo de un uso sofisticado pero poco intuitivo del lenguaje de programación C, ¿para qué se podría utilizar un bucle así?

Pues, para copiar un string. En la biblioteca estándar de C hay una definición de strcpy similar a esta (la versión estándar devuelve un char*):

void strcpy(char *p, const char *q)
{
    while (*p++ = *q++) {}
}

Ahora la pregunta: ¿qué te parece más comprensible? ¿Una línea strcpy(p, q) o un bucle while (*p++ = *q++) {}? Técnicamente no trae ninguna ventaja llamar a la función strcpy, pero semánticamente, es decir, el significado del código es mucho más claro para el programador.

Funciones pequeñas son a menudo parte del estándar. La clase String en C# tiene un método IsNullOrEmpty. La expresión

if (!String.IsNullOrEmpty(miString))

reemplaza a

if (miString != null && miString.Length > 0)

Es lo mismo, pero el método IsNullOrEmpty deja la intención más obvia.

Acotar rangos

Un ejemplo simple. Mira el reloj cuánto tardas en contestar a la pregunta. ¿Qué representa c?

c = a < b ? a : b;

Y ahora los mismo otra vez. ¿Qué representa c?

c = min(a, b);

Esto era fácil, pero ¿qué tal con algunos niveles de anidamiento?

c = c < a ? a : (c > b ? b : c);

Esto limita c al rango [a, b]. Si no estás tan firme en el uso del operador ? : podrías escribir la línea anterior con if.

if (c < a)
{
    c = a;
}
else
{
    if (c > b)
    {
	    c = b;
	}
}

Un poco más amigable sería usar las funciones pequeñas min y max:

c = max(a, min(b, c));

Mi opción preferida sería una función

c = limitToRange(a, c, b);

donde el parámetro en medio será acotado al intervalo [a, b].

Puedes optar por hacer una pequeña función que ocupa una línea o la construción con el if anidado arriba que ocupa once líneas por cada variable que quieres acotar. Y puede haber muchas: si c es un índice de un array, entonces podrías tener la necesidad de acotarlo cada vez que accedes al array.

Algo parecido vale si quieres generar un error al recibir un índice fuera de rango. La manera directa sería escribir algo así

if (index < start || index >= end)
{
    std:ostringstream mensaje;
	mensaje << "Error de índice index = "
	        << index
			<< ". Fuera del rango ("
			<< start
			<< ", "
			<< end
			<< ").";
    throw std::exception(mensaje.str());
}

Pero igual vale la pena hasta escribir una clase de excepción propia.

if (indexOutOfRange(start, index, end))
{
    throw IndexException(start, index, end);
}

Conclusión

Funciones pequeñas aumentan considerablemente la legibilidad del código. Ayudan a reducir el número de líneas dedicadas a comprobar la calidad de los datos y dejan a consecuencia más espacio a la parte principal de una función. Para no perder velocidad en la ejecución, C++ ofrece definir estas pequeñas funciones inline.

Muchas veces las funciones pequeñas son tan generales que se pueden usar en todos los proyectos de programación. Una buena gestión debería procurar que se mantiene una biblioteca que todo el mundo en la empresa pueda usar. Como son funciones muy básicas, conviene también pensarse bien sus nombres y el espacio de nombres en que se encuentran.

Si no eres gerente sino un simple programador, entonces te recomiendo que guardes estas funciones en un fichero personal. Así puedes copiar y pegarlas en cualquier proyecto en que participas. De hecho, mantener una biblioteca personal puede facilitar tu trabajo.

Referencias

Hay muchas formas de programar, pero no todas son igualmente comprensibles. Por eso, especialmente en compañías grandes, se suele elaborar una guía de estilo de programación que recoge reglas de codificación: como separar código en ficheros y directorios, como elegir a nombres para variables y funciones, cómo alinear la sangría de un bloque etc. Hay muchas versiones y ninguna es perfecta. En este artículo no queremos proponer una guía de estilo más, sino dar unos consejos como elaborar una.

Una guía de estilo de programación sirve para unificar la manera de crear código. Un código desconocido es más fácil de entender si las mismas cosas se hacen de la misma manera. Un programador nuevo en el proyecto tarda menos tiempo en entenderlo. Aunque puede ser obvio guardar una clase en un fichero con el mismo nombre, no es requerido por el lenguaje. Y lo que no está requerido se hace. Hay más que un caso que un proyecto en producción se llama “new Project” o lo que la IDE propone por defecto. Meter todo en un fichero es más rápido que usar varios ficheros y llamar a variables i, x y a requiere menos esfuerzo que buscar una solución más literaria.

También es cierto que una guía de estilo afecta a la programación como un plan de tareas de hogar a un piso de estudiantes: la limpieza impone el más guarro. Cualquier definición de estilo no sirve si los programadores no aportan la disciplina necesaria para emplearla. Si los supervisores se cortan a pedir a los programadores a reeditar un fichero porque, por ejemplo, el código está mal alineado, entonces el estilo acabará ser uno más o menos similar a la guía. Esto puede ser un problema si el estilo del código es un criterio de calidad – a veces hasta contractualmente fijado con el cliente.

Al mismo tiempo revela también el peligro de imponer un estilo demasiado exigente. Cuyo carácter tiene un sentido de orden, siempre buscará una manera ordenada de escribir código. Una persona desordenada va a pasarse de cualquier regla si no se supervisa constantemente. Por lo tanto es importante no pedir demasiado y pedir algo justificado. En grupos pequeños no podría ser mala idea de dejar a todos opinar sobre una propuesta de estilo que luego será obligatoria para todos.

Reglas que una guía no demasiadamente exigente podría establecer son:

  • Se define una clase por fichero y este fichero se llama igual que la clase.
  • Se define donde guardar los ficheros fuente y donde los ficheros compilados para poder usar un control de versiones.
  • Se define abrir llaves en una línea nueva – o se usa la notación de Kernighan and Ritchie(o de Java) con la llave { en la misma línea.
  • Se define la sangría como 4 espacios y se descarta tabuladores.
  • Se puede exigir poner comentarios en estilo de javadoc u otra herramienta de documentación automatizada.

Una buena guía de estilo ayuda a evitar errores desde el principio. Por ejemplo, pedir que se asigne NULL a un puntero tras liberar la memoria a que apunta no aparece en ninguna referencia del lenguaje. Sin embargo, esta medida puede ahorrar errores difíciles a localizar. Propuestas igualmente útiles pueden ser

  • Asignar un valor cero a cualquier identificador que apunta a un recurso liberado – que pueden ser un “handler” de una ventana, el id de un evento registrado, referencias a memoría u objetos dinámicamente creados.
  • Obligar el uso de un namespace o prefijos para evitar conflictos de nombres con bibliotecas estándares. Igualmente se puede prohibir el uso del guión bajo como primera letra de un nombre de una variable no privada.
  • Usar una forma estandarizada de formar identificadores como CamelCase, notación húngara o separar nombres compuestas por guión bajo. El estilo puede fijar si nombres que contienen abreviaturas las convierten en minúsculas – algo como loadXML o loadXml.
  • Respecto a los nombres puede haber un énfasis especial a letras y números que fácilmente se confunden como o y 0 o i, j, l, I y 1.
  • Se pueden fijar en qué orden aparecen miembros públicos y privados en una clase.

Como en todo, hay que evaluar el beneficio de más reglas que, en principio, sólo complican el trabajo. En todo caso, un estilo debe ser fijado al principio de un proyecto. Para una compañía pequeña puede ser una buena idea adherirse a un estilo popular como el de Sun para Java o él de la biblioteca STL para C++.

Categorías de conenedores de la STL
Elementos simples Elementos “aparejados”
std::vector std::map
std::deque std::multimap
std::list
std::set

Los contenedores de la Standard Template Library (STL) de C++ pueden dividirse en dos categorías. La mayoría de los contenedores acoge a elementos simples. Estos contenedores son vector, deque, list y set. Al contrario de los contenedores simples están los contenedores asociativos map y multimap que guaradan a una pareja (pair) de valores: una clave y un valor. Se llaman asociativos porque asocian claves con valores.

Internamente un mapa está implementado como un árbol binario que acoge un elemento simple de tipo pair<T_KEY, T_VALUE>. Desgraciadamente los árboles no son un elemento público de la STL aunque prácticamente todas las versiones los implementan.

Insertar

Un problema práctico con los contenedores asociativos es que insertar un nuevo elemento acaba en líneas muy largas. En lugar de escribir algo tan complicado como

std::map.insert(std::pair<T_KEY, T_VALUE>(my_key,  my_value));

preferiríamos un método

std::map.insert(my_key, my_value);

Desgraciadamente este método no forma parte del estándar. Una solución a este problema es crear una clase propia que hereda del mapa estándar. Esto trae la ventaja que podamos añadir tantas funciones que nos convienen y la desventaja que hay que escribir esta clase. Para hacerla completa debemos al menos ofrecer todos los constructores y métodos de operator=.

Nuestro nuevo método de insertar sería entonces algo así:

template <class T_KEY, class T_VALUE>
class Map : public std::map<T_KEY, T_VALUE>
{
    std::pair<iterator,bool> insert(const T_KEY& key, const T_VALUE& value)
    {
        return insert(std::pair<T_KEY, T_VALUE>(key, value);
    }
};

Operator[] para mapas constantes

Otra función práctica que muchas veces falta es el operator[](const T_KEY& key) para mapas constantes. La versión no constante crea un nuevo elemento para la clave si no existe. En un mapa declarado const no podemos hacer esto, pero podemos arriesgar una excepción. Está en manos del usuario del método de asegurar que sólo llama a operator[] const para claves existentes o capturar las excepciones. Aquí está el código:

template <class T_KEY, class T_VALUE>
class Map : public std::map<T_KEY, T_VALUE>
{
    template <class TKey, class TValue>
    const TValue& operator[](const TKey& key) const
    {
        // Esta línea causa una excepción cuando find devuelve end()
        return this->find(key)->second;
    }
};

Iteradores sólo sobre valores

Otra utilidad que falta son iteradores sólo sobre los valores del mapa, es decir un iterador cuyo operator* no devuelve un std::pair<T_KEY, T_VALUE> sino un T_VALUE. Uno puede conseguir esto escribiendo una clase value_iterator, que contiene el iterador de mapa habitual como elemento. Desafortunadamente esto conlleva mucho trabajo ya que será necesario implementar todas las funciones del iterador convencional – muy especialmente todos los operadores sobrecargados.

Conclusión

En fin, hemos propuesto algunas ideas de como se pueden ampliar contenedores estándar por métodos propios – en este caso el contenedor map. Conviene tener en mente que crear una clase propia que hereda de clases estándar es una manera más limpia de codificar que crear funciones globales, macros o simplemente copiar y pegar las expresiones complejas y feas que la interfaz del contenedor estándar nos obliga a tener.

Referencias

Creo que la palabra clave const aumenta bastante las emociones que uno podrá tener sobre C++: cuando es indudablemente un elemento para elevar el estilo de programación, también es uno que puede complicar bastante la compilación. En este artículo ponemos el enfoque más al por qué de usar entidades constante y como implementarlos en programas reales. He descrito en otro artículo de como y donde se puede usar la palabra clave const.

El compilador puede comprobar si un programa intenta modificar un valor constante. Declarar objetos constantes es, por lo tanto, una ayuda para detectar posibles errores en el código. Como es preferible detectar errores durante el tiempo de compilación – ya que el compilador hace el trabajo de encontrarlos – es en general preferible de programar código que cumple lo que se llama “const-correctness“. Además, la palabra const indica al lector del código que el programador intentó sólo leer de un valor. Sin duda, usar objetos constantes incrementa considerablemente la calidad y el estilo del código.

También es cierto que un programa funciona sin constantes. Por eso hay tendencia de no usarlas. Lo gordo viene cuando uno empieza a usarlas en un código que en general no lo usa. Esto puede causar errores de compilación hasta en la n-ésima llamada anidada a una función, que por alguna razón requiere que un valor no sea constante – aunque sólo lo lea y no lo escribe. Más aún fascinan los errores de la STL (Standard Template Library), que puede causar mensajes de error kilométricos que realmente quieren decir algo como “No puedo convertir el tipo const_iterator en iterator.” Como caramelito adicional hay métodos que sólo existen para un tipo de iterador. Por ejemplo, el operador [] del contenedor mapa sólo acepta un iterador a un mapa modificable.

¿Cómo podemos poner orden en el caos? Pues, básicamente en usar wrappers. En nuestro código usamos constantes de forma correcta. Si tenemos que llamar a una función que no podemos o queremos modificar, entonces convertimos los datos de la constante a una variable. El caso más simple sería una simple asignación.

const int constante = 5;
int parametro = constante;
funcion_sin_const_correctness(parametro);

Si no podemos copiar el valor, entonces podemos usar dos técnicas. Una es la de C mediante punteros.

const int constante = 5;
int& parametro_referencia = *(int*)(void*)(&constante);

La otra es con la expresión de C++ const_cast<TIPO>:

const int constante = 5;
int& parametro_referencia = const_cast<int>(constante);

Esta construcción está en el estándar explícitamente para quitar el const. Por lo tanto es la más comprensible a la hora de hacerlo. Aunque el const_cast se parece visualmente a un patrón (template), no lo es. Es una expresión nativa de C++.

Toma nota que no he puesto una copia de la constante al parámetro sino el parámetro es una referencia al valor. Esto puede ser preferible porque ahorra la llamada a un constructor de copia, pero también incrementa el riesgo que un programa erróneo escribe en la constante.

Lo más complicado suele ser convertir los iteradores de la librería estándar de C++, ya que un const_iterator no es lo mismo que un iterator declarado const. Son dos clases distintas que pueden tener diferencias internas importantes. En este caso no suele quedar otra que hacer apaños.

  • Podemos tener la suerte que en nuestra distribución de la librería estándar se puede construir un iterator con un const_iterator. Esto sería lo más fácil.
  • Podemos averiguar si continuamos con un puntero al objeto en lugar del iterador. Es decir algo como
    Contenedor<int>::const_iterator iterador_constante;
    int* puntero_parametro = &*iterador_constante;
    

    Podemos aprovechar que los punteros también son iteradores – al menos para contenedores de acceso aleatorio como vector y deque y arrays de C.

  • Es posible también que sólo necesitamos el valor del elemento que podemos entonces convertir como los variables y objetos constantes mencionados más arriba.
  • Si tenemos un bucle podemos estudiar si la variable del bucle será un iterator en lugar de un const_iterator.

Aún así puede haber el caso en que no podemos hacer nada más que abandonar la const-correctness. Esto sucede especialmente cuando hemos pasado un const_iterator como parámetro de función. En este caso nos consolamos en haber hecho lo máximo posible aunque no era lo máximo deseable.

En general es preferible usar const porque ayuda a mejorar el estilo del código y reduce errores a la hora ejecución. En la vida real nos podemos enfrentar a situaciones difíciles cuando actualizamos código viejo. Sin embargo, podemos intentar adaptar el mejor estilo posible en cada momento. No suele ser ventajoso mejorar el estilo de todo el código de golpe. Pero si lo mejoramos gradualmente en las partes que estamos tocando, acabamos tener actualizaciones de calidad sin necesidad de tocar código que ya funciona aunque tenga peor estilo.

En este artículo nos dedicamos como podemos usar constantes en C++. Hay otro artículo que trata de por qué conviene usar const.

A primera vista sorprende cuantas cosas pueden se constantes en C++. Se pueden declarar const

  • variables,
  • punteros o las variables a que apuntan (doblemente const),
  • referencias,
  • clases,
  • instancias de clases o
  • sólo miembros de datos dentro de una clase y también
  • métodos de una clase.

Para complicar la cosa aún más se puede usar const para sustituir enum y #define.

Variables constantes

Una variable declarada const no se puede cambiar, es decir, es una constante. No obstante, la palabra const sólo se tiene en cuenta durante el tiempo de compilación. El compilador emite un error cuando encuentra una instrucción que quiere asignar un valor a una constante, pero físicamente el valor de una constante puede estar guardado en la memoria RAM y cuesta poco asignar otro valor a esta posición mediante un uso malintencionado de punteros y casts.

Cuando un compilador optimiza el código ejecutable, entonces puede optar por no leer el valor de la memoria sino incrustarlo directamente en las instrucciones del procesador. Así es posible que el valor en la memoria RAM cambie pero el programa sigue con el valor que el compilador determinó durante la compilación. No obstante, la dirección de la constante sigue siendo válida aunque el ejecutable no lee de ahí.

Tener una representación física en la memoria es lo que distingue una variable declarada const de constantes definidas por enum o #define. Es decir, puedo hacer esto

const int c = 5;
const int* p = &c;  // p apunta a la dirección de c. *p vale 5.

pero no puede hacer esto

enum { CONSTANTE_ENUM };
const enum* pe = &CONSTANTE_ENUM // Error de compilación

Esto es porque una constante de enum sola no tiene tipo sino sólo representa un valor. Lo que convierte un enum en un tipo es el conjunto de los valores de que está formado.

La cosa es aún más complicada con los #define. Es una directiva del preprocesador que reemplaza un texto por otro antes de que el código llegue al compilador. Quiere decir, los nombres de las macros definido por #define no existen para el compilador.

#define CONSTANTE 5
int a = CONSTANTE;  // El compilador ve "int a = 5".

Punteros

Los punteros son variables que guardan una dirección de memoria. Como cualquier variable su valor puede ser constante o no. Los punteros son especiales por no tener sólo un tipo de qué son – una dirección de memoria – pero por tener también otro tipo asociado del valor a que apuntan. Y este valor puede ser constante o variable de forma independiente. Por este motivo puede haber dos const en la definición de un puntero.

      int        variable = 1;
const int        constante = 2;
const int *      puntero_a_constante = &constante;
      int *const puntero_constante_a_variable = &variable;
const int *const puntero_constante_a_constante = &constante;
  • El puntero_a_constante puede apuntar a varios objetos, pero no puede modificarlos. Cadenas de caracteres se definen típicamente así: como const char*.
  • Un puntero_constante_a_variable puede modificar el objeto a que apunta, pero no puede apuntar a otra cosa.
  • Finalmente, un puntero_constante_a_constante ni puede modificar el objeto a que apunta ni apuntar a otro objeto. Por este punto de vista una cadena de texto hardcoded tiene realmente el tipo const char *const. Sin embargo, se usa poco.

Como nota quiero decir también que existe la notación

      int const* puntero

pero se usa menos. De hecho no conviene usarlo porque es más ambiguo: ¿el const se refiere al tipo (int) o a la dirección de memoria (el “*”)?

Referencias

Para las referencias valen básicamente las mismas reglas que para punteros. No obstante hay una importante diferencia: referencias no pueden referirse a otra instancia. Al contrario de punteros las referencias no pueden existir por si mismas sino deben inicializarse a la hora de ser declarada.

      int        variable = 1;
const int        constante = 2;
const int &      referencia_a_constante = constante;

// &const es permitido pero innecesario. Por eso nunca se usa.
      int &const referencia_constante_a_variable = variable;
const int &const referencia_constante_a_constante = constante;

Como las referencias apuntan al mismo objeto durante toda su vida, & y &const es lo mismo, y por eso nunca se escribe &const.

Es fácil hacer un cast para convertir un puntero de un objeto a otro objeto con un tipo diferente. Las referencias, en cambio, tienen el mismo tipo que el objeto a que se refieren y aportan más seguridad contra un cast implícito.  Por eso es preferible usar referencias en lugar de punteros siempre cuando sea posible.

Muy especialmente conviene usar referencias a constantes en lugar de constantes como parámetros de funciones. Se usan igual pero sólo requieren copiar un puntero en lugar de un objeto entero. Es decir, no se llamará un constructor para una referencia.

Clases constantes

Igual como se puede convertir un tipo simple como int a constante, se puede declarar const a una estructura compleja. La construcción const MiClase convierte todos los miembros de esta instancia en constantes. Sólo se les pueden asignar un valor en el constructor y después ya no. Se puede forzar una excepción para un campo en la clase declarándolo mutable, pero en general hay poca razón de hacerlo.

También es posible declarar campos individuales constantes dentro de una clase. Por ejemplo, puedo crear una clase que guarde una posición como una posición inicial constante y un desplazamiento variable.

class MiSitio
{
    double desplazamiento;          // una variable
    const double posicion_inicial;  // una constante
};

El valor de posicion_inicial sería asignado en el constructor de la clase y se quedaría inmodificable durante la vida de la instancia. No obstante, cada instancia puede tener otro valor para posicion_inicial. Por lo tanto un miembro constante no es los mismo que una constante global y común a todas las instancias.

Lo que queda por hacer constante son los métodos de una clase. Métodos constantes “prometen” de no modificar ningún dato dentro de la clase. (Más correctamente ninguno que no sea mutable.) Son los únicos métodos que puedo llamar para una instancia constante.

class MiCosa
{
public:
    int mi_variable;

    void mi_metodo_constante() const
    {
        mi_variable = 0;  // Error de compilación.
        // No debo modificar campos en un método constante.
    };

    void mi_metodo_variable()
    {
        mi_variable = 0;  // Ok
    };
};

const MiCosa cosa;
cosa.mi_metodo_constante();  // Ok
cosa.mi_metodo_variable();   // Error de compilación.
// No puedo llamar a un método NO constante para un objeto constante.

Para complicar la cosa aún más, se pueden definir dos métodos iguales en que una es constante y la otra no. Es algo que usa mucho para los métodos que devuelven iteradores de inicio y final en los contenedores de la STL.

iterator begin(void);
const_iterator begin(void) const;

El método constante devuelve un iterador (puntero) a un objeto constante, mientras la versión variable devuelve un iterador a un objeto variable.

Conclusión

La palabra clave const no es fácil de entender, porque se usa en muchos conceptos. Sin embargo, es la pieza que permite a C++ de ser uno de los pocos lenguajes a conseguir la “const-correctness“. Esta a su vez significa que ya el compilador puede comprobar que no se asigna un valor a algo que sea de sólo lectura. Aunque muchos programadores hacen caso omiso al const, conviene tener una noción de ellos cuando uno trabaja con la STL, ya que muchos errores de compilación (que suelen tener un mensaje largo) se deben a confundir iterator con const_iterator.

Aquellos que buscan más motivación puedo recomendar el artículo “Por qué usar const en C++“. Pero también hay consuelo para quienes buscan la manera de esquivar las constantes: puedo eliminar cualquier const con const_cast.

Lectura adicional

Escribe tu dirección de correo electrónico para suscribirte a este blog, y recibir notificaciones de nuevos mensajes por correo.

Únete a otros 52 seguidores

Archivos

diciembre 2018
L M X J V S D
« Nov    
 12
3456789
10111213141516
17181920212223
24252627282930
31