Guião da 4ª Aula Teórica

Sumário

Guião

Mais uma vez que os exemplos dados na aula teórica serão académicos: de tão simples são totalmente inúteis...  Nas aulas práticas se verão exemplos mais ... práticos.

Quero também alertar para o facto de irmos descer um pouco o nível da nossa conversa.  O nível de abstracção, bem entendido...  Vamos falar de coisas que normalmente não nos preocupam.  Vou fazê-lo apenas para que compreendam os conceitos.  Depois de os aprenderem convém regressar a níveis de abstracção mais elevados e não ligar aos pormenores de implementação.

Quando se escreve o código:

int i = 10;

Cria-se uma variável i, do tipo int, com valor 10.  Onde está a variável i quando o programa é executado?

Discutir.  Concluir que está guardada na memória.

O que é a memória?

Discutir.

Do nosso ponto de vista, ou melhor, do ponto de vista do C++ a memória é uma sequência de bytes, com números sucessivos.  Aos números chama-se endereços.  Os bytes são a unidade básica de memória (do ponto de vista do C++) e normalmente têm 8 bits.

Ok.  E quantos bits têm as variáveis do tipo int nas máquinas que costumam usar?

Esperar... (É típico ninguém responder)

32.  Ou seja, quando uma variável é criada ocupa quantos octetos?  4!  Exacto!  Cada um com um endereço.

Suponha-se que a variável i ocupava os endereços 12, 13, 14 e 15.

Desenhar memória como matriz de octetos.  Agrupar os quatro da variável i.  A variável deve ficar dentro de um rectângulo UML (diagrama de objectos).  Usar sempre UML.

Se quisermos dizer onde está i que endereço dizemos?

Discutir.

Exacto: o que é lógico é dizer 12, o mais baixo dos endereços dos octetos ocupados pela variável.

Perfeito.  Que podemos fazer com o endereço de uma variável?  Guardá-lo noutra variável!  De que tipo?  int?  O compilador não deixa.  Para o C++ um endereço é um endereço.  Mais, um endereço de um int não é o mesmo que o endereço de um double!  Então temos de conseguir dizer algo como "esta variável guarda o endereço de uma variável do tipo X".

Se quisermos definir uma variável que guarda endereços de variáveis do tipo int, escrevemos:

int* p;

A sintaxe é sempre esta: tipo, depois um *, e finalmente o nome da variável.  O que mudou foi o *.  Diz-se que p é um ponteiro.  Um ponteiro para int.

E que endereço contém p?

Discutir.  Concluir que tem lixo.

Lixo não serve de muito.  Convinha poder colocar um endereço específico em p.  Qual?

Discutir.  Lembrar que p é um ponteiro para int, e portanto deve conter o endereço de uma variável do tipo int.

Neste caso só há um int: i.  Logo, gostávamos de poder colocar em p o endereço de i.  Para isso usa-se o operador endereço:

int* p = &i;

Note-se que o símbolo & também pode significar referência, ou "e" lógico, ou "e" bit-a-bit.  Depende do contexto.  Já lá vamos...

Costuma-se representar os ponteiros com setas dirigidas para o local de que possuem o endereço.

Desenhar p na memória.  Colocar lá dentro o endereço de i.  Desenhar seta até i.

Perfeito.  Agora p tem um endereço válido.  Que podemos fazer com ele?

É possível usar p para aceder a i indirectamente.  Para isso usa-se o operador conteúdo:

cout << *p << endl;
*p = 20;

Como p é um ponteiro para o int i, então *p é o int i!  Diz-se que é o conteúdo do inteiro apontado por p.  Ou, abusando da linguagem, é o conteúdo de p.  No fundo, por isso *p é uma forma alternativa de escrever i: é um sinónimo de i.

Assim, o código começa por mostrar 10 e depois altera indirectamente o valor de i para 20.

É preciso cuidado com o significado dos símbolos * e &.  O significado é totalmente diferente consoante os símbolos surjam numa declaração ou numa expressão:

int i = 10;
int& j = i;
int* p = &j;
int k = *p;

Explicar muito bem.  Dar exemplo do português ("A nota, se não me engano, foi 16,5.", utilização da vírgula).  Dizer para passarem para o caderno.

Os ponteiros, como vistos até agora, só servem para confundir as coisas: permitem acessos indirectos a objectos aos quais se pode aceder directamente.  Para quê usar p para alterar indirectamente i se posso usar i directamente?  A grande utilidade dos ponteiros só será bem evidente quando se falar de variáveis dinâmicas, na próxima aula.  Paciência...

Os ponteiros têm uma relação muito interessante com as matrizes em C++.  Sejam as seguintes instruções:

int m[5] = {6, 7, 8, 9, 10};
int* p = &m[0];
p = p + 1;
*p = 20;

Que se está a passar?  Vamos desenhar isto na memória:

Desenhar no quadro a memória com m e pPôr m no endereço 20!  Pôr seta de p para o primeiro elemento de m.  Explicar tudo calmamente.  Explicar que os elementos são colocados em endereços crescentes!

Que endereço tem p?  20!  Que acontece a esse endereço quando se lhe soma 1?

Discutir.  Concluir que o que nos interessa é que passe para o próximo elemento da matriz.

O compilador sabe quantos octetos ocupam os objectos de cada tipo e dá os saltos apropriados!  Isto dá muito jeito!  Podemos e devemo-nos abstrair de pormenores como a dimensão exacta dos int.

Então o endereço em p passa para... 24!  É o endereço do segundo elemento da matriz.  Assim, a última instrução altera o segundo elemento da matriz.

Moral da história: podemos somar ou subtrair inteiros de ponteiros.  Sempre?  E se eu tivesse:

int m[5] = {6, 7, 8, 9, 10};
int* p = &m[0];
p = p + 6;
*p = 20;

Não se pode obter o endereço de elementos inexistentes da matriz...  Com uma excepção!  Pode-se obter um ponteiro para o elemento fictício final.  Podia-se ter:

int m[5] = {6, 7, 8, 9, 10};
int* p = &m[0];
p = p + 6;
*p = 20;

Mas nesse caso a atribuição seria um erro.

Infelizmente não se passa o mesmo no caso do elemento fictício inicial...

Mas as relações interessantes entre ponteiros e matrizes não se ficam por aqui.  Que significa m[i]?  Indexação de matriz?  Não.  Bem, sim, mas a coisa é mais complicada do que parece.  A regra é:

X[I] é o mesmo que *(X + I)

desde que X não seja uma instância duma classe.

Mas então, e código inocente como:

int m[5];
m[2] = 10;

?

Quer isto dizer que o C++ interpreta a coisa como

int m[5];
*(m + 2) = 10;

?

Exacto!

Mas que significa m + 2?  É que há outra regra.  Salvo raras excepções, se uma matriz ocorrer numa expressão é convertida num ponteiro para o primeiro elemento!  I.e.,

int m[5];
int* p = m;

é o mesmo que

int m[5];
int* p = &m[0];

Mas então, que significa

int m[5];
m[2] = 10;

?

É o mesmo que

int m[5];
*(m + 2) = 10;

que é o mesmo que

int m[5];
*(&m[0] + 2) = 10;

Conclusão: desde o primeiro semestre que trabalham com ponteiros sem o saberem!

E se for:

int m[5];
int* p = m + 1;
p[3] = 10;

?

É o mesmo que

int m[5];
int* p = &m[0] + 1;
*(p + 3) = 10;

Que é o mesmo que

int m[5];
int* p = &m[0] + 1;
*(&m[0] + 1 + 3) = 10;

que é o mesmo que

int m[5];
int* p = &m[0] + 1;
m[4] = 10;

E se fosse p[-1] = 20?  Onde ficava o 20?

Mas, se as matrizes numa expressão são convertidas num ponteiro para o seu primeiro elemento que sucede se forem passadas como argumento?

int soma(int const m[], int const n)
{
    int soma = 0;
    for(int i = 0; i != n; ++i)
        soma += m[i];
    return soma;
}

int main()
{
    int matriz[] = {2, 4, 6, 8};

    cout << soma(matriz, 4) << endl;
}

Explicar o que faz.  Discutir quanto escreve no ecrã.

Reparem na instrução:

cout << soma(matriz, 4) << endl;

pelo que disse, é o mesmo que

cout << soma(&matriz[0], 4) << endl;

ou ainda

int* p = matriz; // ou &matriz[0]
cout << soma(p, 4) << endl;

O que é passado é um ponteiro!

Mas a função diz que recebe uma matriz!  Que se passa?  É que o C++, quando um parâmetro é declarado como matriz, converte logo a declaração para a declaração de um ponteiro!  Logo,

int soma(int const m[], int n)

é o mesmo que

int soma(int const* m, int n)

e portanto a função pode-se escrever:

int soma(int const* m, int n)
{
    int soma = 0;
    for(int i = 0; i != n; ++i)
        soma += m[i];
    return soma;
}

Discutir rapidamente.  Dizer que é por isso que dissemos no primeiro semestre que as matrizes eram sempre passadas por referência!  Uma mentirita piedosa.

Apresentar rapidamente caso das matrizes multidimensionais (se não houver tempo saltar para classes).

int m[3][2];
int (*p)[2] = m;
++p;
p[0][0] = 10;

Explicar linha a linha.  Notar importância dos ().

Finalmente, como funcionam os ponteiros para classes?  Simplesmente:

class Aluno {
  public:
    Aluno(int número, int nota);

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

  private:
    int número_;
    int nota_;
};

...

int main()
{
    Aluno a(12345, 20);
    Aluno* p = &a;
    cout << (*p).número() << endl
         << (*p).nota() << endl;
}

Os parênteses são fundamentais, pois de outra forma o C++ interpreta a primeira expressão como: *(p.número()), o que não faz qualquer sentido.

A notação é aborrecida... parênteses asterisco ponteiro parênteses ponto...  O C++ fornece uma abreviatura:

int main()
{
    Aluno a;
    Aluno* p = &a;
    cout << p->número() << endl
         << p->nota() << endl;
}

onde p->número() se lê "a operação número() da instância de Aluno apontada por p".  Esta notação pode ser usada tanto para invocar operações, como acima, como para aceder a atributos, desde que estejam acessíveis.