Lenguaje de programación estructurada
Tres estructuras de control, funciones que se entienden por separado y un compilador que traduce todo eso a instrucciones de máquina. C es el lenguaje donde esas dos cosas se ven a la vez.
01De los saltos a los bloques
Los primeros programas se escribían con saltos: «si pasa tal cosa, ir a la línea 340». Funcionaban, pero a partir de cierto tamaño nadie podía seguir el hilo, porque a cualquier línea se podía llegar desde cualquier otra. En 1968 Edsger Dijkstra publicó una carta con un título que se volvió famoso, Go To Statement Considered Harmful: el salto libre hace imposible razonar sobre un programa.
La programación estructurada es la respuesta: escribir todo con las tres estructuras de Böhm y Jacopini, cada una con una sola entrada y una sola salida, y partir el problema en funciones que se pueden entender, probar y reusar por separado. El programa se lee de arriba hacia abajo y cada bloque se puede reemplazar por una caja negra.
C, creado por Dennis Ritchie en los Laboratorios Bell entre 1969 y 1973 para escribir Unix, es un lenguaje estructurado que además deja ver la máquina: direcciones, bits y tamaños. Por eso es el lenguaje de los sistemas operativos y de los microcontroladores, y por eso se enseña en una ingeniería electrónica. Los elementos básicos del lenguaje están desarrollados en elementos básicos del lenguaje C; acá el foco está en cómo pensar un programa estructurado y qué hace la máquina con él.
02Del fuente al ejecutable, con la mano en la masa
El compilador gcc deja detenerse en cada etapa. Vale la pena hacerlo una vez para perderle el
miedo a los mensajes de error, que dicen en qué etapa fallaron:
gcc -E hola.c -o hola.i # sólo preprocesar: ver qué quedó de los #include
gcc -S hola.c -o hola.s # compilar a ensamblador
gcc -c hola.c -o hola.o # ensamblar: código objeto
gcc hola.o -o hola # enlazar con la biblioteca estándar
gcc -Wall -Wextra -std=c11 hola.c -o hola # todo junto, con advertencias
| Mensaje | Etapa | Causa típica |
|---|---|---|
fatal error: stdio.h: No such file | Preprocesador | Nombre de cabecera mal escrito |
expected ';' before … | Compilador | Falta un punto y coma en la línea anterior |
implicit declaration of function | Compilador | Se usa una función sin prototipo ni #include |
undefined reference to 'sqrt' | Enlazador | Falta la biblioteca: agregar -lm |
Compilar siempre con -Wall -Wextra y no dejar ninguna advertencia sin entender. Un
if (x = 5) compila, pero el compilador avisa: es una asignación, no una comparación.
03Tipos y expresiones: lo que C hace sin avisar
En C cada expresión tiene un tipo, y el tipo decide la operación. 7 / 2 es una división
entera y da 3, porque los dos operandos son enteros; 7.0 / 2 da 3,5. El resultado no
depende de dónde se guarde: double x = 7 / 2; guarda 3,0, porque la división se hizo antes.
Cuando se mezclan tipos, C aplica las conversiones aritméticas habituales: los tipos más chicos que
int se promueven a int, y en una operación entre dos tipos distintos el «menor» se
convierte al «mayor». La trampa más conocida:
int a = -1;
unsigned int b = 1;
if (a < b) // ¡falso!
printf("lógico");
Al comparar un int con un unsigned int, el −1 se convierte a sin signo y pasa a
valer 4 294 967 295 (con 32 bits), que es mayor que 1. Los bits no cambiaron: cambió la lectura, exactamente
como en sistemas de numeración.
| Operadores, de mayor a menor precedencia | Asociatividad |
|---|---|
() [] -> . y los sufijos ++ -- | izquierda a derecha |
! ~ - * & sizeof, prefijos y conversiones (tipo) | derecha a izquierda |
* / % | izquierda a derecha |
+ - | izquierda a derecha |
<< >> | izquierda a derecha |
< <= > >=, después == != | izquierda a derecha |
&, después ^, después | | izquierda a derecha |
&&, después || | izquierda a derecha |
?: y las asignaciones = += -= … | derecha a izquierda |
Hay una sorpresa en la tabla: & tiene menos precedencia que ==. Por eso
if (x & 0x08 == 0) no hace lo que parece; se escribe if ((x & 0x08) == 0).
Ante la duda, paréntesis.
04Las estructuras de control en C
| Estructura | Pseudocódigo | C |
|---|---|---|
| Selección simple o doble | Si … entonces … si no | if (c) { … } else { … } |
| Selección múltiple | Según | switch (x) { case 1: … break; default: … } |
| Iteración con condición previa | Mientras | while (c) { … } |
| Iteración con condición posterior | Repetir … hasta | do { … } while (c); (ojo: la condición es de permanencia) |
| Iteración con contador | Para | for (i = 0; i < n; i++) { … } |
En C cualquier valor distinto de cero es verdadero. break sale del ciclo o del
switch y continue salta a la vuelta siguiente: rompen la regla de una sola salida,
pero de manera controlada, y se aceptan cuando simplifican. goto existe; en código
estructurado sólo se tolera para salir de varios niveles ante un error.
Si un case no termina con break, la ejecución sigue en el siguiente. A veces se
hace a propósito para agrupar casos; casi siempre es un olvido.
05Funciones y pasaje de parámetros
Una función es un algoritmo con nombre, entradas (parámetros) y una salida (valor de retorno). En C todos los parámetros se pasan por valor: la función recibe una copia y no puede modificar la variable original. Para que pueda, se le pasa la dirección de la variable, un puntero:
void intercambiar(int *p, int *q)
{
int aux = *p; // lo que hay en la dirección p
*p = *q;
*q = aux;
}
...
intercambiar(&x, &y); // se pasan las direcciones de x e y
Por la misma razón scanf("%d", &n) lleva &: necesita saber
dónde escribir. Los punteros se desarrollan en
contenedores de datos complejos.
| Variable | Alcance (dónde se ve) | Duración (cuánto vive) |
|---|---|---|
| Local | El bloque donde se declara | Mientras se ejecuta el bloque: nace y muere en cada llamada |
Local static | El bloque donde se declara | Todo el programa: conserva el valor entre llamadas |
| Global | Todo el archivo (y otros, con extern) | Todo el programa |
Global static | Sólo su archivo | Todo el programa |
06La pila de llamadas: dónde viven las variables locales
¿Cómo puede una función tener «su» n si se llama a sí misma? Cada llamada crea en la memoria un
marco (stack frame) con sus parámetros, sus variables locales y la dirección a la que hay que
volver. Los marcos se apilan: el último que entra es el primero que sale. Al volver, el marco se descarta y
sus variables dejan de existir. Seguí la ejecución de un factorial recursivo:
Pila (el marco de arriba es el que se está ejecutando)
Salida en pantalla
Como el marco se descarta al volver, una función que devuelve &local entrega la dirección de
algo que ya no existe. El programa puede andar por casualidad y fallar después, lejos del error. Y una
recursión sin caso base apila marcos hasta agotar la pila: es el stack overflow. En un
microcontrolador, con 2 KiB de RAM, la pila se agota mucho antes.
07Programas en varios archivos
Un programa estructurado crece partido en módulos: un archivo .c con el código y un
.h con lo que el resto del programa necesita saber de él, que son los prototipos, los tipos y
las constantes.
/* resistores.h — la interfaz: qué ofrece el módulo */
#ifndef RESISTORES_H // guarda contra la doble inclusión
#define RESISTORES_H
double paralelo(const double r[], int n);
double serie(const double r[], int n);
#endif
/* resistores.c — la implementación: cómo lo hace */
#include "resistores.h"
static int hay_corto(const double r[], int n); // privada del módulo
...
Se compila con gcc main.c resistores.c -o calc. Separar interfaz e implementación permite
cambiar el cómo sin tocar a quien usa el módulo: es la misma idea de la abstracción por capas, a
escala de un programa.
08Entrada y salida estándar
| Especificador | Tipo | Ejemplo |
|---|---|---|
%d / %u | int / unsigned | printf("%d", -5) |
%ld | long | printf("%ld", 100000L) |
%f en printf, %lf en scanf | double | printf("%.2f", 3.14159) → 3.14 |
%e / %g | double, científica o automática | printf("%e", 4.7e-9) |
%x / %o | entero en hexadecimal / octal | printf("0x%02X", 12) → 0x0C |
%c / %s | carácter / cadena | printf("%s", "hola") |
scanf devuelve cuántos datos pudo leer. Un programa robusto lo comprueba:
if (scanf("%lf", &r) != 1) significa que el usuario escribió algo que no es un número.
09En el laboratorio
Programar el ejemplo de la resistencia equivalente del tema de
algoritmos en tres archivos
(main.c, resistores.c, resistores.h), con validación de la entrada
y los casos raros. Comprobar los resultados con la
calculadora de resistores del sitio para
leer los valores de los componentes reales.
Compilar el factorial con gcc -g y ejecutarlo en gdb (o en el depurador de
VS Code). Poner un punto de parada en el caso base y usar bt (backtrace) para ver todos
los marcos apilados, con el valor de n en cada uno.
Compilar una función que sume dos enteros con gcc -S -O1 y ubicar en el .s la
instrucción de suma y el retorno. Repetir con -O0 y comparar el largo. Los entornos de
desarrollo se ven en entornos
de desarrollo C y C++.
10Errores frecuentes
=en lugar de==dentro de unif.- Punto y coma después del
for:for (i = 0; i < n; i++);ejecuta un ciclo vacío. - Olvidar el
&enscanf: el programa escribe en una dirección cualquiera. - División entera sin querer:
1/2 * xvale siempre 0. - Variables locales sin inicializar: no valen cero, valen lo que había en la pila.
- Usar
%dpara undoubleo%fpara unint: imprime basura sin avisar.
11Autoevaluación
¿Qué vale double x = 5 / 2;?
2,0. La división entre enteros se hace primero y da 2; recién después se convierte a double.
¿Por qué scanf necesita &n y printf sólo n?
Porque C pasa todo por valor. printf sólo necesita el valor; scanf tiene que modificar la variable y para eso necesita su dirección.
¿Cuántos marcos hay en la pila, como máximo, al calcular fact(5) llamándolo desde main?
Seis: el de main y cinco de fact, con n = 5, 4, 3, 2 y 1.
¿Qué diferencia hay entre una variable local común y una static?
Las dos se ven sólo en su bloque, pero la static vive todo el programa y conserva su valor entre llamadas; la común se crea y se destruye en cada llamada.
¿Qué etapa da el error undefined reference y qué significa?
El enlazador: la función fue declarada, pero no encuentra su código en ningún objeto ni biblioteca.
¿Qué imprime if (-1 < 1u) printf("a"); else printf("b");?
b. El −1 se convierte a unsigned y queda en el valor máximo.
12Para ampliar
- Brian W. Kernighan y Dennis M. Ritchie. El lenguaje de programación C. 2.ª ed., Prentice Hall Hispanoamericana, 1991. De los creadores del lenguaje. Breve, denso y con ejercicios que enseñan a pensar en C.
- Harvey M. Deitel y Paul J. Deitel. Cómo programar en C/C++ y Java. 4.ª ed., Pearson Educación, 2004. Paso a paso, con muchísimos ejemplos completos y buenas prácticas.
- Luis Joyanes Aguilar e Ignacio Zahonero Martínez. Programación en C. Metodología, algoritmos y estructura de datos. 2.ª ed., McGraw-Hill, 2005. Une el pseudocódigo del tema anterior con el lenguaje, en castellano.
- K. N. King. C Programming: A Modern Approach. 2.ª ed., W. W. Norton, 2008 (en inglés). El mejor libro de texto moderno de C: riguroso con el estándar y muy claro.
- Edsger W. Dijkstra. Go To Statement Considered Harmful. Communications of the ACM, 11(3), 1968 (en inglés). Una página y media que cambió la manera de programar.