C++ detalhado

Tutorial da linguagem C++

As primeiras seções deste tutorial abrangem o material básico já apresentado nos últimos dois módulos e fornecem mais informações sobre conceitos avançados. Neste módulo, vamos nos concentrar na memória dinâmica e em mais detalhes sobre objetos e classes. Alguns tópicos avançados também são introduzidos, como herança, polimorfismo, modelos, exceções e namespaces. Estudaremos isso posteriormente no curso C++ avançado.

Design orientado a objetos

Este é um excelente tutorial sobre design orientado a objetos. Vamos aplicar a metodologia apresentada aqui no projeto deste módulo.

Aprenda com o exemplo 3

Neste módulo, vamos praticar com ponteiros, design orientado a objetos, matrizes multidimensionais e classes/objetos. Confira os exemplos a seguir. Não podemos enfatizar o suficiente que a chave para se tornar um bom programador é a prática, prática e prática.

Exercício 1: pratique mais com os ponteiros

Se você precisar de mais prática com ponteiros, leia este recurso, que aborda todos os aspectos de ponteiros e fornece muitos exemplos de programas.

Qual é o resultado do programa a seguir? Não execute o programa, mas desenhe a imagem da memória para determinar a saída.

void Unknown(int *p, int num);
void HardToFollow(int *p, int q, int *num);

void Unknown(int *p, int num) {
  int *q;

  q = #
  *p = *q + 2;
  num = 7;
}

void HardToFollow(int *p, int q, int *num) {
  *p = q + *num;
  *num = q;
  num = p;
  p = &q;
  Unknown(num, *p);
}

main() {
  int *q;
  int trouble[3];

  trouble[0] = 1;
  q = &trouble[1];
  *q = 2;
  trouble[2] = 3;

  HardToFollow(q, trouble[0], &trouble[2]);
  Unknown(&trouble[0], *q);

  cout << *q << " " << trouble[0] << " " << trouble[2];
}

Depois de determinar a saída manualmente, execute o programa para conferir se ela está correta.

Exercício 2: mais prática com classes e objetos

Se você precisar de mais prática com classes e objetos, consulte este link (em inglês) para conferir um recurso sobre a implementação de duas classes pequenas. Reserve um tempo para fazer os exercícios.

Exercício 3: matrizes multidimensionais

Considere o seguinte programa: 

const int kStudents = 25;
const int kProblemSets = 10;

// This function returns the highest grade in the Problem Set array.
int get_high_grade(int *a, int cols, int row, int col) {
  int i, j;
  int highgrade = *a;

  for (i = 0; i < row; i++)
    for (j = 0; j < col; j++)
      if (*(a + i * cols + j) > highgrade)  // How does this line work?
        highgrade = *(a + i*cols + j);
  return highgrade;
}

int main() {
 int grades[kStudents][kProblemSets] = {
   {75, 70, 85, 72, 84},
   {85, 92, 93, 96, 86},
   {95, 90, 83, 76, 97},
   {65, 62, 73, 84, 73}
 };
 int std_num = 4;
 int ps_num = 5;
 int highest;

 highest = get_high_grade((int *)grades, kProblemSets, std_num, ps_num);
 cout << "The highest problem set score in the class is " << highest << endl;

 return 0;
}

Há uma linha neste programa marcada "Como esta linha funciona?" - consegue descobrir? Confira nossa explicação.

Escreva um programa que inicialize uma matriz de três dimensões e preencha o valor da terceira dimensão com a soma dos três índices. Confira nossa solução.

Exercício 4: um exemplo abrangente de design OO

Aqui está um exemplo de design orientado a objetos detalhado, que abrange todo o processo do início ao fim. O código final é escrito na linguagem de programação Java, mas você poderá lê-lo conforme você já evoluiu.

Reserve um tempo para analisar este exemplo inteiro. É uma ótima ilustração do processo e das ferramentas de design que dão suporte a ele.

Teste de unidade

Introdução

Os testes são uma parte essencial do processo de engenharia de software. Um teste de unidade é um tipo específico de teste que verifica a funcionalidade de um único pequeno módulo de código-fonte.O teste de unidade é sempre feito pelo engenheiro e geralmente é realizado ao mesmo tempo em que ele codifica o módulo. Os drivers usados para testar as classes do Composer e do Database são exemplos de testes de unidade.

Os testes de unidade têm as características abaixo: Ele…

  • testar um componente isoladamente
  • são deterministas
  • geralmente são mapeadas em uma única classe
  • evitar dependências de recursos externos, como bancos de dados, arquivos, redes
  • executar com rapidez
  • podem ser executados em qualquer ordem

Existem frameworks e metodologias automatizados que oferecem suporte e consistência para testes de unidade em grandes organizações de engenharia de software. Existem alguns frameworks sofisticados de teste de unidade de código aberto, que vamos conhecer mais adiante nesta lição. 

Confira abaixo os testes que ocorrem como parte do teste de unidade.

Em um mundo ideal, testamos o seguinte:

  1. A interface do módulo é testada para garantir que as informações fluam corretamente.
  2. As estruturas de dados locais são examinadas para garantir que armazenam os dados corretamente.
  3. As condições de limite são testadas para garantir que o módulo funcione corretamente nos limites que limitam ou restringem o processamento.
  4. Testamos caminhos independentes no módulo para garantir que cada caminho e, portanto, cada instrução no módulo, seja executado pelo menos uma vez. 
  5. Por fim, precisamos verificar se os erros estão sendo tratados corretamente.

Cobertura de código

Na realidade, não podemos atingir a "cobertura de código" completa com nossos testes. A cobertura de código é um método de análise que determina quais partes de um sistema de software foram executadas (cobertas) pelo pacote de casos de teste e quais partes não foram executadas. Se tentarmos alcançar 100% de cobertura, passaremos mais tempo escrevendo testes de unidade do que escrevendo o código real. Considere criar testes de unidade para todos os caminhos independentes a seguir. Isso pode rapidamente se tornar um problema exponencial.

Neste diagrama, as linhas vermelhas não são testadas, enquanto as linhas não coloridas são testadas.

Em vez de tentar 100% de cobertura, nos concentramos em testes que aumentam nossa confiança de que o módulo está funcionando corretamente. Testamos o seguinte:

  • Casos nulos
  • Testes de intervalo, por exemplo, testes de valor positivo/negativo
  • Casos extremos
  • Casos de falha
  • Testar os caminhos com maior probabilidade de serem executados na maior parte do tempo

Frameworks de teste de unidade

A maioria dos frameworks de teste de unidade usa declarações para testar valores durante a execução de um caminho. Declarações são declarações que verificam se uma condição é verdadeira. O resultado de uma declaração pode ser sucesso, falha não fatal ou falha fatal. Depois que uma declaração é realizada, o programa continua normalmente se o resultado for sucesso ou falha não fatal. Se ocorrer uma falha fatal, a função atual será cancelada.

Os testes consistem em um código que configura o estado ou manipula seu módulo, com várias declarações que verificam os resultados esperados. Se todas as declarações em um teste forem bem-sucedidas, ou seja, retornar "true", o teste será bem-sucedido. Caso contrário, ele vai falhar.

Um caso de teste contém um ou vários testes. Agrupamos testes em casos que refletem a estrutura do código testado. Neste curso, vamos usar o CPPUnit como framework de teste de unidade. Com esse framework, é possível programar testes de unidade em C++ e executá-los automaticamente, gerando um relatório sobre o sucesso ou a falha dos testes.

Instalação da CPPUnit

Faça o download do código CPPUnit do SourceForge. Encontre um diretório apropriado e coloque o arquivo tar.gz nele. Em seguida, digite os seguintes comandos (no Linux, Unix), substituindo o nome do arquivo cppunit apropriado:

gunzip filename.tar.gz
tar -xvf filename.tar

Se você está trabalhando no Windows, talvez seja necessário encontrar um utilitário para extrair arquivos tar.gz. A próxima etapa é compilar as bibliotecas. Mude para o diretório cppunit. Há um arquivo INSTALL lá que fornece instruções específicas. Normalmente, você precisa executar:

./configure
make install

Se você encontrar problemas, consulte o arquivo INSTALL. As bibliotecas geralmente são encontradas no diretório cppunit/src/cppunit. Para verificar se a compilação funcionou, entre no diretório cppunit/examples/simple e digite "make". Se tudo for compilado bem, está tudo pronto.

Há um excelente tutorial disponível aqui. Siga este tutorial e crie a classe de número complexo e os testes de unidade associados a ela. Há vários outros exemplos no diretório cppunit/examples.

Por que eu tenho que fazer isso?

O teste de unidade é extremamente importante no setor por vários motivos. Você já está familiarizado com um motivo: precisamos de alguma maneira de verificar nosso trabalho durante o desenvolvimento do código. Mesmo quando estamos desenvolvendo um programa muito pequeno, escrevemos instintivamente algum tipo de verificador ou driver para garantir que nosso programa faça o que é esperado.

Por experiência longa, os engenheiros sabem que as chances de um programa funcionar na primeira tentativa são muito pequenas. Os testes de unidade se baseiam nessa ideia, tornando os programas de teste autoverificação e repetível. As declarações substituem a inspeção manual da saída. Além disso, como é fácil interpretar os resultados (o teste é aprovado ou reprovado), os testes podem ser executados inúmeras vezes, proporcionando uma rede de segurança que torna seu código mais resiliente a mudanças.

Vamos colocar isso em termos concretos: quando você envia pela primeira vez seu código finalizado para a CVS, ele funciona perfeitamente. E ele continua funcionando perfeitamente por um tempo. Então, um dia, outra pessoa mudou seu código. Mais cedo ou mais tarde alguém vai violar seu código. Você acha que ele vai perceber por conta própria? Não é provável. No entanto, quando você cria testes de unidade, há sistemas que podem executá-los automaticamente todos os dias. Esses sistemas são chamados de sistemas de integração contínua. Quando esse engenheiro X quebrar seu código, o sistema vai enviar e-mails desagradáveis para ele até que o problema seja corrigido. Mesmo que o engenheiro X seja VOCÊ!

Além de ajudar você a desenvolver um software e mantê-lo seguro diante de mudanças, o teste de unidade:

  • Cria uma especificação executável e uma documentação que permanecem sincronizadas com o código. Em outras palavras, você pode ler um teste de unidade para saber qual comportamento o módulo oferece suporte.
  • Ajuda a separar os requisitos da implementação. Como você está declarando um comportamento visível externamente, tem a oportunidade de pensar sobre isso explicitamente, em vez de misturar ideias sobre como implementar o comportamento.
  • Compatível com experimentação. Se você tiver uma rede de segurança para receber alertas quando quebrar o comportamento de um módulo, será mais provável que tente fazer alguns testes e reconfigure seus designs.
  • Melhora seus designs. Escrever testes de unidade completos geralmente exige que você torne o código mais testável. O código testável geralmente é mais modular do que o não testável.
  • Manter a alta qualidade. Um pequeno bug em um sistema crítico pode fazer com que uma empresa perca milhões de dólares ou, pior ainda, a satisfação ou a confiança de um usuário. A rede de segurança fornecida pelos testes de unidade diminui essa possibilidade. Ao detectar bugs com antecedência, eles também permitem que as equipes de controle de qualidade passem tempo em cenários de falha mais sofisticados e difíceis, em vez de relatar falhas óbvias.

Dedique algum tempo para programar testes de unidade usando o CPPUnit no aplicativo de banco de dados do Composer. Consulte o diretório cppunit/examples/ para receber ajuda.

Como o Google Funciona

Introdução

Imagine um monge na Idade Média olhando para os milhares de manuscritos nos arquivos do seu mosteiro."Onde está aquela de Aristóteles..."

biblioteca monastária

Felizmente para ele, os manuscritos são organizados por conteúdo e inscritos com símbolos especiais para facilitar a recuperação das informações contidas em cada um. Sem essa organização, seria muito difícil encontrar o manuscrito relevante.

A atividade de armazenamento e recuperação de informações escritas de grandes coleções é chamada de Recuperação de informações (IR, na sigla em inglês). Essa atividade se tornou cada vez mais importante ao longo dos séculos, especialmente com invenções como o papel e a prensa móvel. Antes, apenas algumas pessoas estavam ocupadas. Agora, no entanto, centenas de milhões de pessoas se envolvem na recuperação de informações todos os dias quando usam um mecanismo de pesquisa ou pesquisam no computador.

Primeiros passos com a recuperação de informações

gato no chapéu

Dr. Seuss escreveu 46 livros infantis ao longo de 30 anos. Os livros dele tratavam de gatos, vacas e elefantes, de quem é, de sorrisos e do lorax. Você se lembra de quais criaturas estavam em qual história? A menos que você tenha filhos, apenas crianças podem dizer qual conjunto de histórias de Dr. Seuss têm as criaturas:

(COW e BEE) ou CROWS

Aplicaremos alguns modelos clássicos de recuperação de informações para nos ajudar a resolver esse problema.

Uma abordagem óbvia é o uso de força bruta: confira todas as 46 histórias de Dr. Seuss e comece a ler. Em cada livro, observe quais contêm as palavras COW e BEE e, ao mesmo tempo, procure livros que tenham a palavra CROWS. Os computadores são muito mais rápidos nisso do que nós. Se tivermos todos os textos dos livros do Dr. Seuss em formato digital, por exemplo, arquivos de texto, podemos criar grep nesses arquivos. Essa técnica funciona bem para uma coleção pequena como os livros do Dr. Seuss.

No entanto, há muitas situações em que precisamos de mais. Por exemplo, a coleta de todos os dados on-line atualmente é muito grande para ser processada por grep. Além disso, não queremos apenas os documentos que correspondam à nossa condição. Estamos acostumados a classificá-los de acordo com sua relevância.

Outra abordagem além de grep é criar um índice dos documentos em uma coleção antes de fazer a pesquisa. Um índice de IR é semelhante ao índice no verso de um livro didático. Fazemos uma lista de todas as palavras (ou termos) de cada história do Dr. Seuss, deixando de fora palavras como "o", "e" e outros conectivos, preposições etc. (são chamadas de palavras irrelevantes). Em seguida, representamos essas informações de modo a facilitar a localização dos termos e a identificação das histórias em que eles se encontram.

Uma representação possível é uma matriz com as histórias na parte superior e os termos listados em cada linha. Um “1” em uma coluna indica que o termo aparece na história dessa coluna.

tabela de livros e palavras

Podemos ver cada linha ou coluna como um vetor de bits. O vetor de bits de uma linha indica em quais matérias o termo aparece. O vetor de bits de uma coluna indica quais termos aparecem na história.

Voltando ao problema original:

(COW e BEE) ou CROWS

Usamos os vetores de bits para esses termos e, primeiro, fazemos uma função AND bit a bit e, em seguida, um OR bit a bit no resultado.

(100001 e 010011) ou 000010 = 000011

A resposta: “Sr. Brown Can Moo! Você pode?" e "O Lorax". Esta é uma ilustração do modelo de recuperação booleana, que é um modelo de "correspondência exata".

Imagine que fôssemos expandir a matriz para incluir todas as histórias de Dr. Seuss e todos os termos relevantes nas histórias. A matriz cresceria consideravelmente, e uma observação importante é que a maioria das entradas seria 0. Uma matriz provavelmente não é a melhor representação para o índice. Precisamos encontrar uma maneira de armazenar só os 1s.

Algumas melhorias

A estrutura usada em IR para resolver esse problema é chamada de índice invertido. Mantemos um dicionário de termos e, para cada termo, temos uma lista que registra os documentos em que o termo ocorre. Essa lista é chamada de lista de postagens. Uma lista vinculada funciona bem para representar essa estrutura, conforme mostrado abaixo.

Se você não estiver familiarizado com as listas vinculadas, basta fazer uma pesquisa no Google em "lista vinculada em C++" para encontrar muitos recursos descrevendo como criar uma e como ela é usada. Vamos saber mais sobre isso em um módulo futuro.

Observe que usamos IDs de documentos (DocIDs) em vez do nome da história. Também classificamos esses DocIDs, pois isso facilita o processamento de consultas.

Como processamos uma consulta? Para o problema original, primeiro encontramos a lista de postagens de COW e, em seguida, a lista de postagens de BEE. Em seguida, nós os “mesclamos”:

  1. Mantenha os marcadores nas duas listas e consulte as duas listas de publicações simultaneamente.
  2. Em cada etapa, compare o DocID apontado pelos dois ponteiros.
  3. Se eles forem iguais, coloque esse DocID em uma lista de resultados. Caso contrário, avance o ponteiro apontando para o docID menor.

Veja como criar um índice invertido:

  1. Atribua um DocID a cada documento do seu interesse.
  2. Para cada documento, identifique os termos relevantes (tokenizar).
  3. Para cada termo, crie um registro que consista no termo, o DocID em que ele é encontrado e uma frequência nesse documento. Pode haver vários registros para um termo específico se ele aparecer em mais de um documento.
  4. Classifique os registros por termo.
  5. Para criar o dicionário e a lista de publicações, processe registros únicos de um termo e combine vários registros para termos que aparecem em mais de um documento. Crie uma lista vinculada de DocIDs (em ordem classificada). Cada termo também tem uma frequência, que é a soma das frequências em todos os registros de um termo.

O projeto

Encontre vários documentos de texto simples longos com os quais você pode fazer experimentos. O projeto é criar um índice invertido dos documentos, usando os algoritmos descritos acima. Também será necessário criar uma interface para a entrada de consultas e um mecanismo para processá-las. Encontre um parceiro de projeto no fórum.

Aqui está um processo possível para concluir este projeto:

  1. A primeira coisa a fazer é definir uma estratégia para identificar os termos nos documentos. Faça uma lista de todas as palavras irrelevantes que você consegue imaginar e escreva uma função que leia as palavras dos arquivos, salve os termos e elimine as palavras irrelevantes. Talvez seja necessário adicionar mais palavras temporárias à lista enquanto você revisa a lista de termos de uma iteração.
  2. Crie casos de teste de CPPUnit para testar sua função e um makefile para reunir tudo para seu build. Verifique seus arquivos na CVS, especialmente se você estiver trabalhando com parceiros. Talvez você queira pesquisar como abrir sua instância CVS para engenheiros remotos.
  3. Adicionar processamento para incluir dados de local, ou seja, qual arquivo e em que parte do arquivo está um termo? Faça um cálculo para definir o número da página ou do parágrafo.
  4. Crie casos de teste de CPPUnit para testar essa funcionalidade adicional.
  5. Crie um índice invertido e armazene os dados de localização no registro de cada termo.
  6. Criar mais casos de teste.
  7. Criar uma interface para permitir que um usuário insira uma consulta.
  8. Com o algoritmo de pesquisa descrito acima, processe o índice invertido e retorne os dados de local ao usuário.
  9. Inclua também casos de teste para esta parte final.

Como fizemos em todos os projetos, use o fórum e o chat para encontrar parceiros e compartilhar ideias.

Um recurso extra

Uma etapa de processamento comum em muitos sistemas de IR é chamada de stemming. A ideia principal por trás da derivação é que os usuários que pesquisam informações sobre "recuperação" também terão interesse em documentos que tenham informações que contenham "recuperar", "extrair", "recuperar" e assim por diante. Os sistemas podem ser suscetíveis a erros devido a uma derivação ruim, o que é um pouco complicado. Por exemplo, um usuário interessado em "recuperação de informações" pode receber um documento intitulado "Informações sobre Golden Retrievers" devido à derivação. Um algoritmo útil para derivação é o algoritmo de Porter.

Aplicativo: vá a qualquer lugar

Confira uma aplicação desses conceitos em Panoramas.dk.