C++ Ponteiros e Arrays

Arrays e ponteiros são parentes muito próximos, e entender esse parentesco explica várias coisas estranhas do C++: por que notas[2] funciona, por que um array "esquece" o tamanho quando entra numa função e por que o C++ moderno criou o std::span. A base de tudo você já viu em Como a Memória Funciona: os itens de um array moram colados, um depois do outro, como casas vizinhas numa rua.

Você vai aprender
  • Por que o nome de um array, sozinho, vira o endereço do primeiro item.
  • A aritmética de ponteiros: p + 1 anda um item, não um byte.
  • Que notas[i] é só um jeito curto de escrever *(notas + i).
  • Como percorrer um array com um ponteiro.
  • Por que, dentro de uma função, sizeof de um array dá 8 (e o aviso -Wsizeof-array-argument).
  • O std::span C++20, que resolve esse problema.

Antes, leia Ponteiros e Arrays. Os programas foram compilados na máquina fictícia da Ana (usuária ana, computador notebook, Linux de 64 bits), na pasta ~/cpp, com g++ -std=c++23 -Wall -Wextra arquivo.cpp -o programa. Os endereços mudam a cada execução; o que importa é a diferença entre eles.


A ideia: casas numa rua, sem buracos

Imagine um conjunto de quatro casas iguais, lado a lado, numa rua. Para chegar a qualquer uma delas, basta saber onde fica a primeira e quantas casas andar: "a primeira é a 52; a terceira fica duas casas depois". Um array é isso. E um ponteiro que aponta para a primeira casa pode andar pela rua, de casa em casa.


1. O nome do array vira ponteiro

Quando você usa o nome de um array numa expressão, ele quase sempre se transforma, sozinho, no endereço do primeiro item. Por isso dá para criar um ponteiro sem escrever &:

#include <iostream>

int main() {
    int notas[4] = {7, 8, 6, 9};
    int* p = notas;              // sem &: o nome do array vira o endereço do 1º item

    std::cout << "notas     = " << notas << "\n";
    std::cout << "&notas[0] = " << &notas[0] << "\n";
    std::cout << "p         = " << p << "\n";
    std::cout << "*p        = " << *p << "\n";
}
$ g++ -std=c++23 -Wall -Wextra nome.cpp -o nome
$ ./nome
notas     = 0x7ffed706ca00     « o array "virou" um endereço
&notas[0] = 0x7ffed706ca00     « que é o do primeiro item
p         = 0x7ffed706ca00
*p        = 7                  « seguindo o ponteiro: a primeira nota

Essa transformação se chama decaimento (em inglês, array-to-pointer decay): o array "decai" para um ponteiro. Ela é herança da linguagem C. Duas exceções importantes: sizeof(notas) e &notas enxergam o array inteiro, sem decair. Guarde a palavra "decair": ela explica a armadilha da seção 4.


2. Aritmética de ponteiros: p + 1 anda um item

Você pode somar um número inteiro a um ponteiro. Mas p + 1 não é "o endereço seguinte, um byte depois". É "o próximo item", e o C++ calcula quantos bytes pular a partir do tipo do ponteiro:

#include <iostream>

int main() {
    int notas[4] = {7, 8, 6, 9};
    int* p = notas;

    std::cout << "p     = " << p     << "  *p     = " << *p     << "\n";
    std::cout << "p + 1 = " << p + 1 << "  *(p+1) = " << *(p + 1) << "\n";
    std::cout << "p + 2 = " << p + 2 << "  *(p+2) = " << *(p + 2) << "\n";
    std::cout << "p + 3 = " << p + 3 << "  *(p+3) = " << *(p + 3) << "\n";
}
$ g++ -std=c++23 -Wall -Wextra aritmetica.cpp -o aritmetica
$ ./aritmetica
p     = 0x7ffc2d276340  *p     = 7
p + 1 = 0x7ffc2d276344  *(p+1) = 8      « +4 bytes = sizeof(int)
p + 2 = 0x7ffc2d276348  *(p+2) = 6      « +8 bytes
p + 3 = 0x7ffc2d27634c  *(p+3) = 9      « +12 bytes (c = 12 em hexadecimal)
Expressãopp + 1p + 2p + 3
Endereço…340…344…348…34c
Bytes ocupados340 a 343344 a 347348 a 34b34c a 34f
Valor (*)7869
Mesmo quenotas[0]notas[1]notas[2]notas[3]
Cada int ocupa 4 casas. Somar 1 ao ponteiro pula as 4 de uma vez, caindo bem no começo do próximo item.

Os parênteses em *(p + 1) são obrigatórios: *p + 1 seria "o valor de *p, mais 1", ou seja, 7 + 1 = 8. Aqui deu o mesmo número por coincidência; com *(p + 2) e *p + 2 a diferença aparece (6 contra 9).

Com double, o passo é 8

Para provar que o passo depende do tipo, o mesmo teste com um array de double (8 bytes cada):

#include <iostream>

int main() {
    double precos[3] = {4.5, 3.0, 12.9};
    double* p = precos;

    std::cout << "p     = " << p     << "\n";
    std::cout << "p + 1 = " << p + 1 << "\n";
    std::cout << "p + 2 = " << p + 2 << "\n";
}
$ g++ -std=c++23 -Wall -Wextra double.cpp -o double
$ ./double
p     = 0x7ffc9951c0f0
p + 1 = 0x7ffc9951c0f8     « +8 = sizeof(double)
p + 2 = 0x7ffc9951c100     « +16 (f0 + 10 em hexadecimal = 100)

É por isso que o ponteiro precisa ter o tipo certo (int*, double*): o tipo diz o tamanho de cada passo.

notas[i] é *(notas + i)

Agora o colchete deixa de ser mágico. Escrever notas[2] é exatamente o mesmo que "pegue o endereço do primeiro item, ande 2 itens e leia o que estiver lá". E o colchete funciona em ponteiros também:

#include <iostream>

int main() {
    int notas[4] = {7, 8, 6, 9};
    int* p = notas;

    std::cout << notas[2] << " " << *(notas + 2) << " " << p[2] << " " << *(p + 2) << "\n";
}
$ g++ -std=c++23 -Wall -Wextra colchete.cpp -o colchete
$ ./colchete
6 6 6 6                  « quatro jeitos de escrever a mesma coisa

Isso também explica por que o primeiro item é o [0]: ele está a zero passos do começo.


3. Percorrer um array com um ponteiro

Se p + 1 é o próximo item, então ++p (somar 1 ao próprio p) faz o ponteiro andar para o próximo. Dá para percorrer o array inteiro assim:

#include <iostream>

int main() {
    int notas[4] = {7, 8, 6, 9};
    int* fim = notas + 4;        // uma casa DEPOIS do último item

    for (int* p = notas; p != fim; ++p) {
        std::cout << *p << " ";
    }
    std::cout << "\n";

    std::cout << "Itens entre o começo e o fim: " << fim - notas << "\n";
}
$ g++ -std=c++23 -Wall -Wextra percorrer.cpp -o percorrer
$ ./percorrer
7 8 6 9
Itens entre o começo e o fim: 4
int* fim = notas + 4;
Um ponteiro para a casa logo depois da última nota. O C++ permite calcular esse endereço "um depois do fim", mas não permite ler o que está lá (*fim seria um erro grave).
int* p = notas;
O ponteiro começa no primeiro item.
p != fim
Continua enquanto p não chegou ao fim.
++p
Anda um item (4 bytes).
*p
Lê o item atual.
fim - notas
Subtrair dois ponteiros do mesmo array dá quantos itens há entre eles (não quantos bytes).

Esse par "começo e um depois do fim" é a base dos iteradores da biblioteca padrão (v.begin() e v.end()). No dia a dia, para só percorrer, o for de intervalo (for (int n : notas)) é mais simples e não tem como errar o fim.

Missão: o laço já anda pelas notas com o ponteiro p. Complete o corpo para somar em soma o valor apontado por p. A saída deve ser Soma: 30.

#include <iostream>

int main() {
    int notas[4] = {7, 8, 6, 9};
    int soma = 0;

    for (int* p = notas; p != notas + 4; ++p) {
        // some o valor apontado por p em soma
    }

    std::cout << "Soma: " << soma << "\n";
}
#include <iostream>

int main() {
    int notas[4] = {7, 8, 6, 9};
    int soma = 0;

    for (int* p = notas; p != notas + 4; ++p) {
        soma = soma + *p;
    }

    std::cout << "Soma: " << soma << "\n";
}
Ponteiro fora do array

Nada impede você de escrever *(p + 10) num array de 4 itens. O C++ não confere: ele calcula o endereço e lê o que estiver lá, que pode ser outra variável, lixo ou memória proibida. Isso é comportamento indefinido, e os sanitizers são a ferramenta para pegar esse erro. Com std::vector e std::array, o .at(i) confere e avisa. No editor do guia, ler fora do array passa calado (mostra um valor qualquer e o programa segue): é exatamente o perigo do comportamento indefinido.


4. A armadilha: o array perde o tamanho na função

Lembra do decaimento? Ao passar um array para uma função, o que viaja é só o endereço do primeiro item. A função recebe um ponteiro e não tem como saber quantos itens existem. Mesmo que você escreva o parâmetro como int notas[], ele é um int* disfarçado:

#include <iostream>

void mostrar(int notas[]) {
    std::cout << "Dentro da função: " << sizeof(notas) << " bytes\n";
}

int main() {
    int notas[4] = {7, 8, 6, 9};
    std::cout << "No main:          " << sizeof(notas) << " bytes\n";
    mostrar(notas);
}
$ g++ -std=c++23 -Wall -Wextra funcao.cpp -o funcao
funcao.cpp: In function ‘void mostrar(int*)’:
funcao.cpp:4:49: warning: ‘sizeof’ on array function parameter ‘notas’ will return size of ‘int*’ [-Wsizeof-array-argument]
    4 |     std::cout << "Dentro da função: " << sizeof(notas) << " bytes\n";
      |                                                ~^~~~~~
funcao.cpp:3:18: note: declared here
    3 | void mostrar(int notas[]) {
      |              ~~~~^~~~~~~
$ ./funcao
No main:          16 bytes     « 4 itens × 4 bytes: o array inteiro
Dentro da função: 8 bytes      « o tamanho de um ponteiro!
In function ‘void mostrar(int*)’
Olhe o cabeçalho: o próprio g++ escreve a função como mostrar(int*). O int notas[] que você digitou virou int*.
warning: ‘sizeof’ on array function parameter ‘notas’ will return size of ‘int*’
"sizeof num parâmetro array de função vai devolver o tamanho de int*". O compilador sabe que você provavelmente queria outra coisa.
[-Wsizeof-array-argument]
O nome do aviso. Vem ligado por padrão.

O truque sizeof(notas) / sizeof(notas[0]) da aula Como a Memória Funciona daria, dentro da função, 8 ÷ 4 = 2 itens, um número errado e sem erro nenhum na execução. É por isso que esse truque só serve onde o array foi declarado.

O jeito antigo: mandar o tamanho junto

No estilo C, a função recebe dois parâmetros: o ponteiro e a quantidade.

#include <iostream>
#include <cstddef>

void mostrar(const int* notas, std::size_t quantidade) {
    for (std::size_t i = 0; i < quantidade; ++i) {
        std::cout << notas[i] << " ";
    }
    std::cout << "\n";
}

int main() {
    int notas[4] = {7, 8, 6, 9};
    mostrar(notas, 4);
}
$ g++ -std=c++23 -Wall -Wextra funcao-tam.cpp -o funcao-tam
$ ./funcao-tam
7 8 6 9 

Funciona, mas nada impede alguém de chamar mostrar(notas, 40). O const int* diz que a função só vai ler as notas (veja const com Ponteiros e Referências), e std::size_t é o tipo usado para tamanhos e quantidades.

O jeito moderno: std::span C++20

Um std::span ("vão", "trecho") é um ponteiro que carrega o tamanho junto. Ele não copia os itens: só olha para eles, como uma janela. E aceita, sem mudar nada na função, arrays, std::array e std::vector:

#include <iostream>
#include <span>
#include <vector>

void mostrar(std::span<const int> notas) {
    std::cout << notas.size() << " notas: ";
    for (int n : notas) {
        std::cout << n << " ";
    }
    std::cout << "\n";
}

int main() {
    int notas[4] = {7, 8, 6, 9};
    std::vector<int> outras = {10, 5};

    mostrar(notas);       // array: o span descobre o tamanho sozinho
    mostrar(outras);      // vector também serve
}
$ g++ -std=c++23 -Wall -Wextra span.cpp -o span
$ ./span
4 notas: 7 8 6 9
2 notas: 10 5 
#include <span>
O cabeçalho do std::span.
std::span<const int>
"Uma janela para vários int que eu só vou ler". Sem o const, a função poderia mudar os itens.
notas.size()
O tamanho viajou junto. Funciona também o for de intervalo.

O std::span é do C++20. Com -std=c++17, o g++ 15 responde error: ‘span’ is not a member of ‘std’ e explica: note: ‘std::span’ is only available from C++20 onwards. Antes dele, a alternativa era a dupla ponteiro + tamanho, ou receber const std::vector<int>& (que só aceita vetores). Esta aula só apresenta o std::span; ele tem mais recursos, como pegar só um pedaço (notas.first(2)).


Formas de passar uma lista para uma função: qual usar

ParâmetroSabe o tamanho?AceitaQuando usar
int notas[] ou int* notasNãoArrays e ponteirosSó em código C ou antigo.
const int* notas, std::size_t nSe quem chama acertar o nArrays, ponteirosBibliotecas em estilo C.
const std::vector<int>& notasSimSó std::vectorQuando o programa só usa vetores (o caso mais comum).
std::span<const int> notas C++20SimArrays, std::array, std::vectorFunção que só lê uma sequência, venha de onde vier.

Quando dá errado

Aviso "‘sizeof’ on array function parameter … will return size of ‘int*’"
$ g++ -std=c++23 -Wall -Wextra funcao.cpp -o funcao
funcao.cpp: In function ‘void mostrar(int*)’:
funcao.cpp:4:49: warning: ‘sizeof’ on array function parameter ‘notas’ will return size of ‘int*’ [-Wsizeof-array-argument]
    4 |     std::cout << "Dentro da função: " << sizeof(notas) << " bytes\n";
      |                                                ~^~~~~~
funcao.cpp:3:18: note: declared here
    3 | void mostrar(int notas[]) {
      |              ~~~~^~~~~~~

O que aconteceu: o array decaiu para ponteiro ao entrar na função, e sizeof mediu o ponteiro (8 bytes). No editor do guia (Clang), a mensagem é sizeof on array function parameter will return size of 'int *' instead of 'int[]'.

Como resolver: receba std::span<const int> e use .size(), ou passe a quantidade num segundo parâmetro, ou use std::vector.

"‘span’ is not a member of ‘std’"
$ g++ -std=c++17 -Wall -Wextra span.cpp -o span
span.cpp:5:6: error: variable or field ‘mostrar’ declared void
    5 | void mostrar(std::span<const int> notas) {
      |      ^~~~~~~
span.cpp:5:19: error: ‘span’ is not a member of ‘std’
    5 | void mostrar(std::span<const int> notas) {
      |                   ^~~~
span.cpp:5:19: note: ‘std::span’ is only available from C++20 onwards
…                                « mais erros, todos consequência deste

O que aconteceu: o código pediu C++17, e o std::span só existe a partir do C++20. A nota diz isso com todas as letras: "std::span só está disponível do C++20 em diante". A primeira mensagem ("declarada como void") e as seguintes são consequência: sem saber o que é span, o g++ se perdeu. Leia sempre a primeira mensagem que faz sentido (veja Lendo Erros do Compilador).

Como resolver: compile com -std=c++23 (o padrão do guia) e inclua <span>. No g++ 15, o programa até compila sem o #include <span>, porque outro cabeçalho o traz junto, mas não conte com isso: em outro compilador, pode faltar.

O laço mostrou números estranhos depois dos itens certos

O que aconteceu: o ponteiro passou do fim do array, por exemplo com p <= fim em vez de p != fim, ou com a quantidade errada. O C++ leu as casas vizinhas, sem avisar.

Como resolver: confira a condição de parada; prefira o for de intervalo, std::span ou std::vector, que sabem o tamanho. Para investigar, compile com -fsanitize=address (aula Sanitizers).


Referência rápida

CódigoO que faz
int* p = notas;O nome do array decai para o endereço do primeiro item (&notas[0]).
p + iO endereço do item i passos à frente (anda i × sizeof(tipo) bytes).
*(p + i) ou p[i]O valor do item i.
++p / --pAnda um item para a frente / para trás.
fim - comecoQuantos itens há entre dois ponteiros do mesmo array.
notas + 4 (num array de 4)"Um depois do fim": pode calcular e comparar, não pode ler.
void f(int v[])É o mesmo que void f(int* v): o tamanho se perde.
std::span<const int> vC++20 Ponteiro + tamanho; v.size(), for (int n : v).

Exercícios

1. O segundo item

Com int* p = notas;, complete para mostrar notas[1] usando aritmética de ponteiros:

std::cout << *(p + );

2. Quantos bytes?

Num array de double, p está em 0x1000. Em que endereço está p + 2? (Escreva em hexadecimal, com 0x.)

p + 2 =

3. Com int notas[4] = {7, 8, 6, 9};, quanto vale *notas + 2?

4. Dentro de void f(int v[]), chamada com um array de 10 int, quanto dá sizeof(v) no Linux de 64 bits?

5. O que o std::span traz que um int* não traz?

Missão final: a média de qualquer turma

Missão: a função media recebe as notas como std::span. Complete-a para somar as notas e dividir pela quantidade (notas.size()). Cuidado: divida como double, senão 15 ÷ 2 vira 7. A saída deve ser:

Média A: 7.5
Média B: 8
#include <iostream>
#include <span>

double media(std::span<const int> notas) {
    // some as notas e divida pela quantidade (notas.size())
    return 0;
}

int main() {
    int turma_a[4] = {7, 8, 6, 9};
    int turma_b[2] = {10, 6};

    std::cout << "Média A: " << media(turma_a) << "\n";
    std::cout << "Média B: " << media(turma_b) << "\n";
}
#include <iostream>
#include <span>

double media(std::span<const int> notas) {
    int soma = 0;
    for (int n : notas) {
        soma = soma + n;
    }
    return static_cast<double>(soma) / notas.size();
}

int main() {
    int turma_a[4] = {7, 8, 6, 9};
    int turma_b[2] = {10, 6};

    std::cout << "Média A: " << media(turma_a) << "\n";
    std::cout << "Média B: " << media(turma_b) << "\n";
}

Missão extra (provoque o aviso): dentro de contar, troque o return 0; por return sizeof(lista) / sizeof(lista[0]); e rode. Leia o aviso e veja que a função não responde 6: ela mediu um ponteiro, não o array. (No Linux de 64 bits da Ana, responde Itens: 2.)

#include <iostream>

int contar(int lista[]) {
    return 0;
}

int main() {
    int precos[6] = {4, 3, 12, 7, 1, 5};
    std::cout << "Itens: " << contar(precos) << "\n";
}
#include <iostream>

int contar(int lista[]) {
    return sizeof(lista) / sizeof(lista[0]);
}

int main() {
    int precos[6] = {4, 3, 12, 7, 1, 5};
    std::cout << "Itens: " << contar(precos) << "\n";
}

Resumo

  • O nome de um array, numa expressão, decai para o endereço do primeiro item: int* p = notas;.
  • p + 1 anda um item (sizeof(int) = 4 bytes para int, 8 para double).
  • notas[i] é o mesmo que *(notas + i); o colchete funciona em ponteiros.
  • Dá para percorrer com for (int* p = notas; p != notas + n; ++p), mas o for de intervalo é mais seguro.
  • Array passado para função vira ponteiro e perde o tamanho: sizeof dá 8 (-Wsizeof-array-argument).
  • Use std::span C++20 (ou std::vector) para passar sequências com o tamanho junto.
  • Próxima aula: nullptr, o ponteiro que não aponta para nada.