Algoritmos 2Revisão em C

Aula 2 de 6

Ponteiros

Endereços de memória e alteração de valores.

Ao final desta aula você será capaz de
  • Distinguir endereço (&x) de valor (*p) e ler declarações com ponteiros.
  • Escrever funções que alteram variáveis do chamador ou devolvem vários resultados.
  • Percorrer vetores com aritmética de ponteiros e explicar por que v[i] == *(v + i).
  • Reconhecer os erros clássicos: ponteiro não inicializado, NULL, ponteiro solto e aliasing.

Palavras novas nesta aula: Aliasing, Comportamento indefinido, Desreferenciar, Endereço, NULL, Ponteiro, Ponteiro solto. Cada uma tem uma explicação curta no glossário.

O problema

Um placar guarda a pontuação de um jogador. Você cria uma função para somar 10 pontos, mas o placar original não muda.

Funções e exemplo de uso: as linhas de uso vão dentro do main
void adicionar(int pontos) {
    pontos += 10;          // altera apenas a cópia local
}

int placar = 20;
adicionar(placar);         // placar continua 20
Se a função só recebe cópias, como ela pode alcançar a variável original e atualizá-la?

Pense em um endereço residencial. Você não entrega a casa a alguém: entrega o endereço, e a pessoa vai até lá.

Conceitos centrais

Toda variável ocupa um lugar na memória, e esse lugar tem um endereço. Um ponteiro é uma variável cujo valor é um endereço. Por meio dele, é possível ler e alterar o objeto que mora naquele endereço.

int *p
Declara p como ponteiro para int.
&x
Operador de endereço: o endereço da variável x.
*p
Desreferência (“seguir a seta”): o objeto que está no endereço guardado em p.
NULL
Valor especial que significa “não aponta para nada”.
Trecho: coloque dentro do main de um programa
int numero = 20;
int *p = №   // p guarda o endereço de numero
*p = 30;            // escreve 30 no endereço: numero agora vale 30
printf("%d %d\n", numero, *p);   // 30 30
Um retrato possível da memória depois de executar o código acima (endereços ilustrativos)
EndereçoNomeValor guardado
0x1000numero30
0x2000p0x1000
Ideia-chave

Leia * de duas formas, conforme o contexto. Na declaração, int *p diz que p é um ponteiro para int. Em uma expressão, *p significa o valor apontado. & e * são inversos: *&x é o próprio x.

Passagem por valor, mesmo com ponteiros

C sempre passa argumentos por valor. No exemplo do placar, que aparece completo logo abaixo, em “Na prática”, quando você passa &placar, a função recebe uma cópia do endereço. Como as duas cópias apontam para o mesmo lugar, escrever em *pontos altera o placar original. É isso que costumamos chamar de “passagem por referência” em C.

Na prática

Programa completo: compile e execute
#include <stdio.h>

void adicionar(int *pontos, int bonus) {
    *pontos += bonus;
}

int main(void) {
    int placar = 20;
    adicionar(&placar, 10);
    printf("%d\n", placar);
    return 0;
}

Saída: 30

Devolver mais de um resultado

Uma função em C retorna um único valor. Com ponteiros de saída, ela pode preencher quantos resultados forem necessários. scanf("%d", &x) usa esse mesmo padrão.

No exemplo abaixo, o vetor é recebido como const int v[]. O const avisa que a função só lê o vetor e não o altera. Ele é explicado com mais detalhes no fim da aula.

Funções e exemplo de uso: as linhas de uso vão dentro do main
// Pré-condições: n >= 1; menor e maior apontam para variáveis válidas,
// diferentes entre si e fora do vetor v.
void min_max(const int v[], int n, int *menor, int *maior) {
    *menor = *maior = v[0];
    for (int i = 1; i < n; i++) {
        if (v[i] < *menor) *menor = v[i];
        if (v[i] > *maior) *maior = v[i];
    }
}

int w[] = {4, -2, 9, 0}, mn, mx;
min_max(w, 4, &mn, &mx);    // mn = -2, mx = 9

Ponteiro para struct

Trecho: coloque dentro do main de um programa
typedef struct { char nome[50]; float nota; } Aluno;
Aluno aluno = {"Ana", 8.5f};
Aluno *p = &aluno;
p->nota = 9.0f;      // equivale a (*p).nota = 9.0f

Use . com a variável e -> com o ponteiro. Os parênteses em (*p).nota são necessários, porque . tem precedência maior que *.

Ponteiro para ponteiro Aprofundamento

Um ponteiro também é uma variável, e por isso também tem endereço. Um ponteiro para ponteiro guarda esse endereço. Este programa é independente dos exemplos anteriores:

Programa completo: compile e execute
#include <stdio.h>

int main(void) {
    int x = 7, y = 9;
    int *p = &x;          // p aponta para x
    int **pp = &p;        // pp aponta para p

    **pp = 8;             // dois saltos: altera x
    *pp = &y;             // um salto: altera o próprio p, que passa a apontar para y

    printf("%d %d %d\n", x, *p, **pp);
    return 0;
}

Saída: 8 9 9

*pp é o próprio p; **pp é o inteiro para onde p aponta. Use isso quando uma função precisa alterar o ponteiro do chamador, por exemplo para fazê-lo apontar para outro lugar.

Ponteiros e vetores

Os elementos de um vetor ficam lado a lado na memória, sem espaços entre eles. Em quase todas as expressões, o nome do vetor é convertido em um ponteiro para o primeiro elemento. Por isso, int *p = v; é o mesmo que int *p = &v[0];, e uma função que recebe int v[] na verdade recebe int *v.

Compile e execute este programa:

Programa completo: compile e execute
#include <stdio.h>

int main(void) {
    int v[] = {10, 20, 30, 40, 50};
    int *p = v;                          // o mesmo que &v[0]

    printf("*p       = %d\n", *p);       // primeiro elemento
    printf("*(p + 2) = %d\n", *(p + 2)); // avança 2 elementos
    printf("p[2]     = %d\n", p[2]);     // mesma coisa, outra notação
    printf("&v[3] - &v[0] = %td elementos\n", &v[3] - &v[0]);
    printf("p + 1 fica %td bytes depois de p\n",
           (char *)(p + 1) - (char *)p);
    return 0;
}
*p = 10 *(p + 2) = 30 p[2] = 30 &v[3] - &v[0] = 3 elementos p + 1 fica 4 bytes depois de p

A última linha usa um truque, converter para (char *), só para medir a distância em bytes. É uma curiosidade, e não algo que você precise usar no dia a dia. A figura mostra o que acontece na memória. Os endereços são ilustrativos: os reais mudam a cada execução, mas a distância de 4 bytes entre vizinhos é sempre a mesma em plataformas com int de 4 bytes.

Aritmética de ponteiros sobre um vetor de inteiros O vetor v com os valores 10, 20, 30, 40 e 50 ocupa cinco posições consecutivas, nos endereços 0x1000, 0x1004, 0x1008, 0x100C e 0x1010. O ponteiro p aponta para v[0]. p + 1 aponta para v[1], 4 bytes adiante. p + 2 aponta para v[2], cujo valor é 30. v + 5 aponta para logo depois do último elemento: esse endereço pode ser calculado, mas não pode ser lido. A diferença entre os endereços de v[3] e v[0] é 3 elementos, ou 12 bytes. p = v p + 1 p + 2 *(p + 2) == 30 v + 5 10 20 30 40 50 fim v[0]v[1]v[2]v[3]v[4]não ler 0x10000x10040x10080x100C0x10100x1014 +1 elemento = +4 bytes &v[3] − &v[0] = 3 elementos (12 bytes) pode calcular, não pode desreferenciar
Somar 1 a um ponteiro avança um elemento, e não um byte. O compilador multiplica pelo tamanho do tipo, sizeof(int). Por isso, *(p + i) e p[i] chegam sempre ao mesmo lugar. O endereço logo após o fim (v + n) serve como marca de parada em laços, mas ler o conteúdo dele é comportamento indefinido.

Percorrendo um vetor com ponteiro

Em vez de um índice i que vai de 0 a n − 1, o laço pode usar um ponteiro que anda de v até v + n. A cada p++, ele avança para o próximo elemento:

Funções: copie para um programa que tenha main
int soma(const int *v, int n) {
    int total = 0;
    for (const int *p = v; p < v + n; p++) {  // para ao chegar em "fim"
        total += *p;                           // lê o elemento apontado
    }
    return total;
}

Exemplo real: leituras de um sensor

Uma estação meteorológica registra a temperatura de hora em hora. O programa precisa da média e do pico do dia, além de saber em qual leitura o pico aconteceu. A função maior devolve o endereço da maior leitura, e não o valor. Com o endereço, temos as duas coisas: *pico dá o valor, e pico - temp dá a posição.

Programa completo: compile e execute
#include <stdio.h>

// Soma as n leituras percorrendo o vetor com um ponteiro.
int soma(const int *v, int n) {
    int total = 0;
    for (const int *p = v; p < v + n; p++) {
        total += *p;
    }
    return total;
}

// Devolve o ENDEREÇO da maior leitura. Pré-condição: n >= 1.
const int *maior(const int *v, int n) {
    const int *pico = v;
    for (const int *p = v + 1; p < v + n; p++) {
        if (*p > *pico) {
            pico = p;
        }
    }
    return pico;
}

int main(void) {
    int temp[] = {21, 23, 27, 25, 22};          // leituras de hora em hora
    int n = sizeof temp / sizeof temp[0];       // 5: aqui sizeof enxerga o vetor

    const int *pico = maior(temp, n);
    printf("Leituras: %d\n", n);
    printf("Media: %.1f graus\n", soma(temp, n) / (float)n);
    printf("Pico: %d graus, na leitura %td\n", *pico, pico - temp);
    return 0;
}
Leituras: 5 Media: 23.6 graus Pico: 27 graus, na leitura 2
Cálculo de n
A expressão sizeof temp / sizeof temp[0] divide o tamanho do vetor inteiro pelo tamanho de um elemento. Em main, temp ainda é um vetor. Em uma plataforma com int de 4 bytes, sizeof temp vale 20 e sizeof temp[0] vale 4. Em qualquer plataforma, a divisão dá n = 5. Dentro de soma, isso não funcionaria, como mostra a primeira armadilha abaixo.
pico - temp
A diferença entre dois ponteiros do mesmo vetor conta elementos. O pico está em temp + 2, então a diferença é 2. O especificador %td imprime esse tipo de valor (ptrdiff_t).
(float)n
Sem a conversão, 118 / 5 seria uma divisão inteira e daria 23, em vez de 23.6.
Armadilhas clássicas
  • sizeof em parâmetro: dentro de void f(int v[]), sizeof v é o tamanho de um ponteiro (8 bytes em x86-64), e não o do vetor. Por isso, as funções sempre recebem n.
  • Ponteiro solto (dangling): nunca retorne &local. A variável local deixa de existir quando a função termina.
  • Esquecer o &: scanf("%d", x) passa o valor de x como se fosse um endereço.
  • Sair dos limites: você pode calcular v + n, mas não pode desreferenciá-lo.
Use const para documentar intenção

const int *p permite ler *p, mas não alterá-lo. Em uma assinatura como int soma(const int *v, int n), isso avisa a quem chama que o vetor não será modificado.

Exercícios progressivos

Cada exercício parte de uma situação de sistemas reais. Antes de abrir a dica, desenhe as variáveis como caixas e os ponteiros como setas, como na figura de ponteiros e vetores. A dica não entrega a resposta: ela indica a pergunta certa a fazer.

1. O contador de passos Básico

Um aplicativo de saúde guarda os passos do dia em passos. O módulo do relógio recebe um ponteiro para essa variável e soma os passos que detectou. Logo depois, o celular soma os seus:

Trecho: coloque dentro do main de um programa
int passos = 4000;
int *contador = &passos;           // o relógio recebe o endereço

*contador = *contador + 500;       // relógio detectou 500 passos
passos = passos + 100;             // celular detectou 100 passos

printf("%d %d\n", passos, *contador);

O que o programa imprime? E se o relógio tivesse recebido uma cópia, com int copia = passos; e copia = copia + 500;, os 500 passos apareceriam no total?

Ver uma dica
Conte quantas variáveis int existem de verdade nesse código. contador é um segundo número de passos ou uma seta para o primeiro? Depois de responder, siga cada linha atualizando uma única caixa.
Conferir a resposta

Saída: 4600 4600.

Existe um único inteiro, passos. contador guarda o endereço dele, então *contador e passos são dois nomes para a mesma caixa: 4000 + 500 + 100 = 4600, lido pelos dois caminhos.

Linhapassos*contador
início40004000
*contador = *contador + 50045004500
passos = passos + 10046004600

Com uma cópia, os 500 passos seriam somados em outra caixa. O total continuaria sem eles: o relógio contaria passos que o aplicativo nunca veria. É exatamente o bug do placar no início da aula.

2. Esvaziar o carrinho Básico

Em uma loja virtual, o botão “Esvaziar carrinho” precisa zerar a quantidade de itens e o valor total, que são variáveis da função main. Um estagiário escreveu a função abaixo, mas o carrinho continua cheio depois da chamada:

Funções e exemplo de uso: as linhas de uso vão dentro do main
void esvaziar(int itens, float total) {
    itens = 0;
    total = 0.0f;
}

int itens = 3;
float total = 89.90f;
esvaziar(itens, total);    // itens continua 3, total continua 89.90

Explique o motivo e corrija a função e a chamada. Depois da correção, printf("Itens: %d, total: %.2f\n", itens, total); deve imprimir Itens: 0, total: 0.00.

Ver uma dica
O que exatamente a função recebe quando você escreve esvaziar(itens, total): as variáveis de main ou os valores que estavam nelas? Para alterar duas variáveis do chamador, de que a função precisa receber? E o que muda na chamada?
Conferir a resposta

A versão do estagiário recebe cópias de 3 e 89.90 e zera essas cópias, que somem quando a função termina. As variáveis de main nunca são tocadas.

Funções e exemplo de uso: as linhas de uso vão dentro do main
void esvaziar(int *itens, float *total) {
    *itens = 0;          // escreve no endereço recebido
    *total = 0.0f;
}

int itens = 3;
float total = 89.90f;
esvaziar(&itens, &total);   // passa os ENDEREÇOS
printf("Itens: %d, total: %.2f\n", itens, total);

São duas mudanças: na função, os parâmetros viram ponteiros (int *, float *) e as atribuições passam a usar *. Na chamada, passamos &itens e &total.

Erro comum: mudar só a assinatura e esquecer o & na chamada. O compilador acusa o problema: para o int, costuma ser um aviso de que se passou um valor onde se esperava um endereço; para o float, o MSVC dá erro de compilação. Nunca ignore um aviso: ele quase sempre aponta um bug real.

3. Caixa eletrônico Intermediário

Um caixa eletrônico precisa de uma função de saque que informe duas coisas: se o saque foi autorizado e qual é o novo saldo. Escreva int sacar(float *saldo, float valor) com estas regras:

  • Se valor for menor ou igual a zero, ou maior que o saldo, recusa: retorna 0 e não altera o saldo.
  • Caso contrário, debita o valor e retorna 1.

Partindo de saldo 100, um saque de 30 e depois um saque de 100 devem produzir:

1 70.00 0 70.00
Ver uma dica
Uma função em C só retorna um valor. Qual das duas informações vai no return, e qual vai por ponteiro? Pense também na ordem: em que momento é seguro alterar *saldo? Antes ou depois de verificar as regras?
Conferir a resposta
Funções e exemplo de uso: as linhas de uso vão dentro do main
int sacar(float *saldo, float valor) {
    if (valor <= 0.0f || valor > *saldo) {
        return 0;               // recusado: saldo intacto
    }
    *saldo -= valor;            // só altera depois de validar
    return 1;
}

float saldo = 100.0f;
int ok = sacar(&saldo, 30.0f);
printf("%d %.2f\n", ok, saldo);     // 1 70.00
ok = sacar(&saldo, 100.0f);
printf("%d %.2f\n", ok, saldo);     // 0 70.00

Esse é um padrão muito comum em C: o retorno indica sucesso ou erro, e os ponteiros carregam os resultados. A própria scanf funciona assim: retorna quantos valores conseguiu ler e grava os valores nos endereços recebidos.

Por que validar antes de alterar? Se a função debitasse primeiro e verificasse depois, um erro no meio deixaria o saldo em um estado inválido. Em sistemas financeiros, uma operação deve acontecer por completo ou não acontecer.

Para ir além: bancos reais não usam float para dinheiro, pela imprecisão comentada na aula de Struct. Eles guardam centavos em inteiros.

4. Fila de impressão Intermediário

A impressora do laboratório guarda os números dos trabalhos em uma fila: int fila[] = {101, 102, 103, 104};. O trabalho 103 é a prova que começa em 5 minutos, e o técnico quer trocá-lo de lugar com o primeiro da fila.

Escreva void trocar(int *a, int *b) e use-a para trocar fila[0] com fila[2]. A fila deve ficar 103 102 101 104. Depois, explique por que a versão void trocar(int a, int b) não funcionaria.

Ver uma dica
Faça a troca à mão com dois copos cheios de sucos diferentes. Você consegue trocar o conteúdo sem um terceiro copo? Na chamada, como passar o endereço de um elemento do vetor, e não do vetor inteiro?
Conferir a resposta
Funções e exemplo de uso: as linhas de uso vão dentro do main
void trocar(int *a, int *b) {
    int temp = *a;     // o "terceiro copo"
    *a = *b;
    *b = temp;
}

int fila[] = {101, 102, 103, 104};
trocar(&fila[0], &fila[2]);      // endereços dos dois elementos
for (int i = 0; i < 4; i++) printf("%d ", fila[i]);   // 103 102 101 104

Sem temp, a linha *a = *b apagaria o 101 antes de ele ser copiado para o outro lado. Com int a, int b, a função trocaria apenas suas cópias locais, e a fila não mudaria.

&fila[2] também pode ser escrito como fila + 2. Trocar um elemento com ele mesmo (trocar(&fila[1], &fila[1])) funciona corretamente nesta versão. O desafio hacker mostra uma versão “esperta” que falha exatamente nesse caso.

Guarde esta função: bubble sort e selection sort, nas próximas aulas, são basicamente uma sequência de chamadas a trocar.

5. Monitor cardíaco Intermediário

Um monitor cardíaco guarda as últimas leituras de batimentos por minuto (BPM) em um vetor. Para comparar a leitura atual com as vizinhas, o software mantém um ponteiro atual:

Trecho: coloque dentro do main de um programa
int bpm[] = {72, 75, 90, 110, 95};
int *atual = bpm + 2;

Sem executar, responda:

  1. Quanto valem *atual, atual[-1] e *(atual + 1)? O que cada um representa para o médico?
  2. Quanto vale (bpm + 4) - atual? O que esse número significa?
  3. O programa pode ler atual[3]? Por quê?
Ver uma dica
Desenhe o vetor como na figura da seção de vetores e marque a casa para onde atual aponta. Lembre que atual[k] é o mesmo que *(atual + k), e que k pode ser negativo. Para o item 3, conte quantas casas existem depois de atual.
Conferir a resposta
  1. *atual = 90, a leitura atual. atual[-1] = 75, a leitura anterior. *(atual + 1) = 110, a próxima. O monitor percebe que os batimentos subiram de 75 para 90 e depois para 110.
  2. (bpm + 4) - atual = 2. A diferença entre ponteiros conta elementos: faltam 2 leituras até a última.
  3. Não. atual + 3 é bpm + 5, a posição logo depois do fim do vetor. Esse endereço pode ser calculado e comparado, mas ler o conteúdo dele é comportamento indefinido. O programa pode imprimir lixo, travar ou funcionar por acaso.

atual[-1] só é válido porque atual não aponta para o início do vetor. Em código real, verifique os limites antes de olhar vizinhos: if (atual > bpm) antes de ler a anterior e if (atual + 1 < bpm + 5) antes de ler a próxima.

6. Quem pode devolver um endereço? Avançado

No sistema de uma loja, duas funções devolvem ponteiros. A primeira encontra o produto mais vendido e devolve o endereço dele, para que o chamador possa atualizar o estoque diretamente. A segunda foi escrita às pressas:

Funções: copie para um programa que tenha main
// Devolve o endereço do maior valor do vetor. Pré-condição: n >= 1.
int *maior(int *v, int n) {
    int *m = v;
    for (int *p = v + 1; p < v + n; p++) {
        if (*p > *m) m = p;
    }
    return m;
}

// Calcula o dobro de x.
int *dobro_errado(int x) {
    int resultado = 2 * x;
    return &resultado;
}

Uma delas é segura, e a outra é um bug que pode passar nos testes e falhar em produção. Qual é qual? Explique a diferença usando a ideia de tempo de vida de uma variável e corrija a função errada.

Ver uma dica
Para cada return, pergunte: o objeto para onde esse endereço aponta ainda existe depois que a função termina? Onde o vetor v foi criado? E onde resultado foi criado?
Conferir a resposta

maior é segura. O endereço devolvido aponta para dentro do vetor do chamador, que continua existindo depois do retorno. O chamador pode até alterar o elemento: em {4, 9, 2}, *maior(v, 3) = 0; deixa o vetor como {4, 0, 2}. O endereço só continua válido enquanto o vetor do chamador existir.

dobro_errado é um ponteiro solto. resultado é uma variável local: ela nasce quando a função começa e deixa de existir quando ela retorna. O endereço devolvido aponta para uma área da pilha que será reaproveitada pela próxima chamada de função. Ler esse endereço é comportamento indefinido. Se o chamador fizer int *r = dobro_errado(21); printf("%d\n", *r);, às vezes ainda aparece 42, o que torna o bug traiçoeiro: ele passa nos testes simples.

Funções: copie para um programa que tenha main
int dobro(int x) {
    return 2 * x;      // devolve o VALOR, que é copiado para o chamador
}

Outras correções possíveis: receber um ponteiro de saída do chamador (void dobro(int x, int *saida)), ou alocar memória com malloc. Neste último caso, o chamador fica responsável pelo free.

Regra prática: só devolva endereços de objetos que vivem mais que a função: elementos de vetores do chamador, memória alocada com malloc ou variáveis globais. Os compiladores costumam avisar sobre esse erro. O MSVC emite o aviso C4172: returning address of local variable or temporary, e o GCC avisa com a opção -Wreturn-local-addr, que já vem ligada. Compile sempre com os avisos ligados.

./desafio --nivel=hacker

Desafio hacker: o swap mágico

  • Tipo: opcional
  • Dificuldade: ★★★
  • Tema: aliasing e ponteiros
  • Pré-requisito: XOR bit a bit

Um colega diz que é ninja: troca duas variáveis sem variável temporária, usando XOR (^).

Antes de começar: o que é XOR? O operador ^ compara dois números bit a bit e dá 1 onde os bits são diferentes e 0 onde são iguais. Por exemplo, 3 = 011 e 7 = 111 em binário, então 3 ^ 7 = 100 = 4. Duas propriedades vão ser úteis: x ^ x dá 0, e x ^ 0 dá o próprio x.

Funções: copie para um programa que tenha main
void xor_swap(int *a, int *b) {
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

Os testes com x = 3, y = 7 passam. Depois, ele usa a função em um algoritmo de ordenação que às vezes chama xor_swap(&v[i], &v[j]) com i == j. Os dados começam a sumir.

  1. Prove que a função troca os valores quando a e b apontam para lugares diferentes.
  2. Explique o que acontece quando apontam para o mesmo lugar. Qual será o valor final?
  3. Corrija a função.
  4. Parte 2: escreva void inverter(int *ini, int *fim), que inverte o trecho de *ini até *fim (inclusive) usando só ponteiros, sem índices.
Entrada (parte 2){10, 20, 30, 40, 50} inverter(v, v + 4)
Saída{50, 40, 30, 20, 10}
Dica 1: propriedades do XOR

Para qualquer inteiro: x ^ x == 0, x ^ 0 == x, e XOR é comutativo e associativo.

Dica 2: aliasing

Quando dois ponteiros apontam para o mesmo objeto, dizemos que há aliasing. Nesse caso, escrever em *a também muda *b. Simule a primeira linha com a == b.

Solução comentada

1. Endereços diferentes. Sejam os valores iniciais A e B.

Linha*a*b
*a ^= *bA^BB
*b ^= *aA^BB^(A^B) = A
*a ^= *b(A^B)^A = BA

2. Mesmo endereço. A primeira linha faz *a = A ^ A = 0. Como *b é o mesmo objeto, também vale 0. As linhas seguintes fazem 0 ^ 0 = 0. O valor é apagado. Testando, xor_swap(&v[1], &v[1]) transforma 20 em 0.

3. Correção. Há duas saídas. A melhor é usar a variável temporária, ou seja, a função trocar do exercício 4. Se quiser manter o XOR, detecte o aliasing:

Funções: copie para um programa que tenha main
void xor_swap_seguro(int *a, int *b) {
    if (a == b) return;     // compara ENDEREÇOS, não valores
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

Note que o teste é a == b, e não *a == *b. Com valores iguais em endereços diferentes, o XOR funciona. Na prática, compiladores modernos geram código melhor para a versão com temporária. O “truque” não traz ganho e introduz um bug.

4. Inverter com dois ponteiros. Um ponteiro vem da esquerda, o outro vem da direita, e eles se encontram no meio.

Funções: copie para um programa que tenha main
// Inverte [ini, fim], em que fim aponta para o ÚLTIMO elemento.
void inverter(int *ini, int *fim) {
    while (ini < fim) {
        int temp = *ini;
        *ini = *fim;
        *fim = temp;
        ini++;
        fim--;
    }
}
// Para inverter o vetor inteiro: if (n > 0) inverter(v, v + n - 1);
// Com n == 0, v + n - 1 apontaria para antes do início do vetor.

O laço para quando os ponteiros se cruzam (ini >= fim). O elemento do meio, em tamanho ímpar, não precisa trocar de lugar. Por causa de ini < fim, a função nunca troca um elemento com ele mesmo. Tempo O(n), memória O(1). Essa técnica de dois ponteiros também resolve problemas como palíndromos e pares com soma alvo em vetores ordenados.

Revisão rápida

  • &x obtém o endereço. *p acessa o valor apontado.
  • C passa tudo por valor. Para alterar o chamador, passe o endereço.
  • v[i] é *(v + i), e a aritmética conta elementos, não bytes.
  • Nunca desreferencie ponteiro não inicializado, NULL ou solto.
  • Dois ponteiros podem apontar para o mesmo objeto (aliasing). Teste esse caso.