“““Buenas””” practicas de C

--Joaquín Azcárate

Prefacio

Las “ovbias”

El mítico “código espagueti”

Cabezazos

Tipación Inteligente

b2-4ac (Discriminante)

La revancha del #define

typedef es tu amigo

Encapsularidad

Abort! Abort! ABORT!

Último Comentario

Prefacio

Este documento no pretende enseñar a programar, simplemente es una recopilación de lo que a mi, personalmente, me parecen buenas prácticas de desarrollo.

Las “ovbias”[1]

Temo necesario tener un apartado para enlistar las típicas cuestiones en programación. Son prácticas globalmente aceptadas, y de las cuales no tengo más que enlistarlas. Confío que son suficientemente autoexplicativas, o que lo escuchaste en otro momento:

El mítico “código espagueti”

Empiezo por una de las mayores críticas que tengo sobre la programación de los TPs que he visto, el mítico: “código espagueti”. Si cursaste / estas cursando Paradigmas de Programación, probablemente escuchaste hablar sobre este extraño fenómeno. Con algo de suerte podes poner una mano en tu corazón y jurar que nunca incursionamos en prácticas del ocultismo espaguetital, pero C + un TP complejo + desconocimiento = la receta para terminar con un monstruo del estilo:

main(){
   
while( vivo() ){
       
while( edad < 25 ){
           
if( edad < 19 ){
               yo->destino = ESCUELA;

                mover(yo, ESCUELA);

                while(yo->lugar != ESCUELA);

                estudiar(ESCUELA);
           }
else {

                yo->destino = FACULTAD;

                mover(yo, FACULTAD);

                while(yo->lugar != FACULTAD);

                estudiar(ESCUELA);
           }
       }
       
if( dia == 365 ){
           edad++;
       }
       
if( dia % 7 == 6 || dia % 7 == 7 ){
           
if( condicionesClimaticas == LINDO ){
               
if( hora > 12 && hora < 16 && suenio() ){
                   dormir();
               }
else {
                   salir();
               }
           }
           
if( hora > 23 ){
               dormir();
           }
       }
       
if( hora > 20 ){
           dormir();
       }
   }
}

Ademas de inmantenible, indebuggeable, poco se entiende. Recordemos que estás trabajando con otras 4 personas!

Síntomas de código espagueti: la cantidad de sentencias de “if” y “while”. Pero a no desesperar, estar leyendo esto es el primer paso de la rehabilitacion. Sería un mentiroso diciendo que mi código, cuando curse la materia, no se parecía a “eso”… incluso creo que tenía menos llamadas a funciones.

Función-a

Despues de machacar la idea de que usar funciones es bueno, puede que ocurra el proceso contrario, donde tenemos lo que yo llamo “espagueti funcional”, en donde no es muy claro que tiene que hacer una funcion, simplemente esta porque -Me dijeron que ponga algunas funciones-.

Me niego a que alguien haga algo porque “le dijeron”, sin muchas mayores razones.

Síntomas de este tipo de programación de funciones “forzadas”, son que para cada función le tenes que pasar varios parámetros, o confían en muchas variables globales, y no se les puede poner un nombre lindo. Particularmente esta última razón, aunque bastante “tonta” es de las más útiles.

Esto nos trae a la discusión de cuándo parar con llamadas a funciones, y esta a criterio de cada uno. Lo que sí es universal (por lo menos para mi) es que la repetición de código es de las cosas menos deseables. Si tiene una función de una línea, que se repite con el mismo propósito, entonces esa función esta perfecta. Si tiene una función de una línea que solo se invoca una vez, podria admitir desechar, pero la verdad es que pensar cuando poner una llamada a una funcion y cuando no me lleva mas trabajo que hacer la llamada y potencialmente perder 2 microsegundos en la ejecución.. siempre y cuando el propósito no sea de optimizar!

Modularidad

Otro gran tema a abordar, esta linda palabra: “modularidad”, lo describe bastante bien.

La idea es muy simple, y aún más simple de lograr: “La programación modular consiste en dividir un programa en módulos o subprogramas con el fin de hacerlo más legible y manejable.”.

C particularmente (como muchos otros lenguajes de programación) nos permite codificar en más de un archivo. Asi como lees, no tiene porque estar todo en un archivo que se llame “main.c”. Logramos esto con una directiva del preprocesador de C: “#include”.

En términos muy básicos, podes pensar que cuando apretas “compilar”, lo primero que hace es copiar textual todo el texto de el archivo a incluir, pegarlo donde esta la directiva, y después compilar ese un archivo masivo.

Ahora bien, esto trae un par de problemas “interesantes”, como tener estos dos archivos:

corredor.c

sumar.c

#include "sumar.c"
void main(){
   sumarTres( 5 );
}

int sumarTres(int numero){
   
return numero+3;
}

De intentar compilar en forma automática esto, van a ver un error parecido a:

/sumar.c:3: multiple definition of `sumarTres'

./corredor.o: /sumar.c:3: first defined here

Lamentablemente tiene mucho sentido esto, si miramos a como intentamos compilar, podemos ver algo como: gcc corredor.c sumar.c -o corredor

Entonces, básicamente estamos tratando de compilar dos archivos de esta forma (una vez pasado el preprocesador):

corredor.c

sumar.c

int sumarTres(int numero){
   
return numero+3;
}

void main(){
   sumarTres( 5 );
}

int sumarTres(int numero){
   
return numero+3;
}

Tiene mucho sentido que nos diga que esto no puede ser así, porque “sumarTres” esta dos veces.

Ciertamente podríamos compilar solamente el “corredor.c”: gcc corredor.c -o corredor.

Esto quiere decir que cada vez que editemos algun archivo que esté incluido por corredor (o por cualquier otro que esté incluido en alguno incluido), tiene que volver a compilar todo devuelta. Con el poder computacional que tenemos hoy en dia, no parecería mucho problema, pero es una práctica pobre.

Cabezazos

A lo largo de los años se adoptó una forma de modularizar esto, con el agregado de un archivo como encabezado (aka: header), por lo que tendríamos estos tres archivos:

corredor.c

sumar.c

sumar.h

#include "sumar.h"
void main(){
   sumarTres( 5 );
}

int sumarTres(int numero){
   
return numero+3;
}

int sumarTres(int numero);

Ahora si podemos compilar por separado cada .c, que genera dos objetos diferentes, y al final los “pega” todos juntos y nos da un ejecutable. Si alteramos el código de “sumar.c”, no necesitamos compilar todo devuelta, solo el objeto[2] de “sumar.c” y volver a pegarlo. Pero ahora tenemos un archivo con código, y un archivo diferente con la interfaz. Esto es agradable, pero no el final de la película. Si uno, sin querer, incluimos más de una vez “sumar.h”, la definición de nuestra funcion “sumarTres” estaría definida más de una vez. “/sumar.c: error: redefinition of ‘sumarTres’” seria el error.

Suena raro incluir más de una vez un archivo, pero pasa más seguido de lo que te imagina, para esto C nos trae otra directiva del preprocesador: “#define”, “#ifdef”, “#ifndef” y “#endif”. Esto nos permite tener este código en nuestro archivo:

corredor.c

sumar.c

sumar.h

#include "sumar.h"
void main(){
   sumarTres( 5 );
}

int sumarTres(int numero){
   
return numero+3;
}

#ifndef SUMAR_H_
#define SUMAR_H_

int
sumarTres(int numero);

#endif

Analizemos un poco el codigo agregado de “sumar.h”:

Si algun otro archivo intenta incluirlo, la primera línea le va a decir que ya fue definido, entonces no va a copiar y pegar el código. y esto es bueno, porque significa que ya fue copiado y pegado antes, no que no lo va a hacer ninguna vez.

Suele ser una buena práctica ponerle a la variable a definir, el nombre del archivo. A este truco se lo llama “guardas”.

Última cosa sobre headers, lo prometo!

Supongamos que ahora también queremos un tipo de dato, como que los números tengan también un “numeroAnterior” que guarde el numero pre-suma. Tendriamos una estructura parecida a:

typedef struct{
   
int numero;
   
int numeroAnterior;
}
t_numero;

El drama es donde ponerlo. las alternativas son:

  1. corredor.c
    Cuando intentamos compilar “
    suma.c”, que necesita saber como es la estructura, nos va a decir que no entiende que es un t_numero.
  2. sumar.c
    La función va a poder hacer lo que quiere, pero en “
    corredor.c” no vamos a poder crear un t_numero, porque no existe, y en el header no vamos a poder poner el correcto prototipo.
  3. sumar.h
    sumar.c” no ve el código en su encabezado, porque no tiene ningún #include.

Las tres formas tiene problemas, pero hay uno más facil de arreglar que los otros.

corredor.c

sumar.c

sumar.h

#include "sumar.h"
void main(){

        t_numero cinco;
        cinco.numero = 5;
        cinco.numeroAnterior = 0;
        sumarTres(cinco);
}

#include "sumar.h"

t_numero sumarTres(t_numero numero){
        numero.numeroAnterior =         numero.numero;
        numero.numero += 3;
   
return numero;
}

#ifndef SUMAR_H_
#define SUMAR_H_
typedef struct{
        
int numero;
        
int numeroAnterior;
}
t_numero;

t_numero sumarTres(t_numero numero);

#endif

Con algo de suerte ahora es un poco más evidente porque ponemos las guardas, y como es que varios archivos pueden incluir a uno.

Tipación Inteligente

b2-4ac (Discriminante)

Un pequeño paréntesis antes de hablar más seriamente de los tipos de datos, creo que merece esta idea de “discriminar”; en el concepto de “Separar, diferenciar una cosa de otra”.

Muchas veces tenemos dos o más estructuras que son iguales en términos de datos, pero se manejan de forma muy diferente. Por ejemplo, si estamos programando un Sistema de Archivos, podríamos tener los inodos de archivos, de links y de directorios con los mismos atributos, pero con un tratamiento definitivamente diferente.

Ahora que tenemos nuestro código distribuido en varios archivos, de forma prolija y ordenada, podemos adentrarnos a formas de programar. Esta idea también aplica a el mandar mensajes con una cabecera describiendo que tipo de mensaje es.

Una primera idea podria ser escribir 4 caracteres con el tipo de mensaje antes, y tener un código como:

if( !strncmp(buffer, "DATO", 4) ){
   procesarDato(buffer);
}
else if( !strncmp(buffer, "TIPO", 4) ){
   procesarTipo(buffer);
}
else if ( !strncmp(buffer, "MSGE", 4) ){
   procesarMensaje(buffer);
}
else {
   error(
"No entendi el mensaje");
}

Seria más feliz, probablemente, si C nos dejara hacer un switch con strings. Como no nos deja, otra implementación podria ser (y es un refactor común, y bastante útil):

#define DATO 4
#define TIPO 5
#define MENSAJE 6

switch(buffer[0]){
   
case DATO:
       procesarDato(buffer);
       
break;
   
case TIPO:
       procesarTipo(buffer);
       
break;
   
case MENSAJE:
       procesarMensaje(buffer);
       
break;
   
default:
       error(
"No entendi el mensaje");
       
break;
}

Ciertamente existen menos ifs, y ya es una muy buen cambio; pero sigue teniendo dos “problemas”

  1. Si queremos agregar un nuevo discriminador, tenemos que inventar números que sean diferentes a los anteriores
  2. Al debuggear, el pre-procesador nos borró la idea de “DATO”, “TIPO”, o “MENSAJE”, ahora solo aparecen numeros, entonces podemos ver que llego un 4, pero no sabemos bien que es, tenemos que consultar a los #defines.

Ciertamente no son problemas graves. La alternativa que les propongo es usar un tipo de dato que fue pensado para exactamente esto:

typedef enum{DATO, TIPO, MENSAJE} t_tipo;

t_tipo discriminador= buffer[0];
switch(discriminador){
   
case DATO:
       procesarDato(buffer);
       
break;
   
case TIPO:
       procesarTipo(buffer);
       
break;
   
case MENSAJE:
       procesarMensaje(buffer);
       
break;
   
default:
       error(
"No entendi el mensaje");
       
break;
}

Si, el código del switch es idéntico, pero si alguien en alguna versión agrega un nuevo discriminador e inventa su número, y otra persona agrega otro discriminador, con el mismo numero: problemas. Más importante aún, si uno intenta ver en GDB (debugger) el valor de “discriminador”:

(gdb) print discriminador
$1 = DATO

La revancha del #define

“Los #defines no sirven”.

Nonononono, no pongan bytes en mis dedos. Esta directiva es sumamente útil, si lo que uno quiere hacer es un Copy&Paste automático. Incluso nos permite hacer algo como “copiar, pegar y remplazar” todo en un solo momento; a esto se le dice macros, y no creo que este sea el documento para discutir el uso o abuso de los macros del preprocesador, si bien mantengo que son una herramienta sumamente poderosa (pero altamente peligrosa).

Mas que nada, aliento el uso de estas definiciones para cuestiones “hardcodeadas”.

Si se les presenta un caso en el que tiene que implementar una búsqueda recursiva, pero solamente tiene que evaluar 3 niveles, en una primera instancia podrían tener:

t_elemento buscarINodo(char* nombre, int nivel){
   
if(nivel <= 3){
       
t_nivel nivelABuscar = obtenerNivel(nivel);
       
t_elemento elemento = buscarEnNivel(nombre, nivelABuscar);
       
if( elemento != 0)
           
return elemento;
       
else
           
return buscarINodo(nombre, nivel+1);
   }
else
       
return 0;
}

Probablemente la búsqueda siempre sea hasta el tercer nivel, y jamas cambie. Pero mejor pregunta que ¿Alguna vez cambiara? puede ser ¿Que tan dificil seria que cambie?, o mejor aún, en el contexto de este documento ¿Que tan entendible es ver un 3?. Esta idea de ver números en el medio del código, sin entenderlos muy bien se la conoce como “números mágicos” (Magic Numbers). No quedaria mas lindo leer:

#define MAX_NIVEL_A_BUSCAR 3

...

if(nivel <= MAX_NIVEL_A_BUSCAR)

De necesitar cambiarlo, esta en un lugar fuera de la funcion de busqueda; incluso mas de una funcion podria usar ese número, y cambiarlo en un lugar, cambiaría en todos los lugares.

typedef es tu amigo

Una de los mayores problemas que he detectado es en el uso incorrecto de la memoria, y no me refiero a no hacer “free”, sino más bien a intentar tratar todo como un int o un char.

“¿¡Al fin y al cabo es todo memoria, que importa el tipo!?”, es un buen argumento; tan bueno que me lo he dado muchas veces a mi mismo. Pero nada más lejano de la realidad.

Un hermoso ejemplo de esto son las commons que les damos. Tomemos como ejemplo el encabezado de los bitarrays. Todas las funciones manejan de una u otra forma un “t_bitarray”, que básicamente es un puntero a char y un tamaño. ¿Por qué  no usar esos dos valores y mandar punteros a char y tamaños? Más allá de la comodidad de encapsular la información (que es tema para otra sección).

De hacer esto, ¿Q te distingue una cadena, de un bitarray? ¿Se tratan de forma igual?

La respuesta tendría que ser no, de tratarse de forma idéntica, no tendrían un nombre diferente!

Talvez el ejemplo no queda muy claro, pensemos otro un poco más grueso: Supongan que tiene que emular manejo de memoria. Si tuvieron / tienen que hacer esto, probablemente estén tentados a decir:

-Es memoria, no se que hay adentro, entonces voy a definirlo como void*!-

Es una buena idea, pero rápidamente se dan cuenta que para acceder al byte número 700 tiene que hacer casteos de la forma: ((char*)Memoria)[700][3], y después del 7mo casteo, se cansan y van a cambiar el void* por un char*. Todos lo hemos hecho.

Lo que quiero hacerlos reflexionar es que tal vez no sea estrictamente un char*, y no tenga el mismo tratamiento que un char*, es más bien un: t_memoria.

typedef char* t_memoria

Pensemos en el costo computacional de esta una linea: 0. Cuando se compila, esto pasa a ser un int, no tiene más complicación que tal vez unos nanosegundos en el preprocesador.

¿Qué ganamos?

Supongamos que armamos todo un TAD de memoria, con crearMemoria, destruirMemoria, asignarMemoria, moverMemoria, lo que fuera. Por alguna razón loca, nos equivocamos y tratamos de hacer algo como:

int tamanioMemoria = crearMemoria();

El compilador nos va a avisar prontamente que crearMemoria tendría que devolver un t_memoria, y vos lo estas poniendo en un int. Esto funciona a la perfección, compila y ejecuta, pero no es lo que queríamos; y debuggearlo nos va a llevar más que esos nanosegundos de poner esa una línea de typedef.

Si no los convencí con eso, imaginen que ahora les parece sensato que quieran sincronizar la memoria; que solo se pueda acceder de a uno por vez.

La solucion facil es ir a las funciones que usan la memoria, y ponerles un semáforo global. ¿Si tenemos varios segmentos de memoria que manejamos?

Teniendo el typedef es tan trivial como ir a la especificación del t_memoria y cambiarlo por algo como:

typedef struct{
   
char* dato
   
sem_t* semaforo;
}
t_memoria

Cambiar las funciones que usen la memoria para que usen el semáforo de forma acorde y nada más. No hace falta tocar el código principal, solo cambiar el TAD de memoria. ¿Maravilloso no? Esta increíble hazaña no podría ser sin el grandioso concepto de:

Encapsularidad[4]

Wikipedia lo pone muy simplemente: “En programación modular, y más específicamente en programación orientada a objetos, se denomina encapsulamiento al ocultamiento del estado, es decir, de los datos miembro de un objeto de manera que sólo se pueda cambiar mediante las operaciones definidas para ese objeto.”. Ciertamente no tenemos objetos en C, pero podemos acercarnos a algo parecido.

La idea es, como vimos en el ejemplo de t_memoria, la idea es armar todo un TAD alrededor de un tipo de dato, entonces hacer cualquier cambio de la forma de manejarlo es sumamente facil y afecta a la menor cantidad de componentes. Devuelta, las commons son un muy buen ejemplo de esto.

Abort! Abort! ABORT!

Si llegas a un punto donde queres abortar el programa por un error, en vez de poner exit(-1), poner abort().

Abort termina el programa, pero con una señal que GDB frena, como Segmentation Fault, por lo que uno tiene posibilidad de mirar el estado de todo el sistema antes de abortar, y tratar de entender por que llegó hasta donde llegó.

Último Comentario

Comenten su codigo!

Su “yo” del futuro se los va a agradecer. Si necesitan convencimiento, agarren algun codigo que ustedes mismos hallan escrito hace más de un mes, de Algoritmos y Estructuras de Datos, Paradigmas de Programación, lo que fuese. Saquenle los comentarios (... como si pusieran) e intenten interpretar que es lo que hace.

Si se van a llevar una cosa de todo este palabrerío que sea este: “Pongan comentarios útiles!”


[1] Si, se que esta mal escrito; es a propósito

[2] Si compilamos con gcc sin ningún argumento en particular, todos los objetos y etapas intermedias se pierden, por lo que aunque tengamos separado en archivos, va a tener que re-compilar todo.

[3] El por qué dell casteo esta explicado muy bonito en otra guia.

[4] El término correcto es encapsulamiento.