De Objective Caml para C e C++/Construção de tipos: diferenças entre revisões

Origem: Wikilivros, livros abertos por um mundo aberto.
[edição verificada][edição não verificada]
Conteúdo apagado Conteúdo adicionado
m WP:BOT: Substituindo sintaxe matemática obsoleta de acordo com mw:Extension:Math/Roadmap
Linha 269: Linha 269:
} p;
} p;
float tab [4];
float tab [4];
printf("tamanho de a = %lu\n", sizeof a);
printf("tamanho de a = \%lu\n", sizeof a);
printf("tamanho de p = %lu\n", sizeof p);
printf("tamanho de p = \%lu\n", sizeof p);
printf("tamanho de p.x = %lu\n", sizeof p.x);
printf("tamanho de p.x = \%lu\n", sizeof p.x);
printf("tamanho de p.y = %lu\n", sizeof p.y);
printf("tamanho de p.y = \%lu\n", sizeof p.y);
printf("tamanho de tab = %lu\n", sizeof tab);
printf("tamanho de tab = \%lu\n", sizeof tab);
printf("tamanho de tab[0] = %lu\n", sizeof tab[0]);
printf("tamanho de tab[0] = \%lu\n", sizeof tab[0]);
printf("tamanho de tab[1] = %lu\n", sizeof tab[1]);
printf("tamanho de tab[1] = \%lu\n", sizeof tab[1]);
printf("tamanho de tab[2] = %lu\n", sizeof tab[2]);
printf("tamanho de tab[2] = \%lu\n", sizeof tab[2]);
printf("tamanho de tab[3] = %lu\n", sizeof tab[3]);
printf("tamanho de tab[3] = \%lu\n", sizeof tab[3]);
}
}
A execução desse programa resulte na seguinte impressão na saída padrão:
A execução desse programa resulte na seguinte impressão na saída padrão:
Linha 301: Linha 301:
} p;
} p;
float tab [4];
float tab [4];
printf("endereco de a = %p\n", &a);
printf("endereco de a = \%p\n", &a);
printf("endereco de p = %p\n", &p);
printf("endereco de p = \%p\n", &p);
printf("endereco de p.x = %p\n", &p.x);
printf("endereco de p.x = \%p\n", &p.x);
printf("endereco de p.y = %p\n", &p.y);
printf("endereco de p.y = \%p\n", &p.y);
printf("endereco de tab = %p\n", &tab);
printf("endereco de tab = \%p\n", &tab);
printf("endereco de tab[0] = %p\n", &tab[0]);
printf("endereco de tab[0] = \%p\n", &tab[0]);
printf("endereco de tab[1] = %p\n", &tab[1]);
printf("endereco de tab[1] = \%p\n", &tab[1]);
printf("endereco de tab[2] = %p\n", &tab[2]);
printf("endereco de tab[2] = \%p\n", &tab[2]);
printf("endereco de tab[3] = %p\n", &tab[3]);
printf("endereco de tab[3] = \%p\n", &tab[3]);
printf("tab = %p\n", tab);
printf("tab = \%p\n", tab);
}
}
Observe que foi usada a diretiva de formatação <tt>%p</tt> para imprimir um endereço. A execução desse programa resulte na seguinte impressão na saída padrão:
Observe que foi usada a diretiva de formatação <tt>\%p</tt> para imprimir um endereço. A execução desse programa resulte na seguinte impressão na saída padrão:
endereco de a = 0xbffff8d8
endereco de a = 0xbffff8d8
endereco de p = 0xbffff8e0
endereco de p = 0xbffff8e0
Linha 358: Linha 358:
int n = 42;
int n = 42;
int * pi = &n;
int * pi = &n;
printf("O valor apontado por pi é %i.\n", *pi);
printf("O valor apontado por pi é \%i.\n", *pi);
resultará na impressão da seguinte mensagem na saída padrão:
resultará na impressão da seguinte mensagem na saída padrão:
O valor apontado por pi é 42.
O valor apontado por pi é 42.
Linha 365: Linha 365:
int * pi = &n;
int * pi = &n;
* pi = 44;
* pi = 44;
printf("O valor apontado por pi é %i.\n", *pi);
printf("O valor apontado por pi é \%i.\n", *pi);
printf("O valor de n é %i.\n", *n);
printf("O valor de n é \%i.\n", *n);
resultará na seguinte impressão na saída padrão:
resultará na seguinte impressão na saída padrão:
O valor apontado por pi é 44.
O valor apontado por pi é 44.

Revisão das 16h47min de 2 de fevereiro de 2019

Conceitos iniciais

As linguagens C e C++ possuem muitos mecanismos para construir e estruturar dados complexos. Diferentemente dos tipos básicos, onde há uma forte correspondência entre essas linguagens e a linguagem Objective Caml, a construção de tipos complexos em C e C++ é bem diferente daquela provida por Objective Caml. Esse capítulo não aborda a construção de tipos via classes de objetos, que é um paradigma presente nas linguagens Objective Camle e C++, mas que é ausente da linguagem C.

O sistema de tipos de C e C++ é bastante complexo e, em prol da lisibilidade desse texto, algumas aproximações e simplificações foram realizadas. Apresentaremos as seguintes construções para a criação e manipulação de tipos complexos:

  • Enumerações correspondem a uma forma muito básica de tipos variantes de Objective Caml.
  • Arranjos correspondem aos arranjos de Objective Caml, embora sejam objetos muito mais simples. A linguagem C++ provê, através da sua biblioteca, uma outra implementação de arranjos que tem um nível de abstração mais próximo ao dos arranjos de Objective Caml.
  • Registros correspondem aos tipos registros de Objective Caml.
  • Ponteiros correspondem às referências de Objective Caml.
  • As referências, presentes apenas na linguagem C++, não tem correspondência direta em Objective Caml, embora possam ser simuladas por ponteiros, logo pelas referências de Objective Caml.
  • As uniões correspondem aproximadamente aos tipos variantes de Objective Caml, embora não sejam tão práticas para serem manipuladas.
  • Enfim, terminaremos com a apresentação dos tipos funcionais, que existem em C e em C++. Embora não sejam tão flexíveis quanto os tipos funcionais de Objective Caml, permitem ainda programação de ordem superior.

Vale destacar que em C e em C++, pode-se dar um nome a um tipo, utilizando a seguinte construção, ou alguma variação:

typedef expressaodetipo nome;

Por exemplo, podemos definir um tipo chamado inteiro que é idêntico ao tipo básico int com a seguinte construção:

typedef int inteiro; // exemplo de definição de um tipo chamado "inteiro", que nada mais é que o tipo int

Então a mais simples expressão de tipo que exista é um nome de um tipo básico, ou de outro tipo que já foi definido dessa forma. Uma vez definido um nome para um tipo, ele pode ser usado para declarar variáveis ou funções da mesma forma que os tipos básicos. Por exemplo:

inteiro soma3 (inteiro a, inteiro b, inteiro c) // exemplo (artificial) onde usamos um nome de tipo definido por nós
{
  inteiro resultado = a;
  resultado += b;
  resultado += c;
  return resultado;
}

Também pode ser utilizar uma expressão de tipo diretamente na declaração de uma variável. Assim esse código:

struct Spoint { // nesse exemplo, usamos uma expressão de tipo registro, detalhes sobre essa construção seguem mais adiante
  double x;
  double y;
} point; // point é uma variável cujo tipo é um tipo registro struct Spoint

é equivalente a

typedef struct Spoint {
 double x;
 double y;
} Tpoint; // definimos Tpoint como o nome de um tipo registro struct Spoint
Tpoint point; // point é uma variável cujo tipo é Tpoint, ou seja o tipo registro struct Spoint

Enumerações

Com relação ao sistema de tipos de Objective Caml, os tipos enumerados lembram de forma superficial os tipos variantes. Os tipos enumerados são porém muito mais limitados, e correspondem mais precisamente a tipos variantes onde as alternativas devem ser todas constantes.

Um tipo enumerado contem um número finito de valores. Cada valor tem um nome que o identifica. Por exemplo a seguinte definição:

enum Ecor {vermelho, azul, amarelo};

introduz um tipo enumerado, que possui como rótulo Ecor, e três enumeradores, que são valores inteiros constantes com identificadores vermelho, azul, amarelo.

Pode-se dar um nome a esse tipo, utilizando uma definição de tipo:

typdef enum Ecor {vermelho, azul, amarelo} Tcor;

Em seguida, podemos declarar e utilizar variáveis com esse tipo, e os valores que foram introduzidos na operação podem também aparecer no programa, onde serão consideradas como valores inteiros. Segue um trecho de programa que utiliza a definição acima:

void imprime_cor_em_ingles (Tcor c) // o parâmetro poderia também ter sido declarado como enum Ecor c
{
  switch (c1) {
    case vermelho:
      printf("red");
      break;
    case azul:
      printf("blue");
      break;
    case vermelho:
      printf("amarelo");
      break;
  }
}

Afirmamos que um valor inteiro é associado a cada valor de uma enumeração. Podemos realizar essa associação de forma implícita ou explícita. A forma explícita é realizada associando o número inteiro na definição da enumeração, como demostrado no seguinte exemplo:

enum Edirecao { norte = 0; oeste = 90; sul = 180; leste = 270 };

Caso não aparece associação explícita, a associação é realizada de maneira implícita, utilizando as seguintes regras: o primeiro valor da enumeração recebe o valor zero, e um valor que não está na primeira posição é associado com a soma de um com o inteiro associado ao valor anterior.

Finalmente, podemos também misturar as associações explícitas e implícitas, como no seguinte exemplo:

enum Ecodigo { NO_ERROR = 0; IO_ERROR = 10; FORMAT_ERROR; TIMEOUT_ERROR };

Nesse exemplo, FORMAT_ERROR e TIMEOUT_ERROR denotam respectivamente os inteiros e .

Exercício

Considere o seguinte tipo de dados definido em Objective Caml

type card_suit_t = Spades | Hearts | Diamonds | Clubs

Define um tipo equivalente em C/C++.

Arranjos

A linguagem C permite declarar arranjos uni-dimensionais, dando as seguintes informações: o tipo dos elementos, e o tamanho do arranjo. Por exemplo, o seguinte código define uma variável notas para guardar quatro valores do tipo float, utilizaremos o seguinte código:

float notas [4];

Portanto a sintaxe consiste em identificar o tipo dos elementos, através de uma expressão de tipos, o nome do arranjo, e entre colchetes o tamanho do arranjo.

Opcionalmente, podemos associar um valor inicial aos elementos do arranjo assim definido. Há duas possibilidades

  • Para definir o mesmo valor em todas as posições do arranjo, colocamos apenas o valor do primeiro elemento. Por exemplo, o código seguinte corresponde à definição de um arranjo similar ao exemplo anterior, mas onde todas as posições do arranjo armazenam o valor :
float notas [4] = { 0.0f };

Portanto a sintaxe exige que a expressão de inicialização seja colocada entre chaves.

  • Também pode-se definir individualmente cada posição do arranjo. Isso é realizado colocando entre chaves, a enumeração dos valores do arranjo. O exemplo seguinte portanto é equivalente ao anterior:
float notas [4] = {0.0f, 0.0f, 0.0f, 0.0f};

A sintaxe exige que os elementos da inicialização sejam separados por vírgulas.

Operadores sobre arranjos

C e C++ oferecem a possibilidade de acessar cada posição do arranjo individualmente. Como em Objective Caml, o endereço da primeira posição é e as demais posições são obtidas somando um à anterior. Por exemplo, o código seguinte declara um arranjo e inicializa todas as suas posições em seqüência.

float notas [4];
notas[0] = 0.0f;
notas[1] = 0.0f;
notas[2] = 0.0f;
notas[3] = 0.0f;

Evidentemente, uma repetição for é uma construção particularmente adequada para percorrer todas as posições de um arranjo. Por exemplo:

float notas [4];
for (int i = 0; i < 4; ++i)
  notas[i] = 0.0f;

Objective Caml possui uma função que, aplicada a um arranjo, retorna o número de posições do arranjo. Isso não existe nem C nem em C++ (C++ tem uma outra implementação de arranjo, chamada vector que tem essa funcionalidade; será apresentada mais adiante). Objective Caml possui também mecanismos para verificar que o acesso aos arranjos é realizado dentro dos limites. C e C++ não oferecem esses mecanismos, e podemos perfeitamente escrever o seguinte programa:

#include <cstdio>
int main ()
{
  int arranjo[2] = {1, 2};
  arranjo[2] = 3;
  printf("arranjo[0] = %i, arranjo[1] = %i, arranjo[2] = %i\n", arranjo[0], arranjo[1], arranjo[2]);
}

Observe que a função main inclui a definição de uma variável arranjo, com duas posições armazenando valores do tipo int. As posições válidas portanto são e . O programa realiza um acesso em escrita na posição , portanto fora dos limites e também um acesso em leitura. Utilizando g++ versão 4.0.1, esse programa é compilado sem erro (nem aviso). Ele pode até ser executado! E funciona! Olhe:

daviddeharbe $ g++ -Wall prog.cpp -o prog
daviddeharbe $ ./prog
arranjo[0] = 1, arranjo[1] = 2, arranjo[2] = 3

Então o programa está correto? Claro que não. Nessa oportunidade ele executou normalmente, mas pode ser que em alguma outra plataforma, tenha um erro em tempo de execução. Nesse exemplo, é bastante fácil detectar esse erro lendo o código, mas em programas mais extensos essa tarefa de verificação por leitura é muito mais difícil.

Esse pequeno programa ilustra uma diferença de filosofia entre as linguagens C e C++ de um lado, e a linguagem Objective Caml do outro. As primeiras privilegiam o desempenho em tempo de execução, mas não oferecem suporte para verificações elementares, criando grandes riscos do que erros passam despercebidos durante muito tempo até eles causarem problemas. Objective Caml, do outro lado, é muito mais rigorosa, e aplica um maior número de regras, tanto em tempo de compilação, quanto em tempo de execução. A implementação em Objective Caml de um dado algoritmo, será mais lenta que a implementação em C ou em C++, mas em compensação, oferecerá muito mais garantias de correção e de segurança.

Arranjos como parâmetros de funções

Nas linguagens C e C++, como em Objective Caml, os parâmetros são passados por valor. Isso significa que quando uma função é chamada, as variáveis que correspondem aos parâmetros formais da função comportam se como se fossem variáveis locais ao corpo da função que são inicializadas com o valor dos argumentos que foram utilizados para chamar essa função.

No caso dos arranjos, o parâmetro formal é o mesmo arranjo que o argumento efetivo da chamada da função. O seguinte exemplo ilustra essa particularidade:

#include <cstdio>
void swap1 (int i, int j)
{
  int tmp = i;
  i = j;
  j = tmp;
}
void swap2 (int tab[2])
{
  int tmp = tab[0];
  tab[0] = tab[1];
  tab[1] = tmp;
}
int main ()
{
  int a = 1, b = 2;
  printf("antes: a = %i, b = %i\n", a, b);
  swap1(a, b);
  printf("depois: a = %i, b = %i\n", a, b);
  int arranjo[2] = {1, 2};
  printf("antes: arranjo[0] = %i, arranjo[1] = %i\n", arranjo[0], arranjo[1]);
  swap2(arranjo);
  printf("depois: arranjo[0] = %i, arranjo[1] = %i\n", arranjo[0], arranjo[1]);
}   

A execução desse programa resultará na seguinte impressão na saída padrão

antes: a = 1, b = 2
depois: a = 1, b = 2
antes: arranjo[0] = 1, arranjo[1] = 2
depois: arranjo[0] = 2, arranjo[1] = 1

Resumidamente, a explicação disso é que, em C e C++, um arranjo corresponde ao endereço da primeira posição do arranjo. Quando a função swap2 é chamada com o argumento arranjo, o endereço da primeira posição de arranjo é copiada para tab, que passa a ter então a sua primeira posição nesse endereço. Logo tab[0] e tab[1] são respectivamente idênticos a arranjo[0] e arranjo[1].

Arranjos como resultados de funções

As linguagens C e C++ não permitem que o tipo de retorno de uma função seja um tipo arranjo. Uma possibilidade para contornar essa limitação é ter um parâmetro a mais que será atualizado no corpo da função. O exemplo seguinte ilustra essa técnica.

Um primeiro exemplo

O seguinte programa provê uma implementação em C++ de um algoritmo que calcula e imprime os números primos até um certo valor . Esse algoritmo é conhecido como o crivo de Eratosthenes.

// programa que lê um número n da entrada padrão e imprime na saída padrão todos os números primos até n
// implementa o crivo de Eratosthenes
#include <cstdio>

Observe que o exemplo inclui uma função tal que um dos parâmetros é um arranjo de booleanos.

void compute_primes (bool sieve[], int n) // preenche o crivo de 0 até n-1
{
  sieve[0] = sieve[1] = false;
  for (int i = 2; i * i < n; ++i) {
    if (sieve[j]) {
      for (int j = i + i; j < n; j += i) {
        sieve[j] = false;
      }
    }
  }
}
int main ()
{
  int i, n;
  scanf("%d", &n);
  if (n < 2) return;
  bool sieve [n+1] = {true};
  compute_primes(sieve, n+1);
  for (int i = 2; i < n+1; ++i) {
    if (sieve[i]) printf("%d ", i);
  }
  printf("\n");
}

Detalhes sobre o crivo podem ser obtidos em: Weisstein, Eric W. "Sieve of Eratosthenes." From MathWorld--A Wolfram Web Resource. http://mathworld.wolfram.com/SieveofEratosthenes.html

Arranjos multi-dimensionais

Como em Objective Caml, um arranjo de dimensão de valores de um tipo t podem ser implementados em C e em C++ usando um arranjo de arranjos de dimensão de valores do tipo t.

Uma matriz bi-dimensional de inteiros, de dimensão por , pode então ser declarada como:

int matriz [n][m];

Um segundo exemplo

float media (float tab[], int n) // calculo da media de um arranjo tab de floats de tamanho n.
{
  float sum = 0.0f;
  for (int i = 0; i < n; ++i)
    sum += tab[i];
  return sum/n;
}
int main()
{
  int n;
  cin >> n; // o numero de alunos e lido
  if (n == 0) return 0;
  float notas[3][n];
  for (int i = 0; i < n; ++i) // para cada aluno
    cin >> notas[0][i] >> notas[1][i] >> notas[2][i]; // ler as tres notas e guardar no arranjo notas
  for (int i = 0; i < 3; ++i) { // calculo e impressao da media de cada prova
    cout << "A media da " << i << "a prova e " << media(tab[i], n) << endl;
  }
}

Tipos registros

Os tipos registros de C/C++ são essencialmente idênticos aos de Objective Caml: é um forma de agregar um número fixo de valores de diferentes tipos. Esses valores são chamados campos do registro. Cada campo possui um nome que o identifica e um tipo que especifica quais valores ele pode ter. Vamos agora ver a sintaxe em C/C++ através de um exemplo.

Considere o seguinte tipo registro (em OCaml):

type point = { x : float ; y : float }

Em C/C++, o equivalente é

typedef struct Sponto {
  float x;
  float y;
} point;

Pode-se acessar aos campos utilizando um operador que tem a mesma sintaxe que o de Objective Caml. Assim, caso desejarmos definir uma função que calcula a distância entre dois pontos dados, podemos escrever:

#include <math.h>
float distance (point p1, point p2)
{
  float dx = p1.x - p2.x;
  float dy = p1.y - p2.y;
  return sqrtf(dx*dx + dy+dy);
}

C/C++ diferem de Objective Caml na forma de denotar valores de um tipo registro. Enquanto que em Objective Caml tem que explicitamente associar a cada nome um valor, em C/C++ essa associação é feita pela posição. Uma outra diferença sútil é que os valores dos diferentes campos devem ser separados por vírgulas (é um ponto e vírgula em Objective Caml). Assim, o exemplo seguinte lembra como pode-se escrever um valor de um tipo registro em Objective Caml.

let origin = { x = 0.0 ; y = 0.0 }

O equivalente em C/C++ seria:

point origin = { 0.0 , 0.0 };

[[[ ADICIONAR UM PARÁGRAFO SOBRE PONTEIROS PARA REGISTROS ]]]

Ponteiros

Introdução

Uma variável C/C++ é a abstração de um trecho de memória que guarda um valor de um certo tipo, onde um trecho de memória é uma seqüëncia de posições contíguas na memória. Um trecho de memória é definido pelo endereço da primeira posição e o número de posições no trecho. Mais precisamente, qualquer lvalue (variável, campo de variável de tipo registro, posição de variável de tipo arranjo) é a abstração de um trecho de memória. Na seqüência, empregaremos o termo variável ao invés de lvalue.

Para cada variável, o compilador deve associar algum trecho de memória para aquela variável. Para evitar qualquer problema de superposição e conflito, se o escopo de duas variáveis tem alguma interseção, os trechos de memória aos quais essas variáveis correspondem devem ser disjunAssim, uma variável de tipo long long int vai corresponder a um trecho de oito bytes na memória. O tamanho do tipo vai então depender diretamente do tipo de dados a ser representado. Por exemplo, quando temos um tipo registro, o tamanho de memória necessário para a representação de valores desse tipo é a soma dos tamanhos para representar cada campo do registro. Quando temos um tipo arranjo, o tamanho de memória necessário é igual a produto do número de elementos do arranjo com o tamanho necessário para armazenar cada elemento do arranjo.

As linguagens C e C++ possuem um operador unário, prefixado, nomeado sizeof que, aplicado a uma variável, ou a uma expressão de tipo (entre parênteses), retorna o tamanho em bytes do trecho de memória necessário para representar essa variável, ou valores do dado tipo. O pequeno programa seguinte ilustra os conceitos discutidos:

#include <cstdio>
int main ()
{
  int a;
  struct point {
    double x;
    double y;
  } p;
  float tab [4];
  printf("tamanho de a = \%lu\n", sizeof a);
  printf("tamanho de p = \%lu\n", sizeof p);
  printf("tamanho de p.x = \%lu\n", sizeof p.x);
  printf("tamanho de p.y = \%lu\n", sizeof p.y);
  printf("tamanho de tab = \%lu\n", sizeof tab);
  printf("tamanho de tab[0] = \%lu\n", sizeof tab[0]);
  printf("tamanho de tab[1] = \%lu\n", sizeof tab[1]);
  printf("tamanho de tab[2] = \%lu\n", sizeof tab[2]);
  printf("tamanho de tab[3] = \%lu\n", sizeof tab[3]);
}

A execução desse programa resulte na seguinte impressão na saída padrão:

tamanho de a = 4
tamanho de p = 16
tamanho de p.x = 8
tamanho de p.y = 8
tamanho de tab = 16
tamanho de tab[0] = 4
tamanho de tab[1] = 4
tamanho de tab[2] = 4
tamanho de tab[3] = 4

O operador de endereço

Em C e C++, há um operador que permite saber qual o endereço do trecho de memória associado a uma variável: é o operador &. É um operador unário, e é prefixado. Segue um pequeno programa que ilustra o uso desse operador.

#include <cstdio>
int main ()
{
  int a;
  struct point {
    double x;
    double y;
  } p;
  float tab [4];
  printf("endereco de a = \%p\n", &a);
  printf("endereco de p = \%p\n", &p);
  printf("endereco de p.x = \%p\n", &p.x);
  printf("endereco de p.y = \%p\n", &p.y);
  printf("endereco de tab = \%p\n", &tab);
  printf("endereco de tab[0] = \%p\n", &tab[0]);
  printf("endereco de tab[1] = \%p\n", &tab[1]);
  printf("endereco de tab[2] = \%p\n", &tab[2]);
  printf("endereco de tab[3] = \%p\n", &tab[3]);
  printf("tab = \%p\n", tab);
}

Observe que foi usada a diretiva de formatação \%p para imprimir um endereço. A execução desse programa resulte na seguinte impressão na saída padrão:

endereco de a = 0xbffff8d8
endereco de p = 0xbffff8e0
endereco de p.x = 0xbffff8e0
endereco de p.y = 0xbffff8e8
endereco de tab = 0xbffff8f0
endereco de tab[0] = 0xbffff8f0
endereco de tab[1] = 0xbffff8f4
endereco de tab[2] = 0xbffff8f8
endereco de tab[3] = 0xbffff8fc
tab = 0xbffff8f0

Algumas observações devem ser feitas:

  • os endereços são impressos em base hexadecimal.
  • em um registro, campos sucessivos ficam em endereços sucessivos
  • o endereço de um registro é o mesmo endereço do primeiro campo do registro
  • em um arranjo, posições sucessivas ficam em endereços sucessivos
  • o endereço de um arranjo é o mesmo endereço da primeira posição
  • um arranjo nada mais é que o endereço da primeira posição desse arranjo (como discutimos no parágrafo sobre arranjos como parâmetros de funções).

O leitor atento e curioso pode então fazer a seguinte pergunta. No programa exemplo, quando aplico o operador de endereço a uma variável ou a qualquer lvalue, qual é o tipo dessa expressão? A resposta é que trata-se de um tipo ponteiro, para o qual reservamos o parágrafo seguinte.

Os tipos ponteiros

O conceito de ponteiro é fundamental em programação. Um valor de algum tipo ponteiro é o (endereço de) um trecho de memória que corresponde a alguma variável. Portanto um ponteiro é algo que indica onde fica algo, digamos então que aponta para algo. Se uma variável v tem o tipo t, a expressão &v é portanto uma expressão de tipo ponteiro para t, que é denotada t * (é o mesmo símbolo que o da multiplicação aritmética).

A sintaxe para declarar uma variável de um tipo ponteiro é a seguinte:

tipo * nome;

Nesse caso, nome é o nome da variável, e tipo é o tipo do valor que está na locação de memória apontada. Assim, o trecho de código seguinte fornece exemplos de uso dessa sintaxe:

int * a;
float x;
float * pf = &x;

Foram declaradas três variáveis:

  • A variável a é um ponteiro para um trecho de memória que guarda um valor do tipo int. Observe que não tem inicialização e o valor de a é qualquer. Como uma variável de qualquer tipo, a variável a deverá ser inicializada antes de ser lida, caso contrário o comportamento do programa é indefinido.
  • A variável x é uma variável não ponteiro, do tipo float.
  • A variável pf é uma variável de tipo ponteiro para float, ou float *. É inicializada com o endereço da variável x. Diz-se que pf aponta para x.

Oo símbolo * é associado a nome e não a tipo. Assim, se quisermos declarar mais de um ponteiro para o mesmo tipo de uma vez só, devemos repetir * antes de cada nome. Por exemplo:

int * p1, * p2;
Ponteiros e Objective Caml

Em Objective Caml, um conceito similar, embora mais geral, é o de referência. O conceito de referência OCaml corresponde ao de uma variável C/C++ e também ao de ponteiro. Assim, em OCaml, um valor de tipo int ref é alguma locação de memória que contem um valor do tipo int e que pode ser alterado (dizemos que é mutável). Ou seja é exatamente a mesma coisa que uma variável C ou C++ do tipo int. Tal variável pode ser lida em expressões e alterada com atribuições. O equivalente a um ponteiro C para um inteiro, ou seja a um valor do tipo int * seria, em OCaml, um valor do tipo int ref ref: corresponde à memorização de um local onde um inteiro é memorizado.

O operador de dereferenciamento

Dado um valor de algum tipo ponteiro, o operador de dereferenciamento permite acessar o valor do trecho de memória apontado. É um operador prefixado, que se escreve *. Se p é um valor de tipo t * (ponteiro para o tipo t), então a expressão * p é do tipo t. O valor dela é o valor guardado na memória no endereço guardado por p. Por exemplo, o seguinte trecho de código

int n = 42;
int * pi = &n;
printf("O valor apontado por pi é \%i.\n", *pi);

resultará na impressão da seguinte mensagem na saída padrão:

O valor apontado por pi é 42.

O resultado do operador de referenciamento é um lvalue, significando que pode ser alvo de atribuição. Atribuir um valor a * p consiste em alterar o conteúdo do trecho de memória apontado por p. Por exemplo:

int n = 42;
int * pi = &n;
* pi = 44;
printf("O valor apontado por pi é \%i.\n", *pi);
printf("O valor de n é \%i.\n", *n);

resultará na seguinte impressão na saída padrão:

O valor apontado por pi é 44.
O valor de n é 44.

Efetivamente, n é uma variável que abstrai algum trecho de memória. pi é um ponteiro cujo valor é inicializado com o endereço desse trecho de memória. A atribuição a *pi resultará então em atribuição do trecho de memória apontado por pi, ou seja em atribuição da variável n.

O ponteiro nulo

O ponteiro nulo é um valor comum a todos os tipos "ponteiro para". Corresponde a um endereço inválido, e interpretado como um inteiro é o valor 0. O arquivo stddef.h da biblioteca padrão de C contem uma definição do valor NULL como sendo o ponteiro nulo. Em C, esse arquivo pode ser utilizado com a diretiva

#include <stddef.h>

Em C++ pode ser utilizado com a diretiva

#include <cstddef>

Para qualquer variável ou lvalue, o endereço daquela variável (ou lvalue) é diferente do ponteiro nulo. Note que se aplicamos o operador de dereferenciamento a um ponteiro cujo valor é o ponteiro nulo, então haverá um erro de execução.

O seguinte programa sofrerá um erro de execução na segunda linha:
 #include <cstddef>
 int main ()
 {
   int * p = NULL;
   * p = 0;
 }

Na função main, é declarada uma variável p cujo tipo é ponteiro para int. O valor inicial é o ponteiro nulo. Na linha seguinte, ocorre uma atribuição do valor 0 a *p, que denota o trecho de memória apontado por p. Como p é o ponteiro nulo, ele corresponde a nenhum trecho de memória, e ocorrerá um erro de execução. Por exemplo, apareceu a seguinte mensagem de erro quando executamos o programa acima e a execução foi interrompida:

Bus error


Confusões possíveis com os símbolos * e &

Até o momento, já vimos que o símbolo * possui três papeis diferentes nas linguagens C e C++. São eles:

  • operador de multiplicação para os diferentes tipos numéricos,
  • construtor de expressão de tipo ponteiro.
  • operador de dereferenciamento.

Também vimos dois papeis para o símbolo &, são:

  • operador de conjunção bit a bit,
  • operador de endereço.

Ainda tem um terceiro papel explicado mais adiante.

Para poder entender programas escritos nessas linguagens, é necessário então conhecer a existência desses diferentes papeis e saber identificar qual papel está sendo realizada por cada ocorrência destes símbolos. No caso do símbolo *, quando sucede a uma expressão de tipo e precede um nome, então é um construtir de tipo ponteiro. Quando precede um lvalue sem suceder a uma expressão de tipo, é o operador de dereferenciamento. Finalmente, quando está entre duas expressões numéricas, é o operador de multiplicação. No caso do símbolo &, quando precede um lvalue, sem suceder a uma expressão de tipo, é o operador de endereço. Quando está entre duas expressões inteiras, é o operador de conjunção bit a bit.


Arranjos e ponteiros

No parágrafo sobre arranjos como parâmetros de funções, aludimos ao fato de que um arranjo C nada mais é que o endereço onde está guardado o primeiro elemento. Portanto, um arranjo pode ser visto como um espécie de ponteiro. O parágrafo sobre aritmética de ponteiros explora mais a relação entre ponteiros e arranjos.

Observe então que se temos um arranjo A de valores do tipo t, então o valor de A nada mais é que &A[0], a expressão que representa o endereço do elemento na primeira posição.

Aritmética de ponteiros

Os operadores de adição e de subtração das linguagens C e C++ podem ser aplicadas a ponteiros. Em particular, as seguintes operações são legais:

  • p + n, onde p é um ponteiro, e n é um inteiro;
  • p - n, onde p é um ponteiro, e n é um inteiro;
  • p1 - p2, onde p1 e p2 são ponteiros.

As duas primeiras expressões retornam ambas um novo ponteiro. O valor da expressão p+n é um ponteiro cujo valor é endereço de p somado com o produto de n com o tamanho do tipo apontado por p. Considere o seguinte código:

float * exemplo_de_aritmetica_de_ponteiros(float * p)
{
  return p+1;
}

p é um ponteiro para o tipo float, que necessita de oito bytes para ser representado. A expressão p+1 vai assim ser igual ao valor de p (que guarda um valor do tipo float) mais oito. Assim, se p é um ponteiro para um tipo T, a expressão p+1 aponta para a posição seguinte que pode guardar um valor do mesmo tipo T.

Similarmente, se p é um ponteiro para o tipo T, a expressão p-n é igual à -ésima posição anterior que aponta para o tipo T.

Observe então que se temos um arranjo A de valores do tipo t. O valor de A[n] é igual a A+n: as duas expressões são equivalentes.

Na expressão p1-p2, ambos ponteiros devem apontar para elementos do mesmo arranjo (ou para a primeira posição após a última posição do arranjo), e o resultado será a diferença entre as posições desses elementos no arranjo.

Tipos referências

Introdução ao conceito

A linguagem Objective Caml possui um conceito de referência que pode ser relacionado com as variáveis e os ponteiros das linguagens C e C++. A linguagem C++ possui um outro conceito, que também é chamado de referência, mas que é diferente. Para evitar qualquer confusão, passaremos a chamá-las de "referências C++". Vale destacar que o conceito de referência C++ não existe na linguagem C.

Uma referência C++ permite declarar um sinônimo para um outro objeto. Se T representa algum tipo, então T & representa uma referência para aquele tipo. Observe então que o símbolo tem um terceiro sentido (além de conjunção bit a bit e de operador de endereço), que é de construir um tipo referência. A sintaxe é assim:

tipo & nome = lvalue;

onde tipo é uma expressão de tipo, lvalue é alguma expressão que representa alguma lvalue, e nome é um nome que será sinônima daquela lvalue. Essa associação permanecerá até que termine o escopo de nome. Vejamos agora um exemplo:

#include <cstdio>
int main ()
{
  int a = 1;
  int b = 2;
  int & r = a;
  printf ("1. a = %i, b = %i, r = %i\n", a, b, r);
  r = b;
  printf ("2. a = %i, b = %i, r = %i\n", a, b, r);
  printf ("3. &a = %p, &b = %b, &r = %p\n", &a, &b, &r);
}
A execução desse código resultará na seguinte impressão na saída padrão:
1. a = 1, b = 2, r = 1
2. a = 2, b = 2, r = 2
3. &a = 0xbffff8dc, &b = 0xbffff8e0, &r = 0xbffff8dc

A explicação é a seguinte. O programa possui três variáveis: a e b, ambas inteiras, e r uma referência para a variável a. Assim, na seqüência, r é um sinônimo de a. Na impressão 1, o valor de r é então o mesmo que o de a. Em seguida, r é atribuída b. Como r é uma referência para a, isso significa o valor de b (ou seja ) é atribuído r, logo a a. Na impressão 2, aparece então que tanto a como r tem o mesmo valor que é . Finalmente, a impressão 3, que mostra os endereços das três variáveis comprova que, efetivamente, o compilador associou a a e a r o mesmo endereço.

Discussão

O leitor sagaz pode perguntar-se então para quem servem as referências, já que elas parecem apenas introduzir complexidade: criar novos nomes para objetos já existentes só pode aumentar confusão! Na prática, as referências são principalmente usadas para permitir programar com facilidade um mecanismo de funções com parâmetros por referência, que permite passar um objeto para a função chamada. Esse mecanismo, presente em linguagens clássicas como Pascal, é ausente das linguagens C e Objective Caml, onde deve ser programado através de ponteiros (em C) e de referências (em OCaml).

Lembramos que, normalmente, em C e C++, os argumentos são passados por valor, ou seja os valores são copiados para instanciar os parâmetros formais. O fato de termos um parâmetro por referência faz com que é o próprio argumento que é recebido pela função chamada, e não uma cópia do valor desse argumento.

O exemplo seguinte mostra um aplicação clássica de passagem por referência:

#include <cstdio>
void swap (int & a, int & b)
{
  int tmp;
  tmp = a;
  a = b;
  b = tmp;
}
int main ()
{
  int a = 1;
  int b = 2;
  printf("antes: a = %i, b = %i.\n", a, b);
  swap (a, b);
  printf("depois: a = %i, b = %i.\n", a, b);
}

A execução desse programa resultará na seguinte impressão na saída padrão:

antes: a = 1, b = 2.
depois: a = 2, b = 1.

Para fins de comparação, um programa equivalente em C, com ponteiros e sem referências C++, seria:

#include <stdio.h>
void swap (int * a, int * b)
{
  int tmp;
  tmp = * a;
  * a = * b;
  & b = tmp;
}
int main ()
{
  int a = 1;
  int b = 2;
  printf("antes: a = %i, b = %i.\n", a, b);
  swap (& a, & b);
  printf("depois: a = %i, b = %i.\n", a, b);
}

Inicialização de referências

Quando não é um parâmetro de função, uma referência deve ser inicializada na sua declaração. Se omitirmos essa inicialização, então o compilador emitirá uma mensagem de erro.
#include <cstdio>
int main ()
{
  int a = 1;
  int b = 2;
  int & r;
  printf("a = %i, b = %i, r = %i.\n", a, b, r);
}

A compilação desse código não foi bem-sucedida, e imprimiu a seguinte mensagem na tela:

prog.cpp: In function 'int main()':
prog.cpp:6: error: 'r' declared as reference but not initialized


Tipos uniões

Tipos uniões agrupam diversos tipos em um mesmo tipo. Eles possuem portanto um papel muito similar aos tipos variantes de Objective Caml. Infelizmente, a programação por tipos uniões de C e C++ não é tão simples quanto a de tipos variantes.

A sintaxe para definir um tipo união é a seguinte:

union etiqueta {
  tipo1 nome1;
  tipo2 nome2;
  ...
  tipoN nomeN;
};

Essa sintaxe é muito similar à dos tipos registros. A semântica porém é bem diferente. Para explicar ela, vamos nos basear em um exemplo:

typedef union Unumero {
  long inteiro;
  double real;
} Tnumero;
Tnumero numero.

Então Tnumero é o nome de um tipo união e numero é uma variável do tipo Tnumero. Os valores da variável numero podem ser interpretados como sendo, ou do tipo long ou do tipo double. Para intepretar numero como um long, devemos utilizar o operador de seleção . (um ponto) da seguinte forma: numero.inteiro. Para interpretar numero como um double, utilizamos o operador de seleção com o nome correspondente: numero.real. Segue um pequeno programa que ilustra esses conceitos:

#include <cstdio>
int main ()
{
  typedef union Unumero {
    long inteiro;
    double real;
  } Tnumero;
  Tnumero numero;
  numero.inteiro = 2l;
  numero.inteiro *= numero.inteiro;
  printf("numero.inteiro = %li.\n", numero.inteiro);
  numero.real = 3.0;
  numero.real *= numero.real;
  printf("numero.real = %lf.\n", numero.real);
}

No programa, definimos uma variável numero do tipo Tnumero que é atribuída primeiro um valor inteiro, e segundo um valor flutuante. A variável só pode armazenar um 'único valor' de um desses tipos. Assim, a execução desse programa resulta na seguinte impressão na saída padrão:

numero.inteiro = 4.
numero.real = 9.000000.

O leitor sagaz deve questionar-se agora, como pode saber se o valor que a variável armazena é de um tipo ou de outro? Ou seja qual das duas interpretações possíveis é correta? O exemplo mostra que cada interpretação é correta em trechos distintos do código. Inicialmente, a variável é atribuída é um valor inteiro. O que aconteceria se o programa fosse interpretar esse valor como um valor flutuante? E será que é legal fazer isso? Podemos construir um pequeno programa para discutir as respostas as essas duas perguntas:

#include <cstdio>
typedef union Unumero {
  long inteiro;
  double real;
} Tnumero;
int main ()
{
  Tnumero numero;
  numero.real = 3.0;
  printf("numero.inteiro = %li.\n", numero.inteiro);
}

Aqui, a variável numero recebe um valor flutuante, que é interpretado como um número inteiro na chamada à função de impressão na saída padrão. Nesse caso, o compilador gera um programa sem emitir nenhuma mensagem de erro, nem sequer um aviso. Efetivamente, esse programa é perfeitamente legal. A saída gerada pela execução do programa é:

numero.inteiro = 1074266112.

Opa!?? O que acontece? Bom, a forma como os bits são arranjados para representar 3.0 do tipo double não tem nada a ver como a forma como o valor 3 do tipo long é representado... O que aparece na tela é o valor que a representação binária de 3.0 tem quando é interpretado como sendo tipo long. Mais precisamente, como o tipo long é representado por quatro bytes, e o tipo double com oito, o que aparece na tela a interpretação como valor do tipo long são os quatro primeiros bytes da representação binária do tipo double...

Então, como esse tipo de manipulação é legal tanto em C quanto em C++, é um recurso que podemos utilizar quando precisamos utilizar as representações binárias. Mas se precisamos trabalhar de forma mais abstrata, utilizando mecanismos semelhantes ao dos tipos variantes em Objective Caml, como podemos fazer?

Programação de tipos variantes

Considere o seguinte tipo Objective Caml

type number = Inteiro of int | Flutuante of float | Erro

e funções para combinar valores desses tipos, como por exemplo a soma:

let number_sum (n1: number) (n2: number) =
  match n1, n2 with
    _, Erro | Erro, _ -> Erro
  | Inteiro i1, Inteiro i2 -> let positive n = n > 0 in
    if positive (i1 + i2) <> positive i1 
    then Flutuante ((float_of_int i1) +. (float_of_int i2)) 
    else Inteiro(i1 + i2)
  | Inteiro i, Flutuante f -> Flutuante ((float_of_int i) +. f)
  | Flutuante f, Inteiro i -> Flutuante (f +. (float_of_int i))
  | Flutuante f1, Flutuante f2 -> Flutuante (f1 +. F2)

Tipos funções