Estructuras de datos definidas por el usuario en C

Tipos de dato y relación con checkeos

Hasta ahora venían (veníamos) trabajando con problemas simples de programación para familiarizarnos con los conceptos básicos de la programación estructura, como secuencia de instrucciones, iteraciones (for, while, etc) y decisiones (if, switch, etc). Además de variables, funciones, asignaciones, etc.

Otra cosa que vimos es el proceso que sufre un programa para que, a partir del código fuente como lo escribimos, se transforme en algo ejecutable. Por ejemplo, a través de la compilación.

En los lenguajes compilados, existen entonces 2 etapas: la compilación que sucede durante el desarrollo o escritura del código, y luego la ejecución del código compilado.

Existe entonces en algunos lenguajes, la idea de poder "atajar" los problemas cuanto antes, en ese primer paso, mediante checkeos en tiempo de compilación.

En general si bien existen variantes (como duck-typing o tipos estructurales), lo más tradicional es que estos checkeos se basen en información de tipos.

Qué es un tipo ?

Tipo de dato informático es un atributo de una parte de los datos que indica al ordenador (y/o al programador) algo sobre la clase de datos sobre los que se va a procesar. Esto incluye imponer restricciones en los datos, como qué valores pueden tomar y qué operaciones se pueden realizar. Tipos de datos comunes son: enteros, números de coma flotante (decimales), cadenas alfanuméricas, fechas, horas, colores, etc.

De Wikipedia.

Qué es un struct ?

Algo interesante del concepto de tipo es que permite una abstracción, es decir, cuando decimos float, ya todos pensamos en ciertas condiciones que va a tener el contenido de esa variable (número decimal). Más allá de que luego todo termine representado como bytes internamente. Los bytes quedan escondidos para nosotros, porque el hecho de que existan es un tema de implementación o representación. No es necesario saber cómo se guarda el float, porque lo que necesitamos es poder usarlo (sumarlo, restarlo, etc).

Este concepto de abstracción estaría bueno poder extenderlo, no solo para utilizar los tipos básicos como int, float, char, etc, si no, para poder también nosotros crear abstracciones, por ejemplo que un Producto, tenga código y precio, y así no tener que pasar variables sueltas por todas nuestras funciones.

Si el día de mañana queremos agregar una característica a nuestros productos, no tendríamos que modificar todas las funciones que lo utilizan.

Un struct en C es una forma de agrupar miembros, es decir variables, bajo un mismo nombre.

Definición y uso de un Struct

struct Producto {
    long codigo;
    float precio;
};

Estamos definiendo acá un struct con identificador Producto, que tiene dos miembros: con nombre y tipo.

Luego para utilizarlo:

struct Producto unProducto;
unProducto.codigo = 123456789L;
unProducto.precio = 2.50;
printf("Producto con codigo %lu y precio $%.2f", unProducto.codigo, unProducto.precio);

Acá estamos declarando una variable de tipo "struct Producto", que se llama unProducto.

Al declarar la variable C automáticamente aloca espacio en memoria para el struct.

El tamaño que ocupa un struct es el tamaño de sus miembros. Es decir que se aloca memoria para sus miembros automáticamente. No hace falta alocar con malloc dinámicamente.

Otra cosa que vemos acá es que con el punto (.), accedemos a los miembros del struct.

Structs y funciones

Los structs se pueden utilizar para tipar variables, con lo que podemos utilizarlos en la declaración de funciones.

Entonces, si quisiéramos hacer una función para imprimir productos, no haría falta pasar todas sus propiedades como parámetros, sino un solo parámetro de tipo struct Producto.

Esto:

void imprimir(long codigo, float precio) {
    printf("Producto con codigo %lu y precio $%.2f", codigo, precio);
}

Pasaría a esto:

void imprimir(struct Producto unProducto) {
    printf("Producto con codigo %lu y precio $%.2f", unProducto.codigo, unProducto.precio);
}

Como consecuencia tenemos un código más legible, ya que en el primer caso, la función no expresa claramente en su firma (método y parámetros) que es para imprimir un producto.

Por otro lado, "acoplamos" más fuértemente la nueva función al tipo "Producto". Es lógico, porque es una consecuencia de lo que queríamos. Pero bueno, veamos que antes el imprimir podía ser utilizado para cualquier cosa, no solo para un Producto. No así, ahora. Necesito un struct Producto.

Punteros a structs y pasaje por referencia

Como vimos en Punteros, Referencias y Direcciones el pasaje de parámetros en C es siempre por valor. Es decir que los parámetros se copian al scope de la nueva función. Por lo tanto, las modificaciones no toman efecto sobre las variables del que invocó a la función.

Ahora qué pasa cuando pasamos por parámetro un struct ?

void aumentarPrecio(struct Producto producto) {
    producto.precio += 0.5;
}

Si hacemos...

    aumentarPrecio(unProducto);
    imprimir(unProducto);

Vamos a ver que imprime en pantallas...

Producto con codigo 123456789 y precio $2.50
Producto con codigo 123456789 y precio $2.50

Es decir que al igual que con un parámetro simple, si modificamos uno de los miembros de un struct que se pasó por valor, tampoco toma efecto.

Esto es porque también se hace una copia del struct completo al pasarlo por parámetro,

Esto se resuelve de la misma forma que ya vimos, pasándo un puntero al struct

Modificamos la función entonces para que reciba un puntero al struct.

void aumentarPrecio(struct Producto *producto) {
    producto->precio += 0.5;
}

Y vamos a notar que ya no vamos a poder hacer: "producto.precio", esto es porque "producto" es ahora un puntero, no el struct en sí. Para poder acceder a uno de sus miembros deberíamos primer desreferenciar el puntero con "*" y luego acceder al miembro, así:

(*producto).precio

Pero C ya tiene otro operador para simplificar la sintaxis de esto, que es el "->"

producto->precio

Con este cambio, ahora el main va a imprimir corréctamente:

Producto con codigo 123456789 y precio $2.50
Producto con codigo 123456789 y precio $3.00

TypeDef

En C podemos definir tipos como aliases a otros tipos. Es decir, definir otro nombre para un tipo ya existente. El objetivo de esto es poder tener un código más legible. Donde legible quiere decir que sea más fácil de entender para quien no lo escribió, pero conoce el dominio. O también para quien quizás si sabe programar, pero no conoce el dominio.

Veamos un ejemplo primero del typedef. Declaramos un nuevo tipo "pesos" que es en realidad un float

typedef float pesos;
int main() {
    pesos miSaldo = 33.5;
    printf("Mi saldo es %.2f", miSaldo);
}

Luego lo usamos en la variable "miSaldo".

Es importante señalar que la variable sigue teniendo el mismo tipo de dato original, es decir, es un float. Y puedo pasarla como parámetro de tipo float a cualquier función. Y lo mismo si fuera valor de retorno.

En cualquier momento yo puedo elegir usar float o pesos, y no se hacen checkeos ni habrá incompatibilidades de tipos.

Veamos un ejemplo un poquito más grande para enteder cómo afecta la claridad del código.

Un main con una función para convertir de pesos a dolares:

float pesosADolares(float pesos) {
    return pesos * 5;
}
int main(void) {
    float saldoPesos = 3.2;
    float saldoDolares = pesosADolares(saldoPesos);
    printf("Usted tiene $ %.2f (que equivalen a U$S %.2f)", saldoPesos, saldoDolares);
}

Con typedef quedaría:

typedef float dolar;
typedef float pesos;
dolar pesosADolares(pesos pesos) {
    return pesos * 5;
}
int main(void) {
    pesos saldoPesos = 3.2;
    dolar saldoDolares = pesosADolares(saldoPesos);
   
    printf("Usted tiene $ %.2f (que equivalen a U$S %.2f)", saldoPesos, saldoDolares);
}

De hecho podríamos pensar en reducir el nombre de la función ya que el uso de typedefs hace más clara su intención:

dolar convertir(pesos pesos) {
    return pesos * 5;
}

Cuanto más mi código se parece a una definición del dominio, más fácil de entender, mantener y modificar.

Existen muchas buenas prácticas de código que se dice que aportan a una característica del código llamada Intention Revealing, por ej:

    • Definir nombres claros para las variables, en lugar de cosas como: ptr, var1, var2, tmp, etc
    • Evitar comentarios de código y en su lugar separar las partes en funciones chiquitas usando el nombre de la función para expresar qué es lo que hace.
    • El uso de structs para definir abstracciones de nuestro negocio en lugar de utilizar variables sueltas
    • Y la que vemos acá, usar aliases de tipos o nuestros propios tipos en lugar de tipos básicos dispersos por todo nuestro código.

Pueden ver un ejemplito simple y una buena descripción en wikipedia Intentional Programming

Typedef's para structs

Como vimos los structs también definen un tipo de dato. Uno compuesto. Asignándole un nombre a un grupo de miembros de diversos tipos.

Pero vimos que para tipar una variable con uno de estos structs, o un parámetro de función, etc, debíamos escribir "struct Producto", y no solo el nombre del struct ("Producto").

Si queremos hacer eso mismo, debemos definir un typedef al struct.

Ejemplo sin typedef

struct Producto elDeMenorPrecio(struct Producto productos[]) {
    ...
}

Y con typedef

typedef struct Producto Producto;
Producto elDeMenorPrecio(Producto productos[]) {
    ...
}

Referencias

    • http://en.wikipedia.org/wiki/Struct_%28C_programming_language%29
    • http://en.wikipedia.org/wiki/Typedef
    • http://c.conclase.net/curso/?cap=011
    • http://www.cs.usfca.edu/~wolber/SoftwareDev/C/CStructs.htm