Ponteiros são uma das características mais poderosas e, ao mesmo tempo, mais desafiadoras da linguagem C. Eles permitem manipular diretamente a memória do computador, possibilitando a criação de estruturas de dados dinâmicas, passagem eficiente de parâmetros por referência e implementação de funções que modificam variáveis fora de seu escopo. Dominar ponteiros é essencial para qualquer programador C que deseje escrever código eficiente e flexível.

Nesta aula, vamos começar do zero: entender o que é um ponteiro, como declarar um, como usar os operadores fundamentais & (endereço de) e * (valor apontado por), e por que o valor NULL é tão importante na prática com ponteiros. Prepare-se para uma mudança de paradigma: a partir de agora, você não pensará apenas em variáveis que armazenam valores, mas também em variáveis que armazenam endereços de outras variáveis.

O que é um ponteiro

Um ponteiro é uma variável que armazena o endereço de memória de outra variável. Em vez de conter um valor diretamente (como um inteiro ou caractere), um ponteiro contém a localização onde esse valor está armazenado na memória. Isso permite que você acesse e modifique indiretamente o valor de outras variáveis, o que é fundamental para muitas operações em C.

Imagine a memória do computador como um grande conjunto de caixas numeradas (endereços). Cada variável ocupa uma ou mais caixas. Um ponteiro é como uma anotação que diz "o dado que você procura está na caixa número X". Assim, ao invés de lidar diretamente com o valor, você pode manipular a referência a ele.

Por exemplo, se você tem uma variável inteira x armazenada no endereço 1000, um ponteiro p que aponta para x conterá o valor 1000 (o endereço). Através de p, você pode ler ou modificar o valor de x sem usar o nome x diretamente.

#include <stdio.h>

int main() {
    int x = 42;
    int *p;       // declara um ponteiro para inteiro
    p = &x;       // p recebe o endereço de x

    printf("Valor de x: %d\n", x);
    printf("Endereço de x: %p\n", (void*)&x);
    printf("Valor de p (endereço armazenado): %p\n", (void*)p);
    printf("Valor apontado por p: %d\n", *p);

    return 0;
}

Operadores & e *

O operador & (e comercial) é chamado de operador de endereço. Quando aplicado a uma variável, ele retorna o endereço de memória onde essa variável está armazenada. Por exemplo, &x fornece o endereço de x.

O operador * (asterisco) tem dois usos dependendo do contexto: na declaração de um ponteiro, ele indica que a variável é um ponteiro (ex.: int *p;). Já em uma expressão, ele é o operador de desreferência (ou indireção), que acessa o valor armazenado no endereço contido no ponteiro. Por exemplo, se p aponta para x, então *p equivale a x.

É importante não confundir os dois usos. Na declaração, o asterisco faz parte do tipo. Na expressão, ele é um operador unário que obtém o valor apontado.

#include <stdio.h>

int main() {
    int a = 10;
    int b = 20;
    int *ptr;

    ptr = &a;           // ptr aponta para a
    printf("*ptr = %d\n", *ptr); // imprime 10

    *ptr = 30;          // altera o valor de a para 30
    printf("a = %d\n", a);        // imprime 30

    ptr = &b;           // agora ptr aponta para b
    printf("*ptr = %d\n", *ptr); // imprime 20

    return 0;
}

Declaração

A declaração de um ponteiro segue a sintaxe: tipo *nome;, onde tipo é o tipo de dado para o qual o ponteiro aponta (ex.: int, char, float). O asterisco pode ter espaços em branco ao redor, mas é comum escrever int *p; ou int* p;. A convenção mais adotada é int *p; para evitar ambiguidades quando se declara múltiplos ponteiros na mesma linha.

Por exemplo:

  • int *p; – ponteiro para inteiro
  • char *c; – ponteiro para caractere
  • double *d; – ponteiro para double

É fundamental que o tipo do ponteiro corresponda ao tipo da variável para a qual ele aponta. Embora C permita atribuir endereços de tipos diferentes com casts, isso é perigoso e deve ser evitado, pois o compilador assume que o ponteiro aponta para um objeto do tipo declarado.

#include <stdio.h>

int main() {
    int x = 5;
    double y = 3.14;
    int *pi;
    double *pd;

    pi = &x;   // correto: pi aponta para int
    pd = &y;   // correto: pd aponta para double

    // pi = &y; // erro de compilação (tipos incompatíveis)

    printf("*pi = %d\n", *pi);
    printf("*pd = %f\n", *pd);

    return 0;
}

NULL

NULL é uma constante simbólica definida em várias bibliotecas padrão (como <stddef.h>, <stdio.h>, <stdlib.h>) que representa um ponteiro que não aponta para nenhum endereço válido. É uma boa prática inicializar ponteiros com NULL quando eles não estão apontando para um objeto imediatamente, pois isso evita o uso de ponteiros não inicializados (que contêm lixo de memória).

Você pode testar se um ponteiro é NULL antes de desreferenciá-lo, evitando acessos inválidos que podem causar falhas de segmentação (segmentation fault). Por exemplo:

#include <stdio.h>

int main() {
    int *p = NULL;

    if (p != NULL) {
        printf("%d\n", *p);
    } else {
        printf("Ponteiro nulo, não é seguro desreferenciar.\n");
    }

    return 0;
}

É importante notar que NULL é igual a 0 (zero) em C, mas é semanticamente diferente: NULL é usado para ponteiros, enquanto 0 é um valor inteiro. Por isso, ao comparar ponteiros, use NULL, não 0, para deixar a intenção clara.

Além disso, a macro NULL pode ser definida como (void*)0 ou simplesmente 0, dependendo da implementação. Sempre inclua os cabeçalhos apropriados para ter acesso a ela.

Boas práticas

  • Sempre inicialize ponteiros com NULL se não houver um endereço válido imediato.
  • Verifique se o ponteiro não é NULL antes de desreferenciá-lo.
  • Use nomes significativos para ponteiros (ex.: ptr, p, ou algo que indique o que ele aponta).
  • Evite confundir o operador de declaração com o operador de desreferência.
  • Lembre-se de que o tipo do ponteiro deve corresponder ao tipo do dado apontado.

Exercícios

  1. Crie um programa que declare uma variável inteira num com valor 100, um ponteiro p para inteiro, faça p apontar para num e imprima o valor de num usando o ponteiro.

    ✓ Resposta:
    #include <stdio.h>
    
    int main() {
        int num = 100;
        int *p;
        p = &num;
        printf("Valor de num via ponteiro: %d\n", *p);
        return 0;
    }
  2. Escreva um programa que declare duas variáveis inteiras a e b, e um ponteiro ptr. Faça ptr apontar para a, atribua a a o valor 5 via ponteiro, depois faça ptr apontar para b e atribua a b o valor 10 via ponteiro. Imprima os valores de a e b.

    ✓ Resposta:
    #include <stdio.h>
    
    int main() {
        int a, b;
        int *ptr;
    
        ptr = &a;
        *ptr = 5;
    
        ptr = &b;
        *ptr = 10;
    
        printf("a = %d, b = %d\n", a, b);
        return 0;
    }
  3. O que acontece se você tentar imprimir o valor de um ponteiro não inicializado? Escreva um pequeno programa que demonstre isso e comente o comportamento (não se preocupe em corrigir, apenas observe).

    ✓ Resposta:
    #include <stdio.h>
    
    int main() {
        int *p; // não inicializado
        printf("Endereço armazenado em p: %p\n", (void*)p);
        // O valor impresso será imprevisível (lixo de memória).
        // Tentar desreferenciar p (*p) pode causar falha de segmentação.
        return 0;
    }
  4. Declare um ponteiro para caractere e inicialize-o com NULL. Verifique se ele é NULL e imprima uma mensagem adequada.

    ✓ Resposta:
    #include <stdio.h>
    
    int main() {
        char *c = NULL;
        if (c == NULL) {
            printf("O ponteiro é NULL.\n");
        } else {
            printf("O ponteiro não é NULL.\n");
        }
        return 0;
    }
  5. Explique a diferença entre int *p; e int* p;. Ambas são válidas? Há alguma diferença semântica?

    ✓ Resposta: Ambas as declarações são válidas e semanticamente idênticas: declaram p como um ponteiro para inteiro. A diferença é apenas de estilo. No entanto, ao declarar múltiplas variáveis na mesma linha, a posição do asterisco pode causar confusão. Por exemplo: int* p, q; declara p como ponteiro para inteiro e q como inteiro comum (não ponteiro). Já int *p, *q; declara ambos como ponteiros. Por isso, muitos programadores preferem int *p; para evitar ambiguidades.

Referências