Apuntes · Capítulo 7Memoria y abstracción

Tipos

Tipos

Las últimas tres semanas fueron sobre dónde viven los datos. Con los punteros aprendimos a llegar a un dato de otro marco. Con la memoria dinámica, a pedir el espacio que haga falta y a decidir quién es su owner. Con los módulos, a esconder la representación detrás de una interfaz. Hoy hacemos otra pregunta: ¿qué es un dato?

Un int que vale 2 puede ser una nota, un número de pasajeros o el estado “terminado” de un viaje. El compilador no ve ninguna diferencia, así que tampoco puede avisarnos cuando los confundimos. Elegir bien un tipo es la forma más barata de evitar un bug: lo que el tipo no permite escribir, no hay que probarlo.

Veremos cuatro herramientas de C para decir qué es un dato: typedef, enum, union y la unión etiquetada (tagged union), que representa un valor que puede tener varias formas. En el laboratorio construirán con ella una tabla cuyas celdas pueden ser un entero, un decimal, una cadena o nada. La misma idea volverá en la semana 13, porque un nodo de un árbol de expresión es justamente eso.

La segunda parte del capítulo es sobre lo que C no revisa: las conversiones que hace solo y el comportamiento indefinido. Ahí aprenderán por qué la frase “a mí me funcionó” no prueba nada.

Ruta de lectura.

  • Antes de clase (15 a 20 minutos, obligatoria): la introducción, Un tipo dice qué existe y qué se puede hacer, typedef: otro nombre, no otro tipo y union: varios tipos en el mismo lugar.
  • En clase: enum: valores con nombre, La unión etiquetada, Una variante que posee memoria y Comportamiento indefinido. Conversiones (✂️) entra si alcanza el tiempo.
  • Después de clase: El laboratorio, paso a paso. Luego el Resumen y los Ejercicios. Si Conversiones no se vio en clase, queda como lectura. Para ir más allá: los últimos ejercicios del capítulo.

Código. Los programas de este capítulo están en code/week-07. Dentro del entorno del curso, entren a esa carpeta y corran make para compilar los ejemplos y make test para comprobarlos. Los comandos de este capítulo se corren desde ahí. Los programas de errors/ tienen errores a propósito y se compilan a mano, como se muestra en cada caso.

Un tipo dice qué existe y qué se puede hacer

Un tipo responde dos preguntas: qué valores existen y qué operaciones tienen sentido. Para int, los valores son los enteros de cierto rango, y se pueden sumar. Para un estado de viaje, los valores son tres, “esperando”, “en ruta” y “terminado”, y sumarlos no significa nada.

Si guardamos un estado en un int, el programa puede contener cosas que no significan nada: el estado 7, o el estado 1 más el estado 2. Son estados inválidos: el tipo los permite, aunque el problema no. Cada uno obliga a escribir una revisión, o a confiar en que nunca ocurra.

Por eso la pregunta de diseño de esta semana es:

Idea clave. Antes de escribir un tipo, pregúntense qué valores no deberían existir. Mientras menos valores inválidos se puedan representar, menos casos hay que revisar y menos pruebas hay que escribir.

C no nos deja llegar muy lejos con esa idea, como veremos. Pero incluso lo poco que permite cambia mucho un programa.

typedef: otro nombre, no otro tipo

typedef le da un nombre nuevo a un tipo que ya existe. Lo usaron en la semana 6, con typedef struct image Image;. También sirve para decir qué significa un número. En typedef_units.c:

typedef int Meters;
typedef int Seconds;

double speed(Meters distance, Seconds time) {
    return (double) distance / time;
}

La firma de speed ahora se explica sola, y eso ya vale. Pero veamos qué pasa si alguien pasa los argumentos al revés:

printf("bien: %.2f m/s\n", speed(distance, time));
printf("al revés: %.2f m/s\n", speed(time, distance));
$ build/typedef_units
bien: 20.00 m/s
al revés: 0.05 m/s

El programa compila sin ninguna advertencia. Para el compilador, Meters, Seconds e int son el mismo tipo, con tres nombres. typedef documenta, pero no protege.

Si de verdad queremos que el compilador los distinga, necesitamos tipos distintos, y en C eso se consigue con un struct. Dos struct son tipos diferentes aunque tengan los mismos campos. En errors/struct_units.c:

typedef struct { int value; } Meters;
typedef struct { int value; } Seconds;
$ gcc -Wall -Wextra -std=c17 errors/struct_units.c -o build/struct_units
errors/struct_units.c: In function 'main':
errors/struct_units.c:15:28: error: incompatible type for argument 1 of 'speed'
   15 |     printf("%.2f\n", speed(time, distance));
      |                            ^~~~
      |                            |
      |                            Seconds
errors/struct_units.c:7:21: note: expected 'Meters' but argument is of type 'Seconds'
    7 | double speed(Meters distance, Seconds time) {
      |              ~~~~~~~^~~~~~~~

Ahora el error es de compilación. El precio es escribir distance.value en todas partes y perder la suma directa. Pocas veces vale la pena para unos metros, pero conviene saber que esa puerta existe.

Error común. Creer que typedef crea un tipo nuevo y por eso “el compilador me va a avisar”. No lo hará. typedef es un nombre más para el mismo tipo.

enum: valores con nombre

Un viaje puede estar esperando, en ruta o terminado. La primera solución que uno escribe usa números con nombre, en trip_defines.c:

#define TRIP_WAITING 0
#define TRIP_ON_ROUTE 1
#define TRIP_FINISHED 2
const char *state_name(int state) {
    if (state == TRIP_WAITING) {
        return "esperando";
    }
    if (state == TRIP_ON_ROUTE) {
        return "en ruta";
    }
    return "terminado";
}
$ build/trip_defines
1: en ruta
7: terminado

Es mucho mejor que escribir 0, 1 y 2 sueltos, que se conocen como números mágicos: números cuyo significado no se puede saber leyéndolos. Pero hay un problema, visible en la segunda línea. El estado 7 no existe, y la función dijo “terminado”, porque el tipo del parámetro es int y todo lo que no es 0 ni 1 cae en el último caso. Además, #define solo reemplaza texto: el compilador nunca llega a ver un TRIP_ON_ROUTE, solo ve un 1.

Un enum (enumeración) define un tipo con una lista de valores con nombre. En trip_enum.c:

enum trip_state { TRIP_WAITING, TRIP_ON_ROUTE, TRIP_FINISHED };
const char *state_name(enum trip_state state) {
    switch (state) {
    case TRIP_WAITING:
        return "esperando";
    case TRIP_ON_ROUTE:
        return "en ruta";
    case TRIP_FINISHED:
        return "terminado";
    }
    return "desconocido";
}
$ build/trip_enum
esperando
en ruta
terminado

Se numeran solos desde 0. También se les puede dar un valor, y los siguientes continúan desde ahí. En enum_values.c:

enum fare { FARE_STUDENT = 100, FARE_ADULT = 250, FARE_NIGHT };
$ build/enum_values
0 1 2
100 250 251
sizeof(enum trip_state) = 4
state = 7
total = 252

Las dos últimas líneas de esa salida son importantes, y volveremos a ellas en un momento.

Qué se gana frente a #define

Un gdb que habla. El depurador conoce los nombres, porque son parte del tipo. En trip_defines.c, print state responde 1 y whatis state responde int, porque el nombre desapareció antes de compilar. Miren lo que responde con el enum. También aquí recortamos la ruta de la línea Starting program y quitamos las líneas de libthread_db. Las direcciones y los números de proceso dependen del entorno.

$ gcc -Wall -Wextra -std=c17 -g -O0 trip_enum.c -o build/trip_enum_gdb
$ gdb -q build/trip_enum_gdb
Reading symbols from build/trip_enum_gdb...
(gdb) break state_name
Breakpoint 1 at 0x7b0: file trip_enum.c, line 7.
(gdb) run
Starting program: .../build/trip_enum_gdb

Breakpoint 1, state_name (state=TRIP_WAITING) at trip_enum.c:7
7	    switch (state) {
(gdb) print state
$1 = TRIP_WAITING
(gdb) print/d state
$2 = 0
(gdb) whatis state
type = enum trip_state
(gdb) delete 1
(gdb) continue
Continuing.
esperando
en ruta
terminado
[Inferior 1 (process 133) exited normally]
(gdb) quit

Un switch revisado. Supongamos que agregan un estado nuevo, TRIP_CANCELLED. Con #define, nadie les dice en qué lugares del programa hay que atenderlo. Con un enum, -Wall revisa cada switch sobre ese tipo:

$ gcc -Wall -Wextra -std=c17 errors/missing_case.c -o build/missing_case
errors/missing_case.c: In function 'state_name':
errors/missing_case.c:7:5: warning: enumeration value 'TRIP_CANCELLED' not handled in switch [-Wswitch]
    7 |     switch (state) {
      |     ^~~~~~

Esta advertencia es la razón principal para preferir un enum. En un programa de varios archivos, agregar un caso se vuelve una tarea guiada: se compila, y gcc hace la lista de lugares por cambiar. Lo pueden comprobar al final del laboratorio.

La advertencia desaparece si el switch tiene default. En errors/default_hides.c, con el mismo estado sin atender, gcc no dice nada:

default:
    return "desconocido";
$ gcc -Wall -Wextra -std=c17 errors/default_hides.c -o build/default_hides
$ build/default_hides
desconocido

Error común. Poner default por costumbre en un switch sobre un enum. Le quita al compilador la lista de casos por revisar. Si atienden todas las etiquetas, no lo pongan.

Una mezcla detectada. Si se pasa un enum donde se espera otro, -Wextra lo avisa. En errors/enum_mix.c:

void show(enum trip_state state, int passengers) {
show(SEAT_AISLE, 20);
show(20, TRIP_ON_ROUTE);
$ gcc -Wall -Wextra -std=c17 errors/enum_mix.c -o build/enum_mix
errors/enum_mix.c: In function 'main':
errors/enum_mix.c:12:10: warning: implicit conversion from 'enum seat' to 'enum trip_state' [-Wenum-conversion]
   12 |     show(SEAT_AISLE, 20);
      |          ^~~~~~~~~~
$ build/enum_mix
estado 1, 20 pasajeros
estado 20, 1 pasajeros

Fíjense bien: hay dos errores en main, y gcc avisa de uno. La segunda llamada pasa los argumentos al revés, y no hubo ninguna advertencia. Es la limitación de la que hablamos a continuación.

Lo que un enum no garantiza

Por dentro, un enum de C es un entero. Las etiquetas son de tipo int, y un entero cualquiera se convierte a un enum sin aviso, aunque no corresponda a ninguna etiqueta. Por eso compilan estas líneas de enum_values.c, y por eso salieron state = 7 y total = 252 en la salida de arriba:

enum trip_state state = 7;
int total = TRIP_FINISHED + FARE_ADULT;

Y por eso state_name tiene un return después del switch, aunque atiende todas las etiquetas. Si lo quitan, gcc lo reclama, y tiene razón: state podría valer 7, y la función llegaría al final sin retornar nada.

$ gcc -Wall -Wextra -std=c17 errors/no_final_return.c -o build/no_final_return
errors/no_final_return.c: In function 'state_name':
errors/no_final_return.c:15:1: warning: control reaches end of non-void function [-Wreturn-type]
   15 | }
      | ^

Resumamos cuánto protege cada herramienta:

Situación #define con int typedef int enum struct con un campo
Nombre en gdb No No Sí Muestra el campo
Avisa si falta un caso en switch No No Sí No aplica
Avisa si se mezclan dos tipos así No No Sí Error
Avisa si se usa un int en su lugar No No No Error

En C. const int es otra opción que ya vieron, pero sirve para una constante suelta, no para una lista de valores relacionados. Usen enum si los valores forman un conjunto y #define o const si es un número suelto, como un tamaño máximo.

union: varios tipos en el mismo lugar

Una union se escribe como un struct, pero con una diferencia enorme: sus campos no van uno después del otro, sino uno encima del otro. Todos empiezan en el mismo byte. En union_bytes.c:

struct both {
    int integer;
    double decimal;
};

union either {
    int integer;
    double decimal;
};
Dos barras a escala de bytes. struct both ocupa 16 bytes: integer del byte 0 al 4, relleno del 4 al 8 y decimal del 8 al 16. union either ocupa 8 bytes: decimal del byte 0 al 8, e integer encima, del 0 al 4, porque los dos campos empiezan en el mismo byteDos barras a escala de bytes. struct both ocupa 16 bytes: integer del byte 0 al 4, relleno del 4 al 8 y decimal del 8 al 16. union either ocupa 8 bytes: decimal del byte 0 al 8, e integer encima, del 0 al 4, porque los dos campos empiezan en el mismo byte
El struct tiene lugar para los dos campos a la vez. La union tiene lugar para uno, el más grande.

El struct tiene los dos datos a la vez. La union tiene espacio para uno, el más grande. En este entorno, un int ocupa 4 bytes y un double 8, y el double debe empezar en un múltiplo de 8, así que el struct deja 4 bytes de relleno (padding) sin usar. Esos tamaños dependen de la máquina. La regla no: una union ocupa al menos lo que su campo más grande.

Escribir en un campo pisa a los demás:

union either u;
u.integer = 7;
printf("guardé un int: %d\n", u.integer);

u.decimal = 3.14;
printf("guardé un double: %.2f\n", u.decimal);
printf("leído como int: %d\n", u.integer);
$ build/union_bytes
sizeof(struct both) = 16
sizeof(union either) = 8
guardé un int: 7
guardé un double: 3.14
leído como int: 1374389535

El 7 ya no existe. Y ese 1374389535 no es un error de memoria: son cuatro de los ocho bytes del double 3.14, leídos como si fueran un int. El número exacto depende de cómo la máquina guarda los bytes. Lo importante es que no significa nada.

Una union no recuerda cuál fue el último campo que se escribió. Esa información no está en ninguna parte: si la necesitamos, hay que guardarla nosotros.

La unión etiquetada

Queremos representar el pago de un pasaje, que puede ser en efectivo (cuántos soles recibió el cobrador), con tarjeta (sus últimos cuatro dígitos) o con una billetera del celular (el número). Un pago tiene una de esas formas.

La primera idea es un struct con todos los campos, como en payment_sizes.c:

struct loose_payment {
    int kind;
    double cash;
    int card_end;
    char *phone;
};

Funciona, y es lo que muchos programas hacen. Pero este tipo permite estados que el problema no tiene:

struct loose_payment bad = {0, 2.5, 4821, NULL};

Este valor dice que el pago es en efectivo (kind 0, si es que alguien recuerda qué significa) y además tiene una tarjeta. ¿Cuál vale? Quien lea el código no lo sabe, y el tipo no lo aclara. Cada función tendría que decidir qué hacer con esa mezcla, y cada prueba tendría que considerarla.

Una unión etiquetada junta las tres herramientas de hoy: un enum dice cuál forma tiene el dato, una union guarda el dato, y un struct mantiene los dos juntos. En payment.h:

typedef enum { PAY_CASH, PAY_CARD, PAY_WALLET } PaymentKind;

typedef struct {
    PaymentKind kind;
    union {
        double cash;   // PAY_CASH: soles recibidos
        int card_end;  // PAY_CARD: últimos cuatro dígitos
        char *phone;   // PAY_WALLET: número del celular; lo posee el Payment
    } as;
} Payment;

El campo kind es la etiqueta, y cada campo de la union es una variante. La variante activa es la que la etiqueta indica. El nombre as es una costumbre que se lee bien: p.as.cash se lee “p, como efectivo”.

Tres pagos, cada uno con dos partes. PAY_CASH con cash en 2.5. PAY_CARD con card_end en 4821. PAY_WALLET con phone, del que sale una flecha a la cadena "987654321", que está en el heap y la posee el pagoTres pagos, cada uno con dos partes. PAY_CASH con cash en 2.5. PAY_CARD con card_end en 4821. PAY_WALLET con phone, del que sale una flecha a la cadena "987654321", que está en el heap y la posee el pago
La etiqueta kind dice cuál variante de la union hay que mirar. Solo una de ellas tiene sentido en cada pago.

Ahora, la mezcla de arriba ya no se puede escribir: un pago tiene espacio para una sola variante. Y de paso, ocupa menos:

$ build/payment_sizes
sizeof(struct loose_payment) = 32
sizeof(Payment) = 16
kind = 0, cash = 2.50, card_end = 4821

Esos tamaños dependen del entorno. El ahorro no es la razón principal para usar una union: la razón principal es que el tipo dice la verdad sobre el problema.

Las dos reglas de uso

La disciplina es simple, y es la misma en todos los programas que usen esta técnica.

Regla 1: se crea con un constructor. Un constructor es una función que devuelve un valor bien formado, con la etiqueta y la variante de acuerdo. Así, esa concordancia se escribe en un solo lugar. En payment.c:

Payment payment_cash(double soles) {
    Payment p;
    p.kind = PAY_CASH;
    p.as.cash = soles;
    return p;
}

Regla 2: se lee después de mirar la etiqueta. Lo natural es un switch, sin default, para que -Wall revise que están todos los casos:

void payment_print(const Payment *p) {
    switch (p->kind) {
    case PAY_CASH:
        printf("efectivo: S/ %.2f\n", p->as.cash);
        break;
    case PAY_CARD:
        printf("tarjeta terminada en %04d\n", p->as.card_end);
        break;
    case PAY_WALLET:
        printf("billetera del %s\n", p->as.phone);
        break;
    }
}

Lo que se gana se ve en payment_demo.c: un solo arreglo, de un solo tipo, con pagos de formas distintas. Esto no se podía escribir con un arreglo de int o de double.

Payment payments[PAYMENTS] = {
    payment_cash(2.5),
    payment_card(4821),
    payment_wallet("987654321"),
};
$ build/payment_demo
efectivo: S/ 2.50
tarjeta terminada en 4821
billetera del 987654321

Esta es la forma de la union dentro de cada pago, según gdb. Compilen los dos archivos con -g -O0:

$ gcc -Wall -Wextra -std=c17 -g -O0 payment_demo.c payment.c -o build/payment_demo_gdb
$ gdb -q build/payment_demo_gdb
Reading symbols from build/payment_demo_gdb...
(gdb) break payment_print
Breakpoint 1 at 0xa3c: file payment.c, line 35.
(gdb) run
Starting program: .../build/payment_demo_gdb

Breakpoint 1, payment_print (p=0xfffffffff9f8) at payment.c:35
35	    switch (p->kind) {
(gdb) print *p
$1 = {kind = PAY_CASH, as = {cash = 2.5, card_end = 0, 
    phone = 0x4004000000000000 <error: Cannot access memory at address 0x4004000000000000>}}
(gdb) continue
Continuing.
efectivo: S/ 2.50

Breakpoint 1, payment_print (p=0xfffffffffa08) at payment.c:35
35	    switch (p->kind) {
(gdb) print *p
$2 = {kind = PAY_CARD, as = {cash = 2.500000000002141, card_end = 4821, 
    phone = 0x40040000000012d5 <error: Cannot access memory at address 0x40040000000012d5>}}
(gdb) continue
Continuing.
tarjeta terminada en 4821

Breakpoint 1, payment_print (p=0xfffffffffa18) at payment.c:35
35	    switch (p->kind) {
(gdb) print *p
$3 = {kind = PAY_WALLET, as = {cash = 9.2711410816661211e-310, 
    card_end = -1431563616, phone = 0xaaaaaaac12a0 "987654321"}}
(gdb) delete 1
(gdb) continue
Continuing.
billetera del 987654321
[Inferior 1 (process 151) exited normally]
(gdb) quit

Lean esto con calma, porque gdb no sabe de etiquetas: muestra las tres variantes siempre, aunque solo una tenga sentido. En el primer pago, cash = 2.5 es correcto. Los otros dos son esos mismos bytes leídos de otra forma, y el phone ni siquiera es una dirección válida. En el segundo pago, card_end = 4821 es correcto, y lo que muestra como cash es lo que tocó en esa memoria: 4 bytes nuevos y 4 que ya estaban ahí. Solo la etiqueta dice cuál mirar. Los números de las variantes inactivas dependen del entorno.

Leer la variante equivocada

¿Qué pasa si alguien no mira la etiqueta? En errors/wrong_variant.c, la etiqueta dice que hay una cadena, y se lee un decimal:

v.kind = VALUE_TEXT;
v.as.text = "Callao";
printf("v.as.number = %g\n", v.as.number);
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/wrong_variant.c -o build/wrong_variant
$ build/wrong_variant
texto: Callao
v.as.number = 9.27115e-310

Ni gcc, ni ASan, ni UBSan dijeron nada. Lo que se imprime es la dirección de la cadena, leída como si sus 8 bytes fueran un double. Cambia de una corrida a otra, porque la dirección cambia, y no significa nada.

Conviene ser precisos, porque aquí es fácil decir algo falso. En C, leer de una union un campo distinto del último que se escribió no es, por sí solo, comportamiento indefinido: el estándar dice que esos bytes se interpretan según el tipo del campo leído. Es un bug, y por eso mismo es peligroso, porque ninguna herramienta lo ve. Se vuelve comportamiento indefinido cuando esos bytes se usan como lo que no son. En errors/wrong_text.c pasa al revés: la etiqueta dice decimal, y los bytes de 2.5 se usan como la dirección de una cadena.

$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/wrong_text.c -o build/wrong_text
$ build/wrong_text
==141==ERROR: AddressSanitizer: SEGV on unknown address 0x4000000000000 (pc 0xffff9bc43b68 bp 0xffffe41cc3e0 sp 0xffffe41cc3e0 T0)
==141==The signal is caused by a READ memory access.
...
    #4 0xaaaad4c70c48 in main errors/wrong_text.c:19

Idea clave. La etiqueta es una promesa que ustedes deben cumplir. C no la revisa. Por eso se crea siempre con un constructor y se lee siempre con un switch, y por eso ambas cosas van en un solo módulo.

Hasta dónde llega la protección

Dijimos que los estados inválidos ya no se pueden representar. Seamos honestos: es verdad a medias. La mezcla efectivo-y-tarjeta desapareció, pero nada impide escribir p.kind = PAY_WALLET; sobre un pago en efectivo.

Un tipo opaco, como los de la semana 6, cerraría esa puerta: nadie fuera del módulo podría tocar kind. Pero en una unión etiquetada, quien la usa casi siempre quiere hacer su propio switch sobre las variantes, y para eso necesita ver los campos. Por eso Payment y el Value del laboratorio no son opacos: es una decisión a propósito, y no un olvido. Las dos opciones son razonables, y cada una deja algo a cargo de alguien:

Tipo opaco Unión etiquetada visible
¿Quién garantiza que el dato está bien formado? El módulo La disciplina de quien lo usa
¿Se puede cambiar la representación sin tocar a quien lo usa? Sí No
¿Se puede agregar una operación nueva sin tocar el módulo? No Sí, con otro switch

Una variante que posee memoria

PAY_WALLET guarda una dirección. ¿De quién es esa cadena? Lo aprendieron en la semana 5: todo bloque tiene un owner, y la firma debe decir quién es. Aquí el owner es el pago. El constructor guarda una copia, de modo que el pago no depende de quien lo llamó:

Payment payment_wallet(const char *phone) {
    char *copy = malloc(strlen(phone) + 1);
    if (copy == NULL) {
        return payment_cash(0.0);
    }
    strcpy(copy, phone);

Y hay una función que libera lo que el pago posee. Solo esa variante tiene algo que liberar, así que hay que mirar la etiqueta. Al final, el pago queda convertido en otro válido y sin memoria, de modo que un segundo payment_free sobre la misma variable no hace daño:

void payment_free(Payment *p) {
    if (p->kind == PAY_WALLET) {
        free(p->as.phone);
    }
    *p = payment_cash(0.0);
}

Error común. Liberar sin mirar la etiqueta. Un free(p->as.phone) en un pago en efectivo le pasaría a free los bytes de un double como si fueran una dirección.

Ahora, la trampa. En la semana 3 vimos que asignar un struct copia todos sus campos. Eso sigue siendo cierto, y por eso mismo copia la dirección de la cadena, no la cadena. En errors/shared_owner.c:

Payment first = payment_wallet("987654321");
Payment second = first;
payment_free(&first);
payment_free(&second);
Animación con las cuatro líneas de código, marcando la que corre. Uno, first es un PAY_WALLET cuyo phone apunta a la cadena "987654321". Dos, después de second = first, second también apunta a esa misma cadena. Tres, payment_free(&first) libera el bloque y deja a first como PAY_CASH con cash en 0.0, mientras second sigue siendo PAY_WALLET y apunta al bloque liberado. Cuatro, payment_free(&second) intenta liberarlo otra vez, y ASan informa attempting double-freeAnimación con las cuatro líneas de código, marcando la que corre. Uno, first es un PAY_WALLET cuyo phone apunta a la cadena "987654321". Dos, después de second = first, second también apunta a esa misma cadena. Tres, payment_free(&first) libera el bloque y deja a first como PAY_CASH con cash en 0.0, mientras second sigue siendo PAY_WALLET y apunta al bloque liberado. Cuatro, payment_free(&second) intenta liberarlo otra vez, y ASan informa attempting double-free
Asignar copió la dirección, no la cadena. first queda bien después de liberar, pero nadie le avisa a second.

Son dos owners del mismo bloque. El primer payment_free lo libera y deja a first sin memoria, pero second no se entera: su dirección sigue ahí.

$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined -I. errors/shared_owner.c payment.c -o build/shared_owner
$ build/shared_owner
==156==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
...
    #2 0xaaaad3db18a4 in main errors/shared_owner.c:11
...
freed by thread T0 here:
...
    #2 0xaaaad3db189c in main errors/shared_owner.c:10
...
previously allocated by thread T0 here:
...
    #2 0xaaaad3db1860 in main errors/shared_owner.c:5
...
SUMMARY: AddressSanitizer: double-free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:52 in free

El informe cuenta la historia completa, de abajo hacia arriba: se reservó en la línea 5, se liberó en la 10, y se intentó liberar otra vez en la 11. Noten que el programa escribió bien el pago antes de fallar: mientras nadie libere, compartir no se nota.

Hay dos políticas correctas, y un programa debe elegir y documentar:

  • Mover: después de second = first, el owner es second, y first ya no se usa ni se libera. Es la que usaremos en el laboratorio: cuando una celda de la tabla recibe lo que devolvió value_parse, la cadena pasa a ser de la celda.
  • Copiar de verdad: una función aparte, que reserve memoria nueva para la cadena. Nadie comparte nada, pero cuesta memoria y un malloc más.

Idea clave. Una unión etiquetada con memoria adentro es un struct que no se puede asignar sin pensar. La regla de quién es owner va escrita en el encabezado. Lean el comentario inicial de lab/value.h.

Conversiones

C convierte tipos por su cuenta, sin decir nada. Ya vieron en Introducción a la Computación las conversiones entre int y double, los enteros sin signo, y size_t, incluido por qué strlen(s) - 1 da la vuelta con una cadena vacía. Hoy juntamos eso en unas pocas reglas y les ponemos nombre.

Esta sección se puede estudiar aparte: nada de la unión etiquetada depende de ella.

Empecemos con la pregunta de la semana. ¿Qué escribe este programa? Decidan antes de seguir leyendo.

if (-1 < 1u) {
    puts("menor");
} else {
    puts("mayor");
}
$ gcc -Wall -Wextra -std=c17 errors/minus_one.c -o build/minus_one
errors/minus_one.c: In function 'main':
errors/minus_one.c:5:12: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
    5 |     if (-1 < 1u) {
      |            ^
$ build/minus_one
mayor

Escribe mayor. La u hace que 1u sea un unsigned int. Antes de comparar, los dos operandos deben tener el mismo tipo, y la regla elige unsigned int, aunque ahí no quepa un -1. Entonces el -1 se convierte. Como vieron con (unsigned int) x, convertir a un tipo sin signo suma 2^32 hasta que el valor cabe: -1 pasa a ser 4294967295. La comparación que de verdad se hace es 4294967295 < 1, y es falsa.

Las reglas

Cuando una operación mezcla tipos, C aplica estas reglas, de arriba hacia abajo. Son las conversiones aritméticas habituales, y las versiones siguientes bastan para este curso:

Si los operandos son Se convierten a Ejemplo
Algún double double 7 / 2.0 es 3.5
Más chicos que int (char, unsigned char) int, siempre, aunque los dos sean iguales a + b en promotion.c
int y unsigned int unsigned int -1 < 1u
int y size_t size_t position < strlen(word)

Y en una asignación, en un parámetro o en un return, el valor se convierte al tipo de destino, aunque no quepa.

El mismo -1 cambia de valor según el tipo sin signo al que llegue:

printf("como unsigned int: %u\n", (unsigned int) negative);
printf("como size_t: %zu\n", (size_t) negative);
printf("con cast a int: %d\n", negative < (int) strlen("hola"));
$ build/sign_rule
como unsigned int: 4294967295
como size_t: 18446744073709551615
con cast a int: 1

Ese segundo número depende del tamaño de size_t, que aquí es de 8 bytes.

Con signo y sin signo: size_t

El caso de la pregunta se ve artificial, porque nadie escribe -1 < 1u. Pero sí escribimos esto, en errors/size_compare.c:

const char *word = "hola";
int position = -1;

if (position < strlen(word)) {
$ gcc -Wall -Wextra -std=c17 errors/size_compare.c -o build/size_compare
errors/size_compare.c: In function 'main':
errors/size_compare.c:9:18: warning: comparison of integer expressions of different signedness: 'int' and 'size_t' {aka 'long unsigned int'} [-Wsign-compare]
    9 |     if (position < strlen(word)) {
      |                  ^
$ build/size_compare
la posición está después del final

Una posición -1 “está después del final” de una cadena de 4 letras, porque se comparó con 18446744073709551615. strlen y sizeof devuelven size_t. El arreglo de la advertencia es el de la línea con cast a int de arriba: guardar la longitud en un int, con un cast, si saben que cabe. Es lo que vienen haciendo desde Introducción a la Computación. Un cast no es una decoración: dice que ustedes revisaron que el valor cabe.

Error común. Callar la advertencia -Wsign-compare sin entenderla. Es una de las pocas veces que gcc les avisa de un bug real, y sin ella el programa simplemente da la respuesta contraria.

Promoción de enteros

Lo que es más chico que un int se convierte a int antes de operar. Se llama promoción de enteros (integer promotion). En promotion.c:

unsigned char a = 200;
unsigned char b = 100;

int sum = a + b;
unsigned char small_sum = a + b;
unsigned char top = 255;
printf("top + 1 == 0: %d\n", top + 1 == 0);

unsigned int big_top = 4294967295u;
printf("big_top + 1 == 0: %d\n", big_top + 1 == 0);
$ build/promotion
sum = 300, small_sum = 44
sizeof(a) = 1, sizeof(a + b) = 4
top + 1 == 0: 0
big_top + 1 == 0: 1

Lean línea por línea:

  • a + b no se hace en 8 bits. Los dos pasan a int, y da 300. sum lo recibe entero.
  • small_sum no tiene dónde guardar 300. La conversión a unsigned char reduce módulo 256: 300 - 256 = 44.
  • sizeof(a + b) confirma que el resultado es un int, de 4 bytes en este entorno.
  • top + 1 vale 256, porque top se volvió int: no dio la vuelta. En cambio, big_top + 1 no se promueve a nada, porque unsigned int no es más chico que int, y ahí sí da la vuelta a 0.

Conversiones que pierden información

Una asignación a un tipo más chico no da ninguna advertencia con -Wall -Wextra. Las tres de narrowing.c compilan en silencio:

int total = 300;
unsigned char small = total;
double price = 3.99;
int soles = price;
unsigned int large = 3000000000u;
int wrong = large;
$ build/narrowing
small = 44
soles = 3
wrong = -1294967296

Cada una tiene un estatus distinto:

Conversión Qué dice C
A un tipo sin signo, si no cabe Definida: se reduce módulo 2^n. small vale 44 en cualquier máquina
De double a int, si la parte entera cabe Definida: se descartan los decimales, sin redondear
A un tipo con signo, si no cabe La decide cada compilador, que debe documentarla. gcc reduce módulo 2^32
De double a int, si la parte entera no cabe Comportamiento indefinido

gcc tiene una opción que no viene en -Wall -Wextra y que avisa de todas estas. Conviene probarla de vez en cuando, aunque es ruidosa en un programa grande:

$ gcc -Wall -Wextra -std=c17 -Wconversion narrowing.c -o build/narrowing_checked
narrowing.c: In function 'main':
narrowing.c:6:27: warning: conversion from 'int' to 'unsigned char' may change value [-Wconversion]
    6 |     unsigned char small = total;
      |                           ^~~~~
narrowing.c:10:17: warning: conversion from 'double' to 'int' may change value [-Wfloat-conversion]
   10 |     int soles = price;
      |                 ^~~~~
narrowing.c:14:17: warning: conversion to 'int' from 'unsigned int' may change the sign of the result [-Wsign-conversion]
   14 |     int wrong = large;
      |                 ^~~~~

Tres reglas prácticas cierran esta sección:

  • Para contar y para índices, usen int, mientras los tamaños quepan. Es lo que hace este curso.
  • No mezclen signos en una comparación. Si gcc avisa, conviertan con un cast, después de pensar si el valor cabe.
  • Usen unsigned para trabajar con bits y bytes, no para decir “este número no puede ser negativo”. Un unsigned no impide el negativo: lo convierte en un número enorme, y el bug se vuelve invisible.

Comportamiento indefinido

Lo han visto nombrado varias veces. Ahora lo miramos de frente. El estándar de C divide lo que un programa puede hacer en tres grupos:

Grupo Qué significa Ejemplo
Definido El estándar dice qué pasa unsigned char que da la vuelta
Definido por la implementación Cada compilador elige, y debe documentarlo int wrong = large;, si no cabe
Indefinido El estándar no exige nada Overflow de un int, un índice fuera de rango, leer una local sin inicializar

“No exige nada” es literal. No significa “da un número raro”. Significa que el programa completo deja de tener un significado definido, y que el compilador puede suponer que eso nunca ocurre. Esa suposición es la que sorprende, y es la que veremos ahora.

Por qué “me funcionó” no prueba nada

Si x es el mayor int, ¿x + 1 es mayor que x? Veamos errors/overflow_compare.c:

int next = x + 1;
printf("next = %d, next > x: %d\n", next, next > x);

int steps = 0;
for (int i = x; i > 0 && steps < 5; i++) {
    steps++;
}
printf("steps = %d\n", steps);

Lo compilamos dos veces, con el mismo código y sin ninguna advertencia. La opción -O2 le pide a gcc que optimice, es decir, que genere un ejecutable más rápido sin cambiar lo que hace un programa sin comportamiento indefinido. -O0 le pide que no optimice, y es lo que usamos con gdb.

$ gcc -Wall -Wextra -std=c17 -O0 errors/overflow_compare.c -o build/overflow_o0
$ gcc -Wall -Wextra -std=c17 -O2 errors/overflow_compare.c -o build/overflow_o2
$ echo 2147483647 | build/overflow_o0
next = -2147483648, next > x: 0
steps = 1
$ echo 2147483647 | build/overflow_o2
next = -2147483648, next > x: 1
steps = 5

Lean la línea de -O2: dice que next vale -2147483648, y a la vez que es mayor que 2147483647. Las dos cosas en la misma línea. No es un error de gcc. El compilador razona así: como el overflow de un int no puede ocurrir en un programa válido, x + 1 siempre es mayor que x, y no vale la pena hacer la comparación. Lo mismo con el ciclo: si i empieza siendo positivo y solo aumenta, i > 0 siempre es cierto.

Esa decisión es correcta para todo programa sin comportamiento indefinido, y hace más rápidos a los programas normales. Con 5 como entrada, los dos ejecutables hacen lo mismo:

$ echo 5 | build/overflow_o0
next = 6, next > x: 1
steps = 5
$ echo 5 | build/overflow_o2
next = 6, next > x: 1
steps = 5

Esa es la respuesta a la pregunta de por qué un programa con comportamiento indefinido puede funcionar hoy y fallar mañana:

  • Depende del compilador, de su versión y de sus opciones, como acabamos de ver. Estos resultados son de este gcc; otro puede dar otros.
  • Depende de lo que hay en la memoria, como veremos con la variable sin inicializar. Cambiar una línea lejana puede cambiar esos bytes.
  • Depende de los datos. El programa de arriba es correcto para casi todas las entradas.

Una prueba que pasa solo dice que, en esa corrida y con esos datos, no se notó.

Idea clave. Si un programa tiene comportamiento indefinido, que funcione no es evidencia de nada. No se puede razonar sobre lo que “de verdad” hace la máquina, porque el compilador ya tomó decisiones suponiendo que eso no pasaba.

Cómo encontrarlo: overflow

-fsanitize=undefined, o UBSan, agrega revisiones al ejecutable, y lo vienen usando desde el principio del curso. Sus avisos van a la salida de errores: en una terminal salen así, intercalados con lo que escribe el programa.

$ gcc -Wall -Wextra -std=c17 -g -fsanitize=undefined errors/overflow_compare.c -o build/overflow_ub
$ echo 2147483647 | build/overflow_ub
errors/overflow_compare.c:10:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
next = -2147483648, next > x: 0
errors/overflow_compare.c:14:42: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
steps = 1

Solo avisa si esa línea se ejecuta con esos datos. Con la entrada 5 no diría nada.

Índice fuera de rango

int grades[4] = {14, 9, 17, 11};
printf("grades[%d] = %d\n", i, grades[i]);

Sin sanitizers, un índice 4 da lo que haya ahí, y un índice lejano puede matar el programa. Los resultados dependen del entorno:

$ gcc -Wall -Wextra -std=c17 errors/index_out.c -o build/index_plain
$ echo 4 | build/index_plain
grades[4] = -1053909216
$ echo 1000000 | build/index_plain
bash: line 1:    15 Done                    echo 1000000
        16 Segmentation fault      | build/index_plain

Con los sanitizers de siempre, el error se encuentra en el primer caso:

$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/index_out.c -o build/index_out
$ echo 4 | build/index_out
errors/index_out.c:10:42: runtime error: index 4 out of bounds for type 'int [4]'
...
==201==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xffff84400050 at pc 0xaaaab7d01474 bp 0xffffce169b80 sp 0xffffce169b98
READ of size 4 at 0xffff84400050 thread T0
    #0 0xaaaab7d01470 in main errors/index_out.c:10

Variable sin inicializar

discount no le da valor a percent si la edad está entre 18 y 64. En errors/uninitialized.c:

int discount(int age) {
    int percent;
    if (age < 18) {
        percent = 50;
    } else if (age >= 65) {
        percent = 30;
    }
    return percent;
}

Sin optimizar, gcc no avisa. Y con los dos sanitizers, el programa corre sin ningún reclamo, y responde un descuento. El valor depende del entorno:

$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/uninitialized.c -o build/uninitialized
$ echo 30 | build/uninitialized
descuento: 0%
$ gcc -Wall -Wextra -std=c17 -g -O0 errors/uninitialized.c -o build/uninitialized_gdb
$ echo 30 | build/uninitialized_gdb
descuento: 65535%

La respuesta 0% es la peligrosa: parece correcta, y las pruebas pasarían. ASan y UBSan no detectan esta clase de error. Para esto están valgrind, que sigue qué bytes recibieron un valor, y la advertencia de gcc que solo se activa al optimizar. Recuerden que valgrind se usa sin sanitizers:

$ echo 30 | valgrind -q --track-origins=yes build/uninitialized_gdb
==44== Use of uninitialised value of size 8
...
==44==    by 0x10886B: main (uninitialized.c:19)
==44==  Uninitialised value was created by a stack allocation
==44==    at 0x1087E8: discount (uninitialized.c:4)
$ gcc -Wall -Wextra -std=c17 -O2 errors/uninitialized.c -o build/uninitialized_o2
errors/uninitialized.c: In function 'discount':
errors/uninitialized.c:11:12: warning: 'percent' may be used uninitialized [-Wmaybe-uninitialized]
   11 |     return percent;
      |            ^~~~~~~
errors/uninitialized.c:5:9: note: 'percent' was declared here
    5 |     int percent;
      |         ^~~~~~~

Ninguna herramienta lo ve todo

Un último ejemplo: un double cuya parte entera no cabe en un int también es comportamiento indefinido, y -fsanitize=undefined, a pesar de su nombre, no lo revisa. Hace falta pedirlo aparte. El número de la primera corrida depende de la máquina:

$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/double_too_big.c -o build/double_too_big
$ echo 3e10 | build/double_too_big
n = 2147483647
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=undefined,float-cast-overflow errors/double_too_big.c -o build/double_checked
$ echo 3e10 | build/double_checked
errors/double_too_big.c:9:5: runtime error: 3e+10 is outside the range of representable values of type 'int'
n = 2147483647

Esta tabla resume lo que comprobamos en este capítulo, y test_examples.py comprueba cada fila:

Error -Wall -Wextra ASan + UBSan valgrind
Overflow de un int No Sí, si se ejecuta No
Índice fuera de rango de un arreglo local No, si el índice se lee al correr Sí, si se ejecuta No ve el índice. Aquí solo avisó de un valor sin inicializar
Local sin inicializar Solo con -O2, y no siempre No Sí, si se usa el valor
Doble free No Sí Sí
double que no cabe en un int No No, sin float-cast-overflow No
Leer la variante equivocada, sin usarla como dirección No No No

Cómo evitarlo

Las herramientas encuentran errores en los caminos que se ejecutan, y por eso la defensa principal es escribir de modo que el error no pueda ocurrir. Para el overflow, la técnica es revisar antes de operar, porque después ya es tarde. Es lo que hace value_parse en lab/value.c, y lo van a leer en el laboratorio:

if (value > (INT_MAX - digit) / 10) {
    return 0;
}
value = value * 10 + digit;

Por eso mismo value_parse no usa sscanf con %d para los enteros. En Introducción a la Computación vieron que sscanf no promete ningún aviso si el número no cabe: según el estándar, también eso es comportamiento indefinido.

El código de esta semana

make compila los programas de la clase en build/. make test corre tres archivos de pruebas:

Archivo Qué comprueba
test_examples.py Que los programas compilan sin advertencias, y cada afirmación sobre herramientas: qué avisa gcc, qué detectan los sanitizers y qué detecta valgrind
test_notes.py Vuelve a correr los comandos de este capítulo y compara su salida. Los bloques que dependen del sistema se corren, pero no se comparan
test_lab.py La solución del laboratorio, si está disponible

make test-lab corre esas mismas pruebas de laboratorio sobre su código.

El laboratorio, paso a paso

Los archivos de partida están en code/week-07/lab, y su README.md tiene las preguntas y lo que pide cada parte. Esta semana hay una sola sesión, porque la otra es para la Práctica 2. make test-lab comprueba el módulo: falla hasta que terminen. Aquí están las ideas, no el código.

El tipo es el mismo esquema de Payment, con cuatro variantes, y está en lab/value.h:

typedef enum { VALUE_NONE, VALUE_INT, VALUE_DOUBLE, VALUE_STRING } ValueKind;

typedef struct {
    ValueKind kind;
    union {
        int integer;   // VALUE_INT
        double number; // VALUE_DOUBLE
        char *text;    // VALUE_STRING: cadena en el heap, que posee este Value
    } as;
} Value;

La variante VALUE_NONE no tiene dato. No es un número especial, como un -1, ni una cadena vacía: es otro estado, que no se puede confundir con un dato válido. Por eso no hace falta reservar ningún valor para decir “no hay”.

El módulo Value. value_none, value_int y value_parse ya están hechos: value_int es el modelo de los otros constructores. Lo que debe cuidar cada función:

  • value_string guarda una copia. Quien la llama puede cambiar o perder su cadena después.
  • value_free mira la etiqueta, y deja el valor como nada.
  • value_print y value_equal llevan un switch sin default. value_equal mira primero las etiquetas: si no coinciden, no hay nada más que comparar, y leer la union del otro sería leer la variante equivocada.

La tabla. El archivo es un CSV en el que una misma columna puede mezclar tipos, y eso es lo que justifica Value. La columna tarifa tiene enteros y decimales, y pasajeros tiene un dato ausente y otro escrito en palabras. Una tabla es un solo arreglo de Value, fila por fila, con la misma fórmula de image_from_values.

table.c trae hecha la lectura, y ustedes escriben tres cosas: add_row, table_free y table_summary. Antes de escribir table_free, respondan quién posee qué. La tabla posee el arreglo de celdas, y cada celda posee su cadena. Por eso primero se liberan las cadenas, luego el arreglo y al final la tabla. Si invierten el orden, ASan lo muestra.

La tabla de viajes.csv en memoria. Un Table, con columns en 4, rows en 7 y el puntero cells, apunta a un solo bloque de celdas, fila por fila. La fila 0 tiene un STRING, el INT 18, el DOUBLE 2.50 y un NONE. La fila 1 tiene un STRING, el INT 25, el INT 3 y un STRING, y siguen 5 filas más. Cada STRING apunta a su propia cadena: "Chosica", "Callao" y "hora punta". Los números marcan el orden de table_free: 1, las cadenas; 2, el bloque de celdas; 3, la tablaLa tabla de viajes.csv en memoria. Un Table, con columns en 4, rows en 7 y el puntero cells, apunta a un solo bloque de celdas, fila por fila. La fila 0 tiene un STRING, el INT 18, el DOUBLE 2.50 y un NONE. La fila 1 tiene un STRING, el INT 25, el INT 3 y un STRING, y siguen 5 filas más. Cada STRING apunta a su propia cadena: "Chosica", "Callao" y "hora punta". Los números marcan el orden de table_free: 1, las cadenas; 2, el bloque de celdas; 3, la tabla
Quien posee algo lo libera, y lo poseído se libera primero. Solo las celdas STRING tienen memoria propia.

En table_summary, el error típico es empezar el mínimo y el máximo en 0: una columna con solo números positivos tendría mínimo 0, que no aparece en los datos. El primer número encontrado debe fijar los dos.

Esto debe producir su programa:

$ build/lab/report lab/data/viajes.csv
7 filas y 4 columnas

"Chosica" | 18 | 2.50 | (nada)
"Callao" | 25 | 3 | "hora punta"
"Chosica" | 22 | 2.50 | (nada)
"Ate" | (nada) | 1.80 | "sin conteo"
"Callao" | 31 | 3.50 | "feriado"
"Ate" | 9 | 2 | (nada)
"Chosica" | "sin dato" | 2.50 | "lluvia"

columna      enteros decimales cadenas   nada      suma   media     min     max
ruta               0         0       7      0
pasajeros          5         0       1      1    105.00   21.00    9.00   31.00
tarifa             2         5       0      0     17.80    2.54    1.80    3.50
nota               0         0       4      3

En C. report.c recibe el nombre del archivo en la línea de comandos. Por eso su main se declara como int main(int argc, char *argv[]): argc dice cuántas palabras tiene el comando, y argv es un arreglo de cadenas con esas palabras. argv[0] es el nombre del programa y argv[1] es lo que viene después. No hace falta nada más para este laboratorio.

Comprueben una fila a mano antes de creerle a su programa. En tarifa hay 2 enteros y 5 decimales, que suman 17.80. Como todo corre con ASan, terminar sin un informe significa que ningún bloque quedó sin liberar en esa corrida.

Resumen

  • Un tipo dice qué valores existen y qué se puede hacer con ellos. Mientras menos estados inválidos se puedan representar, menos hay que revisar.
  • typedef da otro nombre al mismo tipo: documenta, pero no protege. Dos struct sí son tipos distintos.
  • Un enum define valores con nombre. Frente a #define se gana el nombre en gdb, la advertencia de -Wswitch y el aviso al mezclar dos enum. Por dentro es un entero: enum trip_state state = 7; compila.
  • En un switch sobre un enum, no pongan default, para que -Wall avise si falta un caso. Dejen un return después si la función devuelve algo.
  • Los campos de una union empiezan en el mismo lugar. Tiene espacio para uno, y no recuerda cuál se escribió.
  • Una unión etiquetada es un struct con un enum y una union. Se crea con constructores y se lee con un switch. La etiqueta es una promesa que C no revisa.
  • Leer la variante equivocada interpreta esos bytes con otro tipo. Ninguna herramienta lo detecta. Si esos bytes se usan como dirección, es comportamiento indefinido.
  • Asignar un struct con un puntero adentro copia la dirección, y deja dos owners: hay que elegir entre mover y copiar, y escribirlo en el encabezado.
  • En una comparación entre int y unsigned int, o size_t, el int se convierte al tipo sin signo: -1 < 1u es falso. Lo más chico que int se promueve a int antes de operar.
  • Las asignaciones a tipos más chicos no avisan con -Wall -Wextra. -Wconversion sí.
  • El comportamiento indefinido significa que el estándar no exige nada, y el compilador puede suponer que no ocurre. Por eso un mismo programa da resultados distintos con -O0 y con -O2, y “me funcionó” no prueba nada.
  • UBSan encuentra el overflow, ASan y UBSan el índice fuera de rango, y valgrind la variable sin inicializar. Cada uno solo en los caminos que se ejecutan, y ninguno lo ve todo. Revisen antes de operar.

Ejercicios

Decidan primero y ejecuten después. Todo programa revisa sus lecturas, compila sin advertencias y libera lo que reserva.

  1. ¿Qué ventaja tiene un enum frente a #define RED 0? Den tres, y digan qué enum no impide. Compruébenlo con un programa pequeño.

  2. Sin compilar, digan qué avisa gcc con -Wall -Wextra en cada caso, y qué escribe. Luego compruébenlo.

    enum light { GREEN, YELLOW, RED };
    a) enum light x = 5; printf("%d\n", x);
    b) switch (x) { case GREEN: puts("g"); break; case RED: puts("r"); break; }
    c) switch (x) { case GREEN: puts("g"); break; default: puts("o"); break; }
  3. Sin compilar, digan qué escribe cada línea, y por qué. Luego compruébenlo. La tercera línea depende del tamaño de int: díganlo.

    if (-1 < 1u) puts("menor"); else puts("mayor");
    if (-1 < 1) puts("menor"); else puts("mayor");
    printf("%u\n", 0u - 1u);
    unsigned char c = 250; c = c + 10; printf("%d\n", c);
    unsigned char d = 250; printf("%d\n", d + 10);
  4. ¿Qué pasa si leen v.as.number cuando la etiqueta dice que hay una cadena? ¿Y si leen v.as.text cuando la etiqueta dice que hay un número, y lo pasan a printf con %s? Expliquen la diferencia. Usen errors/wrong_variant.c y errors/wrong_text.c.

  5. Definan una unión etiquetada Shape, con un círculo (radio), un rectángulo (ancho y alto) y un punto (sin datos), con sus constructores y double shape_area(const Shape *s). Agreguen un triángulo, y lean lo que dice gcc antes de cambiar la función.

  6. En errors/shared_owner.c, escriban Payment payment_copy(const Payment *p), y úsenla para que el programa termine sin errores. Compruébenlo con ASan. ¿Qué variantes pueden copiarse con una simple asignación?

  7. Este ciclo debería escribir una cadena al revés. Encuentren los dos problemas, digan qué avisa gcc, y corríjanlo con un cast.

    for (size_t i = strlen(s) - 1; i >= 0; i--) { putchar(s[i]); }
  8. Escriban int add_checked(int a, int b, int *result), que devuelve 1 y guarda la suma si cabe, y devuelve 0 sin tocar *result si no cabe. No puede ocurrir overflow en ningún caso: revisen antes de sumar, y tengan en cuenta los dos signos. Pruébenla con assert, con INT_MAX e INT_MIN, y córranla con UBSan.

  9. Para cada fragmento, digan si tiene comportamiento indefinido. Si lo tiene, digan qué herramienta lo encontraría, y compruébenlo.

    a) int x = 2147483647; x = x + 1;
    b) unsigned int y = 4294967295u; y = y + 1;
    c) int a[3]; a[0] = 1; printf("%d\n", a[1]);
    d) int n = 3.99;
    e) double big = 1e20; int m = big;
  10. errors/overflow_compare.c da resultados distintos con -O0 y -O2. Compílenlo también con -O1, y con -O2 -fwrapv. Averigüen en la ayuda de gcc qué hace -fwrapv y expliquen el resultado. ¿Con esa opción, sigue siendo comportamiento indefinido en gcc? ¿Es C portable?

  11. Agreguen al laboratorio Value value_copy(const Value *v). Escriban una prueba que copie una cadena, libere el original y siga leyendo la copia, y corran con ASan. ¿Qué mostraría ASan si value_copy fuera solo return *v;?

  12. Agreguen a la tabla una función que sepa si una columna es “pura”, es decir, si todas sus celdas no vacías tienen la misma variante. Después cambien report.c para que lo escriba. Luego expliquen por qué esa información no se podría obtener si value_parse convirtiera todo a double.