Aritmética de Ponteiros e a Ligação Profunda com Vetores

[349] Aritmética de Ponteiros e a Ligação Profunda com Vetores

Somar 1 a um ponteiro não soma 1 ao endereço: avança um elemento inteiro, quatro bytes num int e oito num double. Dessa regra saem a revelação de que os colchetes são só açúcar para aritmética, o decaimento que faz a função perder o tamanho do vetor, e a subtração que devolve distância em elementos.
Linguagem C

9 min de leitura

Na aula anterior, você aprendeu que um ponteiro guarda um endereço. Hoje vamos descobrir que endereços podem sofrer contas — e que essas contas revelam um dos segredos mais bem guardados do C: vetor e ponteiro são, na prática, a mesma coisa vista de dois ângulos. Aquela pista que deixamos no ar lá na aula sobre vetores finalmente vai ser explicada por inteiro.

Somar 1 a um ponteiro não soma 1 ao endereço

Comece por este experimento, que parece bizarro até você entender a lógica:

#include <stdio.h>

int main(void) {
    int    vi[3] = {10, 20, 30};
    double vd[3] = {1.0, 2.0, 3.0};

    int    *pi = vi;
    double *pd = vd;

    printf("pi   = %p\n", (void *) pi);
    printf("pi+1 = %p\n", (void *) (pi + 1));
    printf("pd   = %p\n", (void *) pd);
    printf("pd+1 = %p\n", (void *) (pd + 1));
    return 0;
}

Você vai observar que pi + 1 avança 4 bytes (o tamanho de um int), enquanto pd + 1 avança 8 bytes (o tamanho de um double). O C não somou 1 ao endereço bruto — ele somou "um elemento". Essa é a regra da aritmética de ponteiros: p + n avança n vezes o tamanho do tipo apontado. É o que torna ponteiros úteis para navegar por sequências de dados: dar um passo sempre cai exatamente no próximo elemento, seja ele grande ou pequeno.

A grande revelação: o nome do vetor é um ponteiro

Você reparou que, no exemplo acima, escrevi int *pi = vi; — sem o &? Isso não foi descuido. Em C, o nome de um vetor, usado numa expressão, decai para um ponteiro ao seu primeiro elemento. Ou seja, vi equivale a &vi[0].

Isso significa que estas duas linhas fazem a mesma coisa:

int *p = vi;        // decaimento: vi vira &vi[0]
int *p = &vi[0];    // explícito, idêntico ao de cima

E, mais surpreendente ainda, a indexação com colchetes é açúcar sintático para aritmética de ponteiros. Quando você escreve vi[2], o C traduz para *(vi + 2): "ande dois elementos a partir do início e me dê o valor de lá". Veja com seus próprios olhos:

#include <stdio.h>

int main(void) {
    int v[5] = {10, 20, 30, 40, 50};

    printf("v[2]        = %d\n", v[2]);        // 30
    printf("*(v + 2)    = %d\n", *(v + 2));    // 30 — idêntico!

    // como a[i] == *(a+i), e a soma é comutativa, isto também vale:
    printf("2[v]        = %d\n", 2[v]);        // 30 — sim, isto compila!
    return 0;
}

O 2[v] é uma curiosidade que costuma chocar quem vê pela primeira vez: como v[2] é apenas *(v + 2), e a soma é comutativa, 2[v] é *(2 + v) — a mesma coisa. Ninguém escreve assim na vida real, mas é a prova definitiva de que colchetes são só uma máscara para aritmética de ponteiros.

Percorrendo um vetor com um ponteiro

Com isso em mãos, há duas formas equivalentes de percorrer um vetor. A que você já conhece, com índice:

for (int i = 0; i < 5; i++) {
    printf("%d ", v[i]);
}

E a "forma ponteiro", andando com o próprio ponteiro:

#include <stdio.h>

int main(void) {
    int v[5] = {10, 20, 30, 40, 50};

    for (int *p = v; p < v + 5; p++) {
        printf("%d ", *p);  // desreferencia a posição atual
    }
    printf("\n");
    return 0;
}

Aqui p começa apontando para v[0]; a cada volta, p++ o move para o próximo elemento; e o laço para quando p alcança v + 5, isto é, o endereço logo depois do último elemento válido. Esse endereço "um além do fim" é legal de calcular (e serve de sentinela), mas desreferenciá-lo é comportamento indefinido. As duas versões produzem o mesmo resultado; a segunda é o estilo que você verá em código C mais antigo e em bibliotecas.

Por que uma função "perde" o tamanho do vetor

Agora fecha o raciocínio que ficou pendente na aula Vetores e o Primeiro Contato com a Memória Contígua. Quando você passa um vetor para uma função, o que é copiado não é o vetor inteiro — é apenas o ponteiro para seu primeiro elemento, por causa do decaimento. Por isso o sizeof mente lá dentro:

#include <stdio.h>

void inspecionar(int v[]) {
    // aqui, v é na verdade um int* — não um vetor
    printf("Dentro da função: sizeof(v) = %zu\n", sizeof(v)); // 8 (tamanho de um ponteiro!)
}

int main(void) {
    int numeros[10] = {0};
    printf("Em main: sizeof(numeros) = %zu\n", sizeof(numeros)); // 40 (10 * 4)
    inspecionar(numeros);
    return 0;
}

Em main, sizeof(numeros)40 (dez inteiros de quatro bytes). Dentro da função, sizeof(v)8 — o tamanho de um ponteiro, não do vetor. A informação de tamanho ficou para trás. É exatamente por isso que a convenção do C é passar sempre o vetor e o tamanho como argumentos separados. A assinatura void inspecionar(int v[]), aliás, é sinônimo perfeito de void inspecionar(int *v) — o compilador trata as duas de forma idêntica.

Aritmética entre dois ponteiros: a diferença

Além de somar um número a um ponteiro, você pode subtrair um ponteiro de outro (desde que ambos apontem para dentro do mesmo vetor). O resultado é a distância em elementos entre eles:

#include <stdio.h>

int main(void) {
    int v[6] = {2, 4, 6, 8, 10, 12};
    int *inicio = v;
    int *achou = NULL;

    // procura o primeiro elemento igual a 8
    for (int *p = v; p < v + 6; p++) {
        if (*p == 8) {
            achou = p;
            break;
        }
    }

    if (achou != NULL) {
        printf("Encontrado na posição %ld\n", achou - inicio); // 3
    }
    return 0;
}

A subtração achou - inicio não dá a diferença em bytes, e sim em elementos — aqui, 3, porque o 8 está no índice 3. Esse tipo de conta é a base de como funções de busca e ordenação da biblioteca padrão informam posições.

Um cuidado que nunca envelhece

A aritmética de ponteiros é poderosa justamente porque não tem freios. Você pode calcular v + 1000 num vetor de 5 elementos, e o C não vai reclamar — vai apenas produzir um endereço muito além do válido. Desreferenciar esse ponteiro é o mesmo desastre silencioso que estudamos com índices fora dos limites. A regra continua a mesma: é sua responsabilidade manter o ponteiro dentro das fronteiras do vetor. Ande com ponteiros, mas conte seus passos.

O que vem a seguir

Hoje unimos dois conceitos que pareciam separados e descobrimos que eram um só: percorrer um vetor por índice ou por ponteiro é a mesma operação por baixo, e o nome de um vetor é um ponteiro para seu início. Na próxima aula, vamos aplicar tudo isso a um caso concreto e onipresente: as strings. Você vai ver que uma string em C não passa de um vetor de caracteres terminado por um marcador especial — e que quase tudo que sofre quem lida com texto em C vem de esquecer esse marcador.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Declare int v[5] = {11, 22, 33, 44, 55}; e imprima o valor 33 de três formas equivalentes: com v[2], com *(v + 2) e usando um ponteiro auxiliar int *p = v; seguido de *(p + 2).

Ver resposta

✓ Resposta:

#include <stdio.h>
int main(void) {
    int v[5] = {11, 22, 33, 44, 55};
    int *p = v;
    printf("%d\n", v[2]);        // 33
    printf("%d\n", *(v + 2));    // 33
    printf("%d\n", *(p + 2));    // 33
    return 0;
}

As três chegam ao mesmo lugar porque v[2], *(v+2) e *(p+2) calculam o mesmo endereço.

Exercício 2

Reescreva um laço que soma os elementos de um vetor de inteiros usando apenas um ponteiro que caminha (sem variável de índice i).

Ver resposta

✓ Resposta:

#include <stdio.h>
int main(void) {
    int v[5] = {10, 20, 30, 40, 50};
    int soma = 0;
    for (int *p = v; p < v + 5; p++) {
        soma += *p;   // desreferencia a posição atual
    }
    printf("Soma: %d\n", soma); // 150
    return 0;
}

O ponteiro p é, ao mesmo tempo, o cursor e o critério de parada.

Exercício 3

Escreva um programa que declare um vetor de 5 double e imprima, com %p, os endereços de cada elemento. Confirme que a distância entre endereços vizinhos é sempre 8 bytes. Por quê 8?

Ver resposta

✓ Resposta:

#include <stdio.h>
int main(void) {
    double v[5] = {1, 2, 3, 4, 5};
    for (int i = 0; i < 5; i++) {
        printf("&v[%d] = %p\n", i, (void *) &v[i]);
    }
    return 0;
}

Endereços vizinhos diferem em 8 bytes porque cada double ocupa 8 bytes, e os elementos de um vetor ficam guardados de forma contígua (colados) na memória. Num vetor de int, a distância seria 4.

Exercício 4

Explique por que sizeof dá um valor diferente para o mesmo vetor quando calculado em main e quando calculado dentro de uma função que o recebe como parâmetro.

Ver resposta

✓ Resposta: Em main, v é de fato um vetor, e sizeof(v) devolve o tamanho total ocupado por ele (número de elementos × tamanho do elemento). Ao passar v para uma função, ocorre o decaimento: o que a função recebe é apenas um ponteiro para o primeiro elemento, não o vetor inteiro. Logo, dentro da função, sizeof(v) mede o tamanho de um ponteiro (tipicamente 8 bytes em máquinas de 64 bits), não do vetor. A informação de quantos elementos existem se perdeu na passagem — por isso passamos o tamanho à parte.

Exercício 5

Dado int v[6] = {5, 10, 15, 20, 25, 30};, escreva uma função int indice_de(int *v, int n, int alvo) que retorne o índice do primeiro elemento igual a alvo (ou -1 se não houver), usando aritmética de ponteiros (subtração de ponteiros) para calcular o índice.

Ver resposta

✓ Resposta:

#include <stdio.h>

int indice_de(int *v, int n, int alvo) {
    for (int *p = v; p < v + n; p++) {
        if (*p == alvo) {
            return (int) (p - v);   // distância em elementos = índice
        }
    }
    return -1;
}

int main(void) {
    int v[6] = {5, 10, 15, 20, 25, 30};
    printf("%d\n", indice_de(v, 6, 20)); // 3
    printf("%d\n", indice_de(v, 6, 99)); // -1
    return 0;
}

A subtração p - v devolve quantos elementos p está adiante do início — exatamente o índice procurado.

Comentários

Mais em Linguagem C

typedef e o Design de Tipos Legíveis
typedef e o Design de Tipos Legíveis

Esconder um asterisco dentro de um apelido faz PInt a, b; declarar dois…

Automação de Build com Make
Automação de Build com Make

Indentar o comando com espaço em vez de TAB gera um erro enigmático — é o…

Lendo e Gravando Arquivos de Texto
Lendo e Gravando Arquivos de Texto

Abrir em modo w apaga o arquivo inteiro antes da primeira escrita — detalhe…