Skip to article frontmatterSkip to article content

Punteros

Comenzando con la lengua prohibida de Mordor

Introducción a los Punteros y la Memoria

Para profundizar en cómo funcionan estructuras como los arreglos y para ganar un control más directo y eficiente sobre los recursos de tu programa, es fundamental entender los punteros. Los punteros son la herramienta que nos permite pasar de trabajar solo con los valores de las variables a trabajar con sus ubicaciones en la memoria.

¿Qué es una Dirección de Memoria?

Cada vez que declarás una variable, el sistema operativo le asigna un espacio en la memoria RAM de la computadora. Podés imaginar la memoria como una gigantesca fila de casilleros numerados. Cada casillero puede guardar un dato (el valor de tu variable), y el número del casillero es su dirección de memoria única.

Entonces, ¿qué es un Puntero?

Un puntero es, simplemente, una variable especial cuyo único propósito es guardar la dirección de memoria de otra variable.

En lugar de contener un dato como un número o un carácter, contiene el “número de casillero” donde se encuentra otro dato. Siguiendo la analogía, un puntero no es el casillero en sí, sino una nota adhesiva donde tenés apuntado el número de un casillero específico para no olvidarte dónde guardaste algo importante.

Representación conceptual de un puntero apuntando a una variable en memoria.

Figure 1:Representación conceptual de un puntero apuntando a una variable en memoria.

Declaración de punteros

Para declarar un puntero, debés especificar el tipo de dato al que va a apuntar, seguido de un asterisco (*) y el nombre de la variable. La regla de estilo Regla 0x0018h: El asterisco de los punteros debe declararse junto al identificador indica que el asterisco debe ir junto al nombre de la variable.

1
2
3
int *ptr_entero;
double *ptr_double;
char *ptr_char;

Una vez declarado, un puntero debe ser inicializado para que apunte a una dirección de memoria específica y válida. No hacerlo es una fuente común de errores graves. Tenés principalmente dos formas de inicializar un puntero:

Asignación a una dirección específica

Para que un puntero sea útil, generalmente lo hacés apuntar a una variable existente. Esto se logra utilizando el operador de dirección & (ampersand), el cual obtiene la dirección de memoria de dicha variable.

1
2
int numero = 42;
int *ptr_numero = № // ptr_numero ahora almacena la dirección de 'numero'

Inicializar a Nulo (NULL)

Si al momento de declarar un puntero no tenés una dirección de memoria válida para asignarle, es fundamental inicializarlo a un estado seguro y conocido. Para esto se utiliza la macro NULL.

NULL es una constante de preprocesador, que se encuentra definida en el encabezado <stddef.h> y representa la dirección a «ningún lado».

1
2
3
#include <stddef.h> // Necesario para NULL

int *puntero_seguro = NULL;

Es una práctica habitual en C que las funciones que devuelven punteros retornen NULL para indicar un error o la ausencia de un resultado. Siempre debés comprobar si un puntero es NULL antes de intentar desreferenciarlo (usar el operador * sobre él).

Representación de un puntero nulo y la verificación antes de desreferenciar.

Figure 2:Representación de un puntero nulo y la verificación antes de desreferenciar.

Regla de oro: Siempre inicializá tus punteros, ya sea con la dirección de una variable válida o con NULL.

Operadores de Punteros

Funcionamiento de los operadores & (dirección de) y * (desreferencia).

Figure 3:Funcionamiento de los operadores & (dirección de) y * (desreferencia).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main() {
    int numero = 99;
    int *puntero = &numero; // '&numero' produce un R-value (la dirección)

    // Uso de *puntero como R-value (leemos el valor)
    // La expresión *puntero aquí se evalúa al valor contenido en 'numero'.
    printf("El valor de 'numero' es: %d\n", *puntero); // Imprime 99

    // Uso de *puntero como L-value (escribimos en la locación)
    // La expresión *puntero aquí se refiere a la locación de 'numero'.
    *puntero = 150;
    printf("El nuevo valor de 'numero' es: %d\n", numero); // Imprime 150

    return 0;
}

Esta dualidad del operador de desreferencia es lo que hace a los punteros tan poderosos, ya que nos permiten tanto leer como modificar datos de forma indirecta.

Punteros y arreglos

El nombre de un arreglo es, en esencia, un puntero constante a su primer elemento. Esto significa que arreglo es equivalente a &arreglo[0].

Esta relación nos permite usar punteros para acceder y manipular los elementos de un arreglo, lo cual nos lleva directamente a la aritmética de punteros.

Aritmética de punteros

La aritmética de punteros te permite realizar operaciones matemáticas sobre los punteros. Sin embargo, estas operaciones no son como las operaciones aritméticas tradicionales. El compilador ajusta automáticamente los cálculos según el tamaño del tipo de dato al que apunta el puntero.

Si tenés un puntero ptr a un tipo de dato T que ocupa sizeof(T) bytes, al hacer ptr + 1, la dirección de memoria no se incrementa en 1, sino en sizeof(T). Esto permite “saltar” de un elemento a otro en un arreglo.

Aritmética de punteros: cómo el compilador ajusta los incrementos según el tipo de dato.

Figure 4:Aritmética de punteros: cómo el compilador ajusta los incrementos según el tipo de dato.

Incremento (++) y decremento (--)

Podés incrementar un puntero para que apunte al siguiente elemento de un arreglo o decrementarlo para que apunte al anterior.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *ptr = arr; // ptr apunta a arr[0]

    ptr++; // Ahora ptr apunta a arr[1]
    printf("El segundo elemento es: %d\n", *ptr); // Imprime 20

    ptr++; // Ahora ptr apunta a arr[2]
    printf("El tercer elemento es: %d\n", *ptr); // Imprime 30

    ptr--; // Vuelve a apuntar a arr[1]
    printf("El segundo elemento de nuevo: %d\n", *ptr); // Imprime 20

    return 0;
}

Suma (+) y resta (-)

Podés sumar o restar un valor entero a un puntero para desplazarte varias posiciones dentro de un arreglo.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *ptr = arr; // ptr apunta a arr[0]

    // Acceder al cuarto elemento (índice 3)
    int *ptr_cuarto = ptr + 3;
    printf("El cuarto elemento es: %d\n", *ptr_cuarto); // Imprime 40
    printf("También se puede acceder así: %d\n", *(ptr + 3)); // Imprime 40

    return 0;
}

Un detalle sobre la resta en punteros

Podés restar dos punteros que apunten a elementos del mismo arreglo. El resultado no es una dirección de memoria, sino la cantidad de elementos que hay entre ellos.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stddef.h> // Necesario para ptrdiff_t

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *ptr1 = &arr[1];
    int *ptr2 = &arr[4];

    ptrdiff_t diferencia = ptr2 - ptr1;
    printf("Hay %td elementos entre ptr1 y ptr2.\n", diferencia); // Imprime 3

    return 0;
}

Punteros en funciones y efectos secundarios

Una de las aplicaciones más poderosas de los punteros es su uso en funciones. Por defecto, en C, los argumentos a las funciones se pasan por valor. Esto significa que la función recibe una copia del argumento, y cualquier modificación que haga sobre esa copia no afecta a la variable original.

Al pasar un puntero a una función, lo que estamos pasando es la dirección de memoria de una variable. Aunque la dirección en sí se pasa por valor (la función recibe una copia del puntero), el puntero dentro de la función apunta a la variable original. Esto nos permite modificar la variable original desde dentro de la función, un mecanismo conocido como paso por referencia simulado.

Diferencia entre el paso por valor y el paso por referencia simulado con punteros.

Figure 5:Diferencia entre el paso por valor y el paso por referencia simulado con punteros.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

// La función recibe dos punteros a enteros
void intercambiar(int *a, int *b) {
    int temporal = *a; // Guardamos el valor al que apunta 'a'
    *a = *b;           // Asignamos al valor de 'a' el valor de 'b'
    *b = temporal;     // Asignamos al valor de 'b' el valor guardado
}

int main() {
    int x = 10;
    int y = 20;

    printf("Valores originales: x = %d, y = %d\n", x, y);

    // Pasamos las direcciones de memoria de 'x' e 'y'
    intercambiar(&x, &y);

    printf("Valores intercambiados: x = %d, y = %d\n", x, y);

    return 0;
}

El impacto en los efectos secundarios

La capacidad de una función para modificar variables que no le pertenecen (es decir, que no están en su ámbito local) es un nuevo tipo de efecto secundario (side effect), que en parte, ya vimos en Secuencias: Arreglos y Cadenas.

Si bien los efectos secundarios son extremadamente útiles y necesarios (como en nuestra función intercambiar), también pueden hacer que el código sea más difícil de entender y depurar. Cuando una función modifica una variable externa, tenés que rastrear no solo qué hace la función, sino también qué variables de tu programa podrían haber cambiado después de llamarla.

El Calificador const: el ancla de seguridad con punteros

El calificador const es una de las herramientas más importantes en C para escribir código seguro, predecible y fácil de entender. Actúa como un “contrato” que le dice al compilador y a otros programadores qué se supone que no debe cambiar. Cuando lo usás con punteros, como lo exige la regla Regla 0x0021h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique, te permite “bloquear” o bien el dato apuntado, el puntero en sí, o ambos.

const nos permite poner reglas sobre qué se puede modificar, potencialmente, limitando los efectos secundarios productos de pasar el puntero a la función.

Diferentes combinaciones del calificador const con punteros.

Figure 6:Diferentes combinaciones del calificador const con punteros.

1. Puntero a un Dato Constante (No podés cambiar el VALOR)

Esta es la forma más común. La nota adhesiva es normal (podés borrar el número y escribir otro), pero el casillero al que apunta está cerrado con llave. No podés cambiar su contenido a través de este puntero.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

void imprimir(const char *mensaje) {
    // mensaje[0] = 'X'; // ERROR DE COMPILACIÓN: intentás modificar un dato constante.
    printf("El mensaje es: %s\n", mensaje);
}

int main() {
    char saludo[] = "Hola";
    char despedida[] = "Chau";

    const char *ptr = saludo;

    // *ptr = 'h'; // ERROR DE COMPILACIÓN: no se puede modificar el contenido.

    ptr = despedida; // VÁLIDO: el puntero puede apuntar a otra dirección.

    imprimir(ptr); // Imprime "Chau"
    return 0;
}

¿Cuándo usarlo?: Siempre que pases un puntero a una función que solo necesita leer los datos, pero no modificarlos. Esto previene efectos secundarios accidentales.

2. Puntero Constante a un Dato (No podés cambiar la dirección)

En este caso, la nota adhesiva está escrita con tinta imborrable: siempre apuntará al mismo casillero. Sin embargo, el casillero en sí no tiene llave, por lo que podés cambiar su contenido libremente.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main() {
    int valor_a = 10;
    int valor_b = 20;

    // El puntero debe inicializarse en la declaración, ya que no se puede cambiar después.
    int * const ptr = &valor_a;

    *ptr = 50; // VÁLIDO: podés modificar el valor en la dirección apuntada.
               // Ahora, 'valor_a' es 50.

    // ptr = &valor_b; // ERROR DE COMPILACIÓN: no se puede reasignar un puntero constante.

    printf("El valor de A es: %d\n", valor_a); // Imprime 50
    return 0;
}

Cuándo usarlo: Cuando necesitás que un puntero se refiera siempre a la misma ubicación de memoria, como un búfer fijo o una dirección de hardware específica.

3. Puntero Constante a un Dato Constante (No podés cambiar NADA)

Esta es la forma más restrictiva. La nota está escrita con tinta imborrable y el casillero está cerrado con llave. No podés cambiar ni a dónde apunta el puntero, ni el contenido del lugar al que apunta.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main() {
    int valor_fijo = 100;
    int otro_valor = 200;

    const int * const ptr = &valor_fijo;

    // *ptr = 150;     // ERROR DE COMPILACIÓN: el valor es constante.
    // ptr = &otro_valor; // ERROR DE COMPILACIÓN: el puntero es constante.

    printf("El valor fijo es: %d\n", *ptr); // Imprime 100
    return 0;
}

Cuándo usarlo: Para definir una referencia totalmente inmutable a un dato, como un puntero a una tabla de configuración o a una constante almacenada en memoria de solo lectura.

Punteros Dobles: La Indirección a un Nuevo Nivel

Un puntero doble es, literalmente, un puntero que apunta a otro puntero. Introduce un nivel adicional de indirección, lo que significa que necesitás seguir dos direcciones para llegar al dato final.

Si un puntero (int *p) es una nota con la dirección de un cofre que contiene un tesoro (un int), un puntero doble (int **pp) es una nota con la dirección de otra nota, que a su vez tiene la dirección del cofre del tesoro.

1
2
3
int valor = 100;
int *p = &valor;    // p apunta a 'valor'
int **pp = &p;      // pp apunta a 'p'

Podemos acceder a valor, desreferenciando dos veces el puntero pp;

1
printf("%d\n", **pp);

Esta capacidad de manipular un puntero a través de otro puntero es extremadamente poderosa y se usa principalmente en dos escenarios cruciales.

Uno de ellos lo veremos aquí, el segundo, lo haremos cuando veamos memoria dinámica.

Simular “Pasaje por Referencia” para Punteros

Recordá que C siempre pasa los argumentos a las funciones por valor. Esto significa que la función recibe una copia del argumento. Si pasás un puntero int *p, la función recibe una copia de la dirección que p contiene. Podés usar esa copia para modificar el dato original (*p = 99), pero no podés cambiar a dónde apunta el puntero original.

Para poder modificar el puntero original desde dentro de una función, necesitás pasar la dirección de ese puntero, es decir, un puntero doble.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>

// Función para intercambiar el valor de dos punteros.
// Se utilizan punteros dobles (**), ya que necesitamos modificar
// las direcciones de memoria a las que apuntan los punteros originales.
void intercambiar_punteros(int **puntero1, int **puntero2) {
    int *temp = *puntero1;
    *puntero1 = *puntero2;
    *puntero2 = temp;
}

int main() {
    int a = 10;
    int b = 20;
    int *ptr_a = &a;
    int *ptr_b = &b;

    printf("Antes del intercambio:\n");
    printf("ptr_a apunta a %d (direccion: %p)\n", *ptr_a, ptr_a);
    printf("ptr_b apunta a %d (direccion: %p)\n", *ptr_b, ptr_b);

    // Llamamos a la función pasando las direcciones de los punteros
    intercambiar_punteros(&ptr_a, &ptr_b);

    printf("\nDespues del intercambio:\n");
    printf("ptr_a apunta a %d (direccion: %p)\n", *ptr_a, ptr_a);
    printf("ptr_b apunta a %d (direccion: %p)\n", *ptr_b, ptr_b);

    return 0;
}

Al desreferenciar puntero1 y puntero2, accedemos directamente a los punteros originales (ptr_a y ptr_b en main) y podemos modificar las direcciones de memoria que almacenan.

Documentando funciones con punteros

Cuando una función utiliza punteros como parámetros, especialmente para modificar datos fuera de su propio ámbito (efectos secundarios), una documentación clara y precisa es fundamental. La documentación actúa como un contrato entre la función y quien la llama (el “cliente”). Este contrato establece las responsabilidades de cada parte para garantizar que la función opere de manera segura y predecible.

Usaremos la función intercambiar como ejemplo para ilustrar cómo documentar este contrato, definiendo el flujo de los datos, las precondiciones, las poscondiciones y los invariantes.

Dirección del Flujo de Información

Al trabajar con punteros, no solo es importante el tipo de dato, sino también la “dirección” en la que fluye la información. Se usa una convención simple para indicarlo:


Contratos II: Precondiciones, Poscondiciones e Invariantes con punteros

Recordemos que es cada uno de ellos

Su uso como metodología de documentación es una simplificación de su verdadero poder, pero nos ayuda a pensar que en términos de lo que entra, lo que sale y lo que no cambia.

Ejemplo de Documentación Completa

Aplicando estos conceptos, una documentación exhaustiva para la función intercambiar se vería así, siguiendo el estilo creado por la cátedra.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
 * Intercambia los valores de dos variables enteras a través de sus punteros.
 * @param[in, out] primero Puntero al primer valor. Su contenido será leído y luego
 * sobrescrito con el del segundo.
 * @param[in, out] segundo Puntero al segundo valor. Su contenido será leído y luego
 * sobrescrito con el contenido del primero.
 * @pre Ambos punteros no deben ser NULL y apuntar a direcciones de memoria válidas y modificables.
 * @post El valor almacenado en la dirección apuntada por 'primero' será el valor original
 * que se encontraba en la dirección de 'segundo' y viceversa. No se introducirán otros valores por fuera
 * de los que estén referenciados.
 */
void intercambiar(int *primero, int *segundo) {
    int temporal = *primero;
    *primero = *segundo;
    *segundo = temporal;
}

De esta forma, eliminamos las ambigüedades, y reducimos los potenciales errores.

La “degradación” de arreglos a punteros

Uno de los comportamientos más importantes —y a menudo confusos— en C es que los arreglos se “degradan” (decay) a punteros en la mayoría de los contextos. Este no es un truco, sino una regla de conversión fundamental del lenguaje que explica la íntima relación entre ambos conceptos.

¿Qué significa realmente la “degradación”?

La regla es simple: cuando usás el nombre de un arreglo en una expresión (por ejemplo, al asignarlo a un puntero o pasarlo a una función), el compilador no trabaja con el arreglo completo. En su lugar, lo convierte automáticamente en un puntero al primer elemento de ese arreglo.

Por lo tanto, las siguientes dos líneas de código son funcionalmente idénticas:

1
2
3
4
5
6
7
8
9
10
int numeros[5] = {10, 20, 30, 40, 50};

// La "degradación" ocurre aquí: 'numeros' se convierte en la dirección de numeros[0]
int *p = numeros;

// Esta es la forma explícita y equivalente
int *p_explicito = &numeros[0];

printf("La dirección almacenada en p es: %p\n", (void*)p);
printf("La dirección del primer elemento es: %p\n", (void*)&numeros[0]);

Consecuencias Prácticas (y Cruciales) de la Degradación

Entender esta conversión es vital porque tiene implicaciones directas en cómo escribís tu código, especialmente con funciones y el operador sizeof.

Cuando pasás un arreglo a una función, lo que la función recibe en realidad es una copia del puntero a su primer elemento. La función nunca recibe la copia del arreglo.

Por eso, estas tres declaraciones de función son absolutamente equivalentes para el compilador:

1
2
3
void procesar_datos(int arr[10]); // El 10 es ignorado por el compilador
void procesar_datos(int arr[]);   // Notación más común para indicar que se espera un arreglo
void procesar_datos(int *arr);    // La forma más honesta: la función recibe un puntero

Debido a esto, la función pierde la información sobre el tamaño original del arreglo y el tamaño que obtendremos es únicamente el de la dirección de memoria.

sizeof(arreglo_decaido)=sizeof(puntero)\text{sizeof(arreglo\_decaido)} = \text{sizeof(puntero)}
1
2
3
4
5
6
7
8
#include <stdio.h>

// La función recibe un puntero, sin importar cómo se declare el parámetro.
void imprimir_tamano(int arr[]) {
    // ¡Peligro! Esto NO mide el tamaño del arreglo original.
    // Mide el tamaño de un puntero en tu sistema (usualmente 4 u 8 bytes).
    printf("Tamaño DENTRO de la función: %zu bytes\n", sizeof(arr));
}

sizeof funciona como es esperado solo cuando su uso se hace en el mismo alcance de la declaración del arreglo.

Por lo que hacer sizeof(arreglo) va a devolver el tamaño total en bytes del arreglo, de forma que sea (número de elementos * tamaño del tipo del arreglo).

sizeof(arreglo)=elementos×sizeof(T)\text{sizeof(arreglo)} = elementos \times \text{sizeof(T)}
1
2
3
4
5
6
7
8
9
10
int main() {
    int mi_arreglo[10] = {0};

    // Aquí 'sizeof' conoce el tamaño real del arreglo.
    printf("Tamaño FUERA de la función: %zu bytes\n", sizeof(mi_arreglo)); // Imprimirá 40 (10 * 4 bytes)

    imprimir_tamano(mi_arreglo); // Imprimirá 4 u 8

    return 0;
}

Manipulando arreglos con aritmética de punteros

La relación entre arreglos y punteros en C es tan estrecha que se pueden usar de forma intercambiable en muchos contextos.

1. Recorrido solo lectura

La tarea más básica es iterar sobre todos los elementos para leerlos o imprimirlos. La estrategia consiste en tener un puntero que avanza y un puntero “límite” que nos indica cuándo detenernos.

Método:

  1. Declará un puntero ptr que apunte al inicio del arreglo.

  2. Declará otro puntero fin que apunte a la dirección de memoria inmediatamente posterior al último elemento. Esto substituye el ‘conteo’ de posiciónes.

  3. El lazo while se ejecuta mientras ptr sea menor que fin.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stddef.h> // Para size_t

void imprimir_arreglo(const int *arr, size_t tamano) {
    const int *ptr = arr;
    const int *fin = arr + tamano; // Puntero al final del arreglo + 1

    printf("Contenido del arreglo: ");
    while (ptr < fin) {
        printf("%d ", *ptr); // 1. Leer el valor actual
        ptr++;               // 2. Mover el puntero al siguiente elemento
    }
    printf("\n");
}

int main() {
    int numeros[] = {10, 20, 30, 40, 50};
    imprimir_arreglo(numeros, 5);
    return 0;
}

2. Búsqueda de un elemento

Para buscar un valor, recorremos el arreglo y nos detenemos si encontramos una coincidencia. La función devolverá un puntero al elemento encontrado o NULL si no se encuentra. La comprobación explícita contra NULL sigue la regla Regla 0x0010h: Evitá las condiciones ambiguas basadas en la “veracidad” (truthiness) del tipo de dato.

Método:

  1. Declará los punteros ptr y fin como en el ejemplo anterior.

  2. El lazo while se ejecuta mientras no lleguemos al final y no hayamos encontrado el valor.

  3. Después del lazo, si ptr != fin, significa que el lazo se detuvo porque encontramos el elemento. Si son iguales, es porque recorrimos todo sin éxito.

  4. Si el bucle termina sin encontrar el valor, devolver NULL.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <stdio.h>
#include <stddef.h> // Para NULL y size_t

// Devuelve un puntero al primer elemento que coincida con 'valor', o NULL si no se encuentra.
int* buscar_valor(int *arr, size_t tamano, int valor) {
    int *ptr = arr;
    int *fin = arr + tamano;
    int *resultado = NULL; // Inicializamos con NULL

    // El bucle continúa mientras no hayamos llegado al final
    // Y no hayamos encontrado el valor.
    while (ptr < fin && resultado == NULL) {
        if (*ptr == valor) {
            resultado = ptr; // Asignamos la dirección si se encuentra
        }
        ptr++;
    }

    return resultado; // Devolvemos el resultado final
}

int main() {
    int numeros[] = {10, 20, 30, 40, 50};
    int valor_a_buscar = 30;

    int *encontrado = buscar_valor(numeros, 5, valor_a_buscar);

    if (encontrado != NULL) {
        printf("Valor %d encontrado en la dirección de memoria %p\n", *encontrado, (void*)encontrado);
    } else {
        printf("Valor %d no encontrado en el arreglo.\n", valor_a_buscar);
    }

    return 0;
}

3. Modificando el arreglo

Para modificar los datos, usamos el operador de desreferencia (*) en el lado izquierdo de una asignación. Esto modifica el valor en la memoria a la que apunta el puntero.

Método:

  1. El recorrido es idéntico al de la lectura.

  2. Dentro del lazo, en lugar de leer, realizamos una asignación: *ptr = nuevo_valor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdio.h>
#include <stddef.h> // Para size_t

// Duplica el valor de cada elemento en el arreglo.
void duplicar_valores(int *arr, size_t tamano) {
    int *ptr = arr;
    int *fin = arr + tamano;

    while (ptr < fin) {
        *ptr = *ptr * 2; // Modifica el valor en la memoria apuntada
        ptr++;
    }
}

int main() {
    int numeros[] = {1, 2, 3, 4, 5};

    printf("Arreglo original: 1 2 3 4 5\n");
    // (Código para imprimirlo, podemos usar el de la primera sección)

    duplicar_valores(numeros, 5);

    printf("Arreglo modificado: %d %d %d %d %d\n", numeros[0], numeros[1], numeros[2], numeros[3], numeros[4]);
    // Salida esperada: 2 4 6 8 10

    return 0;
}

4. Copiando un Arreglo

Para copiar un arreglo, necesitamos dos punteros: uno para la fuente (de donde leemos) y otro para el destino (donde escribimos). Ambos deben avanzar en cada paso.

Método:

  1. Creá un puntero fuente para el arreglo original y un puntero destino para el nuevo.

  2. El lazo se ejecuta mientras el puntero fuente no llegue a su final.

  3. Dentro del lazo, copiá el valor y luego incrementá ambos punteros.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <stddef.h> // Para size_t

void copiar_arreglo(int *destino, const int *fuente, size_t tamano) {
    const int *ptr_fuente = fuente;
    int *ptr_destino = destino;
    const int *fin_fuente = fuente + tamano;

    // Bucle principal de copia
    while (ptr_fuente < fin_fuente) {
        *ptr_destino = *ptr_fuente;
        ptr_fuente++;
        ptr_destino++;
    }

    // Una forma más compacta pero potencialmente
    // menos legible de escribir lo de arriba:
    // while (ptr_fuente < fin_fuente) {
    //     *ptr_destino++ = *ptr_fuente++;
    // }
}

int main() {
    int arreglo_a[] = {100, 200, 300};
    int arreglo_b[3]; // Arreglo vacío para recibir la copia

    copiar_arreglo(arreglo_b, arreglo_a, 3);

    printf("Contenido del arreglo copiado: %d %d %d\n", arreglo_b[0], arreglo_b[1], arreglo_b[2]);
    // Salida esperada: 100 200 300

    return 0;
}

5. Versión alternativa

Si la función que necesitamos crear, necesita de la posición en la que nos encontramos, no vamos a escapar de ‘contar’ posiciones.

En ese caso, lo que podemos hacer, es ir sumando al puntero del arreglo la i-esima posición, en lugar de ir incrementando el puntero mismo.

1
2
3
for (size_t i = 0; i < 5; i++) {
    printf("%d ", *(p + i));
}

De esta forma, podemos obtener un código que es más similar al uso tradicional de arreglos

1
2
3
4
5
6
7
void imprimir_arreglo(const int *ptr, size_t tamano) {
    printf("Contenido del arreglo: ");
    for (size_t i = 0; i < tamano; i++) {
        printf("%zu:%d ", i, *(ptr + i));
    }
    printf("\n");
}

Prestá atención a los paréntesis en *(p + i), ya que son cruciales para el orden de las operaciones. Su presencia asegura que primero realicemos la aritmética de punteros (calculando la nueva dirección p + i) y después desreferenciemos esa dirección para obtener el valor que contiene.

Si omitieras los paréntesis y escribieras *p + i, el resultado sería completamente diferente. Debido a la precedencia de operadores, primero se desreferenciaría *p (obteniendo el valor en la dirección actual) y luego se le sumaría i a ese valor, lo cual es una operación matemática, no de punteros.

En resumen:

Ejercicios

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

void intercambiar(int *a, int *b) {
  int temporal = *a; // Guardamos el valor al que apunta 'a'
  *a = *b;           // Asignamos al lugar de 'a' el valor al que apunta 'b'
  *b = temporal;     // Asignamos al lugar de 'b' el valor guardado
}

int main() {
  int x = 10;
  int y = 20;

  printf("Valores originales: x = %d, y = %d\n", x, y);

  // Pasamos las direcciones de memoria de x e y
  intercambiar(&x, &y);

  printf("Valores intercambiados: x = %d, y = %d\n", x, y);

  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stddef.h>
#include <stdio.h>

int encontrar_maximo(const int *arreglo, size_t n) {
  if (n == 0) {
    return 0; // O un valor de error apropiado
  }

  int maximo = *arreglo; // Suponemos que el primer elemento es el máximo

  // Avanzamos el puntero por el resto del arreglo
  for (size_t i = 1; i < n; i++) {
    // Usamos aritmética de punteros para acceder al siguiente elemento
    if (*(arreglo + i) > maximo) {
      maximo = *(arreglo + i);
    }
  }
  return maximo;
}

int main() {
  int numeros[] = {5, 2, 99, 45, 12, 50};
  size_t cantidad = sizeof(numeros) / sizeof(numeros[0]);

  int max = encontrar_maximo(numeros, cantidad);
  printf("El elemento máximo del arreglo es: %d\n", max);

  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

void copiar_cadena(char *destino, const char *origen) {
  // Mientras el valor al que apunta 'origen' no sea el carácter nulo...
  while (*origen != '\0') {
    *destino = *origen; // Copiamos el valor
    origen++;           // Avanzamos el puntero de origen
    destino++;          // Avanzamos el puntero de destino
  }
  *destino = '\0'; // Aseguramos que la cadena destino termine con el nulo
}

int main() {
  const char *fuente = "Hola Punteros!";
  char buffer[50];

  copiar_cadena(buffer, fuente);

  printf("Cadena original: %s\n", fuente);
  printf("Cadena copiada: %s\n", buffer);

  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stddef.h>
#include <stdio.h>

int sumar_arreglo(const int *inicio, const int *fin) {
  int suma = 0;
  // Iteramos mientras el puntero 'p' no haya llegado al puntero 'fin'
  for (const int *p = inicio; p < fin; p++) {
    suma += *p; // Sumamos el valor al que apunta 'p'
  }
  return suma;
}

int main() {
  int arreglo[] = {10, 20, 30, 40};
  size_t n = sizeof(arreglo) / sizeof(arreglo[0]);

  // El puntero 'fin' apunta a una posición después del último elemento
  int suma_total = sumar_arreglo(arreglo, arreglo + n);

  printf("La suma de los elementos es: %d\n", suma_total);

  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stddef.h>
#include <stdio.h>

void invertir_arreglo(int *arreglo, size_t n) {
  if (n < 2) {
    return; // No hay nada que invertir
  }
  int *inicio = arreglo;
  int *fin = arreglo + n - 1;

  while (inicio < fin) {
    // Intercambiamos los valores
    int temp = *inicio;
    *inicio = *fin;
    *fin = temp;

    // Movemos los punteros hacia el centro
    inicio++;
    fin--;
  }
}

void imprimir_arreglo(int *arr, size_t n) {
  for (size_t i = 0; i < n; i++) {
    printf("%d ", arr[i]);
  }
  printf("\n");
}

int main() {
  int mi_arreglo[] = {1, 2, 3, 4, 5, 6};
  size_t cantidad = sizeof(mi_arreglo) / sizeof(mi_arreglo[0]);

  printf("Original: ");
  imprimir_arreglo(mi_arreglo, cantidad);

  invertir_arreglo(mi_arreglo, cantidad);

  printf("Invertido: ");
  imprimir_arreglo(mi_arreglo, cantidad);

  return 0;
}

Próximos Pasos: Memoria Dinámica

Los punteros que estudiaste en este capítulo son fundamentales, pero hasta ahora solo trabajaste con memoria que el compilador gestiona automáticamente (variables locales y globales). El verdadero poder de los punteros se revela cuando aprendés a gestionar memoria dinámicamente durante la ejecución del programa.

En el Gestión de Memoria Dinámica en C, vas a aprender sobre:

Estos conceptos amplían dramáticamente lo que podés hacer en C, permitiéndote crear programas que adaptan su uso de memoria a las necesidades del momento. Sin embargo, con este poder viene una gran responsabilidad: la gestión manual de memoria requiere disciplina y atención a los detalles.

Cuando te sientas cómodo con los conceptos de este capítulo, estás listo para dar el próximo paso hacia la memoria dinámica.

Conceptos Clave

Este apunte desmitifica los punteros, el concepto más distintivo y poderoso de C, revelando su naturaleza como simples variables que almacenan direcciones de memoria.

Conexión con el Siguiente Tema

Los punteros que estudiamos operan sobre memoria estática (conocida en compilación) o automática (stack, gestionada por el sistema). Pero la verdadera potencia de los punteros emerge cuando los combinamos con memoria dinámica: la capacidad de solicitar y liberar memoria durante la ejecución según las necesidades del programa.

El apunte Gestión de Memoria Dinámica en C introduce la gestión explícita de memoria mediante:

La memoria dinámica permite construir estructuras que crecen y encogen según necesidad: una lista que se expande al agregar elementos, un grafo que se construye progresivamente. Sin embargo, introduce responsabilidad total sobre el ciclo de vida de la memoria: cada malloc() debe tener su free() correspondiente.

Los punteros son las herramientas; la memoria dinámica es el material sobre el que trabajan. Juntos, permiten implementar cualquier estructura de datos imaginable, desde simples listas hasta bases de datos completas.

Pregunta puente: Si declaramos int arr[1000000] en una función, el programa probablemente falle con stack overflow. ¿Por qué? ¿Cómo solicitamos memoria para estructuras arbitrariamente grandes? La respuesta está en la memoria dinámica y el heap.

Referencias y Lecturas Complementarias

Textos Fundamentales

Punteros y Arquitectura

Gestión de Memoria y Errores

Recursos en Línea

Visualización y Debugging

Artículos Clásicos

Ejercicios y Práctica

References
  1. Kernighan, B. W., & Ritchie, D. M. (2014). C Programming Language, 2nd Edition.
  2. King, K. N. (2008). C Programming: A Modern Approach (2nd ed.). W. W. Norton & Company.
  3. Reek, K. A. (1997). Pointers on C. Addison-Wesley.
  4. Bryant, R. E., & O’Hallaron, D. R. (2015). Computer Systems: A Programmer’s Perspective (3rd ed.). Pearson.
  5. Patterson, D. A., & Hennessy, J. L. (2017). Computer Organization and Design: The Hardware/Software Interface (5th ed.). Morgan Kaufmann.
  6. Seacord, R. C. (2013). Secure Coding in C and C++ (2nd ed.). Addison-Wesley.
  7. van der Linden, P. (1994). Expert C Programming: Deep C Secrets. Prentice Hall.
  8. Ritchie, D. M. (1993). The Development of the C Language. ACM SIGPLAN Notices, 28(3), 201–208. 10.1145/155360.155580