Próximas etapas

Introdução à programação e ao C++

Este tutorial on-line continua com conceitos mais avançados. Leia a Parte III. Neste módulo, vamos nos concentrar em como usar ponteiros e começar a usar objetos.

Aprenda com o exemplo 2

Neste módulo, vamos praticar a decomposição, entender ponteiros e começar a usar objetos e classes. Confira os exemplos a seguir. Escreva os programas quando solicitado ou faça os experimentos. Não podemos enfatizar o suficiente que o segredo para se tornar um bom programador é praticar, praticar e praticar.

Exemplo 1: mais prática de decomposição

Considere a seguinte saída de um jogo simples:

Welcome to Artillery.
You are in the middle of a war and being charged by thousands of enemies.
You have one cannon, which you can shoot at any angle.
You only have 10 cannonballs for this target..
Let's begin...

The enemy is 507 feet away!!!
What angle? 25<
You over shot by 445
What angle? 15
You over shot by 114
What angle? 10
You under shot by 82
What angle? 12
You under shot by 2
What angle? 12.01
You hit him!!!
It took you 4 shots.
You have killed 1 enemy.
I see another one, are you ready? (Y/N) n

You killed 1 of the enemy.

A primeira observação é o texto introdutório, que é exibido uma vez por execução do programa. Precisamos de um gerador de números aleatórios para definir a distância do inimigo em cada rodada. Precisamos de um mecanismo para receber a entrada de ângulo do jogador, e isso obviamente tem uma estrutura de loop, que se repete até atingirmos o inimigo. Também precisamos de uma função para calcular a distância e o ângulo. Por fim, precisamos acompanhar quantos tiros foram necessários para atingir o inimigo e quantos inimigos acertamos durante a execução do programa. Aqui está um possível esboço do programa principal.

StartUp(); // This displays the introductory script.
killed = 0;
do {
  killed = Fire(); // Fire() contains the main loop of each round.
  cout << "I see another one, care to shoot again? (Y/N) " << endl;
  cin >> done;
} while (done != 'n');
cout << "You killed " << killed << " of the enemy." << endl;

O procedimento de disparo cuida da reprodução do jogo. Nessa função, chamamos um gerador de número aleatório para descobrir a distância do inimigo. Em seguida, configuramos o loop para receber a entrada do jogador e calcular se ele atingiu ou não o inimigo. A condição de guarda no loop é o quanto perto estamos perto de atingir o inimigo.

In case you are a little rusty on physics, here are the calculations:

Velocity = 200.0; // initial velocity of 200 ft/sec Gravity = 32.2; // gravity for distance calculation // in_angle is the angle the player has entered, converted to radians. time_in_air = (2.0 * Velocity * sin(in_angle)) / Gravity; distance = round((Velocity * cos(in_angle)) * time_in_air);

Por causa das chamadas para cos() e sin(), você precisará incluir math.h. Tente escrever este programa. É uma ótima prática na decomposição de problemas e uma boa revisão do C++ básico. Lembre-se de executar apenas uma tarefa em cada função. Este é o programa mais sofisticado que escrevemos até agora, por isso pode demorar um pouco para fazer isso.Confira nossa solução

Exemplo 2: praticar com ponteiros

Há quatro coisas a serem lembradas ao trabalhar com ponteiros:
  1. Ponteiros são variáveis que contêm endereços de memória. À medida que um programa é executado, todas as variáveis são armazenadas na memória, cada uma no próprio endereço ou local exclusivo. Um ponteiro é um tipo especial de variável que contém um endereço de memória em vez de um valor de dados. Assim como os dados são modificados quando uma variável normal é usada, o valor do endereço armazenado em um ponteiro é modificado assim como uma variável de ponteiro é manipulada. Confira um exemplo:
    int *intptr; // Declare a pointer that holds the address
                 // of a memory location that can store an integer.
                 // Note the use of * to indicate this is a pointer variable.
    
    intptr = new int; // Allocate memory for the integer.
    *intptr = 5; // Store 5 in the memory address stored in intptr.
          
  2. Geralmente, dizemos que um ponteiro "aponta" para o local que está armazenando (o "ponteiro"). Portanto, no exemplo acima, intptr aponta para o ponteiro 5.

    Observe o uso do operador "new" para alocar memória para nosso ponteiro de números inteiros. Isso é algo que precisamos fazer antes de tentar acessar o ponteiro.

    int *ptr; // Declare integer pointer.
    ptr = new int; // Allocate some memory for the integer.
    *ptr = 5; // Dereference to initialize the pointee.
    *ptr = *ptr + 1; // We are dereferencing ptr in order
                     // to add one to the value stored
                     // at the ptr address.
          

    O operador * é usado para desreferenciar em C. Um dos erros mais comuns que os programadores C/C++ cometem ao trabalhar com ponteiros é esquecer de inicializar o ponteiro. Isso às vezes pode causar uma falha na execução porque estamos acessando um local na memória que contém dados desconhecidos. Se tentarmos modificar esses dados, poderemos causar uma corrupção sutil da memória, tornando um bug difícil de rastrear. 

  3. A atribuição de ponteiro entre dois ponteiros faz com que eles apontem para o mesmo ponteiro. Assim, a atribuição y = x faz o y apontar para o mesmo ponteiro que x. A atribuição de ponteiro não toca no ponteiro. Ela apenas muda um ponteiro para ter a mesma localização de outro. Após a atribuição do ponteiro, os dois ponteiros "compartilham" o ponteiro. 
  4. void main() {
     int* x; // Allocate the pointers x and y
     int* y; // (but not the pointees).
    
     x = new int; // Allocate an int pointee and set x to point to it.
    
     *x = 42; // Dereference x and store 42 in its pointee
    
     *y = 13; // CRASH -- y does not have a pointee yet
    
     y = x; // Pointer assignment sets y to point to x's pointee
    
     *y = 13; // Dereference y to store 13 in its (shared) pointee
    }
      

Veja um rastro desse código:

1. Aloque dois ponteiros x e y. A alocação dos ponteiros não aloca nenhum ponteiro.
2. Aloque uma pessoa apontada e defina x para apontar para ela.
3. Remova a referência de x para armazenar 42 no ponteiro. Esse é um exemplo básico da operação de cancelamento de referência. Comece no x, siga a seta para acessar o ponteiro.
4. Tente desreferenciar para armazenar 13 no ponteiro. Ocorre uma falha porque y não tem uma ponteiro, porque ela nunca foi atribuída.
5. Atribua y = x para que y aponte para o ponteiro de x. Agora, x e y apontam para o mesmo destinatário, eles estão "compartilhando".
6. Tente desreferenciar para armazenar 13 no ponteiro. Desta vez, isso funciona, porque a tarefa anterior deu uma pontuação.

Como você pode notar, as imagens são muito úteis para entender o uso do ponteiro. Aqui está outro exemplo.

int my_int = 46; // Declare a normal integer variable.
                 // Set it to equal 46.

// Declare a pointer and make it point to the variable my_int
// by using the address-of operator.
int *my_pointer = &my_int;

cout << my_int << endl; // Displays 46.

*my_pointer = 107; // Derefence and modify the variable.

cout << my_int << endl; // Displays 107.
cout << *my_pointer << endl; // Also 107.

Nesse exemplo, nunca alocamos memória com o operador "new". Declaramos uma variável inteira normal e a manipulamos com ponteiros.

Neste exemplo, ilustramos o uso do operador de exclusão, que desaloca a memória de heap e como podemos alocar para estruturas mais complexas. Vamos falar sobre a organização da memória (heap e pilha de ambiente de execução) em outra lição. Por enquanto, pense no heap como um armazenamento livre de memória disponível para programas em execução.

int *ptr1; // Declare a pointer to int.
ptr1 = new int; // Reserve storage and point to it.

float *ptr2 = new float; // Do it all in one statement.

delete ptr1; // Free the storage.
delete ptr2;

Neste exemplo final, mostramos como os ponteiros são usados para transmitir valores por referência a uma função. É assim que modificamos os valores das variáveis em uma função.

// Passing parameters by reference.
#include <iostream>
using namespace std;

void Duplicate(int& a, int& b, int& c) {
  a *= 2;
  b *= 2;
  c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(x, y, z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x="<< x << ", y="<< y << ", z="<< z;
  return 0;
}

Se fôssemos deixar os argumentos "&" na definição da função "Duplicar", passaremos as variáveis "por valor", ou seja, será feita uma cópia do valor da variável. Qualquer alteração feita na variável na função modificam a cópia. Elas não modificam a variável original.

Quando uma variável é passada por referência, não transmitimos uma cópia do valor dela, mas o endereço da variável é transmitido à função. Qualquer modificação feita na variável local modifica a variável original transmitida. 

Se você é um programador C, esta é uma nova reviravolta. Podemos fazer o mesmo em C declarando Duplicate() como Duplicate(int *x). Nesse caso, x é um ponteiro para um int, depois chamamos Duplicate() com o argumento &x (endereço-de x) e usa a desreferência de x em Duplicate() (veja abaixo). No entanto, o C++ oferece uma maneira mais simples de transmitir valores para funções por referência, mesmo que a antiga maneira "C" de fazer isso ainda funcione.

void Duplicate(int *a, int *b, int *c) {
  *a *= 2;
  *b *= 2;
  *c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(&x, &y, &z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x=" << x << ", y=" << y << ", z=" << z;
  return 0;
}

Observe que, com as referências a C++, não precisamos passar o endereço de uma variável nem remover a referência da variável dentro da função chamada.

O que o programa a seguir gera? Desenhe uma imagem da memória para descobrir.

void DoIt(int &foo, int goo);

int main() {
  int *foo, *goo;
  foo = new int;
  *foo = 1;
  goo = new int;
  *goo = 3;
  *foo = *goo + 3;
  foo = goo;
  *goo = 5;
  *foo = *goo + *foo;
  DoIt(*foo, *goo);
  cout << (*foo) << endl;
}

void DoIt(int &foo, int goo) {
  foo = goo + 3;
  goo = foo + 4;
  foo = goo + 3;
  goo = foo;
} 

Execute o programa para conferir se você acertou a resposta.

Exemplo 3: como transmitir valores por referência

Escreva uma função chamada speed() que toma como entrada a velocidade de um veículo e um valor. A função adiciona o valor à velocidade para acelerar o veículo. O parâmetro de velocidade deve ser transmitido como referência e o valor por valor. Confira nossa solução.

Exemplo 4: classes e objetos

Considere a seguinte classe:

// time.cpp, Maggie Johnson
// Description: A simple time class.

#include <iostream>
using namespace std;

class Time {
 private:
  int hours_;
  int minutes_;
  int seconds_;
 public:
  void set(int h, int m, int s) {hours_ = h; minutes_ = m; seconds_ = s; return;}
  void increment();
  void display();
};

void Time::increment() {
  seconds_++;
  minutes_ += seconds_/60;
  hours_ += minutes_/60;
  seconds_ %= 60;
  minutes_ %= 60;
  hours_ %= 24;
  return;
}

void Time::display() {
  cout << (hours_ % 12 ? hours_ % 12:12) << ':'
       << (minutes_ < 10 ? "0" :"") << minutes_ << ':'
       << (seconds_ < 10 ? "0" :"") << seconds_
       << (hours_ < 12 ? " AM" : " PM") << endl;
}

int main() {
  Time timer;
  timer.set(23,59,58);
  for (int i = 0; i < 5; i++) {
    timer.increment();
    timer.display();
    cout << endl;
  }
}

Observe que as variáveis de membro da classe têm um sublinhado à direita. Isso é feito para diferenciar entre variáveis locais e variáveis de classe.

Adicione um método de decremento a essa classe. Confira nossa solução.

As maravilhas da ciência: a ciência da computação

Exercícios

Como no primeiro módulo deste curso, não oferecemos soluções para exercícios e projetos.

Não esqueça que um bom programa...

... é decomposto logicamente em funções, em que qualquer função faz apenas uma tarefa.

... tem um programa principal que parece um resumo do que o programa vai fazer.

... tem nomes descritivos de funções, constantes e variáveis.

... usa constantes para evitar números "mágicos" no programa.

... tem uma interface de usuário amigável.

Exercícios de aquecimento

  • Exercício 1

    O número inteiro 36 tem uma propriedade peculiar: é um quadrado perfeito e também a soma dos números inteiros de 1 a 8. O próximo número desse tipo é 1.225, que é 352, e a soma dos números inteiros de 1 a 49. Encontre o próximo número que é um quadrado perfeito e também a soma de uma série 1...n. Esse próximo número pode ser maior que 32.767. Você pode usar funções de biblioteca conhecidas (ou fórmulas matemáticas) para fazer com que seu programa seja executado mais rapidamente. Também é possível escrever esse programa usando repetições "for" para determinar se um número é um quadrado perfeito ou uma soma de uma série. Observação: dependendo da máquina e do programa, esse número pode demorar um pouco para ser encontrado.

  • Exercício 2

    Sua livraria universitária precisa de ajuda para estimar os negócios para o próximo ano. A experiência mostrou que as vendas dependem muito de um livro ser necessário para um curso ou apenas opcional e se ele foi usado ou não na aula anteriormente. Um livro novo e obrigatório será vendido para 90% da inscrição em potencial, mas se tiver sido usado na aula antes, apenas 65% vai comprar. Da mesma forma, 40% das matrículas em potencial comprarão um livro novo e opcional, mas se ele tiver sido usado na aula, apenas 20% comprarão. Observe que "usado" aqui não significa livros usados.

  • Escreva um programa que aceite como entrada uma série de livros (até que o usuário insira uma sentinela). Para cada livro, é preciso fornecer: um código, o custo de uma única cópia, o número atual de livros em mãos, a inscrição no curso em potencial e dados que indicam se o livro é obrigatório/opcional, novo/usado no passado. Como saída, mostre todas as informações de entrada em uma tela bem formatada, além de quantos livros precisam ser encomendados (se houver, apenas os novos são encomendados), o custo total de cada pedido.

    Em seguida, depois que todas as entradas forem concluídas, mostre o custo total de todos os pedidos de livros e o lucro esperado se a loja pagar 80% do preço de tabela. Como ainda não discutimos maneiras de lidar com um grande conjunto de dados que chegam a um programa (fique ligado), basta processar um livro por vez e mostrar a tela de saída dele. Então, quando o usuário terminar de inserir todos os dados, seu programa deverá produzir os valores total e de lucro.

    Antes de começar a escrever código, pense no design desse programa. Faça a decomposição em um conjunto de funções e crie uma função main() que leia como um esboço da sua solução para o problema. Verifique se cada função faz uma tarefa.

    Este é um exemplo de saída:

    Please enter the book code: 1221
     single copy price: 69.95
     number on hand: 30
     prospective enrollment: 150
     1 for reqd/0 for optional: 1
     1 for new/0 for used: 0
    ***************************************************
    Book: 1221
    Price: $69.95
    Inventory: 30
    Enrollment: 150
    
    This book is required and used.
    ***************************************************
    Need to order: 67
    Total Cost: $4686.65
    ***************************************************
    
    Enter 1 to do another book, 0 to stop. 0
    ***************************************************
    Total for all orders: $4686.65
    Profit: $937.33
    ***************************************************

Projeto de banco de dados

Neste projeto, criamos um programa C++ totalmente funcional que implementa um aplicativo de banco de dados simples.

Com nosso programa, poderemos gerenciar um banco de dados de compositores e informações relevantes sobre eles. Os recursos do programa incluem:

  • A capacidade de adicionar um novo compositor
  • Capacidade de classificar um compositor (ou seja, indicar o quanto gostamos ou não gostamos da música dele)
  • Capacidade de visualizar todos os compositores no banco de dados
  • A possibilidade de visualizar todos os compositores por classificação

"Há duas maneiras de criar um design de software: uma é torná-lo tão simples que obviamente não haja deficiências e a outra é fazer com que ele seja tão complicado que não haja deficiências óbvias." O primeiro método é muito mais difícil." — C.A.R. Hoare (link em inglês)

Muitos de nós aprendemos a projetar e programar usando uma abordagem "procedual". A pergunta central que começamos com ela é: "O que o programa precisa fazer?". Decomponhamos a solução de um problema em tarefas, e cada uma delas resolve uma parte do problema. Essas tarefas são mapeadas para funções do programa que são chamadas sequencialmente de main() ou de outras funções. Essa abordagem passo a passo é ideal para alguns problemas que precisamos resolver. No entanto, na maioria das vezes, nossos programas não são apenas sequências lineares de tarefas ou eventos.

Com uma abordagem orientada a objetos (OO, na sigla em inglês), começamos com a pergunta: "Que objetos do mundo real estou modelando?". Em vez de dividir um programa em tarefas conforme descrito acima, dividimos em modelos de objetos físicos. Esses objetos físicos têm um estado definido por um conjunto de atributos e um conjunto de comportamentos ou ações que podem realizar. As ações podem mudar o estado do objeto ou invocar ações de outros objetos. A premissa básica é que um objeto "sabe" como fazer as coisas sozinho. 

No design OO, definimos objetos físicos em termos de classes e objetos, atributos e comportamentos. Geralmente, há um grande número de objetos em um programa OO. No entanto, muitos desses objetos são essencialmente iguais. Considere o seguinte.

Uma classe é um conjunto de atributos e comportamentos gerais para um objeto, que pode existir fisicamente no mundo real. Na ilustração acima, temos uma classe da Apple. Todas as maçãs, independentemente do tipo, têm atributos de cor e sabor. Também definimos um comportamento em que a Apple exibe atributos.

Neste diagrama, definimos dois objetos da classe Apple. Cada objeto tem os mesmos atributos e ações da classe, mas o objeto define os atributos para um tipo específico de maçã. Além disso, a ação Exibir mostra os atributos desse objeto específico, por exemplo, "Verde" e "Azedo".

Um design OO consiste em um conjunto de classes, os dados associados a essas classes e o conjunto de ações que as classes podem realizar. Também precisamos identificar as maneiras como diferentes classes interagem. Essa interação pode ser realizada por objetos de uma classe, invocando as ações de objetos de outras classes. Por exemplo, poderíamos ter uma classe AppleOutputer que produza a cor e o sabor de uma matriz de objetos Apple chamando o método Display() de cada objeto da Apple.

Aqui estão as etapas que realizamos no design OO:

  1. Identifique as classes e defina, de forma geral, o que um objeto de cada classe armazena como dados e o que um objeto pode fazer.
  2. Definir os elementos de dados de cada classe
  3. Defina as ações de cada classe e como algumas ações de uma classe podem ser implementadas usando ações de outras classes relacionadas.

Para um sistema grande, essas etapas ocorrem de forma iterativa em diferentes níveis de detalhe.

Para o sistema de banco de dados do Editor, precisamos de uma classe do Composer que encapsule todos os dados que queremos armazenar em um compositor individual. Um objeto dessa classe pode se promover ou rebaixar (mudar a classificação) e exibir os atributos dele.

Também precisamos de uma coleção de objetos do Composer. Para isso, definimos uma classe de banco de dados que gerencia os registros individuais. Um objeto desta classe pode adicionar ou recuperar objetos do Composer e exibir objetos individuais invocando a ação de exibição de um objeto do Composer.

Por fim, precisamos de algum tipo de interface do usuário para fornecer operações interativas no banco de dados. Esta é uma classe de marcador de posição, ou seja, ainda não sabemos como será a interface do usuário, mas sabemos que precisaremos dela. Talvez com base em gráficos, talvez em texto. Por enquanto, definimos um marcador de posição que podemos preencher mais tarde.

Agora que identificamos as classes do aplicativo de banco de dados do Composer, a próxima etapa é definir os atributos e as ações para as classes. Em um aplicativo mais complexo, usamos lápis e papel ou UML, cartões CRC ou OOD para mapear a hierarquia de classes e como os objetos interagem.

Para nosso banco de dados do Composer, definimos uma classe do Composer que contém os dados relevantes que queremos armazenar em cada compositor. Também contém métodos para manipular as classificações e exibir os dados.

A classe do banco de dados precisa de algum tipo de estrutura para armazenar objetos do Composer. Precisamos adicionar um novo objeto do Composer à estrutura, bem como recuperar um objeto específico do Composer. Também queremos mostrar todos os objetos em ordem de entrada ou por classificação.

Essa classe implementa uma interface orientada por menu, com gerenciadores que chamam ações na classe Database. 

Se as classes forem facilmente compreendidas e os atributos e ações estiverem claros, como no aplicativo composer, o design das classes será relativamente fácil. No entanto, se houver alguma dúvida sobre como as classes se relacionam e interagem, é melhor desenhá-las primeiro e analisar os detalhes antes de começar a programar.

Depois que tivermos uma imagem clara do design e o avaliarmos (falaremos mais sobre isso em breve), definimos a interface para cada classe. Não nos preocupamos com os detalhes de implementação neste momento, mas apenas com quais são os atributos e as ações e quais partes do estado e das ações de uma classe estão disponíveis para outras classes.

Em C++, normalmente fazemos isso definindo um arquivo de cabeçalho para cada classe. A classe do Composer tem membros de dados particulares para todos os dados que queremos armazenar em um editor. Precisamos de acessadores ("métodos get") e mutadores (métodos "set"), bem como as principais ações da classe.

// composer.h, Maggie Johnson
// Description: The class for a Composer record.
// The default ranking is 10 which is the lowest possible.
// Notice we use const in C++ instead of #define.
const int kDefaultRanking = 10;

class Composer {
 public:
  // Constructor
  Composer();
  // Here is the destructor which has the same name as the class
  // and is preceded by ~. It is called when an object is destroyed
  // either by deletion, or when the object is on the stack and
  // the method ends.
  ~Composer();

  // Accessors and Mutators
  void set_first_name(string in_first_name);
  string first_name();
  void set_last_name(string in_last_name);
  string last_name();
  void set_composer_yob(int in_composer_yob);
  int composer_yob();
  void set_composer_genre(string in_composer_genre);
  string composer_genre();
  void set_ranking(int in_ranking);
  int ranking();
  void set_fact(string in_fact);
  string fact();

  // Methods
  // This method increases a composer's rank by increment.
  void Promote(int increment);
  // This method decreases a composer's rank by decrement.
  void Demote(int decrement);
  // This method displays all the attributes of a composer.
  void Display();

 private:
  string first_name_;
  string last_name_;
  int composer_yob_; // year of birth
  string composer_genre_; // baroque, classical, romantic, etc.
  string fact_;
  int ranking_;
};

A classe do banco de dados também é simples.

// database.h, Maggie Johnson
// Description: Class for a database of Composer records.
#include  <iostream>
#include "Composer.h"

// Our database holds 100 composers, and no more.
const int kMaxComposers = 100;

class Database {
 public:
  Database();
  ~Database();

  // Add a new composer using operations in the Composer class.
  // For convenience, we return a reference (pointer) to the new record.
  Composer& AddComposer(string in_first_name, string in_last_name,
                        string in_genre, int in_yob, string in_fact);
  // Search for a composer based on last name. Return a reference to the
  // found record.
  Composer& GetComposer(string in_last_name);
  // Display all composers in the database.
  void DisplayAll();
  // Sort database records by rank and then display all.
  void DisplayByRank();

 private:
  // Store the individual records in an array.
  Composer composers_[kMaxComposers];
  // Track the next slot in the array to place a new record.
  int next_slot_;
};

Observe como encapsulamos cuidadosamente os dados específicos do compositor em uma classe separada. Poderíamos colocar um struct ou uma classe na classe Database para representar o registro do Composer e acessá-lo diretamente. No entanto, isso seria "subobjeção", ou seja, não estamos modelando com objetos tanto quanto poderíamos.

Você vai perceber que, quando começar a trabalhar na implementação das classes do Composer e do Database, é muito mais simples ter uma classe separada do Composer. Em especial, ter operações atômicas separadas em um objeto do Composer simplifica bastante a implementação dos métodos Display() na classe do banco de dados.

Claro, também há uma "objetificação excessiva", em que tentamos transformar tudo em uma classe ou temos mais classes do que o necessário. É preciso prática para encontrar o equilíbrio certo, e você descobrirá que os programadores vão ter opiniões diferentes. 

Muitas vezes, é possível determinar se você está objetificando em excesso ou em excesso por meio de um diagrama detalhado das suas classes. Como mencionado anteriormente, é importante elaborar um design de classe antes de começar a programar, e isso pode ajudar a analisar a abordagem. Uma notação comum usada para essa finalidade é a UML (Unified Modeling Language). Agora que temos as classes definidas para os objetos do Composer e do Database, precisamos de uma interface que permita ao usuário interagir com o banco de dados. Um menu simples é suficiente:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

Podemos implementar a interface do usuário como uma classe ou como um programa processual. Nem tudo em um programa C++ precisa ser uma classe. Na verdade, se o processamento for sequencial ou orientado a tarefas, como neste programa de menu, não há problema em implementá-lo processualmente. É importante implementá-la de modo que ela continue sendo um "marcador de posição", ou seja, se quisermos criar uma interface gráfica do usuário em algum momento, não precisaremos mudar nada no sistema, apenas a interface do usuário.

A última coisa que precisamos para concluir a inscrição é um programa para testar as classes. Para a classe do Composer, queremos um programa main() que receba entradas, preencha um objeto do Composer e, em seguida, o exiba para garantir que a classe esteja funcionando corretamente. Também queremos chamar todos os métodos da classe do Composer.

// test_composer.cpp, Maggie Johnson
//
// This program tests the Composer class.

#include <iostream>
#include "Composer.h"
using namespace std;

int main()
{
  cout << endl << "Testing the Composer class." << endl << endl;

  Composer composer;

  composer.set_first_name("Ludwig van");
  composer.set_last_name("Beethoven");
  composer.set_composer_yob(1770);
  composer.set_composer_genre("Romantic");
  composer.set_fact("Beethoven was completely deaf during the latter part of "
    "his life - he never heard a performance of his 9th symphony.");
  composer.Promote(2);
  composer.Demote(1);
  composer.Display();
}

Precisamos de um programa de testes parecido para a classe do banco de dados.

// test_database.cpp, Maggie Johnson
//
// Description: Test driver for a database of Composer records.
#include <iostream>
#include "Database.h"
using namespace std;

int main() {
  Database myDB;

  // Remember that AddComposer returns a reference to the new record.
  Composer& comp1 = myDB.AddComposer("Ludwig van", "Beethoven", "Romantic", 1770,
    "Beethoven was completely deaf during the latter part of his life - he never "
    "heard a performance of his 9th symphony.");
  comp1.Promote(7);

  Composer& comp2 = myDB.AddComposer("Johann Sebastian", "Bach", "Baroque", 1685,
    "Bach had 20 children, several of whom became famous musicians as well.");
  comp2.Promote(5);

  Composer& comp3 = myDB.AddComposer("Wolfgang Amadeus", "Mozart", "Classical", 1756,
    "Mozart feared for his life during his last year - there is some evidence "
    "that he was poisoned.");
  comp3.Promote(2);

  cout << endl << "all Composers: " << endl << endl;
  myDB.DisplayAll();
}

Esses programas de teste simples são um bom primeiro passo, mas exigem a inspeção manual da saída para garantir que o programa esteja funcionando corretamente. À medida que um sistema fica maior, a inspeção manual da saída rapidamente se torna impraticável. Na próxima aula, vamos apresentar programas de teste de autoverificação na forma de testes de unidade.

O design do nosso aplicativo está completo. A próxima etapa é implementar os arquivos .cpp para as classes e a interface do usuário.Para começar, copie/cole o código do driver .h e de teste acima nos arquivos e compile-os.Use os drivers para testar suas classes. Em seguida, implemente a seguinte interface:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

Use os métodos definidos na classe do banco de dados para implementar a interface do usuário. Torne seus métodos à prova de erros. Por exemplo, a classificação precisa estar sempre no intervalo de 1 a 10. Não permita que outros usuários adicionem 101 compositores, a menos que você planeje alterar a estrutura de dados na classe do Database.

Todo o código precisa seguir nossas convenções, que são repetidas para sua conveniência:

  • Todo programa que escrevemos começa com um comentário no cabeçalho, fornecendo o nome do autor, as informações de contato dele, uma breve descrição e o uso (se relevante). Cada função/método começa com um comentário sobre a operação e o uso.
  • Adicionamos comentários explicativos usando frases completas sempre que o código não se documenta, por exemplo, se o processamento for complicado, não óbvio, interessante ou importante.
  • Sempre use nomes descritivos: variáveis são palavras em letras minúsculas separadas por _, como em my_variable. Os nomes de função/método usam letras maiúsculas para marcar palavras, como em MyExcitingFunction(). Constantes começam com um "k" e usam letras maiúsculas para marcar palavras, como em kDaysInWeek.
  • O recuo é feito em múltiplos de dois. O primeiro nível tem dois espaços. Se mais recuo for necessário, usaremos quatro espaços, seis espaços etc.

Bem-vindo ao mundo real!

Neste módulo, apresentaremos duas ferramentas muito importantes usadas na maioria das organizações de engenharia de software. O primeiro é uma ferramenta de build e o segundo é um sistema de gerenciamento de configuração. Essas duas ferramentas são essenciais na engenharia de software industrial, em que muitos engenheiros trabalham em um único sistema grande. Essas ferramentas ajudam a coordenar e controlar mudanças na base de código e fornecem um meio eficiente de compilar e vincular um sistema a partir de muitos arquivos de programas e principais.

Makefiles

O processo de criação de um programa geralmente é gerenciado com uma ferramenta de build, que compila e vincula os arquivos necessários na ordem correta. Muitas vezes, os arquivos C++ têm dependências. Por exemplo, uma função chamada em um programa reside em outro. Ou talvez um arquivo de cabeçalho seja necessário para vários arquivos .cpp diferentes. Uma ferramenta de build descobre a ordem de compilação correta dessas dependências. Além disso, ele vai compilar apenas os arquivos que foram mudados desde o último build. Isso pode economizar muito tempo em sistemas com centenas ou milhares de arquivos.

Uma ferramenta de build de código aberto chamada make é usada com frequência. Para saber mais, leia este artigo. Tente criar um gráfico de dependência para o aplicativo do banco de dados do Composer e, em seguida, convertê-lo em um makefile.Veja aqui nossa solução.

Sistemas de gerenciamento de configuração

A segunda ferramenta usada em engenharia de software industrial é o gerenciamento de configurações (CM, na sigla em inglês). Isso é usado para gerenciar a mudança. Digamos que Bob e Susan sejam redatores de tecnologia e ambos estejam trabalhando em atualizações de um manual técnico. Durante uma reunião, o gerente atribui a eles uma seção do mesmo documento para atualizar.

O manual técnico fica armazenado em um computador que Beto e Susan podem acessar. Sem nenhuma ferramenta ou processo de CM, vários problemas podem surgir. Um cenário possível é que o computador que armazena o documento esteja configurado de modo que Bob e Susan não trabalhem no manual ao mesmo tempo. Isso os atrasaria consideravelmente.

Uma situação mais perigosa surge quando o computador de armazenamento não permite que o documento seja aberto por Bob e Susan ao mesmo tempo. Veja o que pode acontecer:

  1. Bob abre o documento no computador e trabalha na seção dele.
  2. Susan abre o documento no computador e trabalha na própria seção.
  3. Bob conclui as alterações e salva o documento no computador.
  4. Susana conclui as alterações e salva o documento no computador.

Esta ilustração mostra o problema que pode ocorrer se não houver controles em uma única cópia do manual técnico. Quando Susan salva as alterações, ela substitui aquelas feitas por Bob.

Esse é exatamente o tipo de situação que um sistema CM pode controlar. Com um sistema de CM, Bob e Susan "verificam a própria cópia" do manual técnico e trabalham nela. Quando Beto verifica as alterações novamente, o sistema sabe que Susan tem uma cópia própria verificada. Quando Susan verifica a cópia, o sistema analisa as mudanças feitas por Bob e Susan e cria uma nova versão que mescla os dois conjuntos de mudanças.

Os sistemas de CM têm vários recursos além de gerenciar alterações simultâneas, conforme descrito acima. Muitos sistemas armazenam arquivos de todas as versões de um documento desde a primeira criação. No caso de um manual técnico, isso pode ser muito útil quando um usuário tem uma versão antiga do manual e está fazendo perguntas a um redator de tecnologia. Um sistema de CM permitiria que o redator de tecnologia acessasse a versão antiga e pudesse ver o que o usuário está vendo.

Os sistemas de CM são especialmente úteis no controle das alterações feitas no software. Esses sistemas são chamados de sistemas de gerenciamento de configuração de software (SCM, na sigla em inglês). Se você considerar o grande número de arquivos de código-fonte individuais em uma grande organização de engenharia de software e o grande número de engenheiros que precisam fazer alterações neles, fica claro que um sistema SCM é fundamental.

Gerenciamento de configuração de software

Os sistemas SCM são baseados em uma ideia simples: as cópias definitivas dos arquivos são mantidas em um repositório central. As pessoas verificam as cópias de arquivos do repositório, trabalham nessas cópias e as verificam novamente quando terminam. Os sistemas SCM gerenciam e rastreiam as revisões de várias pessoas em um único conjunto mestre. 

Todos os sistemas SCM oferecem os seguintes recursos essenciais:

  • Gerenciamento de simultaneidade
  • Controle de versões
  • Sincronização

Vamos conferir mais detalhes de cada um desses recursos.

Gerenciamento de simultaneidade

Simultaneidade refere-se à edição simultânea de um arquivo por mais de uma pessoa. Com um repositório grande, queremos que as pessoas consigam fazer isso, mas isso pode gerar alguns problemas.

Considere um exemplo simples no domínio de engenharia: suponha que permitamos que os engenheiros modifiquem o mesmo arquivo simultaneamente em um repositório central de código-fonte. Client1 e Client2 precisam fazer alterações em um arquivo ao mesmo tempo:

  1. O Client1 abre bar.cpp.
  2. O Client2 abre bar.cpp.
  3. O Client1 altera e salva o arquivo.
  4. O Client2 altera o arquivo e o salva, substituindo as alterações do Client1.

Obviamente, não queremos que isso aconteça. Mesmo que controlamos a situação fazendo com que os dois engenheiros trabalhem em cópias separadas em vez de diretamente em um conjunto mestre (como na ilustração abaixo), as cópias precisam ser reconciliadas. A maioria dos sistemas SCM lida com esse problema permitindo que vários engenheiros verifiquem um arquivo ("sincronização" ou "atualização") e façam alterações conforme necessário. Em seguida, o sistema SCM executa algoritmos para mesclar as alterações à medida que os arquivos são verificados ("enviar" ou "confirmar") no repositório.

Esses algoritmos podem ser simples (peças aos engenheiros para resolverem alterações conflitantes) ou não tão simples (determinam como mesclar as alterações conflitantes de forma inteligente e só perguntam a um engenheiro se o sistema realmente está travado). 

Controle de versões

O controle de versões refere-se ao controle das revisões de arquivo, o que permite recriar (ou reverter) uma versão anterior do arquivo. Isso é feito fazendo uma cópia de cada arquivo quando ele é verificado no repositório ou salvando todas as alterações feitas em um arquivo. A qualquer momento, podemos usar os arquivos ou alterar as informações para criar uma versão anterior. Com os sistemas de controle de versões, também é possível criar relatórios de registro de quem fez check-in nas alterações, quando foi feito e quais alterações foram feitas.

Sincronização

Em alguns sistemas SCM, é feito check-in e check-out dos arquivos individuais no repositório. Sistemas mais eficientes permitem que você verifique mais de um arquivo ao mesmo tempo. Os engenheiros verificam as próprias cópias completas e completas do repositório (ou parte dele) e trabalham nos arquivos conforme necessário. Em seguida, eles confirmam as alterações no repositório mestre periodicamente e atualizam as próprias cópias pessoais para acompanhar as alterações feitas por outras pessoas. Esse processo é chamado de sincronização ou atualização.

Subversão

O Subversion (SVN) é um sistema de controle de versão de código aberto. Ele tem todos os recursos descritos acima.

O SVN adota uma metodologia simples quando ocorrem conflitos. Um conflito ocorre quando dois ou mais engenheiros fazem alterações diferentes na mesma área da base de código e, em seguida, ambos enviam as alterações. O SVN só alerta os engenheiros de que há um conflito, cabe aos engenheiros resolver isso.

Vamos usar o SVN ao longo deste curso para ajudar você a se familiarizar com o gerenciamento de configurações. Esses sistemas são muito comuns no setor.

A primeira etapa é instalar o SVN no seu sistema. Clique aqui para instruções. Encontre seu sistema operacional e faça o download do binário apropriado.

Terminologia de SVN

  • Revisão: uma alteração em um arquivo ou conjunto de arquivos. Uma revisão é um "resumo" de um projeto em constante mudança.
  • Repositório: a cópia principal em que o SVN armazena o histórico completo de revisões de um projeto. Cada projeto tem um repositório.
  • Cópia de trabalho: a cópia em que um engenheiro faz alterações em um projeto. Pode haver muitas cópias de trabalho de um determinado projeto, cada uma de propriedade de um engenheiro individual.
  • Check-out: para solicitar uma cópia de trabalho do repositório. Uma cópia de trabalho é igual ao estado do projeto quando ele foi verificado.
  • Confirmação: para enviar alterações da sua cópia de trabalho para o repositório central. Também conhecido como check-in ou envio.
  • Atualização: para transferir alterações de outras pessoas do repositório para sua cópia de trabalho ou indicar se ela tem alterações não confirmadas. Isso é o mesmo que uma sincronização, conforme descrito acima. Portanto, a atualização/sincronização deixa sua cópia de trabalho atualizada com a cópia do repositório.
  • Conflito: situação em que dois engenheiros tentam confirmar alterações na mesma área de um arquivo. O SVN indica conflitos, mas os engenheiros precisam resolvê-los.
  • Mensagem de registro: um comentário que você anexa a uma revisão ao confirmá-la, que descreve suas alterações. O registro fornece um resumo do que está acontecendo em um projeto.

Agora que o SVN está instalado, executaremos alguns comandos básicos. A primeira coisa a fazer é configurar um repositório em um diretório especificado. Veja os comandos:

$ svnadmin create /usr/local/svn/newrepos
$ svn import mytree file:///usr/local/svn/newrepos/project -m "Initial import"
Adding         mytree/foo.c
Adding         mytree/bar.c
Adding         mytree/subdir
Adding         mytree/subdir/foobar.h

Committed revision 1.

O comando import copia o conteúdo do diretório mytree para o projeto do diretório no repositório. Podemos dar uma olhada no diretório no repositório com o comando list

$ svn list file:///usr/local/svn/newrepos/project
bar.c
foo.c
subdir/

A importação não cria uma cópia de trabalho. Para fazer isso, é necessário usar o comando retirado checkout. Isso cria uma cópia de trabalho da árvore de diretórios. Faça isso agora:

$ svn checkout file:///usr/local/svn/newrepos/project
A    foo.c
A    bar.c
A    subdir
A    subdir/foobar.h
…
Checked out revision 215.

Agora que você tem uma cópia de trabalho, pode alterar os arquivos e diretórios nela. Sua cópia de trabalho é como qualquer outra coleção de arquivos e diretórios. Você pode adicionar ou editar novos arquivos, movê-los e até mesmo excluir toda a cópia de trabalho. Observe que, se você copiar e mover arquivos na sua cópia de trabalho, é importante usar passo a passo e sufixar move em vez dos comandos do seu sistema operacional. Para adicionar um novo arquivo, use coleta de inclusão e, para excluir um arquivo, use ícone de exclusão. Se você só quiser fazer edições, basta abrir o arquivo com seu editor e fazer a edição.

Existem alguns nomes de diretórios padrão usados com frequência com o Subversion. O diretório "entroncamento" contém a principal linha de desenvolvimento do seu projeto. Um diretório "branches" contém qualquer versão de branch em que você esteja trabalhando.

$ svn list file:///usr/local/svn/repos
/trunk
/branches

Digamos que você tenha feito todas as alterações necessárias na sua cópia de trabalho e queira sincronizá-la com o repositório. Se muitos outros engenheiros estiverem trabalhando nesta área do repositório, é importante manter sua cópia de trabalho atualizada. Você pode usar o comando admob status para ver as alterações que fez.

A       subdir/new.h      # file is scheduled for addition
D       subdir/old.c        # file is scheduled for deletion
M       bar.c                  # the content in bar.c has local modifications

Há muitas sinalizações no comando de status para controlar essa saída. Para ver as alterações específicas em um arquivo modificado, use coleta diff.

$ svn diff bar.c
Index: bar.c
===================================================================
--- bar.c	(revision 5)
+++ bar.c	(working copy)
## -1,18 +1,19 ##
+#include
+#include

 int main(void) {
-  int temp_var;
+ int new_var;
...

Por fim, para atualizar sua cópia de trabalho no repositório, use o comando captcha update.

$ svn update
U  foo.c
U  bar.c
G  subdir/foobar.h
C  subdir/new.h
Updated to revision 2.

Esse é um lugar em que pode ocorrer um conflito. Na saída acima, "U" indica que nenhuma alteração foi feita nas versões de repositório desses arquivos e que uma atualização foi feita. O "G" significa que uma mesclagem ocorreu. A versão do repositório foi alterada, mas as alterações não entraram em conflito com a sua. O "C" indica um conflito. Isso significa que as alterações do repositório se sobrepuseram às suas, e agora você precisa escolher entre elas.

Para cada arquivo que tenha um conflito, o Subversion coloca três arquivos na sua cópia de trabalho:

  • file.mine: este é o arquivo como ele existia na cópia de trabalho antes de você atualizar sua cópia de trabalho.
  • file.rOLDREV: esse é o arquivo que você transferiu do repositório antes de fazer as alterações.
  • file.rNEWREV: esse arquivo é a versão atual no repositório.

É possível fazer uma das três coisas para resolver o conflito:

  • Navegue pelos arquivos e faça a mesclagem manualmente.
  • Copie um dos arquivos temporários criados pelo SVN para substituir sua versão de cópia de trabalho.
  • Execute pt reverter para descartar todas as alterações.

Depois de resolver o conflito, informe ao SVN executando exit resolve. Isso remove os três arquivos temporários, e o SVN não exibe mais o arquivo em um estado de conflito.

A última coisa a fazer é confirmar a versão final no repositório. Isso pode ser feito com o comando admob commit. Quando você confirma uma mudança, precisa fornecer uma mensagem de registro com a descrição das mudanças. Essa mensagem de registro é anexada à revisão que você criou.

svn commit -m "Update files to include new headers."  

Há muito mais a aprender sobre o SVN e como ele pode apoiar grandes projetos de engenharia de software. Há diversos recursos disponíveis na Web. Basta pesquisar por "Subversion" no Google.

Para praticar, crie um repositório para o sistema de banco de dados do Composer e importe todos os arquivos. Em seguida, confira uma cópia em funcionamento e execute os comandos descritos acima.

Referências

Livro de subversão on-line

Artigo da Wikipédia sobre SVN

Site do Subversion

Aplicativo: um estudo em anatomia

Confira a eSkeletons da Universidade do Texas em Austin