Control y funciones en C

Lección 3 de 8: control y funciones en C. Un programa casi nunca va en línea recta: decide, repite y se divide en partes. Esta lección cubre if, switch y los bucles, qué cuenta como verdadero en C, las funciones y sus prototipos, cómo se pasan los argumentos, la recursión y la pila de llamadas, y los avisos de GCC que evitan los errores más típicos.

Decidir: if y else

#include <stdio.h>

int main(void)
{
    int temperatura = 31;

    if (temperatura >= 30) {
        printf("Hace calor\n");
    } else if (temperatura >= 15) {
        printf("Se está bien\n");
    } else {
        printf("Hace frío\n");
    }

    int edad = 17;
    bool con_permiso = true;
    if (edad >= 18 || con_permiso) {
        printf("Puede pasar\n");
    }
    if (!(edad >= 18) && !con_permiso) {
        printf("No puede pasar\n");
    }
    return 0;
}
salidaHace calor
Puede pasar

Los operadores de comparación son ==, !=, <, <=, > y >=, y los lógicos, && (y), || (o) y ! (no). Los dos primeros son de cortocircuito: si con la parte izquierda ya se sabe el resultado, la derecha ni se evalúa. Eso permite escribir con seguridad cosas como if (divisor != 0 && total / divisor > 10).

Qué es verdadero en C

Una condición en C es un número: 0 es falso y cualquier otro valor es verdadero. Las comparaciones devuelven 1 o 0, y por eso se pueden sumar o guardar en un int:

#include <stdio.h>

int main(void)
{
    int pendientes = 3;
    if (pendientes) {
        printf("Hay %d pendientes\n", pendientes);
    }

    int notas[] = {7, 4, 9, 5, 8};
    int aprobados = 0;
    for (int i = 0; i < 5; i++) {
        aprobados += notas[i] >= 5;
    }
    printf("Aprobados: %d\n", aprobados);
    printf("%d %d %d\n", 3 > 2, 3 < 2, -8 && 1);
    return 0;
}
salidaHay 3 pendientes
Aprobados: 4
1 0 1

El error que te ahorra: = en lugar de ==

#include <stdio.h>

int main(void)
{
    int intentos = 0;
    if (intentos = 3) {
        printf("Último intento\n");
    }
    printf("intentos vale %d\n", intentos);
    return 0;
}
gcc: avisoasignar.c: In function 'main':
asignar.c:6:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
    6 |     if (intentos = 3) {
      |         ^~~~~~~~

intentos = 3 es una asignación, y su valor es 3, que es verdadero: la condición siempre se cumple y además machaca la variable. Es uno de los errores más antiguos de C. Si de verdad quieres asignar dentro de una condición, gcc te pide un par de paréntesis extra para dejar claro que no es un descuido.

switch y el peligro de olvidar break

switch compara un entero (o un carácter) con varios valores constantes. Cada case marca un punto de entrada, no un bloque: la ejecución empieza en el caso que coincide y sigue hacia abajo hasta encontrar un break.

#include <stdio.h>

int main(void)
{
    char opcion = 'g';

    switch (opcion) {
    case 'n':
        printf("Nuevo archivo\n");
        break;
    case 'g':
    case 's':
        printf("Guardar\n");
        break;
    case 'q':
        printf("Salir\n");
        break;
    default:
        printf("Opción desconocida\n");
    }
    return 0;
}
salidaGuardar

Que 'g' y 's' compartan código es un uso intencionado de esa caída. El problema es cuando no es intencionado:

El error que te ahorra: el break que falta

#include <stdio.h>

int main(void)
{
    int nivel = 2;

    switch (nivel) {
    case 1:
        printf("Principiante\n");
        break;
    case 2:
        printf("Intermedio\n");
    case 3:
        printf("Avanzado\n");
        break;
    }
    return 0;
}
gcc: avisosin_break.c: In function 'main':
sin_break.c:12:9: warning: this statement may fall through [-Wimplicit-fallthrough=]
   12 |         printf("Intermedio\n");
      |         ^~~~~~~~~~~~~~~~~~~~~~
sin_break.c:13:5: note: here
   13 |     case 3:
      |     ^~~~

Este aviso viene con -Wextra. Sin el break, el nivel 2 imprimiría «Intermedio» y también «Avanzado». Si la caída es a propósito, C23 permite decirlo con el atributo [[fallthrough]]; justo antes del siguiente case, y el aviso desaparece.

Repetir: while, do while y for

#include <stdio.h>

int main(void)
{
    int saldo = 100;
    while (saldo > 0) {
        saldo -= 35;
    }
    printf("Saldo final: %d\n", saldo);

    int intento = 0;
    do {
        intento++;
    } while (intento < 0);
    printf("El do while se ejecutó %d vez\n", intento);

    for (int i = 10; i > 0; i -= 3) {
        printf("%d ", i);
    }
    printf("\n");

    for (int i = 1; i <= 10; i++) {
        if (i % 2 == 0) continue;
        if (i > 7) break;
        printf("%d ", i);
    }
    printf("\n");
    return 0;
}
salidaSaldo final: -5
El do while se ejecutó 1 vez
10 7 4 1 
1 3 5 7 

while comprueba la condición antes de cada vuelta; do while, después, así que se ejecuta al menos una vez (útil para menús o para pedir un dato hasta que sea válido). for junta en una línea la preparación, la condición y el paso. continue salta a la siguiente vuelta y break sale del bucle. La variable declarada dentro del for solo existe dentro de él.

Funciones

Una función tiene un tipo de retorno, un nombre y una lista de parámetros con su tipo. Si no devuelve nada, su tipo es void; si no recibe nada, se escribe (void):

#include <stdio.h>

double precio_final(double base, double descuento)
{
    return base * (1.0 - descuento);
}

void separador(void)
{
    printf("----------\n");
}

int main(void)
{
    separador();
    printf("%.2f\n", precio_final(800.0, 0.15));
    printf("%.2f\n", precio_final(120.0, 0.0));
    separador();
    return 0;
}
salida----------
680.00
120.00
----------

Prototipos: declarar antes de usar

C lee el archivo de arriba abajo, así que una función tiene que estar declarada antes de usarse. Si quieres escribir main primero, o si la función está en otro archivo, se pone antes un prototipo: la primera línea de la función, terminada en punto y coma. Es justo lo que contienen las cabeceras como stdio.h:

#include <stdio.h>

bool es_bisiesto(int anio);

int main(void)
{
    for (int a = 2024; a <= 2028; a++) {
        printf("%d %s\n", a, es_bisiesto(a) ? "sí" : "no");
    }
    printf("1900 %s, 2000 %s\n", es_bisiesto(1900) ? "sí" : "no", es_bisiesto(2000) ? "sí" : "no");
    return 0;
}

bool es_bisiesto(int anio)
{
    return (anio % 4 == 0 && anio % 100 != 0) || anio % 400 == 0;
}
salida2024 sí
2025 no
2026 no
2027 no
2028 sí
1900 no, 2000 sí

El operador condición ? a : b vale a si la condición es verdadera y b si no: es un if que se puede usar dentro de una expresión.

Paso por valor

En C, una función recibe copias de los argumentos. Lo que haga con ellas no afecta a las variables de quien la llama:

#include <stdio.h>

void intercambiar(int a, int b)
{
    int tmp = a;
    a = b;
    b = tmp;
    printf("dentro:  a=%d b=%d\n", a, b);
}

int main(void)
{
    int x = 1, y = 2;
    intercambiar(x, y);
    printf("fuera:   x=%d y=%d\n", x, y);
    return 0;
}
salidadentro:  a=2 b=1
fuera:   x=1 y=2

Para que una función pueda cambiar una variable de fuera hay que pasarle su dirección, con un puntero. Es exactamente lo que hacía strtol(linea, &fin, 10) en la lección 1, y lo veremos a fondo en la lección 5.

El error que te ahorra: una función que no siempre devuelve

#include <stdio.h>

int signo(int n)
{
    if (n > 0) return 1;
    if (n < 0) return -1;
}

int main(void)
{
    printf("%d\n", signo(0));
    return 0;
}
gcc: avisosin_return.c: In function 'signo':
sin_return.c:7:1: warning: control reaches end of non-void function [-Wreturn-type]
    7 | }
      | ^

Para n == 0 la función llega al final sin devolver nada, y quien la llama recibe un valor indefinido. C lo permite; -Wall lo avisa.

Recursión y la pila de llamadas

Una función puede llamarse a sí misma. Cada llamada tiene sus propias variables, guardadas en un marco de la pila (en inglés, stack), que se apila al llamar y se libera al volver:

#include <stdio.h>

long factorial(int n)
{
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

int main(void)
{
    for (int i = 1; i <= 5; i++) {
        printf("%d! = %ld\n", i, factorial(i));
    }
    printf("20! = %ld\n", factorial(20));
    return 0;
}
salida1! = 1
2! = 2
3! = 6
4! = 24
5! = 120
20! = 2432902008176640000

La pila de llamadas: main llama a factorial de 3, que llama a factorial de 2, que llama a factorial de 1. Cada llamada apila un marco con sus variables. Al volver, factorial de 1 devuelve 1, factorial de 2 devuelve 2, factorial de 3 devuelve 6 y main recibe 6La pila de llamadas al calcular factorial(3)mainresultado = ?factorial(3)n = 3, esperafactorial(2)n = 2, esperafactorial(1)n = 1, devuelve 1cada llamada apila un marcocrece al llamarfactorial(1) devuelve 1factorial(2) devuelve 2 × 1factorial(3) devuelve 3 × 2main recibe 6y se desapila al volver

Cada llamada a factorial apila un marco con su propia n; al llegar al caso base, los resultados vuelven hacia abajo. Esquema: decodigo.com.

La pila es pequeña, normalmente 8 MB en Linux. Una recursión sin caso base, o demasiado profunda, la agota y el programa muere con un stack overflow, que en Linux se ve como Segmentation fault. Para recorridos largos, un bucle es más seguro.

Ámbito y duración: local, global y static

#include <stdio.h>

int llamadas_totales = 0;

int siguiente_ticket(void)
{
    static int ticket = 100;
    int local = 0;

    ticket++;
    local++;
    llamadas_totales++;
    printf("ticket %d, local %d\n", ticket, local);
    return ticket;
}

int main(void)
{
    siguiente_ticket();
    siguiente_ticket();
    siguiente_ticket();
    printf("Llamadas: %d\n", llamadas_totales);
    return 0;
}
salidaticket 101, local 1
ticket 102, local 1
ticket 103, local 1
Llamadas: 3

Una variable local nace al entrar en su bloque y desaparece al salir, así que local vuelve a valer 0 en cada llamada. Una variable static dentro de una función se crea una sola vez y conserva su valor entre llamadas, pero solo se ve desde esa función. Una variable global, declarada fuera de toda función, la ve todo el archivo. Las globales son cómodas pero hacen el código difícil de seguir y de probar: úsalas lo menos posible.

Ojo con la seguridad: no ignores lo que devuelve una función

En C, la mayoría de las funciones informan de los errores por su valor de retorno: fgets devuelve NULL, scanf el número de datos leídos, fopen NULL si no puede abrir. Si no lo miras, el programa sigue con datos que no existen, y muchos fallos de seguridad empiezan así. C23 permite marcar tus funciones con [[nodiscard]] para que el compilador avise a quien las ignore:

#include <stdio.h>

[[nodiscard]] bool guardar_copia(const char *ruta)
{
    return ruta != nullptr;
}

int main(void)
{
    guardar_copia("copia.dat");
    return 0;
}
gcc: avisonodiscard.c: In function 'main':
nodiscard.c:10:5: warning: ignoring return value of 'guardar_copia', declared with attribute 'nodiscard' [-Wunused-result]
   10 |     guardar_copia("copia.dat");
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~
nodiscard.c:3:20: note: declared here
    3 | [[nodiscard]] bool guardar_copia(const char *ruta)
      |                    ^~~~~~~~~~~~~

Más en Los fallos de seguridad típicos de C.

Resumen

  • ParaEscribeRecuerda
  • Decidirif / else if / else0 es falso, lo demás verdadero
  • Comparar=== asigna; -Wall avisa
  • Elegir entre constantesswitch con break[[fallthrough]] si la caída es a propósito
  • Repetir al menos una vezdo { } while (c);la condición va al final
  • Usar antes de definirun prototipolo que traen las cabeceras
  • Cambiar una variable de fuerapasar su direcciónlección 5
  • Recordar entre llamadasstatic localmejor que una global
  • Obligar a mirar el resultado[[nodiscard]]C23

Retos

Reto 1. Escribe una función bool es_primo(int n) y úsala para imprimir los primos menores que 30.

Ver la solución
#include <stdio.h>

bool es_primo(int n)
{
    if (n < 2) return false;
    for (int d = 2; d * d <= n; d++) {
        if (n % d == 0) return false;
    }
    return true;
}

int main(void)
{
    for (int i = 0; i < 30; i++) {
        if (es_primo(i)) printf("%d ", i);
    }
    printf("\n");
    return 0;
}
salida2 3 5 7 11 13 17 19 23 29 

Basta con probar divisores hasta la raíz cuadrada: si n tuviera un divisor mayor, tendría también uno menor. La condición d * d <= n lo comprueba sin usar sqrt.

Reto 2. El máximo común divisor de dos números se calcula con el algoritmo de Euclides: mcd(a, b) es a si b es 0, y si no, mcd(b, a % b). Escríbelo de forma recursiva y calcula mcd(1071, 462).

Ver la solución
#include <stdio.h>

int mcd(int a, int b)
{
    if (b == 0) return a;
    return mcd(b, a % b);
}

int main(void)
{
    printf("%d\n", mcd(1071, 462));
    printf("%d\n", mcd(17, 5));
    return 0;
}
salida21
1

Cada llamada reduce el problema, y el caso base, b == 0, garantiza que termina. Es uno de los algoritmos más antiguos que se conocen: aparece en los Elementos de Euclides.

Reto 3. ¿Qué escribe este programa? Piénsalo antes de mirar: un switch sobre x = 2 con case 1: printf("uno "); case 2: printf("dos "); case 3: printf("tres "); default: printf("otro");, todo sin ningún break.

Ver la solución
#include <stdio.h>

int main(void)
{
    int x = 2;
    switch (x) {
    case 1: printf("uno ");
    case 2: printf("dos ");
    case 3: printf("tres ");
    default: printf("otro");
    }
    printf("\n");
    return 0;
}
salidados tres otro

Entra por case 2 y sigue cayendo por todos los de abajo, default incluido. Por eso, con -Wextra, gcc avisa en cada caída.

Todo el código de esta lección se compiló con GCC 15.3 (C23, -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con gcc --version: hace falta GCC 15 o Clang 19 para todo lo de C23.