Introdução C++

De MediaWiki do Campus São José
Ir para navegação Ir para pesquisar

Bibliografia


Introdução

A linguagem de programação C++ estende a linguagem C, de forma a aderir ao paradigma de orientação a objetos. Com isso, a linguagem oferece construções que possibilitam a expressão de programas modelados segundo uma análise orientada a objetos. A linguagem se apresenta como um superconjunto da linguagem C, o que significa que um programa em C pode ser visto também como um programa C++, mas não o contrário.

Este primeiro exemplo apresenta o clássico programa de demonstração hello world escrito em C++:

#include <iostream>

using namespace std;

int main() {
  cout << "Hello world !" << endl;

  return 0;
}

Para compilar este exemplo, deve-se gravá-lo em um arquivo com extensão .cc ou .cpp. O compilador C++ disponível no Linux se chama g++, sendo da mesma família de compiladores do gcc. Supondo que o nome do arquivo seja hello.cc, pode-se compilá-lo assim:

g++ -o hello hello.cc

O comando acima deve gerar um arquivo executável chamado hello. Para executá-lo, faça o seguinte:

./hello

O exemplo acima pode ser estendido para mostrar como ler dados do teclado:

#include <iostream>
#include <stdio.h>
 
using namespace std;
 
int main() {
  int x;

  cout << "Hello world !" << endl;
  cout << "x: ";
  cin >> x;
  cout << "Voce digitou " << x << endl; 
  return 0;
}

Organização de código-fonte


Programas em linguagens C e C++ são usualmente organizados em um conjunto de arquivos de código-fonte de dois tipos:

  • Arquivos de cabeçalho (extensão .h): nesse tipo de arquivo se escrevem declarações necessárias em outros arquivos de código-fonte. Essas declarações incluem tipos de dados, protótipos de funções, classes e templates, variáveis globais e macros. Com exceção de templates e macros, nesses arquivos não se escrevem implementações (corpos de funções e métodos).
  • Arquivos de implementação (extensão .cpp, .cc ou .c): nesses arquivos se escrevem as implementações de funções e métodos de classes. Arquivos de cabeçalhos podem ser incluídos com a diretiva #include para obter declarações necessárias à implementação.

Argumentos de linha de comando


Argumentos de linha de comando são dados passados a um programa no momento de sua execução. Na época em que a linguagem C foi inventada, e quando então os sistemas Unix se popularizaram, a interface com usuários era em modo texto. Usuários interagiam com o sistema operacional por meio de um programa chamado de interpretador de comandos, ou simplesmente shell. Para um usuário executar um programa, ele deveria digitar o nome desse programa no prompt do shell e pressionar a tecla ENTER (ou RETURN). Cabia então ao shell ordenar ao sistema operacional que o arquivo de programa solicitado fosse executado. Além do nome do programa, um usuário poderia opcionalmente especificar um ou mais argumentos, que são strings a serem passadas ao programa no momento de sua execução. Os dois exemplos a seguir mostram a execução do programa ls por meio de um shell.


aluno@M1:$ ls
Área de trabalho  Downloads  Modelos  Público   Vídeos
Documentos        Imagens    Música   teste.sh
aluno@M1:$

Execução do programa ls sem argumentos de linha de comando. O prompt do shell é a string aluno@M1:$.


aluno@M1:$ ls -l
total 48
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Área de trabalho
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Documentos
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Downloads
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Imagens
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Modelos
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Música
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Público
-rw-r--r-- 1 aluno aluno 14076 abr 12  2018 teste.sh
drwxr-xr-x 2 aluno aluno  4096 abr 12  2018 Vídeos
aluno@M1:$

Execução do programa ls com um argumento de linha de comando (a string -l). Cabe ao programa ls interpretar esse argumento, e usá-lo para desempenhar sua funcionalidade.


Do ponto de vista do programador, os argumentos de linha de comando são acessíveis por meio de dois parâmetros da função main, os quais são tradicionalmente denominados:

  • argc: um número inteiro que informa quantos argumentos de linha de comando foram passados ao programa. Esse parâmetro tem no mínimo o valor 1 (um), pois o próprio nome do programa é considerado seu primeiro argumento.
  • argv: um vetor de strings que contém os argumentos. A primeira string desse vetor é sempre o próprio nome do programa. A posição no vetor em seguida ao último argumento contém o ponteiro NULL.


O programa C a seguir mostra como esses parâmetros são especificados na função main, e como podem ser usados. Neste exemplo, apresentam-se na tela todos os argumentos de linha de comando (um por linha), na ordem em que foram passados ao programa.

#include <stdio.h>

int main(int argc, char * argv[]) {
  int n;

  for (n=0; n < argc; n++) {
    printf("argv[%d]=%s\n", n, argv[n]);
  }

  return 0;
}


A versão em C++ desse exemplo é praticamente idêntica, mudando somente a forma com que os dados são mostrados na tela.

#include <iostream>

using namespace std;

int main(int argc, char * argv[]) {
  int n;

  for (n=0; n < argc; n++) {
    cout << "argv[" << n << "]=" << argv[n] << endl;
  }

  return 0;
}


Apesar de hoje em dia as interfaces de usuário em modo texto serem menos usadas, o modelo de execução de programas continua o mesmo. Mesmo programas gráficos se valem de argumentos de linha de comando para modularem a forma com que devem funcionar (ver, por exemplo, o manual do firefox). Independente do tipo de interface de usuário, argumentos de linha de comando são de grande utilidade para passar informação a programas quando forem executados.

Classes e objetos

A linguagem C foi projetada segundo um paradigma de programação chamado de programação estruturada. Essa forma de expressar programas se baseia em subrotinas (funções no caso da linguagem C), blocos de instruções e laços de repetição, os quais atuam sobre variáveis que contêm os dados a serem acessados e transformados. O que se deve notar é a forma com que se costuma modelar um programa nesse caso, dando-se ênfase aos algoritmos que modificam os dados. Não é à toa que uma ferramenta de modelagem tradicional e ainda muito usada seja o fluxograma. Porém existem outras formas de pensar os problemas a serem resolvidos e expressar programas, dentre elas o paradigma de orientação a objetos seguido por diversas linguagens tais como C++, Java e Python.


Um programa orientado a objetos é composto por objetos que interagem. Um objeto é uma abstração que representa uma coisa que faz parte do problema a ser resolvido. Um objeto contém dados que representam seu estado (chamados de atributos), e procedimentos que representam seu comportamento (chamados de métodos). No caso do exemplo dos horários, um horário pode ser representado por um objeto. O estado desse objeto Horario é definido pelos valores de hora, minuto e segundo, e seu comportamento é dado pelos métodos mostreSe e acrescenta. Isso deve ser entendido da seguinte forma: um objeto 'Horario pode ser mostrado, ao se chamar seu método 'mostreSe, e pode ser somado a outro objeto Horario, ao se chamar seu método acrescenta. Essas são as únicas operações definidas para esse objeto, e assim nada mais se consegue fazer com ele ... a não ser que se definam novas operações.


Muitos objetos similares podem existir. Por exemplo, pode haver muitos diferentes objetos Horario, que se diferenciam por seus estados (valores de hora, minuto e segundo). Todos os objetos cujos estados são definidos pelo mesmo conjunto de atributos, e que possuem o mesmo comportamento (conjunto de métodos), pertencem à mesma classe. O conceito de classe é semelhante ao de tipo de dados, porém tem uma implicação mais profunda. Um tipo de dados define como o conteúdo de uma variável deve ser interpretado (ex: um número inteiro, ponto flutuante, uma string, ...), porém não define os procedimentos que podem manipular essas variáveis. Desta forma, uma classe vincula a seus objetos os algoritmos que atuam sobre eles. Isso tem uma importante implicação no acoplamento e coesão de um programa, que são métricas fundamentais de qualidade de um projeto de software modular.


Declaração de classes

Uma classe é declarada usando-se a palavra-chave class seguida do nome da classe:

class MinhaClasse {
 // declarações internas da classe
};

De certa forma, isso se assemelha à declaração de struct. No entanto, as declarações internas da classe têm um significado um pouco diferente. Por exemplo, suponha-se que a classe MinhaClasse represente um número de telefone, incluindo seu código de área. A declaração seria estendida desta forma:


class MinhaClasse {
 private:
  string numero;
  string ddd;
 public:
 // declarações dos procedimentos da classe
};

A classe agora possui dois atributos:

  • string numero: armazena o número de telefone
  • string ddd: armazena o código DDD

No exemplo, as declarações desses atributos na classe são precedidas pela cláusula private:. Isso significa que esses atributos não podem ser acessados diretamente de fora da classe (por procedimentos implementados fora da classe). Logo após a declaração desses atributos, aparece a cláusula public:. Ela define que o que for declarado em seguida pode ser acessado diretamente de fora da classe. Por exemplo, os métodos da classe podem ser declarados logo após public:.


class MinhaClasse {
 private:
  string numero;
  string ddd;
 public:
  // Construtor: executado quando se cria um objeto desta classe
  MinhaClasse(string umNumero, string umDDD);

  // Destrutor: executado quando se destrói um objeto desta classe
  ~MinhaClasse();

  // métodos para acessar os valores dos atributos
  string obtemNumero();
  string obtemDDD();
  string obtemNumeroDDD();
};

Um método é uma função que pertence a uma classe. A idéia é que um objeto de uma classe possui um estado dado pelos valores de seus atributos, e somente os métodos daquela classe podem acessar ou modificar esse estado. Quer dizer, somente métodos da classe podem acessar diretamente esses atributos. Dois métodos são especiais:

  • construtor: este método é executado sempre que se cria um objeto de uma classe. Ele tem por finalidade iniciar o estado daquele objeto. Isso envolve definir os valores iniciais dos atributos, e conferir se os parâmetros que porventura tenham sido passados são válidos (ex: se o número tem somente caracteres numéricos).
  • destrutor: este método é executado quando um objeto é destruído. Ele é responsável por limpar o estado desse objeto, desfazendo qualquer ação que ele tenha iniciado. Por exemplo, se o objeto alocou memória dinamicamente em algum momento, o destrutor deve liberá-la.


A declaração da classe apresentada apenas informa quais métodos ela implementa, mas não mostra como eles são implementados. Essa implementação pode ser feita de duas formas:

  • inline: a implementação de um método é feita dentro da própria declaração da classe. Por exemplo, o método obtemNumeroDDD poderia estar implementado assim:
    class MinhaClasse {
     private:
      string numero;
      string ddd;
     public:
      // Construtor: executado quando se cria um objeto desta classe
      MinhaClasse(string umNumero, string umDDD);
    
      // Destrutor: executado quando se destrói um objeto desta classe
      ~MinhaClasse();
    
      // métodos para acessar os valores dos atributos
      string obtemNumero();
      string obtemDDD();
    
      // implementação inline
      string obtemNumeroDDD() {
        string num = ddd + '-' + numero;
        return num;
      }
    };
    
  • fora da classe: a implementação é feita externamente, possivelmente em outro arquivo. Deve-se declarar a classe a que pertence o método, prefixando seu nome com Nome_da_classe::. No caso de obtemNumeroDDD, isso poderia ser feito assim:
    // deve-se prefixar o nome do método com o nome da classe, para indicar que é um método dessa classe
    string MinhaClasse::obtemNumeroDDD() {
      string num = ddd + '-' + numero;
      return num;
    }
    

Criação de objetos

Uma vez tendo declarado e implementado uma classe, podem-se criar objetos. A isso se chama instanciação, e os objetos também podem ser chamados de instâncias. Objetos podem ser criados diretamente, como se fossem variáveis usuais, como mostrado a seguir:

#include "minhaclasse.h"
#include <iostream>

using namespace std;

int main() {
  string num, ddd;

  cout << "Numero: ";
  cin >> num;
  cout << "DDD: ";
  cin >> ddd;

  // o construtor da classe é chamado quando se cria um objeto
  // Aqui, o objeto é dado pela variável "fone"
  MinhaClasse fone(num, ddd);

  cout << "Numero com DDD é " << fone.obtemNumeroDDD() << endl;

  // objetos são destruídos automaticamente ao final do escopo em que foram criados
  // o destrutor é então executado 
}


Objetos podem também ser criados dinamicamente, em que a memória necessária é alocada. Para isso usa-se o operador new (e não a função malloc !). Objetos criados dessa forma são referenciados por ponteiros, e devem ser explicitamente destruídos usando-se o operador delete (e não a função free !). O exemplo a seguir mostra a criação e destruição de objetos com new e delete:

#include "minhaclasse.h"
#include <iostream>

using namespace std;

int main() {
  string num, ddd;
  // o ponteiro fone é capaz de apontar objetos de MinhaClasse
  MinhaClasse * fone;

  cout << "Numero: ";
  cin >> num;
  cout << "DDD: ";
  cin >> ddd;

  // o construtor da classe é chamado quando se cria um objeto com o operador "new"
  // Aqui, o objeto é dado pelo ponteiro "fone"
  fone = new MinhaClasse(num, ddd);

  cout << "Numero com DDD é " << fone->obtemNumeroDDD() << endl;

  // objetos dinâmico devem ser destruídos explicitamente usando o operador "delete"
  // o destrutor é então executado 
  delete fone;
}

Invocação do construtor

A criação de um objeto causa a execução do método construtor da classe. Esse método é responsável por iniciar os valores dos atributos do objeto (chamados de variáveis de instância), tornando-o pronto para ser utilizado. O construtor recebe os parâmetros passados na criação do objeto (ver linhas 16 do primeiro exemplo e 18 do segundo). No exemplo em questão, o método construtor poderia fazer algo assim:

MinhaClasse::MinhaClasse(string umNumero, string umDDD) {
  numero = umNumero;
  ddd = umDDD;
}

... quer dizer, apenas copiar os valores dos parâmetros para os respectivos atributos. Outra forma de implementar o construtor e iniciar os valores dos atributos é esta:

MinhaClasse::MinhaClasse(string umNumero, string umDDD) : numero(umNumero), ddd(umDDD) {
}


Deve-se observar que os atributos são iniciados usando uma sintaxe especial. Logo após o nome do método construtor declara-se uma lista de inicialização de atributos. Essa lista é iniciada com :, seguida dos nomes dos atributos invocados como se fosse outros objetos sendo criados. Na verdade é exatamente isso que acontece nessa forma de iniciar os atributos: eles são criados como objetos que fazem parte do objeto criado. Essa forma de iniciar atributos é imprescindível quando uma classe possui um ou mais atributos que são de fato objetos de alguma classe, e que precisam receber parâmetros ao serem criados.

Destruição de objetos

A destruição de um objeto ocorre em duas situações:

  1. Quando se chega ao fim o escopo dentro do qual o objeto foi criado: isso vale para objetos declarados e instanciados diretamente. Ex:
    void ecoa() {
      string palavra; // aqui se cria um objeto string
    
      cout << "Digite uma palavra: ";
      cin >> palavra;
      cout << "Você digitou: " << palavra << endl;
    } // fim do escopo ... o objeto string "palavra" é destruido automaticamente
    
  2. Quando se destroi o objeto com o operador delete: isso vale somente para objetos criados dinamicamente com o operador new. Ex:
    void ecoa() {
      string * palavra; // aqui se declara um ponteiro para string
    
      palavra = new string; // aqui se cria dinamicamente um objeto string
      cout << "Digite uma palavra: ";
      cin >> *palavra;
      cout << "Você digitou: " << *palavra << endl;
    
      delete palavra; // aqui se destroi o objeto string
    }
    


A destruição de um objeto implica liberar as áreas de memória a ele alocadas dinamicamente em sua criação, ou durante seu tempo de vida. Essa faxina deve ser feita pelo método destrutor da classe (chamado simplesmente de destructor). Tal método é declarado da seguinte forma em uma classe:

classe Demo {
 public:
  Demo(); // constructor da classe
  ~Demo(); // destructor da classe

  // demais métodos da classe
};

// implementação do destructor
Demo::~Demo() { 
  // limpeza do objeto
}

A implementação desse método deve ser responsável pela limpeza do objeto. Apenas áreas de memória alocadas dinamicamente precisam ser liberadas. O exemplo a seguir mostra uma classe com memória alocada dinamicamente:

classe Demo {
 public:
  Demo(const string & name); // constructor da classe
  ~Demo(); // destructor da classe

  // demais métodos da classe
 private:
  // atributos privativos da classe
  string nome;
  char * buffer;
};

// implementação do constructor
Demo::Demo(const string & name) { 
  nome = name;
  buffer = new char[1024]; // aloca dinamicamente 1kB
}

// implementação do destructor
Demo::~Demo() { 
  // libera a área de memória alocada no constructor
  delete buffer;
}

Um exemplo

O exemplo abaixo pode esclarecer melhor os conceitos de classe e objeto. Ele declara uma classe Vetor, que representa vetores bidimensionais. As operações sobre tais objetos são: módulo, produto escalar, subtração e escrita em um arquivo. Veja como pode ser escrita essa classe.

// a classe Vetor define objetos que representam vetores bidimensionais
class Vetor {
 private: // as declarações abaixo são privativas (podem ser acessadas somente dentro da classe)

  double x, y;

 public: // as declarações abaixo são públicas, podendo ser acessadas de fora da classe

  // construtor da classe: um método especial que cria objetos desta classe
  Vetor(double px, double py) {
    // inicia o estado de um objeto
    x = px;
    y = py;
  }

  // destrutor da classe: um método especial que destrói objetos desta classe
  ~Vetor() {} // nada demais precisa ser feito

  // este método retorna o módulo do vetor
  double modulo() {
    return sqrt(x*x + y*y);
  }

  // este método retorna o produto escalar deste vetor com outro vetor
  double produto_escalar(const Vetor & outro) {
    return x*outro.x + y*outro.y;
  }

  // este método calcula a soma entre este e outro vetor (este + outro),
  // retornando um novo vetor como resultado
  Vetor adicione(const Vetor & outro) {
    Vetor novo(x+outro.x, y+outro.y);

    return novo;
  }

  // este método calcula a diferença entre este e outro vetor (este - outro),
  // retornando um novo vetor como resultado
  Vetor subtraia(const Vetor & outro) {
    Vetor novo(x-outro.x, y-outro.y);

    return novo;
  }

  // este método escreve uma representação do objeto no arquivo "out"
  // (que é um objeto da classe ostream)
  void mostreSe(ostream & out) {
    out << "(";
    out << x;
    out << ", ";
    out << y;
    out << ")";
  }

};

Uma vez existindo a classe Vetor, pode-se escrever um programa que use objetos dessa classe. No exemplo abaixo, calcula-se o vetor resultante de dois outros vetores:

int main() {
  int x, y;

  cout << "Coordenada X do vetor 1: ";
  cin >> x;
  cout << "Coordenada Y do vetor 1: ";
  cin >> y;
  cout << endl;

  // Aqui se cria o primeiro objeto Vetor
  Vetor v1(x, y);

  cout << "Coordenada X do vetor 2: ";
  // Aqui se lê pela entrada padrão a coordenada X
  cin >> x;
  cout << "Coordenada Y do vetor 2: ";
  // Aqui se lê pela entrada padrão a coordenada Y
  cin >> y;
  cout << endl;

  // Aqui se cria o segundo objeto Vetor
  Vetor v2(x, y);

  // Aqui se obtém a resultante dos vetores v1 e v2. Note como a operação "adicione"
  // do objeto "v1" é chamada.
  Vetor v3 = v1.adicione(v2);

  cout << "Vetor resultante: ";
  // Aqui se escreve uma representação de "v3" na tela. "cout" é um objeto da biblioteca C++ padrão
  // que representa a saída padrão de um processo. A saída padrão funciona como um arquivo. O objeto 
  // "cout" é similar ao "stdout" da linguagem C.
  v3.mostreSe(cout);
  cout << endl;
}

O programa em si se apresenta como um pequeno algoritmo que lê pela entrada padrão valores de coordenadas e cria os respectivos vetores (v1 e v2), calculando sua resultante a apresentando na saída padrão o novo vetor (v3).


Além das regras de escrita da linguagem, o que se denomina sintaxe, ressalta-se o uso dos objetos feito no programa. O pequeno programa criado trata de calcular a resultante de dois vetores bidimensionais. As coisas a serem diretamente manipuladas pelo programa são os vetores, sendo estes representado por objetos. Outros objetos usados são os arquivos que representam a entrada e saída padrão do programa (respectivamente cin e cout). O programa usa os objetos da classe Vetor, explorando as operações que eles oferecem. Assim, a declaração da classe Vetor se preocupa em definir o que é um objeto Vetor (seus atributos) e o que ele é capaz de fazer (seu comportamento). Já o programa apenas utiliza esses objetos.

O ponteiro predefinido this


Ao se implementarem métodos de uma classe pode-se usar uma variável predefinida chamada this, que é um ponteiro para o objeto no escopo do qual se executa um método. Sempre que dentro de um método for necessário eliminar uma ambiguidade quanto ao uso de um atributo do objeto, ou por clareza, pode-se usar esse ponteiro this. O exemplo a seguir ilustra o uso de this.

class Coisa {
 public:
  Coisa(int x, int y);
  ~Coisa() {}
  // outros métodos da classe ...
 private:
  // os atributos dos objetos desta classe
  int x,y;
};

// construtor da classe Coisa: os parâmetros "x" e "y" são usados para iniciar os 
// atributos "x" e "y". Para resolver a ambiguidade entre parâmetros e atributos, dentro do construtor
// se usa o ponteiro "this"
Coisa::Coisa(int x, int y) {
  this->x = x;
  this->y = y;
}


Existem outros casos em que o ponteiro this tem utilidade. Um exemplo pode ser visto na seção sobre construtor de cópia.

Operador de atribuição

Em C++ pode-se definir como um operador específico atua sobre um objeto. Operadores típicos são: = (atribuição), + (adição), - (subtração), e outros. Um operador é definido por uma função ou método que implementa sua operação. No caso da Fila, um operador útil é o de atribuição, pois pode ser usado para que uma fila existente seja modificada para se tornar idêntica a outra fila. Supondo que esse operador tenha sido implementado, o exemplo a seguir mostra a fila f1 sendo atribuída a f2 (linha 19), a qual já existe e tem inclusive dados armazenados, e demonstra em seguida como ficou o conteúdo de f2.


#include <iostream>
#include "fila.h"

using namespace std;

int main() {
  Fila f1(5);
  Fila f2(7);

  f1.enfileira(2);
  f1.enfileira(7);
  f1.enfileira(4);

  f2.enfileira(3);

  cout << "Comprimento de f1: " << f1.comprimento() << endl;
  cout << "Comprimento de f2: " << f2.comprimento() << endl;

  f2 = f1; // atribui f1 a f2 ... f2 torna-se uma cópia de f1

  cout << "Comprimento de f1: " << f1.comprimento() << endl;
  cout << "Comprimento de f2: " << f2.comprimento() << endl;

  while (not f1.vazia()) cout << "Desenfileirou de f1: " << f1.desenfileira() << endl;
  
  cout << "Comprimento de f1: " << f1.comprimento() << endl;
  cout << "Comprimento de f2: " << f2.comprimento() << endl;

  while (not f2.vazia()) cout << "Desenfileirou de f2: " << f2.desenfileira() << endl;
}


O operador de atribuição da fila pode ser declarado como mostrado abaixo:

class Fila {
 public:
  Fila(int umaCapacidade); // cria uma fila capaz de guardar "capacidade" elementos
  Fila(const Fila & outra);
  ~Fila(); // destroi a fila
  Fila& operator=( const Fila& outra ); // OPERADOR DE ATRIBUIÇÃO
  // demais declarações da Fila ...
};


Como se pode notar, sua declaração é muito parecida com a de um método qualquer da classe. A palavra-chave operator indica tratar-se de um método de operador, e o símbolo que aparece logo em seguida indica o operador a ser redefinido (no caso, =). O único parâmetro a ser passado é o objeto a ser copiado (no caso, uma Fila). O valor retornado é uma referência ao próprio objeto que foi o destino da atribuição (aquele que foi modificado). A implementação desse método segue as regras usadas nos demais métodos:

Fila& Fila::operator=( const Fila& outra ) {
  // desalocar a memória de "buffer"
  // copiar os valores de atributos de "outra"
  // alocar memória para "buffer"
  // copiar conteúdo da "buffer" da "outra" fila
  
  // retorna uma referência ao próprio objeto 
  return *this;
}

A implementação do método do operador de atribuição em fila.cpp

Construtor de cópia

Um construtor de cópia é um método construtor para criar um objeto que é uma cópia idêntica a um objeto existente. Esse tipo de criação de objeto aparece em casos como este:

Fila<int> f1(10); // cria uma fila capaz de guardar 10 números inteiros

f1.enfileira(5);
f1.enfileira(8);
f1.enfileira(2);
f1.enfileira(4);

Fila<int> f2 = f1; // cria a fila f2, que é uma cópia idêntica de f1

f1.esvazia(); // esvazia a fila f1

// Mostra o conteúdo da fila f2: deve ser igual ao que existia em f1
while (not f2.vazia()) {
  cout << f2.desenfileira() << endl;
}

Para que o exemplo acima funcione, a classe Fila deve implementar seu construtor de cópia:

template <typename T> class Fila {
 public:
  // construtor de cópia: o objeto criado deve ser idêntico ao objeto "outra"
  Fila(const Fila<T> & outra);

  // demais declarações da Fila ...
};

O construtor de cópia deve ser responsável por copiar o estado do objeto copiado, de forma que o objeto criado seja idêntico a ele. Isso envolve copiar os valores dos atributos desses objetos, porém em certos casos isso não é suficiente. Se o objeto copiado tiver como atributos outros objetos ou variáveis criados dinamicamente, então o novo objeto deve também criar dinamicamente cópias desses objetos ou variáveis.


Deve-se observar que um construtor de cópia é muito parecido com um operador de atribuição. A diferença reside no fato de que uma atribuição ocorre entre dois objetos já existentes. Por isso um objeto que recebe uma atribuição deve primeiro limpar seu estado (se necessário, o que envolve destruir eventuais objetos e variáveis dinâmicos) para depois se tornar uma cópia do outro objeto. Apesar disso, se um operador de atribuição já existir ele pode ser usado para facilmente implementar um construtor de cópia. O exemplo a seguir mostra como isso pode ser feito para a Fila:

// implementação do construtor de cópia
template <typename T> Fila<T>::Fila(const Fila<T> & outra) {
  // primeiro devem-se atribuir valores aos atributos desta Fila 
  // para que ela pareça vazia

  // em seguida, basta atribuir a "outra" Fila a este objeto
  // Note o uso do ponteiro predefinido "this", que sempre aponta o objeto que recebe a operação
  // dentro de um método de sua classe
  *this = outra;
}

Templates


Templates possibilitam a parametrização de tipos em C++. Com isso, podem-se escrever funções, structs e classes que referenciam ou armazenam dados cujos tipos sejam desconhecidos de antemão. Esses tipos são denominados tipos genéricos. No nosso caso, isso será usado para generalizar as estruturas de dados a serem criadas, para que elas possam armazenar qualquer tipo de dado. Na explicação a seguir, usa-se a estrutura de dados Fila como exemplo.


A declaração de uma classe template inicia com o prefixo template <typename T>. A cláusula <typename T> serve para especificar um identificador para o tipo genérico a ser usado dentro da classe (no exemplo, escolheu-se o identificador T, mas pode ser qualquer outra coisa). No caso da Fila, deseja-se torná-la capaz de guardar qualquer tipo de dado. Quer dizer, seu funcionamento não depende do que ela estiver armazenando, portanto o tipo do dado armazenado pode ser generalizado. Esse é um bom exemplo de uso de classe template, estando exemplificado a seguir:

template <typename T> class Fila {

// declarações de atributos e métodos da classe

};


Todas as referências ao tipo genérico devem ser escritas usando o identificador especificado (no caso, T). Aproveitando o exemplo da Fila, deve-se observar que o atributo buffer depende do tipo genérico, pois trata-se de um vetor onde devem ser armazenados os dados enfileirados. Os métodos enfileira, desenfileira e frente também dependem do tipo genérico. A declaração da classe template Fila segue abaixo, porém com alguns detalhes omitidos para ficar mais evidente o uso de template.


template <typename T> class Fila {
  public:
    Fila(unsigned int N);
    ~Fila();

    // enfileira um dado do tipo T
    void enfileira(const T & algo);

    // desenfileira um dado da frente da fila, e retorna uma 
    // cópia desse dado (cujo tipo é T)
    T desenfileira();

    // Retorna uma referência ao dado de tipo T que está na frente da fila
    T & frente() const;

    bool vazia() const {return itens == 0; }
    bool cheia() const { return itens == capacidade;}
    unsigned int tamanho() const { return itens;}
  private:
    int capacidade;
    int itens, inicio, fim;
    T* buffer; // área de memória para guardar os dados de tipo T enfileirados
};


Quando se declara uma classe template, tanto a declaração da classe (sua interface) quanto a implementação de seus métodos devem estar no mesmo arquivo com extensão .h. Isso se deve a detalhes específicos da compilação do código-fonte. Assim, a implementação dos métodos pode estar dentro da própria declaração da classe, como no caso dos métodos vazia, cheia e tamanho, ou fora da classe. Quando forem implementados fora da classe, deve-se repetir o prefixo template <typename T> na frente do nome de cada método, como mostrado a seguir:

template <typename T> void Fila<T>::enfileira(const T& algo) {
  // implementação do método
}


Uma vez declarada uma classe template, ela pode ser utilizada. Somente no momento em que uma classe é usada que o tipo genérico é especificado. Por exemplo, a Fila acima declarada pode guardar qualquer coisa, o que inclui strings e números inteiros. Para declarar duas filas, de forma que uma armazene inteiros e outra strings, deve-se usar esta sintaxe:

int main() {
  // Uma fila capaz de guardar até 10 números inteiros
  Fila<int> f1(10);

  // uma fila capaz de guardar até 5 strings
  Fila<string> f2(5);

  // enfileira-se o número 23 na fila de inteiros
  f1.enfileira(23);

  // enfileira-se a string "uma coisa" na fila de strings
  string algo = "uma coisa";
  f2.enfileira(algo);

  // outras declarações da função main ...
}


Do ponto de vista da linguagem, um template define um padrão de geração de código para o compilador (maiores detalhes). Quer dizer, um template em si não é compilável, pois ele representa código-fonte ainda incompleto. No entanto, no momento em que se usa um template, o código-fonte é completado e pode ser compilado. Em outras palavras, o template da Fila se torna uma Fila de fato. O programa de teste abaixo aproveita o exemplo das filas, enfileirando e desenfileirando dados de duas filas.

#include <iostream>
#include "fila.h"
 
using namespace std;
 
int main() {
  // Uma fila de strings
  Fila<string> fila1(10);
 
  // Uma fila de inteiros
  Fila<int> fila2(10);
 
  // Armazena três strings na fila de strings
  for (int x=0; x < 3; x++) {
    string palavra;
 
    cout << "Digite uma palavra: ";
    cin >> palavra;
    fila1.enfileira(palavra);
  }
 
  // Armazena alguns números na fila de inteiros
  for(int x=0; x < 8; x += 1) lista2.enfileira(x);
 
  // Mostra os dados da fila de strings
  while (not fila1.vazia()) {
    cout << "desenfileirou: " << fila1.desenfileira() << endl;
  }
 
  // Mostra os dados da fila de inteiros
  while (not fila2.vazia()) {
    cout << "desenfileirou: " << fila2.desenfileira() << endl;
  }
 
}

Passagem de parâmetros

A passagem de parâmetros em C++ pode ser feita de duas maneiras:

  1. Por valor: o parâmetro recebe uma cópia do valor que foi passado. Se o parâmetro for alterado dentro da função, o valor original não é modificado.
  2. Por referência: o parâmetro é uma referência ao valor que foi passado. Se o parâmetro for alterado, o valor original também será modificado.

OBS: o caso de um parâmetro passado por ponteiro funciona da mesma forma que em C. No caso, se o o valor apontado pelo ponteiro for alterado dentro da função, o valor original será modificado. Isso funciona de forma parecida com a passagem de parâmetro por referência, apesar de haver uma diferença sutil.

Os exemplos a seguir ilustram ambas formas de passagem de parâmetros:

Passagem por valor Passagem por referência
#include <iostream>

using namespace std;

int incrementa(int x) {
  x++;
  return x;
}

int main() {
  int valor = 10;
  int resultado;

  resultado = incrementa(valor);
  cout << "Resultado=" << resultado;
  cout << ", e valor=" << valor << endl;
}
#include <iostream>

using namespace std;

// passagem por referência: observe o operador & 
// antes do nome do parâmetro
int incrementa(int & x) {
  x++;
  return x;
}

int main() {
  int valor = 10;
  int resultado;

  resultado = incrementa(valor);
  cout << "Resultado=" << resultado;
  cout << ", e valor=" << valor << endl;
}



É possível também a passagem de parâmetros por referência, porém somente-leitura. A utilidade disso é aproveitar a eficiência da passagem de parâmetro por referência, que evita uma cópia de valor, porém evitando que o valor original seja modificado dentro da função. Exemplo (experimente compilar estes programas):

Programa 1 Programa 2
#include <iostream>

using namespace std;

int incrementa(const int & x) {
  x++;
  return x;
}

int main() {
  int valor = 10;
  int resultado;

  resultado = incrementa(valor);
  cout << "Resultado=" << resultado;
  cout << ", e valor=" << valor << endl;
}
#include <iostream>

using namespace std;

int incrementa(const int & x) {
  int y = x;

  y++;
  return y;
}

int main() {
  int valor = 10;
  int resultado;

  resultado = incrementa(valor);
  cout << "Resultado=" << resultado;
  cout << ", e valor=" << valor << endl;
}

Alocação dinâmica de memória: operadores new e delete

A alocação dinâmica de memória é uma técnica para reservar e liberar memória por demanda, e sob controle do programador. Ela funciona como se o programador criasse variáveis, porém tivesse que explicitamente destrui-las. Assim, as variáveis não deixariam de existir automaticamente, quando a execução do programa saísse do escopo em que foram criadas.

A alocação dinâmica está diretamente relacionada com variáveis do tipo ponteiro. Ao se alocar memória dinamicamente, obtém-se o endereço inicial da área de memória obtida, e esse endereço deve ser armazenado em um ponteiro. O uso dessa área de memória, portanto, se faz por meio desse ponteiro.

A alocação dinâmica de memória na linguagem C++ se faz com o operador new. Esse operador aloca memória de acordo com o tipo de dados informado, e retorna um ponteiro para essa área de memória. Quando essa área de memória não for mais necessária, ela deve ser liberada com o operador delete. Veja o exemplo a seguir, que mostra o uso dos operadores new e delete em uma situação muito simplificada.

#include <iostream>
#include <string>

using namespace std;

template <typename T> void mostra(const string & varname, T * val) {
  cout << "######################################################" << endl;
  cout << "Dado apontado por " << varname << " = " << *val << endl;
  cout << "Endereço da área de memória usada por " << varname << ": " << (void*)val << endl;
  cout << "Memória alocada: " << sizeof(*val) << " bytes" << endl;
}

int main() {
  // algumas variáveis
  // a memória para elas é alocada automaticamente
  int * x;
  double  * h;

  // aloca dinamicamente memória para um valor do tipo int
  x = new int;

  // aloca dinamicamente memória para um valor do tipo double
  h = new double;

  // armazena valores nas áreas de memória alocadas, usando-se
  // os ponteiros
  *x = 35;
  *h = 6.6260715e-34;

  mostra("x", x); 
  mostra("h", h); 

  // libera as áreas de memória
  delete x;
  delete h;
}

Ao executá-lo, obtém-se isto:

######################################################
Dado apontado por x = 35
Endereço da área de memória usada por x: 0x561931d5de70
Memória alocada: 4 bytes
######################################################
Dado apontado por h = 6.62607e-34
Endereço da área de memória usada por h: 0x561931d5de90
Memória alocada: 8 bytes

Inicialização da área de memória alocada

Uma forma alternativa (e importante, como se poderá observar mais pra frente) de usar o operador new possibilita inicializar a área de memória assim que for alocada. O interessante dessa inicialização é que ela depende do tipo de dados para que foi alocada a memória. Para tipos de dados primitivos, como int, float, double e char, essa inicialização se limita a copiar um valor inicial. Veja como ficaria a alocação de memória do exemplo anterior com esse uso do operador new:

  // aloca dinamicamente memória para um valor do tipo int e depois para um double
  // e armazena valores nas áreas de memória alocadas
  x = new int(35);

  // aloca dinamicamente memória para um valor do tipo double
  h = new double(6.6260715e-34);

  mostra("x", x); 
  mostra("h", h);

No caso de um tipos de dados que possui um construtor, que é uma função-membro executada automaticamente quando se cria um valor desse tipo, o operador new o executa logo após alocar memória.

#include <iostream>
#include <string>

using namespace std;

struct Nome {
  string nome, sobrenome;

  // construtor: separa um nome completo, 
  // armazenando nome e sobrenome nos respectivos atributos
  Nome(const string & nome_completo) {
    int pos = nome_completo.find(' ');
    nome = nome_completo.substr(0, pos);
    int pos2 = nome_completo.find_first_not_of(" ", pos);
    sobrenome = nome_completo.substr(pos2);
  }
};

int main() {
  auto alguem = new Nome("Dona Bilica da Silva");

  cout << "Nome: " << alguem->nome << endl;
  cout << "Sobrenome: " << alguem->sobrenome << endl;

  delete alguem;
}

Alocação de array (vetor)

O exemplo a seguir mostra a alocação de memória para um vetor (array), em que o operador new aloca memória com capacidade de armazenar múltiplos valores de um certo tipo. Além disso, o exemplo mostra a persistência da área alocada de memória fora do escopo em que ela foi criada:

#include <iostream>
#include <string>

using namespace std;

// cria um vetor de inteiros com tamanho "size" e preenche
// todas suas posições com "valor_inicial"
int * cria_vetor(int size, int valor_inicial) {
  if (size <= 0) return nullptr;

  // cria um vetor com o tamanho dado por "size"
  auto v = new int[size];

  // preenche o vetor com o valor inicial
  for (int i=0; i < size; i++) v[i] = valor_inicial;
  return  v;
}

int main() {
  int size;

  cout << "Qual o tamanho do vetor: ";
  cin >> size;

  // v é um ponteiro para int
  auto v = cria_vetor(size, 99);

  cout << "Endereço do vetor: " << (void*)v << endl;
  cout << "Conteúdo do vetor: ";
  for (int x=0; x < size; x++) cout << v[x] << ',';
  cout << endl;

  // libera a memória do vetor
  delete[] v;
}

Um exemplo de execução é mostrado a seguir:

Qual o tamanho do vetor: 5
Endereço do vetor: 0x561a4d6a4690
Conteúdo do vetor: 99,99,99,99,99,


O uso do operador new nesse exemplo foi um pouco diferente. Como se pode notar, para criar um vetor indicou-se sua capacidade entre colchetes, logo após o tipo de dados. Isso pede que se aloque memória suficiente para conter aquela quantidade de valores do tipo informado:

// aloca memória suficiente oara guardar "size" valores do tipo int
auto v = new int[size];

Além disso, a função cria_vetor retorna como resultado o vetor que foi alocado. Isso mostra que a área de memória reservada para o vetor sobrevive ao final da função cria_vetor. Essa área de memória continuará alocada até que o operador delete a libere (o que se faz ao final da função main) ... ou até que o programa termine.

Resumo

  • O operador new aloca uma área de memória com capacidade suficiente para um tipo de dados indicado, e retorna um ponteiro para essa área de memória
  • Após alocar a memória, o operador new a inicializa ao executar o construtor para o tipo de dados
  • Toda área de memória alocada deve ser liberada usando o operador delete, quando não for mais necessária

Funções template


Funções, assim como classes, podem ser template, em que um ou mais tipos de dados são desconhecidos em tempo de programação. Em uma função template, os tipos de dados de um ou mais parâmetros, ou do valor devolvido como resultado, são desconhecidos em tempo de projeto. Outra forma de ver funções template é perceber que o algoritmo contido na função funciona com (quase) qualquer tipo de dados.

Por exemplo, imagine que uma função deva ser capaz de ordenar um vetor qualquer, independente do tipo de dados desse vetor. Tal função poderia ser declarada com um template como este:

// Função template "ordena": ordena um "vetor" de comprimento "N"
template <typename T> void ordena(T vetor[], int N);

Sua implementação poderia ser esta:

template <typename T> void ordena(T vetor[], int N) {
  int i, j;

  for (i=0; i < N-1; i++) {
    for (j=i+1; j < N; j++) {
      if (vetor[i] > vetor[j]) {
        T aux = vetor[i];
        vetor[i] = vetor[j];
        vetor[j] = aux;
      }
    }
  }     
}

A utilização de uma função template implica informar o tipo de dados desconhecido:

int main() {
  long v1[5] = {45, 72, 12, 8, 0};
  string v2[7] = {"banana", "abacate", "sapoti", "maracuja", "abacaxi", "caju", "goiaba"};

  ordena<long>(v1, 5);
  ordena<string>(v2, 7);
}

string


Na linguagem C++ podem-se trabalhar com duas representações de string:

  • vetor de char (char * ou char []): essa forma é a mesma utilizada na linguagem C.
  • classe string: objetos da classe string implementam strings com diversas facilidades, possibilitando um maior nível de abstração (fica mais fácil).


A classe string foi projetada para que se possa trabalhar com strings de forma simplificada. Por exemplo, o programa abaixo mostra como criar, mostrar na tela e concatenar strings:

Usando classe string Usando vetor de char
#include <string>
#include <iostream>

using namespace std;

int main(){
  // declaração dos objetos string: 
  // inicialmente eles são com strings vazias
  string nome;
  string sobrenome;

  // atribui "Manuel" ao objeto string "nome", 
  // e "Alexievitvh" a "sobrenome"
  nome = "Manuel"
  sobrenome = "Alexievitch";

  // cria um novo objeto string, e o inicia com o 
  // resultado da concatenação de nome e sobrenome
  string nome_completo = nome + ' ' + sobrenome;

  // mostra o objeto string na tela
  cout << "Nome: " << nome_completo << endl;
}
#include <string.h>
#include <iostream>

using namespace std;

int main(){
  char nome[16];
  char sobrenome[32];

  strncpy(nome, "Manuel", 16);
  strncpy(sobrenome, "Alexievitch", 32);

  char nome_completo[64];
  strcpy(nome_completo, nome);
  nome_completo[strlen(nome_completo)] = ' ';
  nome_completo[strlen(nome_completo)] = 0;
  strcat(nome_completo, sobrenome);

  cout << "Nome: " << nome_completo << endl;
}


A classe string oferece diversas operações, tais como:

  • size: Obter o comprimento da string
  • operator[]: Obter um caractere em uma determinada posição. Esse tipo de acesso é sintaticamente idêntico ao acesso a uma posição de um vetor
  • operator+=: anexa uma string
  • push_back: anexa um caractere
  • erase: remove caracteres de uma string
  • replace: substitui parte de uma string
  • c_str: retorna uma representação em vetor de char
  • find: encontra a primeira ocorrência de uma substring ou um caractere
  • rfind: encontra a última ocorrência de uma substring ou um caractere
  • substr: gera uma substring

Lendo ou escrevendo strings em streams

Podem-se ler ou escrever strings em streams usando os operadores << ou >>. Por exemplo, para ler uma string da entrada padrão (ex: teclado), pode-se fazer assim:

string algo;

// lê uma string da entrada padrão e grava-a em "algo"
cin >> algo;


Para escrever uma string em um stream (ex: um arquivo), pode-se faz desta forma:

// abre um arquivo para escrita
ofstream arq("teste.txt");

string algo = "Um teste";

// escreve a string no arquivo
arq << algo;


No caso da leitura de uma string que contenha espaços, ou mesmo de uma linha completa, deve-se usar a função getline:

string linha;

cout << "Escreva uma frase: ";

getline(cin, linha);

cout << "Frase digitada é: " << linha;

A função getline usa o caractere newline (\n) como delimitador. Se for desejado usar outro caractere como delimitador, deve-se fazer o seguinte:

string linha;
char delimitador=',';

cout << "Escreva uma frase que contenha ao menos uma vírgula: ";

getline(cin, linha, delimitador);

cout << "Frase digitada até a vírgula é: " << linha;

Conversão de string para tipos numéricos


Em C++ há diferentes formas de converter strings para dados numéricos, e vice-versa. Podem-se, por exemplo, usar as funções da biblioteca C padrão (sscanf, snprintf, strtod, ...). Mas uma forma particular existente em C++ explora o conceito de streams.

Arquivos abertos são acessados como streams em C++. Na verdade, essa é uma interface corriqueira para acesso a arquivos, estando disponível em diferentes linguagens (ex: as operações de arquivo em C, como fscanf, fprintf, fread, fwrite, fgets, ...). Em C++ streams possibilitam ler texto de um arquivo e convertê-lo para um dado numérico, e vice-versa. Isso é feito normalmente em programas, como no exemplo abaixo:

#include <iostream>

using namespace std;

int main() {
  ifstream arq("numeros.txt");
  int numero;
  string palavra;

  arq >> numero;
  arq >> palavra;

  cout << "numero=" << numero << endl;
  cout << "palavra=" << palavra << endl;

  arq.close();

  return 0;
}


Se o arquivo numeros.txt possuir este conteúdo:

2015 Furious
1999 Matrix


... a execução do programa exemplo, que lê apenas a primeira linha, resultará em:

numero=2015
palavra=Furious


Assim, o operador >> de streams possibilita converter texto para um outro tipo de dado, dependendo do tipo da variável onde o resultado deva ser gravado. No exemplo, esse operador é usado duas vezes:

arq >> numero; // converte texto para inteiro, pois "numero" 
               // é uma variável do tipo int
arq >> palavra;// converte texto para string, pois palavra é do tipo string


Parece que C++ possibilita converter facilmente texto (string) para dado numérico SE o texto estiver guardado em um arquivo. Isso não é conveniente para converter um valor que esteja guardado em uma variável string, pois tornaria necessário escrevê-la em um arquivo temporário para depois ler desse arquivo e efetuar a conversão. De fato, se fosse assim dificilmente streams seriam usadas para converter dados em C++. No entanto existe um tipo de stream que possibilita que se acesse uma string como se fosse um arquivo. Quer dizer, a leitura e a escrita dos caracteres de uma string podem ser feitas com operações de arquivo. O exemplo abaixo mostra o uso dessa stream de string (ou stringstream):

#include <iostream>
// as classes stringstream estão em sstream
#include <sstream>

using namespace std;

int main() {
  istringstream stream("2015 Furious");
  int numero;
  string palavra;

  stream >> numero;
  stream >> palavra;

  cout << "numero=" << numero << endl;
  cout << "palavra=" << palavra << endl;

  return 0;
}


O exemplo mostra uma stringstream criada com conteúdo 2015 Furious. Em seguida, usa-se o operador >> para ler um número inteiro e uma string. O resultado desse exemplo é idêntico ao do exemplo anterior, em que se liam esses dados de um arquivo.

A possibilidade de usar stringstream para converter dados torna simples criar funções de conversão, como esta que converte string para inteiro:

int converte_para_int(const string & dado) {
  istringstream stream(dado);
  int r;

  stream >> r;
  if (stream.fail()) throw 1; // erro de conversão

  return r;
}


Outras funções para tipos numéricos podem ser criadas da mesma forma:

float converte_para_float(const string & dado) {
  istringstream stream(dado);
  float r;

  stream >> r;
  if (stream.fail()) throw 1; // erro de conversão

  return r;
}


double converte_para_double(const string & dado) {
  istringstream stream(dado);
  double r;

  stream >> r;
  if (stream.fail()) throw 1; // erro de conversão

  return r;
}


Note que são todas muito parecidas. A única diferença entre elas é o tipo do dado numérico da conversão. Assim, é natural substitui-las por uma função template:

template <class T> T converte(const string & dado) {
  istringstream stream(dado);
  T r;

  stream >> r;
  if (stream.fail()) throw 1; // erro de conversão

  return r;
}


Essa função template seria usada da seguinte forma:

#include <iostream>
#include <sstream>

using namespace std;

template <class T> T converte(const string & dado) {
  istringstream stream(dado);
  T r;

  stream >> r;
  if (stream.fail()) throw 1; // erro de conversão

  return r;
}

int main() {
  string algo1 = "2015";
  string algo2 = "3.1416";
  string algo3 = "xyz";
  int ano;
  float pi;

  ano = converte<int>(algo1);
  pi = converte<float>(algo2);

  cout << "ano=" << ano << endl;
  cout << "pi=" << pi << endl;

  try {
    ano = converte<int>(algo3);
    cout << "Outro ano=" << ano << endl;
  } catch (int e) {
    cout << "Erro de conversão: " << algo3 << " não é um int" << endl;
  }
  return 0;
}

streams e arquivos

Em diversas linguagens de programação o conceito de stream (fluxo ou correnteza) é utilizado para o acesso a arquivos em disco. Por stream entende-se um mecanismo e leitura e escrita em que dados fluem sucessivamente. Dessa forma, ao se escrever em um arquivo fazem-se sucessivas operações de escrita, e os dados são armazenados na ordem em que essas operações se realizaram. Algo parecido acontece na leitura de um arquivo, em que as operações de leitura realizadas fornecem dados de acordo com a ordem em que estão gravados no arquivo.


O acesso a arquivos apresenta algumas diferenças nas linguagens C e C++. Em C++ existem algumas classes para a manipulação de arquivos. Desta forma, arquivos nesta linguagem são acessados por meio de objetos dessas classes. No exemplo abaixo, apresentam-se dois programas em C e C++ para ler as linhas de um arquivo e mostrá-las na tela.


Linguagem C Linguagem C++
#include <stdio.h>
#include <errno.h>

#define MAX_SIZE 10240

int main() {
  FILE * arq;
  char linha[MAX_SIZE];

  // abre o arquivo
  arq = fopen("/etc/hosts", "r");

  // testa se conseguiu abrir o arquivo
  if (arq == NULL) {
    perror("Ao abrir /etc/hosts");
    return errno;
  }

  // lê cada linha do arquivo, mostrando-a na tela
  while (fgets(linha, MAX_SIZE, arq)) {
    printf("%s", linha);
  }

  // fecha o arquivo
  fclose(arq);
}
#include <iostream>
#include <fstream>
#include <stdio.h>
#include <errno.h>
 
#define MAX_SIZE 10240

using namespace std;
 
int main() {
  // cria o objeto "arq" da classe "ifstream", que representa um arquivo para leitura
  // a criação desse objeto já abre o arquivo
  ifstream arq("/etc/hosts");
 
  // verifica se o arquivo foi de fato aberto
  if (! arq.is_open()) {
    perror("Ao abrir /etc/hosts");
    return errno;
  }

  // lê cada linha do arquivo, apresentando-a na tela
  string linha;
  while (getline(arq, linha)) {
    cout << linha << endl;
  }

  // observe que o arquivo não precisa ser fechado ... isso ocorre automaticamente
  // quando o objeto for destruído, ficando a cargo de seu destrutor
}


Os exemplos acima demonstram que a sintaxe no acesso a arquivos pode ser diferente, porém funcionalmente parece haver uma equivalência entre as duas formas de acesso. De fato tudo que se consegue fazer em C++ é possível fazer em C, mudando apenas a praticidade ou conveniência na forma com que se expressa isso nessas linguagens. Porém como a linguagem C já é bem conhecida por vocês, interessa ver o que se pode fazer em C++.

Em C++ existem três classes para acessar arquivos:

  • ofstream: acesso a arquivos em modo escrita.
Exemplo
#include <iostream>
#include <fstream>

using namespace std;

int main() {
  ofstream arq("demo.txt");

  if (not arq.is_open()) {
    cerr << "Algum erro ao abrir o arquivo ..." << endl;
    return 0;
  }

  arq << "Iniciando uma gravação de várias linhas inúteis ..." << endl;

  for (int i=0; i < 10; i++) {
    arq << "Linha " << i << endl;    
  }
}
  • ifstream: acesso a arquivos em modo leitura.
Exemplo
#include <iostream>
#include <fstream>

using namespace std;

int main() {
  ifstream arq("/etc/hosts");

  if (not arq.is_open()) {
    cerr << "Algum erro ao abrir o arquivo ..." << endl;
    return 0;
  }

  while (not arq.eof()) {
    string algo;

    arq >> algo;
    cout << "Leu isto: " << algo << endl;
  }
}
  • fstream: acesso a arquivos em modo leitura/escrita.
Exemplo
#include <iostream>
#include <fstream>

using namespace std;

#define MAX_LINE 10240

int main() {
  fstream arq("demo.txt");

  if (not arq.is_open()) {
    cerr << "Algum erro ao abrir o arquivo ..." << endl;
    return 0;
  }

  for (int i=0; i < 10; i++) {
    arq << "Linha " << i << endl;    
  }
  arq.flush();

  arq.seekg(0);
  
  while (not arq.eof()) {
   char linha[MAX_LINE];

   arq.getline(linha, MAX_LINE);
   cout << "Leu: " << linha << endl;
  }
}


O interessante em C++ é que a saída padrão e a entrada padrão são representados por objetos stream. De fato, veja como estão declarados esses objetos na biblioteca C++ padrão:

  • cout: saída padrão
  • cin: entrada padrão
  • cerr: saída de erros padrão

Leitura e escrita formatada

streams implementam leitura e escrita formatadas por meio dos operadores >> (leitura) e << (escrita). Esses operadores fazem a conversão automática entre string e outros tipos de dados.

A leitura de um stream pode ser feita como no exemplo a seguir:

int x;

cout << "Digite um número inteiro: ";

// aqui se faz a leitura de um número inteiro
cin >> x;

cout << "Você digitou " << x << endl;


Nesse exemplo, a leitura é feita de cin, uma stream que representa a console (usualmente o teclado). Lê-se um número inteiro digitado pelo usuário, o qual é armazenado na variável x. A conversão de string para inteiro é feita automaticamente, porque a variável onde se deseja armazenar o valor lido é do tipo int. Assim, a conversão depende do tipo da variável onde se deve armazenar o valor lido. Este outro exemplo reforça a questão da conversão de tipo:

char algo;
int numero;

cout << "Digite um caractere qualquer seguido de um número (ex: !33): ";
cin >> algo;
cin >> numero;

cout << "Você digitou: " << algo << numero << endl;


Nesse novo exemplo, dois valores são lidos: um valor do tipo char seguido de um int. O primeiro valor corresponde a um único caractere, e o segundo a um ou mais caracteres numéricos consecutivos. As conversões são desencadeadas pelos tipos das variáveis usados nas leituras. Algo similar acontece quando se escrevem dados em um stream.


O exemplo a seguir mostra a escrita de alguns valores em cout, um stream que representa também a console (usualmente a tela):

double pi = 3.1416;
char letra = 'H';

// escreve um valor do tipo char
cout << letra;

// escreve um valor do tipo string (char*)
cout << " = ";

// escreve um valor do tipo double
cout << pi;

// endl corresponde a \n (newline)
cout << endl;


Como mostra o exemplo, são escritos valores do tipo char, , char* e double. Em todos os casos, a conversão automática entre esses valores e string depende do tipo da variável ou constante onde está o valor a ser escrito.

Lendo linha por linha

Este outro exemplo mostra uma forma simples de ler todas as linhas de um arquivo. A função getline extrai uma linha a cada vez que é chamda. Neste exemplo, o efeito final será ler linha por linha do arquivo:

string linha;
ifstream arq("teste.txt");

// enquanto conseguir ler uma string do arquivo ...
// quando chegar ao fim do arquivo, a leitura irá falhar
while (getline(arq, linha)) {
  // faz algo com o conteúdo de "linha"
}

Lendo palavra por palavra

Este outro exemplo mostra uma forma simples de ler todos os valores de um arquivo. O operador >> extrai um valor a cada vez que é executado sobre o arquivo. Neste exemplo, o efeito final será ler palavra por palavra do arquivo:

string palavra;
ifstream arq("teste.txt");

// enquanto conseguir ler uma string do arquivo ...
// quando chegar ao fim do arquivo, a leitura irá falhar
while (arq >> palavra) {
  // faz algo com o conteúdo de "palavra"
}

Deve-se notar que o operador >> considera como delimitadores os caracteres brancos (espaço, TAB, newline).


A escrita e leitura formatada está implementada para os tipos elementares de C e C++ (int, long, short, char, char*, string, bool, float, double). Isso significa que essas operações não funcionam para novos tipos de dados ou classes classes, a não ser que se implementem esses operadores de leitura e escrita para esses novos tipos.

Operações de streams

streams oferecem várias operações para leitura e escrita de dados. Algumas mais comuns são listadas a seguir, junto com exemplos.

Operação Entrada Saída Para que serve Exemplo
>> x leitura formatada
int x;
double y;
string palavra;

// lê um número inteiro
cin >> x;

// lê um número double
cin >> y;

// lê uma string
cin >> palavra;
<< x escrita formatada
int x = 5;
double y=3.1416;
string palavra="alguma coisa";

// escreve um número inteiro seguido de uma vírgula
cout << x << ", ";

// escreve um número double seguido de uma vírgula
cout << y << ", ";

// escreve uma string seguida de nova linha
cout << palavra << endl;

// outra forma de escrever os três valores 
cout << x << ", " << y << ", " << palavra << endl;
getline x lê uma linha (até encontrar '\n')
string linha;

getline(cin, linha);

cout << "Linha: " << linha << endl;
ignore x lê e descarta caracteres
cout << "Tecle ENTER para continuar";
cin.ignore(256, '\n'); // lê e descarta até 256 caracteres, ou até ler '\n'
eof x x Verifica se chegou ao fim da stream
ifstream arq("/etc/hosts");

while (true) {
  string linha;

  getline(arq, linha);
  if (arq.eof()) break; // se não conseguir ler algo (fim de arquivo)
  cout << linha << endl;
}
is_open x x Verifica se arquivo foi aberto
ifstream arq("/etc/hosts");

if (not arq.is_open()) {
  cout << "Erro: não abriu o arquivo" << endl;
  return 0;
}

Acessando strings como se fossem arquivos

Em C++ esse conceito de stream é levado um pouco além. Além de arquivos e a entrada e saída padrão, é possível também manipular strings como se fossem streams. Quer dizer, pode-se escrever em uma string (criá-la) ou extrair partes de uma string como se estivesse acessando um arquivo. Isso possibilita que funções ou objetos que escrevem ou lêem em arquivos possam fazer o mesmo em memória, o que pode ser útil em diferentes programas.

O uso de strings como se fossem streams é implementado por três classes:


O exemplo mais simples envolve criar uma string a partir dos valores de algumas variáveis. Veja como isso era feito em C e como se faz em C++:

Linguagem C Linguagem C++
#include <stdio.h>

int main() {
  int dia, mes, ano;
  char data[32];

  printf("Dia: ");
  scanf("%d", &dia);
  printf("Mes: ");
  scanf("%d", &mes);
  printf("Ano: ");
  scanf("%d", &ano);

  snprintf(data, 32, "%d/%d/%d", dia, mes, ano);

  printf("Data: %s\n", data);

}
#include <iostream>
#include <sstream>

using namespace std;

int main() {
  int dia, mes, ano;

  cout << "Dia: ";
  cin >> dia;
  cout << "Mes: ";
  cin >> mes;
  cout << "Ano: ";
  cin >> ano;

  //cria-se uma stringstream para escrita
  ostringstream out;

  // escreve-se na stringstream
  out << dia << "/";
  out << mes << "/";
  out << ano;

  // Aqui se obtém o conteúdo armazenado na stringstream
  cout << "Data: " << out.str() << endl;
}


Estes outros exemplos mostram a extração de dados que existem em strings:

Linguagem C Linguagem C++
#include <stdio.h>
 
int main() {
  int dia, mes, ano;
  char data[32];

  printf("Data (dia/mes/ano): ");
  fgets(data, 32, stdin); // lê uma linha

  // extrai os dados da linha
  sscanf(data, "%d/%d/%d", &dia, &mes, &ano);

  // Aqui se mostram os dados extraídos da linha
  printf("Dia: %d\n", dia);
  printf("Mes: %d\n", mes);
  printf("Ano: %d\n", ano);
}
#include <iostream>
#include <sstream>
 
using namespace std;
 
int main() {
  int dia, mes, ano;
  char data[32];

  cout << "Data (dia/mes/ano): "; 
  cin.getline(data, 32);

  //cria-se uma stringstream para leitura
  istringstream inp(data);
  char separador;

  // lê-se da stringstream
  inp >> dia;
  inp >> separador;
  inp >> mes;
  inp >> separador;
  inp >> ano;
 
  // Aqui se mostram os dados extraídos da stringstream
  cout << "Dia: " << dia << endl;
  cout << "Mes: " << mes << endl;
  cout << "Ano: " << ano << endl;
}

Sobrecarga de operador

  • Operator overloading: uma explicação mais detalhada sobre o assunto. Inclui também uma tabela com os operadores que podem ser redefinidos.


Operadores C++ podem ser redefinidos, o que se chama sobrecarga de operador (operator overloading). Assim, dependendo do tipo de dados ou da classe do valor, variável ou objeto que recebe a operação, o operador pode funcionar de uma forma específica. Um operador pode ser implementado como uma função, que pode ou não ser membro de uma classe.


O nome da função que implementa um operador é formado pela palavra chave operator seguida do operador que se deseja implementar. Por exemplo, para o operador == a função se chama operator==. O tipo do valor de retorno dessa função depende do operador a ser implementado (ex: o operador == retorna um valor booleano, mas o operador = retorna uma referência à variável ou objeto que foi alvo da operação). A quantidade de parâmetros dessa função também dependem do operador em questão, e os tipos de desses parâmetros dependem dos tipos de dados envolvidos na operação.


Um operador pode ser implementado de duas formas:

  1. Como um método de uma classe: um método implementa a operação a ser realizada quando se usa o operador com um objeto dessa classe. A execução desse método acontece no escopo do objeto que recebe a operação (ex: em operadores unários é o próprio objeto alvo do operador, e em operadores binários é o objeto que aparece à esquerda do operador).
  2. Como uma função: uma função implementa a operação a ser realizada quando se usa o operador. Essa função recebe como parâmetros as variáveis ou objetos envolvidos na operação.


Dois exemplos são apresentados para esclarecer a sobrecarga de operadores usando funções que não são membros de classe (i.e. não são métodos):

  • Operador unário !: o operador ! corresponde à operação NÃO, mas ele pode ser redefinido para um tipo de dados específico. Suponha-se que se deseje que o operador ! aplicado a uma string deve retornar true se ela for vazia, e false caso contrário. Isso pode ser feito redefinido o operador ! para string da seguinte forma:
bool operator!(const string & s) {
  if (s.size() == 0) return true;
  return false;
}
  • Operador binário ==: o operador == serve para comparar dois valores, resultando em true se eles forem considerados iguais, e false caso contrário. Suponha-se que se deseje que esse operador, quando aplicado a string, resulte em true se duas string tiverem mesmo comprimento. Isso pode ser feito da seguinte forma:
bool operator==(constr string & s1, const string & s2) {
  return s1.size() == s2.size();
}


Esses dois operadores podem ser exemplificados também como métodos uma classe. Usando-se a classe Fila, ambos operadores podem ser implementados da seguinte forma. O operador unário ! deve retornar true se fila estiver vazia, e o operador == deve retornar true se as filas forem iguais:

template <typename T> class Fila {
 private:
  // atributos da fila
 public:
  // métodos da fila

  bool operator!() const {
    return vazia();
  }

  bool operator==(const Fila<T> & outra) const {
    // para serem iguais, devem ter mesma quantidade de dados
    if (itens == outra.itens) { 
      // ... e os dados devem ser iguais e estarem na mesma ordem
      int n = itens;

      while (n > 0) {
        T dado1 = desenfileira();
        T dado2 = outra.desenfileira();
        enfileira(dado1);
        enfileira(dado2);
        if (dado1 != dado2) return false;
        n--;
      }
      return true;
    }
    return false;
  }
};

Sobrecarga de operadores << e >> de streams

Pode-se desejar que objetos de uma classe possam ser escritos em streams diretamente por meio do operador <<. Para descobrir uma solução para esse problema, deve-se entender como o operador << está definido. E basicamente ele está implementado de duas formas:

  1. Na classe ostream: a classe ostream representa arquivos/streams abertos para escrita, e já possui implementações desse operador para tipos básicos de C++.
  2. Em funções de sobrecarga de operador (operator overloading): podem-se definir funções que implementam esse operador para novos tipos/classes. Por exemplo, existe uma dessas funções para a classe string. Um exemplo de função de sobrecarga de operador para um novo tipo de dados segue abaixo:
    #include <iostream>
    
    using namespace std;
    
    // Define-se um novo tipo de dados
    struct Ponto {
      int x,y;
    };
    
    // Define-se o operador << de ostream  para poder escrever um Ponto.
    // Esse operador mostra o ponto da seguinte forma: (x,y)
    ostream& operator<<(ostream &out, const Ponto& p) {
      out << "(" << p.x << "," << p.y << ")";
      return out;
    }
    
    // Agora pode-se usar o operador << para valor do tipo "Ponto"
    int main() {
      Ponto p1 = {1,1}, p2 = {10,20};
    
      cout << "Ponto 1: " << p1 << endl;
      cout << "Ponto 2: " << p2 << endl;
    }
    


O mesmo vale para o operador >>. Para que se possa ler diretamente de um stream um valor de um novo tipo de dados ou classe, esse operador precisa ser definido.

Exceções


Condições de erro podem ser avisadas por meio de exceções. Por exemplo, no caso da lista considera-se erro tentar obter um valor em uma posição inexistente. A operação para obter um dado da lista deve, portanto, comunicar de alguma forma que esse acesso inválido aconteceu. Outro exemplo mais simples envolve a conversão de uma string numérica com a função stoi. Se a string não contiver um número inteiro, ocorre um erro. O programa a seguir demonstra esse caso, mostrando o erro:

#include <string>
#include <iostream>

using namespace std;

int main() {
  string n1 = "123";
  string n2 = "abc";
  int x1, x2;

  // esta conversão é feita com sucesso
  x1 = stoi(n1);

  cout << "Primeira conversão: " << x1 << endl;

  // ... mas esta causa um erro 
  x2 = stoi(n2);

  cout << "Segunda conversão: " << x2 << endl;

  return 0;
}


Ao se compilar e executar esse programa, obtém-se este resultado, que revela o erro de execução:

aluno@curl:~$ g++ -o erro -std=c++11 erro.cpp 
aluno@curl:~$ ./erro
Primeira conversão: 123
terminate called after throwing an instance of 'std::invalid_argument'
  what():  stoi
Abortado (imagem do núcleo gravada)


O erro manifestado no programa, e que causou sua terminação abrupta, se chama exceção. O mecanismo de exceções existente na linguagem C++ possibilita expressar e tratar condições de erro de uma forma mais prática. Um algoritmo pode disparar uma exceção se detectar uma situação anômala, interrompendo a execução do programa. Essa interrupção causa a execução de um outro trecho de código, responsável por tratar a exceção. Sendo assim, na parte do código onde pode ocorrer uma exceção, deve-se capturá-la e tratá-la. Para isso usa-se a construção try ... catch, que possibilita executar um tratador de exceção. No exemplo de conversão de string numérica, o uso de try .. catch ficaria assim:

  string n1 = "123";
  string n2 = "abc";
  int x1, x2;

  try {
    // esta conversão é feita com sucesso
    x1 = stoi(n1);
    cout << "Primeira conversão: " << x1 << endl;
  } catch (...) {
    cout << "Erro de conversão: " << n1 << " não é uma string numérica inteira !" << endl;
  }

  try {
    // esta conversão dispara uma exceção
    x2 = stoi(n2);
    cout << "Segunda conversão: " << x2 << endl;
  } catch (...) {
    cout << "Erro de conversão: " << n2 << " não é uma string numérica inteira !" << endl;
  }

  return 0;


No exemplo acima, o bloco try .. catch foi usado de forma a capturar qualquer exceção, o que se define com catch (...). No entanto, exceções podem ser de diferentes tipos, tais como números, string, ou mesmo objetos. Uma exceção portanto é representada por algum valor informativo, e esse valor pertence a algum tipo de dados ou classe. Com isso, diferentes tipos de exceção podem ser disparadas em um trecho de código, e para capturá-las e tratá-las individualmente é necessário definir tratadores de exceção específicos. Por exemplo, considere que um trecho de código possa disparar exceções do tipo int, string, e possivelmente outras. A forma de tratá-las poderia ser assim:

  try {
    // trecho de código que pode disparar diferentes tipos de exceções
  } catch (int e1) { 
    // aqui se tratam exceções do tipo int
  } catch (string e2) {
    // aqui se tratam exceções do tipo string
  } catch (...) {
    // aqui se tratam demais exceções de tipos quaisquer
  }


Por outro lado, quando se programa um algoritmo (embutido em uma função ou método de classe), erros ou situações inesperadas que forem detectadas implicam o disparo de uma exceção por meio da palavra chave throw. O valor da exceção pode ser qualquer, e no exemplo a seguir é representado por um número inteiro:

void maiusculas(string & palavra) {
  if (palavra.size() == 0) throw 1; // string vazia !!!

  for (int i=0; i < palavra.size(); i++) palavra[i] = toupper(palavra[i]);
}


OBS: se uma exceção não for capturada usando try ... catch, ela causará o término do programa.