Resumo da Aula 4

Sumário

Ponteiros

Do ponto de vista de um programa em C++, a memória do computador é constituída por uma sequência de unidades básicas de memória ou bytes (que usualmente têm 8 bits e por isso são conhecidos por octetos).  Os octetos da memória são numerados a partir de zero, sendo o número associado a cada octeto conhecido como o seu endereço.

Todas as variáveis são guardadas na memória do computador* em octetos com endereços consecutivos e portanto têm um determinado endereço de memória.  Se uma variável ocupar mais do que um octeto (os int, por exemplo, se tiverem 32 bit, ocupam quatro octetos), então o seu endereço é o menor dos endereços dos octetos que a variável ocupa na memória.  Como é óbvio, duas variáveis que existam num determinado instante de tempo ocupam forçosamente zonas disjuntas de memória, pelo que têm endereços diferentes (há uma excepção: uma instância de uma classe e o seu primeiro atributo podem ter o mesmo endereço, embora nesse caso se distingam forçosamente pelo tipo, pois uma classe não pode ter atributos que sejam instâncias dela própria).

Um ponteiro é uma variável que contém o endereço de um objecto de um dado tipo, normalmente outra variável.  A declaração de um ponteiro não reserva memória para a variável endereçada.  Um ponteiro, quando não inicializado, contém um endereço arbitrário (lixo), geralmente inválido. Usar o conteúdo (a zona de memória) correspondente ao endereço contido num ponteiro não inicializado é um erro.

A definição/declaração de ponteiros faz-se tal como para as outras variáveis, mas seguindo o nome do tipo de *:

int* p;

A inicialização do valor do ponteiro p, fazendo com que aponte para a variável i definida anteriormente, faz-se à custa do operador endereço &:

int i;
int* p = &i;

Depois destas instruções p contém o endereço da variável i, dizendo-se que p aponta para i.

O acesso ao valor da variável apontada por p (ou seja, ao conteúdo do endereço), faz-se à custa do operador conteúdo (ou des-referenciação, com o significado "de conteúdo da variável apontada por") *:

cout << *p;

Os ponteiros permitem, assim, o acesso indirecto às variáveis apontadas.

Os ponteiros contêm endereços de variáveis de um dado tipo, indicado na sua definição, e não se lhes pode atribuir o endereço de objectos de outros tipos:

int i = 1;
double d = 2.0;
int* pi = &d;    // erro!
double* pd = &i; // erro!

Há duas excepções a esta regra.  A primeira é que se pode sempre atribuir o endereço de um objecto de uma classe derivada a um ponteiro para uma classe base, assunto que se verá em pormenor em aulas posteriores.  A segunda é que existe um tipo especial de ponteiros, void*, compatível com qualquer outro ponteiro.  Este tipo de ponteiros era extremamente importante na linguagem C, mas em C++ só é verdadeiramente útil em raras, mesmo muito raras ocasiões.

Não se deve confundir a definição/declaração de um ponteiro para uma variável com a utilização do operador conteúdo, da mesma forma não se deve confundir a declaração/definição de uma referência com a utilização do operador endereço.  Os símbolos * e & têm significados completamente diferentes consoante ocorram numa expressão ou numa declaração:

int i = 10;
int& j = i;  // definição de uma referência para int inicializada com i (j fica 
             // sinónimo de i).
int* p = &j; // definição de um ponteiro para int inicializado com o endereço de j
             // ou seja, inicializada com  o endereço de i.
cout << *p;  // acesso ao conteúdo do endereço em p para a escrita do valor de i.

* Excepto quando as variáveis podem existir apenas nos registos do processador, o que é uma optimização típica realizada pelos compiladores.

Matrizes e ponteiros

Os elementos numa matriz estão sempre em posições de memória consecutivas e crescentes.  Por exemplo, dada a matriz

int m[20];

o endereço do elemento 4 (o quinto elemento) é igual ao endereço do elemento 3 somado de uma unidade.  Ou seja, &m[4] = &m[3] + 1.  Claro está que, se os inteiros ocuparem quatro bytes, estes endereços na realidade diferem de quatro unidades básicas de memória, mas isso é transparente para o programador: o compilador encarrega-se de calcular o tamanho dos objectos do tipo apontado pelo ponteiro, neste caso int, de uma forma automática.  De qualquer forma, o operador sizeof pode ser usado para saber quantos bytes ocupa cada objecto de um dado tipo, muito embora a sua utilidade em C++ seja reduzida, ao contrário do que acontece na linguagem C.

Quando uma matriz ocorre numa expressão, é convertida num ponteiro constante para o respectivo primeiro elemento (excepto, por exemplo, quando a matriz ocorra como operando do operador sizeof).  Por exemplo, sendo

int m[20];

a ocorrência de m numa qualquer expressão é normalmente interpretada como significando o mesmo que &m[0].  Por outro lado, todas as operações de indexação do tipo m[i] são interpretadas como significando *(m + i), desde que m não seja seja uma instância de uma classe (e, como a adição é comutativa, também a indexação o é, logo m[i] é o mesmo que i[m]).

Por exemplo, dadas as seguintes definições:

int m[20];
int* p = m; // ou, o que é o mesmo, int* p = &m[0].

ambas as instruções

p[19] = 1;
m[19] = 1;

são válidas e têm o mesmo efeito.

O caso das matrizes de matrizes (ou matrizes multi-dimensionais) é idêntico, mas merece algumas observações.  Seja a seguinte definição:

double m[3][2];

Quando a matriz m é usada numa expressão é convertida num ponteiro para o seu primeiro elemento.  Mas a matriz m é uma matriz com três elementos, cada uma dos quais é uma matriz com dois double.  Assim, o nome m numa expressão é interpretado como um ponteiro para uma matriz com dois elementos double.  Isto é, é possível escrever:

double m[3][2];
double (*p)[2] = m; // ou &m[0].

São necessários os parênteses dadas as regras de precedência do C++.  Sem eles a definição seria interpretada como double* (p[2]), com o significado totalmente diferente de "matriz com dois elementos do tipo ponteiro para double".

Aritmética de ponteiros

Os ponteiros podem conter endereços de objectos: endereços de variáveis simples de um tipo compatível, endereços de quaisquer elementos de matrizes de um tipo compatível (é considerado endereço válido o endereço do elemento fictício imediatamente depois do último elemento de uma matriz), endereços de zonas de memória dinâmica contendo objectos de um tipo compatível (a ver em aulas posteriores), etc.  Os ponteiros podem ainda conter o valor especial nulo 0 (zero), que é garantido nunca ser um endereço válido de qualquer objecto.  A este valor chama-se um valor singular do ponteiro.

A soma de ponteiros com inteiros e a subtracção de inteiros de ponteiros, incluindo os operadores de incrementação e decrementação, estão bem definidos para ponteiros, desde que o endereço resultante se refira a um elemento (incluindo o elemento fictício final) da mesma matriz (que pode ser dinâmica, como se verá em aulas posteriores).  Por exemplo, sendo

int m[20];
int* p = m; // ou, o que é o mesmo, int* p = &m[0].
p += 3;

ambas as instruções

p[16] = 1;
m[19] = 1;

são válidas e têm o mesmo efeito.

A subtracção de ponteiros também está bem definida desde que ambos os ponteiros se refiram a elementos válidos (incluindo o elemento fictício final) da mesma matriz (que pode ser dinâmica).  Dado o código atrás

cout << p - m << endl;

tem como resultado aparecer

3

no ecrã.

O caso das matrizes de matrizes merece, mais uma vez, algumas observações.  Sejam as seguintes definições:

double m[3][2] =
{
    {1, 2},
    {3, 4},
    {5, 6},
};
double (*p)[2] = m; // ou &m[0].

Que acontece se se incrementar p?  Qual o resultado do seguinte código?

++p:
cout << p[0][0] << ' ' << p[0][1];

Dado que p é do tipo ponteiro para uma matriz com dois elementos double, a sua incrementação leva o ponteiro a apontar para a seguinte matriz de dois double, que é o segundo elemento da matriz m.  Depois, a expressão p[0][0] é interpretada como (p[0])[0], ou seja, (*(p + 0))[0], ou seja (*p)[0] .  Sendo p um ponteiro para o segundo elemento de m, *p é o segundo elemento, ou seja, é equivalente a m[1], pelo que p[0][0] é o mesmo que m[1][0].  Logo, o que aparece no ecrã são os dois elementos da matriz que é o segundo elemento de m:

3 4

Passagem de ponteiros e matrizes como argumento

As matrizes, quando definidas como parâmetro de uma rotina, são sempre interpretadas como ponteiros (não constantes).  Isto é fundamental porque, porque só assim se compatibiliza o tipo dos parâmetros com o tipo das respectivos argumentos, uma vez que nestes, sendo expressões, quaisquer nomes de matrizes que ocorram são interpretados como ponteiros para  o respectivo primeiro elemento.

Isto significa que é sempre possível, na invocação de uma rotina, passar um ponteiro como argumento no lugar de um parâmetro especificado como uma matriz.  Por exemplo, a função

int soma(int const m[], int const n)
// ou int soma(int const* m, int const n)
{
    int soma = 0;

    for(int i = 0; i != n; ++i)
        soma += m[i];

    return soma;
}

pode ser invocada da seguinte forma:

int matriz[] = {1, 2, 3, 4};
int *p = matriz; // ou seja, p = &matriz[0].
int z = soma(p, 4);

A função soma() poderia ser implementada como se segue:

int soma(int const m[], int n)
// ou int soma(int const* m, int n)
{
    int soma = 0;

    for(; n != 0; n--) {
        soma += *m;
        ++m;
    }

    return soma;
}

A utilização de ponteiros tem algumas semelhanças com a utilização de referências, nomeadamente porque permite a alteração do valor apontado pelo ponteiro.  Por exemplo, o procedimento

inline void troca(int* x, int* y) 
{

    int aux = *x;
    *x = *y;
    *y = aux;
}

permite trocar os valores apontados por dois ponteiros.  O resultado de

int a = 10, b = 20;
troca(&a, &b);
cout << a << ' ' << b << endl;

seria aparecer

20 10

no ecrã.  Repare-se que a invocação do procedimento se faz usando o operador endereço (&) explicitamente.

O procedimento também pode ser usado como se segue:

int a = 10, b = 20;
int* pa = &a;
int* pb = &b;
troca(pa, pb);
cout << *pa << ' ' << *pb << endl;

Note-se que

int* a, b;

define a como ponteiro para int e b como int simplesmente!  Para evitar estas ambiguidades convém separar claramente as definições de cada variável.

Finalmente, note-se bem a diferença face à utilização de referências propriamente ditas na versão alternativa abaixo:

inline void troca(int& x, int& y)
{

    int aux = x;
    x = y;
    y = aux;
}

...

int a = 10, b = 20;
troca(a, b);
cout << a << ' ' << b << endl;

Qual prefere?

Ponteiros, referências e const

Até agora, quando se pretendia definir (ou declarar) uma constante, colocou-se a palavra chave const depois do tipo.  Por exemplo,

double const pi = 3.14159265358979323846264338327950288419716939937510;

define uma constante de nome pi.  A notação alternativa, com a palavra chave const antes do tipo, presta-se a ambiguidades no caso dos ponteiros.  Suponha-se a seguinte definição:

int m[5] = {9, 8, 7, 6, 5};

Que significa a definição que se segue?

const int* p = m; // ou &m[0].

O que é constante?  O ponteiro ou o valor apontado?  Note-se que a variável apontada (o primeiro elemento da matriz m) não é constante.  Um pequeno teste permite verificar rapidamente o significado da definição:

*p = 0; // erro de compilação: não se pode alterar um int tratado como constante!
++p;    // tudo bem, p passa a apontar o segundo elemento da matriz.

Logo, p é um ponteiro não constante para um int tratado como constante (embora na realidade possa não o ser).  Se se pretendesse que fosse um ponteiro constante para um inteiro não constante, teria de se definir p como:

int* const p = m; // ou &m[0].

Acontece que a palavra chave const se aplica sempre ao tipo que está à sua esquerda, excepto se não existir nada à esquerda, caso em que se aplica ao tipo à sua direita.  Isto significa que, para manter uma certa coerência de utilização, as constantes simples se devem definir com a palavra chave const após o tipo, como se tem feito até aqui.

Assim, têm-se as seguintes alternativas para a definição de um ponteiro:

int* p1 = m;              // ponteiro não-constante para int tratado como 
                          // não-constante.
int const* p2 = m;        // ponteiro não-constante para int tratado como 
                          // constante.
int* const p3 = m;        // ponteiro constante para int tratado como
                          // não-constante.
int const * const p4 = m; // ponteiro constante para int tratado como
                          // constante.

Note-se que é proibido inicializar um ponteiro simples com o endereço de uma constante:

int const número_de_alunos = 50;
int* p = &número_de_alunos; // erro!

Se assim não fosse, poder-se-ia sempre alterar uma constante através de um ponteiro, o que seria um constra-senso!

Atente-se agora no seguinte exemplo, um pouco mais complicado:

typedef int* Item; // Item é um sinónimo de "ponteiro para int".

void f(Item const& i) // passagem por referência constante.
{
    ++i;    // erro!
    ++(*i); // ok!
}

void g(int const* p) // passagem por valor de um ponteiro não constante 
                     // para um int tratado como constante.
{
    f(p); // erro!
}

A operação ++i é um erro porque i é uma referência para um ponteiro tratado como constante (e não uma referência para um ponteiro para um int tratado como constante).  Assim, o ponteiro não pode ser alterado.  Mas o seu conteúdo pode, daí que a operação ++(*i) seja totalmente válida.

Finalmente, a invocação de f() a partir de g() é um erro.  Essa invocação implica a inicialização de uma referência para um ponteiro constante para um int (Item const& i) a partir de um ponteiro não constante para um int tratado como constante (int const*).  Esta inicialização, a ser feita, descartaria o const do valor apontado pelo ponteiro p, sendo esta operação proibida pelo C++!

Ponteiros e classes

Podem-se definir ponteiros para qualquer tipo, incluindo classes.  Por exemplo:

#include <iostream>
#include <string>

using namespace std;

class Aluno {
  public:
    Aluno(string const& nome = "", int const número = 0);

    string nome() const;
    int número() const;

    void alteraNomePara(string const& nome);
    void alteraNúmeroPara(int const& número);

  private:
    string nome_;
    int número_;
};

Aluno::Aluno(string const& nome = "", int const número = 0)
    : nome_(nome), número_(número) 
{

}

string Aluno:: nome() const
{

    return nome_;
}

void Aluno::alteraNomePara(string const& nome) 
{

    nome_ = nome;
}

int Aluno::número() const 
{

    return número_;
}

void Aluno::alteraNúmeroPara(int const& número) 
{

    número_ = número;
}

int main()
{
    Aluno a("Ambrósio Amaral", 1234);
    Aluno* p = &a;
    cout << (*p).nome() << endl;
}

Note-se na utilização de parênteses envolvendo *p.  Os parênteses são necessários porque o operador * (conteúdo) tem menor precedência que o operador . (selecção de membro).  Para evitar esta notação complicada, o C++ fornece um operador -> de selecção de membro de uma instância apontada por um ponteiro:

cout << p->nome() << endl;