Apuntes · Capítulo 4Memoria y abstracción
Punteros
Punteros
La semana pasada terminamos con una flecha. Al pasar un arreglo a una función, dibujamos el parámetro
como una flecha hacia los elementos de main, y gdb dijo que su tipo era int *. Hoy esa flecha deja de
ser un detalle del paso de arreglos y se vuelve algo que ustedes declaran, guardan, pasan y siguen: un
puntero (pointer).
Con punteros se resuelven tres problemas que ya conocen. Una función de la semana 3 no podía cambiar un
int de quien la llama, y ahora podrá. Una función solo podía devolver un valor con return, y ahora podrá
entregar varios. Y cada filtro copiaba 16396 bytes al recibir una imagen de 6 píxeles, y ahora copiará una
dirección. También vamos a abrir recetas que quedaron cerradas en Introducción a la Computación: el & de
scanf, el FILE * de fopen y el const char s[] de las cadenas.
Nada de esto cambia el modelo de entornos. C sigue copiando el valor de cada argumento, y un puntero es un valor más. Lo único nuevo es que, desde una casilla, ahora se puede llegar a otra casilla, incluso en otro marco.
Ruta de lectura.
- Antes de clase (15 a 20 minutos, obligatoria): la introducción y Una variable que guarda una dirección.
- En clase: Paso por puntero, Arreglos y punteros, Punteros a
structy->,consty punteros, Devolver la dirección de una variable local e Imágenes por puntero.- Después de clase:
FILE *, sin receta y El laboratorio, paso a paso. Luego el Resumen y los Ejercicios. Para ir más allá: los últimos ejercicios del capítulo.
Código. Los programas de este capítulo están en
code/week-04. Dentro del entorno del curso, entren a esa carpeta y corranmakepara compilar los ejemplos ymake testpara comprobarlos. Los comandos de este capítulo se corren desde ahí. Los programas deerrors/tienen errores a propósito y se compilan a mano, como se muestra en cada caso.
Una variable que guarda una dirección
En Introducción a la Computación vieron que la memoria es una fila de bytes numerados, que &x da la
dirección de memoria (memory address) de x, y que %p la escribe. Una dirección es un valor, como un
int. Se puede guardar en una variable. Una variable que guarda una dirección es un puntero.
Miren el comienzo de pointer_basics.c:
int x = 5;
int y = 20;
int *p = &x;
La línea nueva se lee de derecha a izquierda: &x es la dirección de x, y se guarda en p, cuyo tipo es
int *, “dirección de un int”. En el modelo de entornos, p es una casilla más del marco de main. Lo que
guarda se dibuja como una flecha, porque casi nunca importa el número, sino a qué casilla lleva.
Para seguir el puntero, es decir, llegar a la casilla a la que apunta, se usa *. La expresión *p
significa “lo que hay en la dirección que guarda p”. Por eso se puede leer y también se puede escribir:
*p = 7;
Esa línea no cambia p. Sigue la flecha y escribe en la casilla de llegada, que es x. La flecha queda igual.
En cambio, una asignación a p, sin asterisco, cambia la flecha y no toca ningún int:
p = &y;
*p = *p + 1;


Esta es la distinción de todo el capítulo. La regla 3 del modelo sigue siendo cierta: asignar cambia una sola casilla. La pregunta es cuál.
| Se escribe | Qué casilla cambia |
|---|---|
p = &y; |
La de p: ahora apunta a otro lado |
*p = 7; |
Aquella a la que apunta p. La casilla de p no cambia |
El programa completo, corrido en este entorno. Las direcciones dependen del entorno y de la corrida: en su
máquina saldrán otras. Lo que debe coincidir es que p valga lo mismo que &y.
$ build/pointer_basics
x = 5, *p = 5
x = 7, *p = 7
x = 7, y = 21, *p = 21
&x = 0xffffb5e00030
&y = 0xffffb5e00040
p = 0xffffb5e00040
sizeof(p) = 8, sizeof(*p) = 4
El (void *) que usa el programa dentro de cada printf es la misma regla de Introducción a la Computación:
%p siempre va con (void *). Y la última línea dice dos cosas distintas. p ocupa 8 bytes en este entorno,
porque es una dirección. *p ocupa 4, porque es un int. El lenguaje no fija ninguno de esos dos números.
Los dos significados de * y de &
El asterisco cambia de significado según dónde aparezca, y eso confunde al principio:
| Dónde | Ejemplo | Significado |
|---|---|---|
| En una declaración | int *p |
p es un puntero a int |
| En una expresión | *p = 7 |
Seguir el puntero |
| Entre dos valores | a * b |
Multiplicar |
Y & delante de una variable da su dirección, mientras que entre dos valores es otra operación que aquí no
usamos. & y * son operaciones contrarias: si p vale &x, entonces *p es x.
Error común.
int *p, q;declara un punteropy unintq, aunque parezca que los dos son punteros. El asterisco pertenece a cada nombre, no al tipo de toda la línea. Por eso escribimosint *pcon el asterisco junto al nombre, y una declaración por línea.
Comprobarlo en gdb
print entiende & y *, y whatis dice el tipo. Como la semana pasada, compilamos con -g -O0 y sin
sanitizers, recortamos la ruta de Starting program y quitamos las dos líneas de libthread_db. Las direcciones
y los números de proceso dependen del entorno.
$ gcc -Wall -Wextra -std=c17 -g -O0 pointer_basics.c -o build/pointer_basics_gdb
$ gdb -q build/pointer_basics_gdb
Reading symbols from build/pointer_basics_gdb...
(gdb) break 11
Breakpoint 1 at 0x7e4: file pointer_basics.c, line 11.
(gdb) run
Starting program: .../build/pointer_basics_gdb
x = 5, *p = 5
Breakpoint 1, main () at pointer_basics.c:11
11 *p = 7;
(gdb) print x
$1 = 5
(gdb) print &x
$2 = (int *) 0xfffffffff9f4
(gdb) print p
$3 = (int *) 0xfffffffff9f4
(gdb) print *p
$4 = 5
(gdb) whatis p
type = int *
(gdb) next
12 printf("x = %d, *p = %d\n", x, *p);
(gdb) print x
$5 = 7
(gdb) print *p
$6 = 7
(gdb) next
x = 7, *p = 7
14 p = &y;
(gdb) next
15 *p = *p + 1;
(gdb) print p
$7 = (int *) 0xfffffffff9f0
(gdb) print &y
$8 = (int *) 0xfffffffff9f0
(gdb) delete 1
(gdb) continue
Continuing.
x = 7, y = 21, *p = 21
&x = 0xfffffffff9f4
&y = 0xfffffffff9f0
p = 0xfffffffff9f0
sizeof(p) = 8, sizeof(*p) = 4
[Inferior 1 (process 22) exited normally]
(gdb) quit
Comparen esto con la animación. print p y print &x dan el mismo número: eso es lo que la flecha significa.
Después de *p = 7, x vale 7, aunque ninguna línea nombró a x. Después de p = &y, el valor de p cambió,
pero x e y siguen igual. Y en esta corrida x e y están a 4 bytes de distancia, pero nada garantiza
eso: son variables separadas, y el compilador las ordena como quiere.
Un puntero tiene un tipo
Una dirección solo dice dónde empieza algo. El tipo del puntero dice qué hay ahí, y por tanto cuántos bytes
leer y cómo interpretarlos. Por eso no se puede mezclar: guardar la dirección de un double en un int * haría
leer 4 bytes de los 8 de ese double, como si fueran un entero. En este entorno, gcc lo rechaza.
$ gcc -Wall -Wextra -std=c17 errors/wrong_type.c -o build/wrong_type
errors/wrong_type.c: In function 'main':
errors/wrong_type.c:6:14: error: initialization of 'int *' from incompatible pointer type 'double *' [-Wincompatible-pointer-types]
6 | int *p = &d;
| ^
NULL y los punteros que no apuntan a nada
A veces un puntero no tiene a dónde apuntar. Para eso existe el puntero nulo (null pointer), que se escribe
NULL, y que ya conocen como lo que devuelve fopen cuando falla. NULL significa “ninguna dirección”, y se
puede guardar y comparar. Lo que no se puede es seguirlo. Hacerlo es comportamiento indefinido (undefined
behavior). gcc no dice nada en este caso, y el error solo aparece al correr:
int *p = NULL;
printf("antes\n");
*p = 3;
$ gcc -Wall -Wextra -std=c17 errors/null_deref.c -o build/null_deref
$ build/null_deref; echo "estado: $?"
bash: line 1: 18 Segmentation fault build/null_deref
estado: 139
Es el segmentation fault de Introducción a la Computación: el programa tocó memoria de una forma no
permitida, y el sistema operativo lo terminó. Falta el antes porque esta salida no se escribió en una terminal:
printf guardaba el texto, y se perdió al morir el programa. Con sanitizers, el informe dice exactamente dónde pasó. Lo
filtramos con grep para ver lo esencial. Las direcciones y el ==35==, que es el número de proceso,
cambian en cada corrida.
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/null_deref.c -o build/null_deref_asan
$ build/null_deref_asan 2>&1 | grep -E "runtime error|ERROR|#0"
errors/null_deref.c:8:8: runtime error: store to null pointer of type 'int'
==35==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0xaaaacb0b0dd0 bp 0xffffe63d6640 sp 0xffffe63d6640 T0)
#0 0xaaaacb0b0dd0 in main errors/null_deref.c:8
Se lee en orden: store to null pointer es lo que el programa intentó hacer, escribir en NULL, y
errors/null_deref.c:8 es la línea. El siguiente caso es peor. Un puntero declarado sin inicializar no vale
NULL, sino cualquier cosa. Como con cualquier local, seguirlo es comportamiento indefinido, y puede caer
sobre otra variable sin que nada falle a la vista. En errors/uninit_pointer.c, -Wall sí avisa, porque es
evidente: 'p' is used uninitialized.
Idea clave. Antes de escribir
*p, pregúntense a qué casilla apuntap, y si esa casilla todavía existe. Si no pueden dibujar la flecha, no pueden seguirla. Un puntero siempre se declara con un valor: una dirección válida oNULL.
Paso por puntero
La semana pasada, void inc(int x) no pudo cambiar la variable de quien la llama, porque x era una copia. El
ejemplo clásico es intercambiar dos variables. En swap.c está el intento que no funciona:
void swap_values(int a, int b) {
int old_a = a;
a = b;
b = old_a;
}
int x = 1;
int y = 2;
swap_values(x, y);
Lo dibujamos justo antes de retornar. La función intercambió sus propias casillas, y al retornar el marco desaparece:
swap_values a = 2 b = 1 old_a = 1
main x = 1 y = 2
La solución es no pasar los valores, sino dónde están:
void swap(int *a, int *b) {
int old_a = *a;
*a = *b;
*b = old_a;
}
swap(&x, &y);
Aquí no hay ninguna regla nueva. La regla 1 calcula los argumentos, &x y &y, y copia esos valores en los
parámetros. a y b son casillas del marco de swap, y contienen copias de dos direcciones. Pero una copia de
una dirección lleva al mismo lugar. Desde un marco se llega a una casilla de otro marco:


| Línea | old_a |
*a (es x) |
*b (es y) |
|---|---|---|---|
int old_a = *a; |
1 | 1 | 2 |
*a = *b; |
1 | 2 | 2 |
*b = old_a; |
1 | 2 | 1 |
Al retornar, el marco de swap desaparece, con sus dos punteros. Los cambios quedaron en main.
$ build/swap
después de swap_values: x = 1, y = 2
después de swap: x = 2, y = 1
Idea clave. C siempre copia el argumento. El paso por puntero no es otra forma de pasar parámetros: es pasar por valor una dirección. El que llama da la dirección con
&, y la función llega a la casilla con*. Lo ven escrito en la llamada:swap(&x, &y)avisa quexeypueden cambiar, yswap_values(x, y)garantiza que no.
En gdb se ve que a y &x son el mismo número, aunque estén en marcos distintos:
$ gcc -Wall -Wextra -std=c17 -g -O0 swap.c -o build/swap_gdb
$ gdb -q build/swap_gdb
Reading symbols from build/swap_gdb...
(gdb) break swap
Breakpoint 1 at 0x7e4: file swap.c, line 11.
(gdb) run
Starting program: .../build/swap_gdb
después de swap_values: x = 1, y = 2
Breakpoint 1, swap (a=0xfffffffffa0c, b=0xfffffffffa08) at swap.c:11
11 int old_a = *a;
(gdb) backtrace
#0 swap (a=0xfffffffffa0c, b=0xfffffffffa08) at swap.c:11
#1 0x0000aaaaaaaa0864 in main () at swap.c:23
(gdb) info args
a = 0xfffffffffa0c
b = 0xfffffffffa08
(gdb) print *a
$1 = 1
(gdb) print *b
$2 = 2
(gdb) finish
Run till exit from #0 swap (a=0xfffffffffa0c, b=0xfffffffffa08) at swap.c:11
main () at swap.c:24
24 printf("después de swap: x = %d, y = %d\n", x, y);
(gdb) info locals
x = 2
y = 1
(gdb) print &x
$3 = (int *) 0xfffffffffa0c
(gdb) print &y
$4 = (int *) 0xfffffffffa08
(gdb) delete 1
(gdb) continue
Continuing.
después de swap: x = 2, y = 1
[Inferior 1 (process 39) exited normally]
(gdb) quit
Dos errores frecuentes
El primero es olvidar el & en la llamada. Darle un int a una función que espera una dirección haría que swap
siguiera el número 1 como si fuera una dirección. En este entorno, gcc lo rechaza:
$ gcc -Wall -Wextra -std=c17 errors/missing_ampersand.c -o build/missing_ampersand 2>&1 | head -9
errors/missing_ampersand.c: In function 'main':
errors/missing_ampersand.c:14:10: error: passing argument 1 of 'swap' makes pointer from integer without a cast [-Wint-conversion]
14 | swap(x, y);
| ^
| |
| int
errors/missing_ampersand.c:4:16: note: expected 'int *' but argument is of type 'int'
4 | void swap(int *a, int *b) {
| ~~~~~^
El mensaje dice qué esperaba, int *, y qué recibió, int. La frase “makes pointer from integer” es la forma como
gcc describe este error: intentar sacar una dirección de un entero.
El segundo es arreglar inc de la semana pasada con un puntero, y escribir *p++. Parece sumar 1 a lo apuntado,
pero ++ se aplica antes que *. Lo que avanza es el puntero, que es una copia local, y el resultado de * se
descarta. -Wall lo nota:
void inc(int *p) {
*p++;
}
$ gcc -Wall -Wextra -std=c17 errors/star_increment.c -o build/star_increment
errors/star_increment.c: In function 'inc':
errors/star_increment.c:5:5: warning: value computed is not used [-Wunused-value]
5 | *p++;
| ^~~~
$ build/star_increment
x = 7
Lo correcto es (*p)++, o, más claro, *p = *p + 1. Una advertencia de este tipo casi nunca es un detalle: aquí
está diciendo que el programa no hace lo que parece.
Error común. Asignar al parámetro y creer que
mainlo ve. Dentro de una función,p = otra_direccion;solo cambia la copia de la flecha, igual quex = 99enchange_int. Quien llama conserva su puntero. Para cambiar la casilla a la que apunta se escribe con*. El laboratorio tiene un caso así.
Parámetros de salida
La otra gran razón para pasar direcciones es devolver varios resultados. return entrega uno solo. Un parámetro que
una función solo usa para escribir un resultado se llama parámetro de salida (output parameter). En
out_params.c, divide tiene dos, y usa el return para decir si pudo:
// Devuelve 1 si pudo dividir y 0 si divisor es 0. Si falla, no toca *quotient ni *remainder.
int divide(int dividend, int divisor, int *quotient, int *remainder) {
if (divisor == 0) {
return 0;
}
*quotient = dividend / divisor;
*remainder = dividend % divisor;
return 1;
}
int q = -1;
int r = -1;
if (divide(17, 5, &q, &r)) {
printf("17 / 5: cociente %d, resto %d\n", q, r);
}
Justo antes de que divide retorne:
divide dividend = 17 divisor = 5 quotient = ──┐ remainder = ──┐
│ │
▼ ▼
main q = 3 r = 2
$ echo 8 | build/out_params
17 / 5: cociente 3, resto 2
17 / 0: no se puede, y q sigue en -1
Escriban un número: leí 8
La última línea del programa cierra una receta. Desde su primer curso escriben esto:
if (scanf("%d", &n) != 1) {
Ahora se lee completa: scanf es una función con parámetros de salida. No puede devolver el número por
return, porque no sabe cuántos valores le van a pedir, y su return ya se usa para decir cuántos pudo leer. Por
eso recibe la dirección de la casilla donde debe escribir. Es el mismo patrón de divide, y es la convención de este
curso para las funciones que pueden fallar:
- El
returndice si funcionó: 1 si pudo, 0 si no. - Los datos salen por parámetros de salida.
- Si falla, no escribe en ellos, y la documentación lo dice.
- Quien llama siempre revisa el
return, antes de usar los datos.
scanf es más antigua que esta convención, y no cumple todo. Su return es una cuenta, no un 1 o un 0, y con "%d %d"
puede escribir el primer valor y fallar en el segundo. Por eso se compara con la cantidad exacta de valores pedidos.
En C. Esto también explica una diferencia que ya conocían. A
scanf("%19s", word)no se le pone&porquewordes un arreglo y, en esa expresión, ya da una dirección. Ascanf("%d", &values[i])sí, porquevalues[i]es unint.
Arreglos y punteros
En la semana 3 vimos que, en casi toda expresión, el nombre de un arreglo da la dirección de su primer elemento. Ahora
podemos decirlo con precisión: da un puntero a su primer elemento. Si a es un arreglo de int, en esa expresión a
es un int * que vale &a[0]. Las excepciones son sizeof(a), que mide el arreglo completo, y &a, que no
necesitamos todavía.
Por eso, en un parámetro, estas dos formas significan exactamente lo mismo. No es una costumbre: la primera es otra manera de escribir la segunda.
| Parámetro | Qué es en realidad |
|---|---|
int values[] |
int *values |
const int a[] |
const int *a |
const char name[] |
const char *name |
Desde esta semana usamos sobre todo la forma con asterisco, porque dice lo que de verdad llega. Cuando la función trabaja con un arreglo, el otro parámetro, la longitud, sigue haciendo falta.
Aritmética de punteros
Si p apunta a un elemento de un arreglo, p + 1 apunta al siguiente. Eso es la aritmética de punteros (pointer
arithmetic), y lo importante es qué significa ese ”+ 1”: un elemento, no un byte. El compilador multiplica por el
tamaño del tipo al que apunta el puntero.
Con un arreglo de int, a + 2 es la dirección que está 2 elementos después de a. Son 2 * sizeof(int) bytes, es
decir 8 en este entorno. Y *(a + 2) es lo que hay ahí, el elemento de índice 2. De hecho, el lenguaje define a[i]
como *(a + i). Los corchetes son esa cuenta escrita de forma cómoda. En array_pointer.c:
int a[4] = {10, 20, 30, 40};
printf("a[2] = %d, *(a + 2) = %d\n", a[2], *(a + 2));
printf("a = %p\n", (void *) a);
printf("a + 1 = %p\n", (void *) (a + 1));
printf("a + 2 = %p\n", (void *) (a + 2));
Los valores de las direcciones dependen de la corrida. Lo que no depende de ella es la distancia: de ...20 a ...24
hay 4 bytes, y de ...24 a ...28, otros 4.
$ build/array_pointer
a[2] = 30, *(a + 2) = 30
a = 0xffff96100020
a + 1 = 0xffff96100024
a + 2 = 0xffff96100028
*p = 20, p[0] = 20, p[1] = 30
q - p = 2 elementos
sumas: 100 y 100
desde a[2]: 70
sizeof(a) en main = 16
sizeof(a) en la función = 8
Recuerden el error de const que vieron en la semana 10 de Introducción a la Computación: gcc escribía a[i] por dentro
como *(a + (sizetype)((long unsigned int)i * 4)), es decir, “desde a, avanza i por 4 bytes”. Era esta cuenta.
El resto de la salida se explica con estas líneas:
int *p = a + 1;
printf("*p = %d, p[0] = %d, p[1] = %d\n", *p, p[0], p[1]);
int *q = &a[3];
printf("q - p = %td elementos\n", q - p);
Tres cosas para notar:
- Un puntero también admite corchetes. Como
papunta aa[1],p[0]esa[1]yp[1]esa[2]. Los índices cuentan desde donde esté el puntero. - La resta de dos punteros da elementos, no bytes.
q - pvale 2, aunque estén a 8 bytes. Solo tiene sentido si los dos apuntan al mismo arreglo. Su tipo se llamaptrdiff_t, y por esoprintfusa%td. - Las mismas reglas de los límites siguen vigentes.
*(a + 4)es tan inválido comoa[4], porque son lo mismo. Se puede calcular la dirección que está una posición después del último elemento,a + 4, y compararla. Lo que no se puede es seguirla.
Recorrer con un puntero
Esa última regla permite una forma de recorrer un arreglo que van a ver en todo el código de C:
int sum_with_pointer(const int *a, int n) {
int total = 0;
for (const int *p = a; p < a + n; p++) {
total = total + *p;
}
return total;
}
p empieza en el primer elemento, y p++ lo lleva al siguiente, porque avanza un elemento. El ciclo termina cuando p
llega a a + n, la dirección justo después del último, sin haberla seguido. Hace lo mismo que sum_with_index, con i y
a[i], y ninguna es mejor por sí misma. Usen la que se lea más clara. Para un arreglo suele ser el índice. Para una cadena,
como veremos, muchas veces el puntero.
Pasar una dirección intermedia da algo más, que no teníamos: darle a una función solo una parte del arreglo.
printf("desde a[2]: %d\n", sum_with_pointer(a + 2, 2));
Para la función, a + 2 es simplemente el comienzo de un arreglo de 2 elementos. Sumó 30 + 40, es decir 70, sin
saber que antes había otros elementos.
Un arreglo no es un puntero
Aunque se parezcan al usarlos, son cosas distintas. El arreglo es los elementos. El puntero es una casilla que guarda una dirección.
int a[4] |
int *p = a; |
|
|---|---|---|
| ¿Qué hay en el marco? | Los 4 elementos | Una casilla con una dirección |
sizeof |
16 en este entorno | 8 en este entorno |
| ¿Se puede asignar? | No | Sí: p = otra_direccion; |
x[i] |
*(a + i) |
*(p + i) |
Lo de sizeof ya lo vieron: en main da 16, y en la función, donde a es un puntero, da 8. Y un arreglo no se puede asignar:
$ gcc -Wall -Wextra -std=c17 errors/array_assign.c -o build/array_assign
errors/array_assign.c: In function 'main':
errors/array_assign.c:8:7: error: assignment to expression with array type
8 | a = b;
| ^
p = b;, en la línea anterior del mismo programa, sí compila. En gdb se nota al usar whatis a en
sum_with_pointer y luego up: el mismo nombre es const int * en la función e int [4] en main.
Punteros a struct y ->
Un puntero puede apuntar a un struct, y struct_pointer.c lo usa para que una función cambie un struct de quien llama:
struct product {
int stock;
double price;
};
void restock(struct product *item, int amount) {
(*item).stock = (*item).stock + amount;
}
Para llegar a un campo hay que seguir el puntero y después elegir el campo, en ese orden. Los paréntesis son obligatorios,
porque el punto se aplica antes que el asterisco. Sin ellos, *item.stock significaría “sigue item.stock”, y item no es
un struct:
$ gcc -Wall -Wextra -std=c17 errors/star_dot.c -o build/star_dot 2>&1 | head -5
errors/star_dot.c: In function 'get_x':
errors/star_dot.c:7:14: error: 'p' is a pointer; did you mean to use '->'?
7 | return *p.x;
| ^
| ->
gcc ya sugirió la solución. Como escribir (*item).stock es incómodo y se hace todo el tiempo, C tiene un operador para eso: item->stock, que es
exactamente (*item).stock.
void discount(struct product *item, double percent) {
item->price = item->price * (100 - percent) / 100;
}
double total_value(const struct product *item) {
return item->stock * item->price;
}
La regla para elegir es simple. A la izquierda del punto va un struct, y a la izquierda de -> va un puntero a struct.
discount item = ──────────┐ percent = 20
│
▼
main rice = { stock = 15, price = 4.5 }
En gdb, print *item muestra el struct completo desde discount, y print item->price un solo campo. print &rice, después de up, da el
mismo número que print item.
La tabla de la semana pasada ahora tiene una tercera fila:
| Se pasa | Se copia | ¿Puede cambiar el original? |
|---|---|---|
f(rice) |
Todo el struct |
No |
f(rice.stock) |
Un int |
No |
f(&rice) |
Una dirección | Sí |
const y punteros
En Introducción a la Computación usaron const int a[] para prometer que una función no cambia un arreglo. Ahora que esa
escritura significa const int *a, podemos ser más precisos: con un puntero hay dos cosas que podrían no cambiar, la
casilla a la que apunta y el puntero mismo. La posición de const dice cuál. En const_pointers.c:
const int *to_const = &a; // no se escribe a través de él, pero puede apuntar a otro
to_const = &b;
int *const fixed = &a; // se escribe a través de él, pero siempre apunta a a
*fixed = 10;
Un truco para leerlas es ir de derecha a izquierda desde el nombre. to_const es un puntero (*) a un int constante. fixed
es constante (const), y es un puntero (*) a un int.
| Declaración | *p = 1; |
p = &b; |
Para qué |
|---|---|---|---|
int *p |
Sí | Sí | Puntero normal |
const int *p |
No | Sí | Una función que solo lee: la forma más común |
int *const p |
Sí | No | Poco común: una flecha que no se mueve |
const int *const p |
No | No | Ninguna de las dos |
Cada intento prohibido da un error distinto. Fíjense en la palabra que cambia, location o variable:
$ gcc -Wall -Wextra -std=c17 errors/const_location.c -o build/const_location
errors/const_location.c: In function 'main':
errors/const_location.c:6:8: error: assignment of read-only location '*p'
6 | *p = 2;
| ^
$ gcc -Wall -Wextra -std=c17 errors/const_variable.c -o build/const_variable
errors/const_variable.c: In function 'main':
errors/const_variable.c:7:7: error: assignment of read-only variable 'p'
7 | p = &b;
| ^
La promesa es sobre el camino
const int * no dice que la casilla sea constante. Dice que por este camino no se escribe. La variable original se puede
seguir cambiando por su nombre:
b = 20; // const promete por el puntero, no por la variable
printf("*to_const = %d\n", *to_const);
$ build/const_pointers
*to_const = 2
a = 10
*to_const = 20
Por eso, un int * se puede pasar donde se espera un const int *, sin decir nada: agregar una promesa no molesta a nadie.
Lo contrario pierde la promesa, y gcc lo advierte con initialization discards 'const' qualifier, como
en errors/drop_const.c. Compila, y la función termina cambiando un arreglo que había prometido solo leer. No
ignoren esa línea.
La regla del curso sigue siendo la misma, ahora con su forma completa: si una función no cambia lo que hay detrás de un puntero,
ese parámetro lleva const. Es parte de lo que la firma comunica a quien la lee.
const char *: cadenas
Una cadena es un arreglo de char terminado en '\0'. Una función que la recibe, recibe un char *. Y como casi ninguna
función debe cambiarla, casi siempre es const char *. Con eso se escribe length sin un índice, en text_pointers.c:
int length(const char *s) {
const char *p = s;
while (*p != '\0') {
p++;
}
return (int) (p - s);
}
p avanza hasta encontrar el carácter nulo, y la resta da cuántos elementos recorrió. Un char ocupa un byte, así que eso
es la cantidad de bytes de la cadena. La longitud no hace falta como parámetro: la marca el '\0'.
Otra receta queda cerrada aquí. En Introducción a la Computación llamaban a strchr dos veces, porque no podían guardar su
resultado. Lo que devuelve es un puntero, y ahora se puede guardar:
const char *email = "ana@example.com";
const char *at = strchr(email, '@');
if (at != NULL) {
printf("dominio: %s\n", at + 1);
printf("el usuario tiene %d bytes\n", (int) (at - email));
}
$ build/text_pointers
ana@example.com tiene 15 bytes
dominio: example.com
el usuario tiene 3 bytes
Hola
at + 1 es una dirección dentro de la misma cadena, y lo que empieza ahí hasta el '\0' también es una cadena. No se copió
nada. Y la primera declaración responde a una pregunta pendiente. email guarda la dirección de un literal, y un literal no se
debe cambiar, así que el tipo correcto es const char *. Con const, un intento de escribir en él ya no compila, en vez de morir al correr como
en Introducción a la Computación. Un arreglo, en cambio, tiene su propia copia, y esa sí se puede cambiar por medio de un puntero:
char word[] = "hola";
char *first = word;
*first = 'H';
FILE *, sin receta
En Introducción a la Computación, FILE * fue una excepción: lo guardaban, lo comparaban con NULL y lo pasaban a otras funciones.
Ahora se entiende. fopen devuelve un puntero a un FILE, y un FILE es el objeto donde la biblioteca lleva la cuenta de un archivo
abierto, incluido por dónde va la lectura. En este entorno es un struct, pero el estándar no dice qué tiene adentro, así que siempre se usa a través de las funciones de la
biblioteca, y su programa nunca escribe *file.
Eso explica algo que quizás no se preguntaron. En file_pointer.c, skip_line recibe el archivo, lee, y al volver, main sigue leyendo
desde donde quedó. fgetc, que el módulo de imágenes ya usaba, lee un solo byte, y devuelve EOF si no queda nada:
int skip_line(FILE *file) {
int c = fgetc(file);
while (c != '\n' && c != EOF) {
c = fgetc(file);
}
return c == '\n';
}
skip_line(file);
skip_line(file);
$ build/file_pointer
tamaño: 3 x 2
tiny.pgm tiene primero P2 y un comentario, y el programa se salta esas dos líneas. El parámetro file es una copia de la dirección, como
siempre. Pero la posición no está en esa casilla, sino en el FILE, que hay uno solo:
skip_line file = ─────┐ c = ...
│
▼
FILE { ..., por dónde va la lectura, ... }
▲
│
main file = ─────┘
Es paso por puntero, como swap. read_number, del módulo de imágenes, funciona así. El dibujo también muestra otra cosa: el FILE no
está en un marco. Sobrevive a la llamada a fopen y sigue ahí hasta fclose. La semana 5 explica cómo puede una función devolver la
dirección de algo que sigue existiendo después de que ella retorna. Sin eso, sería justo el error de la siguiente sección.
Devolver la dirección de una variable local
Ahora que una función puede devolver un puntero, aparece el error clásico. Es exactamente esta función:
int *f(void) {
int x = 1;
return &x;
}
Usemos el modelo. x vive en el marco de f. La regla 4 dice que al retornar, se calcula el valor, que es la dirección de x, y el marco
desaparece. main recibe una flecha que apunta a una casilla que ya no existe. Se llama un puntero colgante (dangling pointer).
gcc lo detecta en este caso, porque es evidente:
$ gcc -Wall -Wextra -std=c17 errors/return_local.c -o build/return_local
errors/return_local.c: In function 'f':
errors/return_local.c:6:12: warning: function returns address of local variable [-Wreturn-local-addr]
6 | return &x;
| ^~
Es solo una advertencia, y el programa se compila. Seguir ese puntero es comportamiento indefinido, de modo que lo que pase no está
prometido. En este entorno muere, y el sanitizer da la pista de por qué: gcc, que ya sabía que esa dirección no servía, hizo que f
devolviera un puntero nulo.
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/return_local.c -o build/return_local_asan 2>/dev/null
$ build/return_local_asan 2>&1 | grep -E "runtime error|ERROR|#0"
errors/return_local.c:11:5: runtime error: load of null pointer of type 'int'
==28==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0xaaaadabb0f80 bp 0xfffff3576d70 sp 0xfffff3576d70 T0)
#0 0xaaaadabb0f80 in main errors/return_local.c:11
El caso peligroso es otro: basta guardar la dirección en una variable antes de devolverla, y sin optimizar gcc deja de notarlo. Eso hace
errors/dangling.c, y compila sin una sola advertencia:
int *make_value(void) {
int value = 42;
int *p = &value;
return p;
}
int *p = make_value();
printf("primero: %d\n", *p);
other_call();
printf("después: %d\n", *p);
Sin sanitizers, en esta corrida:
$ gcc -Wall -Wextra -std=c17 errors/dangling.c -o build/dangling
$ build/dangling
primero: 42
después: 65535
Esta es la peor forma que puede tomar un bug. Primero dio el resultado correcto. La casilla ya no era de nadie, pero su contenido seguía
ahí, como vimos en la semana 3: al terminar la vida de una variable, su valor no se borra. Luego, la llamada a printf, o a other_call, usó ese
espacio para sus marcos, y el valor cambió. Otro compilador, u otras opciones, darán otros números, y un cambio en otra parte del programa puede
hacer aparecer el error años después. En esta máquina se ve 65535, y en la suya puede salir otro valor, o el mismo 42 dos veces.
ASan, en este entorno, lo atrapa en el primer acceso. Lo filtramos otra vez:
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address,undefined errors/dangling.c -o build/dangling_asan
$ build/dangling_asan 2>&1 | grep -E "ERROR|^READ|#0|variable"
==21==ERROR: AddressSanitizer: stack-use-after-return on address 0xffffbbf00020 at pc 0xaaaae22711dc bp 0xfffff1df4050 sp 0xfffff1df4068
READ of size 4 at 0xffffbbf00020 thread T0
#0 0xaaaae22711d8 in main errors/dangling.c:18
#0 0xaaaae2270fb4 in make_value errors/dangling.c:4
[32, 36) 'value' (line 5) <== Memory access at offset 32 is inside this variable
El informe es casi una explicación con marcos. stack-use-after-return significa uso de la pila después de retornar. El primer #0 es
donde ocurrió, la línea 18 de main. El segundo es el marco al que pertenecía esa memoria, make_value, y la última línea dice incluso la variable.
Las soluciones correctas son tres, y salen de la misma pregunta: ¿de quién es esa casilla?
| Solución | Ejemplo | Por qué funciona |
|---|---|---|
| Devolver el valor, no la dirección | int f(void) { ... return x; } |
Se copia el valor antes de que el marco desaparezca |
| Recibir la dirección de quien llama | void f(int *result) |
La casilla está en el marco de quien llama, que sigue vivo |
| Devolver una dirección recibida | strchr devuelve un puntero dentro de la cadena que recibió |
Esa cadena ya existía antes de la llamada, y sigue existiendo |
La cuarta solución, reservar memoria que no pertenezca a ningún marco, es malloc, y es el tema de la próxima semana.
Idea clave. Un puntero no debe vivir más que la casilla a la que apunta. Un marco más reciente puede usar direcciones de un marco más antiguo, porque este sigue vivo: es lo que hace
swap. Al revés, no: un marco que ya retornó no deja nada que se pueda usar.
Imágenes por puntero
Volvamos al módulo de imágenes. La semana pasada medimos que cada llamada que recibe una struct image copia todo el espacio reservado. Ahora
podemos comparar. En image_demo.c, paint_copy es la misma función de la semana 3, y paint_first es la versión por puntero:
void paint_copy(struct image img) {
img.pixels[0][0] = 99;
void paint_first(struct image *img) {
img->pixels[0][0] = 99;
void paint_row(int *row, int width) {
for (int x = 0; x < width; x++) {
row[x] = 77;
}
}
$ build/image_demo
sizeof(img) = 16396, sizeof(&img) = 8
3 x 2, max 255
0 10 20
30 40 50
En paint_copy, el primer píxel vale 99
Después de paint_copy:
3 x 2, max 255
0 10 20
30 40 50
En paint_first, el primer píxel vale 99
Después de paint_first:
3 x 2, max 255
99 10 20
30 40 50
Después de paint_row:
3 x 2, max 255
99 10 20
77 77 77
no_existe.pgm no se pudo leer
Son 16396 bytes contra 8, en este entorno, sea cual sea el tamaño de la imagen, y ahora el original puede cambiar. Por eso paint_first, a
diferencia de paint_copy, sí se nota al volver. paint_row no cambió casi nada. img.pixels[1] es una fila de la matriz, es decir, un arreglo
de int, y como argumento llega como un int * a su primer elemento.
La nueva interfaz
En lab/image.h están los cambios. El struct es el mismo, con su tamaño fijo. Lo que cambió son las firmas:
| Semana 3 | Semana 4 |
|---|---|
struct image image_load(const char name[]) |
int image_load(struct image *img, const char *name) |
int image_save(struct image img, const char name[]) |
int image_save(const struct image *img, const char *name) |
struct image image_new(int width, ...) |
int image_new(struct image *img, int width, ...) |
struct image image_from_values(int width, ...) |
int image_from_values(struct image *img, int width, ...) |
int image_is_valid(struct image img) |
Ya no hace falta |
int image_equal(struct image a, struct image b) |
int image_equal(const struct image *a, const struct image *b) |
void image_print(struct image img) |
void image_print(const struct image *img) |
void image_show(struct image img) |
void image_show(const struct image *img) |
Hay tres decisiones en esa tabla, y todas salen de este capítulo:
- El
constdice quién cambia la imagen. Quien lee la firma deimage_savesabe, sin abririmage.c, que no la toca. image_loadusa la convención de la sección de parámetros de salida.*imges un parámetro de salida, y elreturndice si funcionó. La semana pasada, como una función no podía devolver dos cosas, el error se escondía en un dato:widthigual a 0. Ya no hace falta, y por esoimage_is_validdesapareció.- Si falla, no cambia
*img. Vean cómo lo cumpleimage_load:
int image_load(struct image *img, const char *name) {
FILE *file = fopen(name, "r");
if (file == NULL) {
return 0;
}
// Se lee en una imagen local: si el archivo está mal a medias, *img no cambia.
struct image loaded;
int ok = read_image(&loaded, file);
fclose(file);
if (ok) {
*img = loaded;
}
return ok;
}
loaded es una local, y read_image escribe en ella, también por puntero. Solo al final, si todo salió bien, se copia en la imagen de quien
llama. Esa línea, *img = loaded, copia todo el struct, como cualquier asignación de struct. Usar un puntero evita copias, pero no prohíbe las que sí se
necesitan. Y ojo: &loaded es la dirección de una local, pero image_load solo se la da a una función que corre mientras su marco existe.
Es correcto. El error de la sección anterior era devolverla.
Quien llama declara la imagen en su propio marco, y da su dirección. Así queda show_sun.c:
struct image img;
if (!image_load(&img, "lab/images/sun.pgm")) {
$ build/show_sun | tail -1
Tamaño: 32 x 16
Error común. Declarar un puntero en vez de una imagen:
struct image *img;no crea ninguna imagen, solo una flecha sin destino. Hay que declararstruct image img;y pasar&img. Si lo hacen mal,-Wallavisa, como enerrors/uninit_image.c, con'img' is used uninitialized, y hacerle caso les evita un segmentation fault.
Y el const se cumple. Una función que promete no cambiar la imagen no puede hacerlo por descuido:
$ gcc -Wall -Wextra -std=c17 errors/const_image.c lab/image.c -o build/const_image
errors/const_image.c: In function 'count_pixels':
errors/const_image.c:5:16: error: assignment of member 'width' in read-only object
5 | img->width = 3;
| ^
Filtros que cambian la imagen
Un filtro por puntero ya no devuelve nada: cambia la imagen ahí mismo. Este es el modelo, brighten, en lab/filters.c. Compárenlo con la versión de la semana
3. Los ciclos son los mismos. Cambian la firma y los . por ->, y no hay return.
void brighten(struct image *img, int amount) {
for (int y = 0; y < img->height; y++) {
for (int x = 0; x < img->width; x++) {
int value = img->pixels[y][x] + amount;
if (value < 0) {
value = 0;
}
if (value > img->max_value) {
value = img->max_value;
}
img->pixels[y][x] = value;
}
}
}
Esto tiene un precio, y hay que decirlo. Con los filtros de la semana pasada, quien llamaba conservaba su imagen, y podía anidar:
flip_horizontal(invert(img)). Ahora, invert(&img) destruye el original, y los filtros se aplican uno tras otro, en líneas separadas. Si
quieren conservar el original, copien ustedes, una sola vez, y solo cuando lo necesiten. Así lo hace lab/filter_demo.c:
struct image copy = img;
invert(©);
copy = img;
threshold(©, 100);
| Por valor (semana 3) | Por puntero (semana 4) | |
|---|---|---|
| Cuánto se copia por llamada | Todo el struct, siempre |
8 bytes, en este entorno |
| El original | Queda intacto | Cambia |
| Si se quiere conservar | No hay que hacer nada | Una copia explícita |
| Componer filtros | Anidando llamadas | Una línea por filtro |
Ninguna es la correcta siempre. Por valor es más seguro cuando el dato es pequeño. Por puntero es lo que se hace cuando es grande, y va a ser la única
opción la próxima semana, cuando el tamaño de la imagen deje de estar en el struct.
Varios resultados: image_stats
La última función del laboratorio reúne todo: recibe una imagen que no cambia, y entrega tres resultados.
void image_stats(const struct image *img, int *min, int *max, double *mean);
Tres cosas para cuidar: el mínimo y el máximo no se pueden empezar en un número fijo cualquiera, sino que hay que pensar con qué empezar. El promedio
debe tener decimales, y recuerden lo que pasa con la división entre dos int. Y solo cuentan los píxeles en uso, no todo el espacio reservado. Al
terminar, filter_demo debe empezar así:
mínimo 40, máximo 255, promedio 95.26
Original:
................................
................................
........@.......................
El laboratorio, paso a paso
Los archivos de partida están en code/week-04/lab, y su README.md tiene las preguntas de cada parte. Esta semana hay una sola sesión de laboratorio, porque
la otra es para la Práctica 1. Está pensada para unos 90 minutos. make test-lab comprueba los filtros: falla hasta que terminen. Aquí están las ideas,
no el código.
Parte 1: dibujar punteros
pointers_basic.c, pointers_params.c y pointers_array.c ponen a prueba cada idea del capítulo. El método es el de siempre: primero el dibujo en papel,
y luego gdb. make gdb los compila en build/gdb/, con -g -O0. Cuando dibujen una flecha, compruébenla comparando dos números: print p en un marco y
print &x en el otro. Dediquen unos 35 minutos a esta parte, y pasen a la siguiente aunque no hayan respondido todo: pointers_array.c puede quedar para antes de la próxima semana.
Parte 2: los filtros
Tomen el cuerpo de cada filtro de code/week-03/lab/filters.c, pónganlo en la función que corresponde de lab/filters.c y reescríbanlo. La conversión es casi mecánica, y brighten muestra cómo. Lean lab/filters.h,
y antes de cada función calculen a mano un caso en lab/filter_tests.c.
invertusaimg->max_value, no 255.threshold: un píxel igual alevelpasa amax_value. Pruébenlo con ese caso exacto.flip_horizontal: ahora el intercambio se ve más claro. Sirve una fila,img->pixels[y], y elswapde la clase. Pruébenlo con ancho par y con ancho impar.image_stats: pruébenla con el extremo en la primera casilla, en la última, y con todos los píxeles iguales. Revisen también un promedio que no sea entero.
Si box_blur les quedó pendiente de la semana 3, aquí no se pide. Es el ejercicio 10.
El código de la semana
| Archivo o carpeta | Qué hay |
|---|---|
pointer_basics.c, swap.c, out_params.c |
Punteros, paso por puntero y parámetros de salida |
array_pointer.c, struct_pointer.c |
Aritmética de punteros y -> |
const_pointers.c, text_pointers.c, file_pointer.c |
const, cadenas y FILE * |
image_demo.c, show_sun.c |
El módulo de imágenes por puntero |
errors/ |
Los programas con errores a propósito de este capítulo |
lab/ |
Los archivos de partida del laboratorio |
test_examples.py, test_notes.py |
Las pruebas de make test: comprueban los programas, el módulo y lo que este capítulo muestra |
test_lab.py |
Las pruebas de make test-lab |
Resumen
- Un puntero es una variable que guarda una dirección.
&xda la dirección dex, y*psigue el puntero. Se dibuja como una flecha. p = &ycambia la flecha, y*p = 7cambia la casilla de llegada. Un puntero tiene un tipo, que dice qué hay en esa dirección.NULLsignifica ninguna dirección. SeguirNULLo un puntero sin inicializar es comportamiento indefinido.- C sigue copiando los argumentos. El paso por puntero es copiar una dirección, y así una función llega a una casilla de otro marco.
- Un parámetro de salida entrega un resultado. Nuestra convención: el
returndice si funcionó, y si falla no se escribe en la salida.scanfes así. - Un arreglo, como argumento, llega como puntero a su primer elemento.
int a[]en un parámetro esint *a. a[i]es*(a + i).p + 1avanza un elemento, es decirsizeof(*p)bytes. La resta de dos punteros da elementos.- Un arreglo no es un puntero:
sizeoflo mide completo, y no se puede asignar. p->campoes(*p).campo.const int *pno escribe a través dep, y es la forma común para una función que solo lee.int *const pno cambia a dónde apunta.FILE *es un puntero a lo que la biblioteca usa para un archivo abierto. La posición está en elFILE, por eso una función puede avanzar y quien llama lo nota.- Un puntero no debe vivir más que su casilla. Devolver la dirección de una local deja un puntero colgante, y puede parecer que funciona.
struct imageahora se pasa por puntero, 8 bytes en este entorno contra 16396. Los filtros cambian la imagen, y una copia explícita conserva el original.
Ejercicios
Dibujen primero y ejecuten después. Todo programa revisa sus lecturas y compila sin advertencias.
-
Dibujen la memoria después de cada línea, y digan cuánto vale
xal final. Compruébenlo engdb.int x = 5; int y = 1; int *p = &x; *p = 7; p = &y; *p = x + 1; -
Sin compilar, digan si cada línea compila, dado
int a = 1; int b = 2;. Luego compruébenlo.a) const int *p = &a; p = &b; b) const int *p = &a; *p = 3; c) int *const p = &a; *p = 3; d) int *const p = &a; p = &b; e) const int c = 1; int *p = &c; -
Si
aes un arreglo deint, ¿qué significa*(a + 2)? ¿Cuántos bytes avanza en este entorno? ¿Y si fuera un arreglo dedouble? ¿Y dechar? Escriban un programa que lo muestre con%p. -
Escriban
int min_max(const int *a, int n, int *min, int *max), que devuelva 0 sines menor que 1, sin tocar las salidas, y 1 en otro caso. Pruébenla conassert. -
Escriban
int count_char(const char *s, char c)con un puntero que avance, sin índices ni corchetes. -
Sean
int a[3] = {1, 2, 3}; int *p = a;. Para cada línea, por separado, digan qué valen*py los elementos deadespués. Luego compruébenlo.a) (*p)++; b) p++; c) *(p + 1) = 9; d) p[2] = *p; -
Escriban
void reverse(int *a, int n)con dos punteros, uno que empieza en el primer elemento y otro en el último, usandoswap. ¿Cuándo debe terminar el ciclo? -
Escriban
int *find_largest(int *a, int n), que devuelva un puntero al elemento mayor, y úsenla así:*find_largest(data, 5) = 0;. ¿Por qué esta función no tiene el error demake_value, si también devuelve un puntero? ¿Qué debería devolver sines 0? Dibujen los marcos. -
Reescriban
make_valuede las tres formas correctas de la tabla. Para cada una, escriban la llamada y comprueben con ASan que no hay errores. -
Escriban
void box_blur(struct image *img, int radius), por puntero, con las reglas de borde de la semana 3. ¿Por qué necesita una imagen local? ¿Cuántos bytes ocupa esa local? ¿En qué línea termina copiándose el resultado en*img? -
Escriban
int image_crop(struct image *dst, const struct image *src, int left, int top, int width, int height), que devuelva 0 sin tocar*dstsi el rectángulo no cabe en la imagen. Pruébenla con una imagen de 3 x 2. ¿Por quésrcllevaconstydstno? -
Escriban
void flip_into(struct image *dst, const struct image *src), que deje en*dstel espejo de*srcsin cambiar*src, escribiendo píxel por píxel. Luego llámenla conflip_into(&img, &img). ¿Qué sale? ¿Por qué no es una contradicción con elconst? Dibujen los dos punteros, y arreglen la función para que este caso también funcione.