Este documento apresenta os erros mais comuns cometidos nas resoluções do Problema 1. Os erros são apontados através de troços de código extraídos dos programas realizados pelos alunos. Esses troços não são identificados. Pretende-se ilustrar os erros cometidos para que possam ser corrigidos em trabalhos posteriores.
Muitos dos erros encontrados mostram claramente que os alunos não leram as folhas teóricas, o que é lamentável. Chama-se também a atenção para que é altamente recomendável os alunos imitarem o estilo de expressão C++ usado ao longo desta disciplina, desde as folhas teóricas às aulas práticas.
Este documento está dividido em secções correspondentes a tipos genéricos de erros.
É comum ouvir-se entre os alunos e encontrar nos relatórios a expressão
"é um void
", querendo com isso os alunos
referir-se a um procedimento. Um procedimento não é "um void
".
Um procedimento tem tipo de devolução
void
, o que significa que não devolve nada ao retornar. Um
procedimento não calcula, faz.
Não é correcto dizer "tipo de retorno" ou "retorna um dado valor". O verbo "retornar" significa "voltar ao ponto de onde se partiu", que é exactamente o que sucede no final de uma rotina, seja ela função ou procedimento. Pelo contrário, a palavra "devolver" significa (embora não seja a acepção mais comum) "dizer em resposta", que é exactamente o que sucede no final de uma função, mas não no final de um procedimento.
Em português é correcto dizer "retornar a algum lado" ou simplesmente "retornar", mas é incorrecto dizer "retornar alguma coisa". Por outro lado, é perfeitamente correcto dizer "devolver alguma coisa". Assim, deve-se dizer:
É importante perceber-se que há conceitos diferentes em programação que têm idêntica expressão na linguagem C++. Um exemplo é o da distinção entre os conceitos de função e procedimento, que a linguagem C++ não distingue, possuindo apenas o conceito de função. Há inúmeros exemplos semelhantes a este, quase todos com a complicação adicional de a ferramenta da linguagem C++ ter um nome que induz em erro, pois é o mesmo que um dos conceitos entre muitos que podem ser representados à sua custa.
A linguagem C++ tem uma ferramenta, os comentários, que podem servir para representar dois conceitos distintos: os comentários (propriamente ditos) e a documentação. Um comentário serve para explicar uma parte pouco clara do código. A documentação serve para colocar a especificação dos módulos constituintes de um programa. Ambos se representam à custa dos comentários C++, embora convencionalmente se introduza uma distinção gráfica:
//
... ou /*
... */
///
... ou /**
... */
Assim, enquanto é desejável que o código seja escrito de uma forma tão clara que os comentários sejam dispensáveis (pior que um comentário acerca de um pedaço de código perfeitamente óbvio só mesmo um comentário errado!), a documentação é sempre imprescindível.
A documentação consiste sempre pelo menos numa primeira parte com uma descrição informal do objectivo do módulo e da sua especificação formal, na forma de uma pré-condição e uma condição objectivo, pelo menos no caso da rotinas.
Por razões desconhecidas, alguns alunos parecem procurar activamente no C++ as heranças pesadas da velha linguagem C. Seguem-se alguns exemplos.
Deve-se usar sempre a versão C++ dos operadores lógicos (booleanos).
Isto é, deve-se usar and
, or
e not
, e
não os correspondentes &&
, ||
e !
,
pois são muito menos claros. É verdade que alguns compiladores não
suportam os primeiros. Nesse caso a melhor solução é colocar as
seguintes linhas no início do programa:
#define and &&
#define or ||
#define not !
Também é comum encontrar a utilização de velhas convenções do C, tais como colocar os nomes de constantes em maiúsculas. Essa convenção em C era necessária, pois nas suas primeiras versões não existia o conceito de constante, obtendo-se um efeito semelhante à custa das chamadas macros. Em C++ a utilização de macros é muito mais restrita. O C++ suporta directamente o conceito de constante. Por isso devem-se usar para os nomes das constantes exactamente a mesma convenção que no caso das variáveis. Por exemplo, em vez de
int const MAXFALHANCOS = 6;//numero maximo de erros permitidos.
deve-se escrever
int const máximo_de_falhancos = 6;
ou pelo menos
int const maximo_de_falhancos = 6;
enquanto os compiladores não lidarem bem com caracteres acentuados.
Nota: Neste documento usam-se caracteres acentuados nos identificadores para aumentar a clareza da nomenclatura usada.
Finalmente, com alguma frequência surgem casos de utilização de variáveis booleanas como se tivessem valores inteiros, ou a utilização de variáveis inteiras para guardar valores booleanos. Por exemplo:
/**
verifica se a letra pretendida se encontra na string*/
bool verificaletra(char x, string palavra)
{
bool aux=0;
for (string::size_type i = 0; i !=palavra.length(); ++i)
{
if(palavra[i] == x)
aux = aux + 1;
}
return aux;
}
Deve-se sempre seleccionar o tipo mais restrito que permita representar os
valores em causa (mas, no caso dos inteiros, fugir à utilização de short
int
ou mesmo char
face ao mais claro int
).
Por exemplo, se se pretender guardar o número de falhanços ocorridos durante o
jogo da forca, escolher-se-á para a variável o tipo int
, e não double
.
Aparte questões de precisão, uma variável double
também
resolveria o problema, mas permitindo guardar valores que não inteiros, o
programador fica mais à mercê dos seus próprios erros. Mais tarde se
verá que muitas vezes a solução passa por definir novos tipos de dados (Tipos
Abstractos de Dados) especializados para os valores a guardar.
Uma versão mais apropriada do código seria:
/**
Devolve booleano que indica se a cadeia dada contém o caractere dado.
@pre V.
@post contém = (E j : 0 <= j <
cadeia
.size
() :cadeia
[j] ==caractere
).*/
bool contém(string cadeia, char caractere)
{
bool contém = falso;
for(string::size_type i = 0; i != cadeia.size(); ++i)
contém = contém or cadeia[i] == caractere;
return contém;
}
ou mesmo,
/**
Devolve booleano que indica se a cadeia dada contém o caractere dado.
@pre V.
@post contém = (E j : 0 <= j <
cadeia
.size
() :cadeia
[j] ==caractere
).*/
bool contém(string cadeia, char caractere)
{
bool contém = falso;
for(string::size_type i = 0; i != cadeia.size(); ++i)
if(cadeia[i] == caractere)
contém = true;
return contém;
}
ou ainda,
/**
Devolve booleano que indica se a cadeia dada contém o caractere dado.
@pre V.
@post contém = (E j : 0 <= j <
cadeia
.size
() :cadeia
[j] ==caractere
).*/
bool contém(string cadeia, char caractere)
{
for(string::size_type i = 0; i != cadeia.size(); ++i)
if(cadeia[i] == caractere)
return true;
return false;
}
A modularização é o mecanismo de redução de complexidade por excelência usado na programação. Uma boa modularização é meio caminho andado para o sucesso.
Um erro comum foi a definição de duas rotinas rigorosamente iguais, excepto no seu nome e no nome dos seus parâmetros, sem que os alunos tenham compreendido que ambas as rotinas podiam e deviam ser vistas como uma só rotina dedicada a resolver o caso genérico. Como exemplo, numa das resoluções foram definidas três funções devolvendo valores booleanos:
bool letraExiste(char letra_escolhida, string palavra_a_advinhar)
{
for(string::size_type i = 0; i != palavra_a_advinhar.length(); ++i)
if(palavra_a_advinhar[i] == letra_escolhida)
return true;
return false;
}
bool letraJaFoiEscolhida(char letra, string letras_disponíveis)
{
for(string::size_type i = 0; i != letras_disponíveis.length(); ++i)
if(letras_disponíveis[i] == letra)
return false;
return true;
}
bool jogoEstáGanho(string palavra_escondida)
{
for(string::size_type i = 0; i != palavra_escondida.length(); ++i)
if(palavra_escondida[i] == '-')
return false;
return true;
}
Como é evidente, basta um destas funções para resolver o problema em qualquer dos casos:
bool estáContidoEm(char caractere, string cadeia)
{
for(string::size_type i = 0; i != cadeia.length(); ++i)
if(cadeia[i] == caractere)
return true;
return false;
}
Note-se como o nome da função e os nomes dos parâmetros permitem perceber imediatamente que a função tem utilização genérica.
Uma rotina deve ter uma função bem definida. Veja-se, por exemplo, a seguinte definição:
bool éLetra(char caractere)
{
for(string::size_type i = 0; i != letras.length(); ++i)
if(letras[i] == letra)
return true;
cout << "Deve apenas inserir letras!"<< endl;
return false;
}
Esta função começa por ser inútil, pois existe uma função isalpha()
na biblioteca do C++ que faz o mesmo efeito. Ainda que a sua definição
fizesse sentido, a função tem um erro grave. Ao contrário do que o nome
indicia, não se limita a devolver true
se o argumento for uma
letra e false no caso contrário: se não for uma letra surgirá uma mensagem no
ecrã! Assim, um nome apropriado para a função seria éLetraEAssinalaErro()
...
Embora o nome ficasse mais correcto, continuariam a existir dois problemas
graves relacionados:
cout
,
através da inserção nele realizada, e o ecrã). Ou seja, é aquilo
a que se chama uma função com efeitos laterais, e que nesta disciplina
merece o nome de "coiso". Os coisos são muito
problemáticos! Isso é claro se se tentar imaginar uma futura
utilização desta função. Com a escrita em cout
,
torna-se impossível reutilizar esta rotina em contextos em que o caractere
não tenha sido dada pelo utilizador, pois nesse caso a mensagem de erro
não faria qualquer sentido.Estes dois problemas são maneiras diferentes de ver o problema geral do "dois em um": um módulo com duas funções bem (?) definidas... Veja-se por exemplo a seguinte rotina (coiso):
string letraSubstituídaNaPalavraOculta(char const letra,
string const& palavra,
string& palavra_oculta)
{
for(string::size_type i = 0; i != palavra.length(); ++i)
if(palavra[i] == letra)
palavra_oculta[i] = letra;
return palavra_oculta;
}
Esta rotina regista na palavra oculta os acertos de uma letra na palavra. Por isso recebe a palavra oculta por referência, de modo a que quaisquer alterações que essa palavra sofra internamente à rotina se reflictam no respectivo parâmetro. O problema é que, além disso, devolve a palavra oculta já com os acertos registados! A rotina deveria ser transformada ou numa função ou num procedimento. Por exemplo:
void registaAcertosNaPalavraOculta(char const letra,
string const& palavra,
string& palavra_oculta)
{
for(string::size_type i = 0; i != palavra.length(); ++i)
if(palavra[i] == letra)
palavra_oculta[i] = letra;
}
Regra geral:
cin
, cout
, cerr
,
e clog
são variáveis globais), então diz-se que tem efeitos
laterais e, por isso, só deve ser um procedimento.É conveniente a utilização de convenções para a forma de grafar os nomes das entidades usadas num programa, de acordo com a sua categoria. A utilização de convenções permite que a leitura de código escrito por outrem seja facilitada. Afinal, programar é comunicar simultaneamente com o compilador e com programadores humanos, pelo que a utilização de convenções diferentes da usual só poderá levar a que o leitor (humano) do código tenha de perder tempo lutando contra um aspecto gráfico diferente, em vez de dedicar a sua atenção ao essencial: o conteúdo. Fazendo uma analogia com a língua portuguesa, diria que usar uma convenção pouco habitual na pontuação, por exemplo, como o Saramago faz, pode ter intuitos artísticos, mas claramente não facilita a comunicação.
Assim, ao longo desta disciplina usa-se e recomenda-se vivamente a utilização por parte dos alunos das seguintes convenções para os nomes das entidades:
número_de_alunos
, número_máximo_de_alunos
e palavra_a_adivinhar
.seno()
factorialDe
éPrimo(int)
FilaDeString::tiraItem()
Racional
FilaDeString
PilhaDeInt
Tal como em português, também em C++ existe uma forma usual de organizar graficamente um programa. A utilização criteriosa de espaços e linhas em branco, por exemplo, podem contribuir em muito para clarificar a estrutura de um programa.
Ao efeito obtido pela colocação de espaços no início das linhas chama-se indentação, pois a margem esquerda do programa fica como que marcada por uma mordidela (marcas dos dentes). A indentação é fundamental para identificar instruções relacionadas e separar visualmente as instruções controladas por instruções de iteração ou selecção, por exemplo.
Está nos plano desta disciplina produzir um documento concentrando todas as regras de organização gráfica de um programa. Por enquanto, e à falta desse documento, recomenda-se a observação atenta de todos os exemplos de código dados na disciplina. E recomenda-se vivamente que usem o comando <ctrl-c s> do XEmacs para que o código seja indentado correctamente.
A atribuição de nomes às entidades (rotinas, variáveis, constantes, tipos, etc.) usadas num programa não é um problema menor. Muitos alunos tendem a escolher nomes razoavelmente arbitrários argumentando que (a) os nomes não são fundamentais e (b) a posteriori, quando houver tempo, alterarão os nomes para que "agradem mais aos docentes da disciplina".
É verdade que a correcção de um programa, no sentido em que produz os resultados desejados, não depende dos nomes usados para as entidades. Mas é um argumento falacioso. Quando se escreve um programa em C++ o objectivo não é simplesmente conseguir que ele produza os resultados pretendidos. Um programa em C++ é um texto escrito numa linguagem específica e limitada, o C++. Mas esse texto tem como objectivo não apenas ser traduzido para linguagem máquina por um compilador imbecil, para quem os nomes das entidades nada importam, mas fundamentalmente comunicar com leitores humanos. Esses leitores podem ser o próprio programador (o aluno), elementos da sua equipa (do seu grupo de alunos) ou mesmo com o docente da disciplina. É muito importante, por isso, que o programa seja possível de ler com facilidade.
Existem algumas convenções e boas regras que se devem usar ao atribuir nomes a entidades. Essas convenções e regras são seguidas em todo o material disponível nesta disciplina (salvo ocasionais falhas) e são apresentadas explicitamente nas folhas teóricas.
Os nomes devem ser escolhidos de forma a tornar o código tão legível quanto possível em português, ou pelo menos numa língua natural dominada por todos os potenciais programadores envolvidos no projecto. No caso desta disciplina trata-se do português, muito embora os alunos também possam usar inglês, desde que o façam de forma consistente.
Uma forma simples de atribuir nomes a entidades de um programava é responder da forma mais sintética possível mas sem perda de precisão (nem pontapés na gramática ou abreviaturas) às perguntas abaixo, retirando a parte a sublinhado das respostas:
As variáveis ou constantes têm usualmente por nome um sintagma nominal,
embora normalmente extirpado do artigo inicial, por exemplo, int
número_de_alunos
, int palavra_adivinhada
, Carro carro
e Barco barco
(mas é defensável usar como nome, por exemplo, o_número_de_alunos
).
Quando as variáveis não guardam nada de especial, é típico usar-se um nome
parecido com o respectivo tipo. Por exemplo, int inteiro
, vector<int>
vector_de_inteiros
e Racional racional
(este último exemplo
ajuda a compreender a razão para a convenção gráfica proposta na secção
anterior!). Nestes casos também é usual usar-se nomes muito abreviados,
na tradição da matemática, tais como i
, v
ou r
.
No entanto, há que resistir à tentação de dar este tipo de nomes quando as
variáveis guardarem algo de mais concreto, por exemplo,
posições_da_letra_na_palavra
, é muito mais claro que v
.
As variáveis booleanas têm usualmente por nome uma proposição, i.e., uma
frase que pode ser verdadeira ou falsa, por exemplo, bool
existe_um_divisor
, bool jogo_foi_abortado
, bool
janela_está_aberta
, a maior parte das vezes também sem o artigo inicial
(mas é defensável usar como nome, por exemplo, bool
a_janela_está_aberta
).
As funções têm o mesmo nome (à parte variações do grafismo) que
variáveis que guardem o valor por elas devolvido, por exemplo, bool
existe_ímpar_no_vector
e bool denominador_do_racional
vs. bool existeÍmparEm(vector<int>
vector)
e bool denominadorDe(Racional racional)
.
Os parâmetros das funções e procedimentos fazem parte da das respostas às
perguntas propostas atrás, mas não devem fazer parte do nome. É o que
se passa no caso int denominadorDe(Racional racional)
, onde a
resposta à pergunta sugerida seria "Esta função devolve o denominador
de um Racional". Desta resposta o sintagma nominal "um
Racional" transforma-se num parâmetro da função. Por vezes a
sintaxe do C++ obriga a alterar a ordem natural das palavras. É o caso de
uma função que indica se um inteiro está contido num vector (de inteiros),
que se traduz para bool estáContidoEm(int inteiro, Vector<int>
vector)
, onde o sujeito da frase teve de se transformar no primeiro
parâmetro da função (os complementos da frase correspondem normalmente a
parâmetros). Uma alternativa seria dizer que a função indica se um
vector (de inteiros) contém um inteiro, que se traduz para bool
contém(Vector<int> vector, int inteiro)
. A frase só
encontra uma tradução verdadeiramente natural quando a função é membro da
classe C++ Vector<int>
, pois nesse caso fica bool
vector<int>::contém(int inteiro)
, sendo a variável implícita (o
vector) o sujeito activo da frase.
Os procedimentos têm sempre como nome uma frase imperativa, pelo que devem
começar por um verbo no imperativo (segunda pessoa do singular).
Presume-se que a ordem é dada à entidade encarregue de executar o programa,
por exemplo, o processo ou o computador. Por exemplo, se a resposta à
pergunta sugerida acima for "Este procedimento mostra um racional no
ecrã", o procedimento será void mostraNoEcrã(Racional
racional)
, onde foi necessário fazer um ajustamento na ordem das
palavras. No caso das classes a ordem presume-se dada à variável
através da qual o procedimento foi invocado. Por exemplo,
FilaDeString fila_de_BI;
...
fila_de_BI.tiraPrimeiroItem();
Deve-se ler "fila de bilhetes de identidade, tira o (teu) primeiro item!"
Qualquer linguagem tem as suas expressões idiomáticas. A linguagem C++ não é excepção. As expressões idiomáticas têm a vantagem de ser conhecidas por todos os que conhecem a linguagem e de permitirem exprimir ideias recorrentes de uma forma condensada. Seguem-se alguns exemplos.
É comum encontrar-se código com o seguinte aspecto:
if(contém(palavra_a_adivinhar, letra) == true)
...
if(contém(palavra_a_adivinhar, letra) != false)
...
ou
if(contém(palavra_a_adivinhar, letra) != true)
...
if(contém(palavra_a_adivinhar, letra) == false)
...
Este código não está incorrecto, mas a forma idiomática de o escrever é
if(contém(palavra_a_adivinhar, letra))
...
ou
if(not contém(palavra_a_adivinhar, letra))
Há três razões para isso.
A primeira razão é que estas últimas formas são mais sintéticas do que as primeiras.
A segunda razão é que as últimas versões podem ler em português como
"se a palavra a adivinhar contém a letra, então..."
ou
"se a palavra a adivinhar não contém a letra, então..."
em vez da leitura pouco natural e verbosa das primeiras versões, que se lêem como
"se a palavra a adivinhar contém a letra for verdadeiro, então..."
"se a palavra a adivinhar contém a letra não for falso, então..."
ou
"se a palavra a adivinhar contém a letra não for verdadeiro, então..."
"se a palavra a adivinhar contém a letra for falso, então..."
A terceira razão tem a ver com o facto de as últimas formas serem idiomáticas em C++: toda a gente está à espera de as encontrar, pelo que encontrar variações é um factor que perturba a leitura e compreensão do código.
Da mesma forma se pode objectar relativamente ao seguinte código:
if(contém(palavra_a_adivinhar, letra))
return true;
else
return false;
ou
if(contém(palavra_a_adivinhar, letra))
return false;
else
return true;
Versões muito mais sintéticas e claras passam por devolver a própria guarda da instrução de selecção ou a sua negação:
return contém(palavra_a_adivinhar, letra);
ou
return not contém(palavra_a_adivinhar, letra);
Neste caso o argumento a legibilidade não colhe, pois estas instruções têm de ser lidas de uma forma pouco natural:
"Retorna devolvendo a veracidade da afirmação 'a palavra a adivinhar contém a letra'."
ou
"Retorna devolvendo a veracidade da afirmação 'a palavra a adivinhar não contém a letra'."
Mas o argumento a favor das expressões idiomáticas fala mais alto...
Finalmente, acerca da incrementação/decrementação. Na linguagem C é comum dar-se preferência à versão sufixo,
i++;
enquanto em C++ se prefere a versão prefixo,
++i;
Estas formas são equivalentes, pelo menos se o valor resultante da operação de incrementação não for usado para nenhum outro efeito. Mas são equivalente apenas em resultado. Não em eficiência. Se para os tipos básicos do C++ são equivalentes mesmo em eficiência, para Tipos Abstractos de Dados (definidos pelo utilizador usando classes C++) a versão prefixo é sempre pelo menos tão eficiente, se não mais, do que a versão sufixo. Por essa razão é conveniente usar sempre a versão prefixo dos operadores de incrementação e decrementação.
Quando a recursividade foi apresentada no final das aulas sobre modularização, fez-se a ressalva importante de que a recursividade só deve ser usada se:
Isto faz com que os casos de utilização apropriada de recursividade sejam relativamente raros. Em nenhum dos enunciados dos trabalhos desta disciplina há em caso algum justificação para a sua utilização.
Qualquer iteração pode ser transformada na invocação recursiva de rotinas, e qualquer invocação recursiva de rotinas pode ser transformada em iteração. Por exemplo, que faz o seguinte código?
void mostra(vector<int> vector_de_inteiros, vector<int>::size_type início = 0)
{
assert(inicio <= vector_de_inteiros.size());
if(início == vector_de_inteiros.size()
return;
else {
cout << vector_de_inteiros[início] << endl;
mostra(vector_de_inteiros, início + 1);
}
}
...
int main()
{
vector<int> vector_de_inteiros;
...
mostra(vector_de_inteiros);
}
Convenhamos que não é muito claro... Melhor seria escrever
void mostra(vector<int> vector_de_inteiros)
{
for(vector<int>::size_type i == 0; i != vector_de_inteiros.size(); ++i)
cout << vector_de_inteiros[i] << endl;
}
Por exemplo, no código
void jogaJogo()
...
{
}
void jogaJogos()
{
cout << "1 - Jogar." << endl;
cout << "2 - Sair." << endl;
cout << "Introduza uma opção: " ;
char opção_do_utilizador;
cin >> opção_do_utilizador;
switch(opção_do_utilizador) {
case '1':
jogaJogo();
break;
case '2':
break;
default:
cout << "Opção inválida!" << endl;
jogaJogos();
break;
}
}
usa-se a recursividade de uma forma absurda, pois o código é pouco claro, além de não haver um número limite natural para o número de invocações repetidas do procedimento, pois tudo depende da paciência do utilizador do programa. Melhor seria usar um ciclo:
void jogaJogo()
...
{
}
void jogaJogos()
{
char opção_do_utilizador;
do {
cout << "1 - Jogar." << endl;
cout << "2 - Sair." << endl;
cout << "Introduza uma opção: " ;
cin >> opção_do_utilizador;
switch(opção_do_utilizador) {
case '1':
jogaJogo();
break;
case '2':
break;
default:
cout << "Opção inválida!" << endl;
break;
} while(opção_do_utilizador != '2');
}
ou ainda
void jogaJogo()
...
{
}
void jogaJogos()
{
while(true) {
cout << "1 - Jogar." << endl;
cout << "2 - Sair." << endl;
cout << "Introduza uma opção: " ;
char opção_do_utilizador;
cin >> opção_do_utilizador;
switch(opção_do_utilizador) {
case '1':
jogaJogo();
break;
case '2':
return;
default:
cout << "Opção inválida!" << endl;
break;
}
}
Um outro exemplo é
bool utilizadorQuerVoltarAJogar(char opção)
{
if(opção == 's')
return true;
else if (opção == 'n')
return false;
cout << "Quer voltar a jogar? [(s)im ou (n)ão]: ";
cin >> opção;
return utilizadorQuerVoltarAJogar(tolower(opção));
}...
void jogaJogos()
...
{
cout << "Quer voltar a jogar? [(s)im ou (n)ão]: ";
char opção;
cin >> opção;
bool utilizador_quer_voltar_a_jogar =
utilizadorQuerVoltarAJogar(tolower(opção));
...
}
que deveria ser reescrito como
bool utilizadorQuerVoltarAJogar()
{
char opção_do_utilizador;
do {
cout << "Quer voltar a jogar? [(s)im ou (n)ão]: ";
cin >> opção_do_utilizador;
opção_do_utilizador = tolower(opção_do_utilizador);
} while(opção_do_utilizador != 's' and opção_do_utilizador != 'n');
return opção_do_utilizador == 's';
}
...
void jogaJogos()
...
{
bool utilizador_quer_voltar_a_jogar = utilizadorQuerVoltarAJogar();
...
}
ou mesmo
bool utilizadorQuer(string o_que_quer)
{
char opção_do_utilizador;
do {
cout << "Quer " << o_que_quer << "? [(s)im ou (n)ão]: ";
cin >> opção_do_utilizador;
opção_do_utilizador = tolower(opção_do_utilizador);
} while(opção_do_utilizador != 's' and opção_do_utilizador != 'n');
return opção_do_utilizador == 's';
}
...
void jogaJogos()
...
{
bool utilizador_quer_ voltar_a_jogar = utilizadorQuer("voltar a jogar");
...
}
A escrita de ciclos não é trivial. Durante as aulas sugeriu-se que o desenvolvimento fosse disciplinado, de modo a evitar erros e a evitar a utilização de instruções de iteração não apropriadas. Seguem abaixo alguns exemplos retirados das resoluções.
Exemplo 1:
/**
Devolve booleano que indica se a cadeia dada contém o caractere dado.
@pre V.
@post contém = (E j : 0 <= j <
cadeia
.size
() :cadeia
[j] ==caractere
).*/
bool contém(string cadeia, char caractere)
{
for(string::size_type i = 0; i < cadeia.size(); ++i)
if(cadeia[i] == caractere)
return true;
return false;
}
Diagnóstico:
Correcção:
/**
Devolve booleano que indica se a cadeia dada contém o caractere dado.
@pre V.
@post contém = (E j : 0 <= j <
cadeia
.size
() :cadeia
[j] ==caractere
).*/
bool contém(string cadeia, char caractere)
{
for(string::size_type i = 0; i < cadeia.size(); ++i)
if(cadeia[i] == caractere)
return true;
return false;
}
Exemplo 2:
void jogaJogos()
{
int opcao_do_utilizador = 0;
while(opcao_do_utilizador != 2) {
cout << endl
<< "Jogo do enforcado" << endl
<< "1 Novo jogo" << endl
<< "2 Sair" << endl
<< endl
<<"Introduza uma opção: ";
cin >> opcao_do_utilizador;
while(not cin) {
cout << "Escolha 1 ou 2!" << endl;
cin.clear();
char c;
while(cin.get(c) and c != '\n')
;
cin >> opcao_do_utilizador;
}
switch(opcao_do_utilizador) {
case 1:
jogaJogo();
break;
case 2:
cout << "Terminando." << endl;
break;
default:
cout << "Escolha 1 ou 2!" << endl;
break;
}
}
}
Diagnóstico:
do
while
. O segundo pode e deve
ser reescrito usando o mesmo idioma usado no terceiro ciclo while
.
Ver correcções abaixo.Correcção:
void jogaJogos()
{
int opcao_do_utilizador;
do {
cout << endl
<< " Jogo do enforcado " << endl
<< "1 Novo jogo" << endl
<< "2 Sair" << endl
<< endl
<<"Introduza uma opção: ";
while(not (cin >> opcao_do_utilizador)) {
cout << "Escolha 1 ou 2!" << endl;
cin.clear();
char c;
while(cin.get(c) and c != '\n')
;
}
switch(opcao_do_utilizador) {
case 1:
jogaJogo();
break;
case 2:
cout << "Terminando." << endl;
break;
default:
cout << "Escolha 1 ou 2!" << endl;
break;
}
} while(opcao_do_utilizador != 2);
}
Praticamente nenhuma resolução continha comentários, completos e correctos, de documentação dos módulos existentes no programa. A documentação de um módulo deve conter:
As partes constituintes do contrato do módulo podem ser escritas também em português, se for difícil exprimi-las em termos formais (matemáticos).
Exemplo:
/**
Devolve booleano que indica se a cadeia dada contém o caractere dado.
@pre V.
@post contém = (E j : 0 <= j <
cadeia
.size
() :cadeia
[j] ==caractere
).*/
bool contém(string cadeia, char caractere)
...
{
}
Ver também a secção seguinte.
A documentação de todos os módulos de um programa é fundamental, como se referiu. A documentação de um módulo corresponde grosso modo ao seu manual de utilização. Diz o que é que o módulo faz exactamente. Nesse sentido a documentação representa um contrato estabelecido implicitamente entre o produtor do módulo (que o desenvolveu ou produziu) e o seu consumidor (que o usa ou consome). Esse contrato baseia-se nas noções de pré-condição e condição objectivo (ou pós-condição). A documentação de uma rotina tem o seguinte aspecto geral:
/**
Descrição breve e informal do que o módulo faz.@pre PC (Pré-condição).
@post CO (Condição objectivo).
*/
declaração ou definição da rotina
O contrato estabelecido entre produtor e consumidor deve ser lido da seguinte forma:
O Produtor desta rotina compromete-se a garantir a verificação da condição CO quando a rotina retornar desde que o Consumidor se comprometa a invocá-la de modo a que a condição PC se verifique no seu início.
Note-se que o contrato acima não dá quaisquer garantias ao consumidor se este não cumprir a sua parte do contrato. Nem prevêem qualquer penalização para o produtor caso este não cumpra a sua parte do contrato. Há duas questões importantes a responder neste caso:
A primeira questão tem uma resposta simples: as violações do contrato devem-se sempre a erros do programador. Se a violação ocorrer por não se verificar a pré-condição no início da rotina, tal deve-se a um erro do programador consumidor da rotina. Se a violação ocorrer por não se verificar a condição objectivo no final da rotina, apesar de se verificar a pré-condição no seu início, tal deve-se a um erro do programador produtor da rotina.
A segunda questão é mais fácil de responder depois de se responder à primeira. Quando um contrato é violado, tal deve-se sempre a um erro de programação. Claro está que estes erros a) não deviam ocorrer e b) não são conhecidos a priori, pois de outra forma um programador (normal...) tê-los-ia já corrigido... O problema é que estes erros infelizmente vão mesmo ocorrer, pois o programador é humano. Como lidar com eles, então?
As instruções de asserção servem para explicitar o contrato de uma rotina no código. As instruções de asserção permitem que as violações do contrato produzam sempre o mesmo resultado: a terminação abrupta do programa com indicação do local onde a violação foi detectada. As instruções de asserção são importantes mesmo que o programa nos pareça totalmente correcto. Não devemos confiar na nossa suposta capacidade de escrever programas sem erros.
Por outro lado, há que distinguir claramente entre erros do programador e erros do utilizador do programa. Com os primeiros temos de viver o melhor possível, usando-se para isso as instruções de asserção. Quanto aos segundos, o programa deve prevê-los e lidar com eles directamente. Exemplifiquemos com um pequeno programa:
#include <iostream>
Devolve a raiz quadrada do argumento.
using namespace std;
/**@pre 0 <=
valor
.@post
raizDe
2 aproximadamente igual avalor
.*/
double raizDe(double const valor)
{
double raiz_anterior = 0.0;
double raiz = valor;
while(raiz != raiz_anterior) {
raiz_anterior = raiz;
raiz = (raiz_anterior * raiz_anterior + valor) / (2.0 * raiz_anterior);
}
return raiz;
}
int main()
{
cout << "Qual o valor (não-negativo)? ";
double valor;
cin >> valor;
cout << "A raiz de " << valor << " é " << raizDe(valor) << '.' << endl;
}
Se ao executar este programa utilizador introduzir um número negativo, o ciclo onde se calcula a raiz não termina. Quem errou?
Terá sido o utilizador do programa? É verdade que o programa pedia
explicitamente que o valor fosse não-negativo. Mas erros do utilizador do
programa devem ser previstos no próprio programa! Qualquer programa que
se preze verifica os erros do utilizador e dá-lhe a possibilidade de os
corrigir. Conclusão, quem errou foi o programador do programa. Mas
enquanto produtor ou enquanto consumidor da função raizDe()
?
Terá sido enquanto produtor? A verdade é que a função raizDe()
tem um contrato tal que só garante bons resultados para argumentos não
negativos. Será que o produtor deverá enfraquecer a pré-condição do
contrato de modo a prever também o cálculo de raízes de valores
negativos? Essa é uma boa solução, em geral, mas neste caso é
impossível fazê-lo sem alterar pelo menos o tipo de devolução para suportar
números complexos. É claramente preferível não o fazer. Quando
muito poder-se-ia definir uma outra versão da função, sobreposta à primeira,
lidando com números complexos.
Então porque não corrigir a função de modo a verificar se o valor é negativo e, em caso afirmativo, pedir de novo um valor ao utilizador?
double raizDe(double const valor)
{
while(valor < 0) {
cout << "Tem de ser não-negativo!" << endl;
cout << "Qual o valor (não-negativo)? ";
cin >> valor;
}
double raiz_anterior = 0.0;
double raiz = valor;
while(raiz != raiz_anterior) {
raiz_anterior = raiz;
raiz = (raiz_anterior * raiz_anterior + valor) / (2.0 * raiz_anterior);
}
return raiz;
}
Esta é a pior solução possível! Transformou-se uma modularização
simples, clara e eficiente, numa grande confusão. Experimente-se
descrever claramente o que a função faz, dar-lhe um nome apropriado ou
escrever a sua condição objectivo... Melhor seria, apesar de tudo, se a
leitura do valor estivesse totalmente contida na função, mas ainda assim
seria uma fraca modularização. Uma forma de o ver é tentar atribuir um
nome apropriado à função. Teria de ser algo como lêValorNãoNegativoEDevolveASuaRaiz()
...
Veja-se a discussão na Secção 4.
Claramente o erro não é do programador enquanto produtor de raizDe()
,
mas enquanto seu produtor. Antes de alterar o código consumidor da
função de modo a garantir a passagem de um valor não-negativo há, no
entanto, que fazer uma pergunta. Se o contrato da função só garante que
a condição objectivo se verifica se o consumidor passar um argumento não
negativo, o que acontece quando o consumidor viola a sua parte do contrato?
Em rigor, pode acontecer qualquer coisa. O produtor é livre de lidar (ou não) com a situação como lhe aprouver. Pode inclusivamente decidir apagar o disco rígido do computador... Mas o que seria desejável que acontecesse? Como se trata de um erro de programação, dificilmente o próprio programa pode lidar com ele... (Mais tarde se verá que o mecanismo de lançamento de excepções pode ser usado para tentar tornar os programas resistentes aos seus próprios erros.) Assim, uma solução pode passar simplesmente por, em caso de violação do contrato por parte do consumidor da função, o programa abortar emitindo uma mensagem de erro que indique claramente o local do erro. Isso consegue-se usando uma instrução de asserção no início da função:
#include <iostream>
Devolve a raiz quadrada do argumento.
#include <cassert>
using namespace std;
/**@pre 0 <=
valor
.@post
raizDe
2 aproximadamente igual avalor
.*/
double raizDe(double const valor)
{
assert(0 <= valor);
double raiz_anterior = 0.0;
double raiz = valor;
while(raiz != raiz_anterior) {
raiz_anterior = raiz;
raiz = (raiz_anterior * raiz_anterior + valor) / (2.0 * raiz_anterior);
}
return raiz;
}
int main()
{
cout << "Qual o valor (não-negativo)? ";
double valor;
cin >> valor;
cout << "A raiz de " << valor << " é " << raizDe(valor) << '.' << endl;
}
É muito razoável, pois, "armadilhar" o programa para detectar
erros de programação e abortar com uma mensagem de erro minimamente simpática
quando tal ocorrer. Mas este programa, pelo menos no que à função raizDe()
diz respeito, detecta apenas erros do programador consumidor. O
programador produtor também pode cometer erros! Para os detectar, é
necessário usar uma instrução de asserção no final da função. Esta
instrução de asserção deve verificar se a condição objectivo é
verdadeira, abortando o programa caso não o seja. O problema é que a
condição objectivo não está bem enunciada. O que significa
"aproximadamente"?
Uma constante importante quando se trabalha com números de vírgula
flutuante é o chamado épsilon. Esta constante é a diferença
entre 1 e o menor valor superior a 1 que é representável para um dado
tipo. No caso do tipo double
o valor de épsilon é
2,22045×10-16, e acede-se a ela através de numeric_limits<double>::epsilon()
(tem de se fazer #incude <limits>
). Esta constante no
fundo representa a precisão máxima do tipo em uso. Assim, uma boa
condição objectivo para a função seria que o erro relativo cometido fosse
inferior ou igual a épsilon. Como para calcular o erro relativo seria
necessário saber o valor real (e não aproximado) da raiz, a solução passa
por exprimir a condição objectivo em termos do erro relativo (em módulo)
entre o valor dado e o quadrado da raiz aproximada encontrada:
#include <iostream>
Devolve a raiz quadrada do argumento.
#include <cassert>
#include <limits>
#include <cmath>
using namespace std;
/**@pre 0 <=
valor
.@post |
raizDe
2 -valor
| <=numeric_limits
<double
>::epsilon
() ×valor
.*/
double raizDe(double const valor)
{
assert(0 <= valor);
double raiz_anterior = 0.0;
double raiz = valor;
while(raiz != raiz_anterior) {
raiz_anterior = raiz;
raiz = (raiz_anterior * raiz_anterior + valor) / (2.0 * raiz_anterior);
}
assert(abs(raiz * raiz - valor) <=
numeric_limits<double>::epsilon() * valor);
return raiz;
}
int main()
{
cout << "Qual o valor (não-negativo)? ";
double valor;
cin >> valor;
cout << "A raiz de " << valor << " é " << raizDe(valor) << '.' << endl;
}
Tudo isto é muito bonito e elegante (?), mas... O programa continua errado! É necessário corrigir o erro do programador consumidor, i.e., tornar o programa capaz de lidar com os possíveis erros do utilizador do programa! A solução aqui passa por garantir que não se tenta calcular a raiz de um valor negativo:
#include <iostream>
Devolve a raiz quadrada do argumento.
#include <cassert>
#include <limits>
#include <cmath>
using namespace std;
/**@pre 0 <=
valor
.@post |
raizDe
2 -valor
| <=numeric_limits
<double
>::epsilon
() ×valor
.*/
double raizDe(double const valor)
{
assert(0 <= valor);
double raiz_anterior = 0.0;
double raiz = valor;
while(raiz != raiz_anterior) {
raiz_anterior = raiz;
raiz = (raiz_anterior * raiz_anterior + valor) / (2.0 * raiz_anterior);
}
assert(abs(raiz * raiz - valor) <=
numeric_limits<double>::epsilon() * valor);
return raiz;
}
int main()
{
double valor;
do {
cout << "Qual o valor (não-negativo)? ";
cin >> valor;
} while(valor < 0);
cout << "A raiz de " << valor << " é " << raizDe(valor) << '.' << endl;
}
(Note-se que, de modo a deixar o código simples, não se previu o caso de o utilizador introduzir algo que não um valor aritmético.)
Uma questão se coloca aqui: uma vez corrigido o erro do programador
consumidor, para que serve a primeira asserção da função raizDe()
?
Espero que esta explicação longa vos permita compreender o que há de errado nas seguintes frases retiradas de algumas resoluções do Problema 1:
Assim, a única explicação relevante prende-se com a ausência de asserções, facto precavido por nós, ao criarmos nos procedimentos funções [instruções?] que substituem essas mesmas asserções [...].
[...] optamos por não incluir [instruções de] asserção, pré-condições e pós-condições, porque usamos funções e/ou procedimentos ou condições para prevenir possíveis erros do utilizador.
Ao ler o código verifica[-se] a inexistência de [instruções de] asserções, pois conseguimos que o programa filtrasse os erros possíveis do utilizador.
Como referido ao longo da disciplina, as variáveis globais são de evitar. Suponha-se o seguinte programa, cujo objectivo é calcular o quadrado de um número introduzido pelo utilizador:
//
Programa absurdo! Má ideia! Não copiar!
#include <iostream>
using namespace std;
double valor;
void elevaAoQuadrado()
{
valor *= valor;
}
int main()
{
cout << "Introduza um valor: ";
cin >> valor;
elevaAoQuadrado();
cout << "O quadrado é " << valor << '.' << endl;
}
O programa faz o que é suposto. Mas fá-lo mal. Restrinja-se o
olhar à função main()
:
Estas dificuldades de leitura são escusadas, e favorecem a ocorrência e
difícil correcção de erros. Por outro lado tornam o código
"prisioneiro" do problema em causa, tornando a generalização
difícil. Suponha-se, por exemplo, que o objectivo agora é encontrar as
raízes de um polinómio do segundo grau. Para manter o procedimento elevaAoQuadrado()
como está, recorrendo a uma variável global, o problema tem de ser resolvido
como se segue:
//
Programa absurdo! Má ideia! Não copiar!
#include <iostream>
using namespace std;
double valor;
void elevaAoQuadrado()
{
valor *= valor;
}
int main()
{
cout << "Introduza os coeficientes a, b e c (ax^2 + bx + c): ";
double a, b, c;
cin >> a >> b >> c;
valor = b;
elevaAoQuadrado();
double discriminante = valor + 4 * a * c;
if(discriminante < 0) {
cerr << "Polinómio sem raízes reais." << endl;
return 1;
}
double r1 = (-b - sqrt(disciminante)) / 2 / a;
double r2 = (-b + sqrt(disciminante)) / 2 / a;
cout << "As raízes são " << r1 << " e " << r2 << '.' << endl;
}
Uma solução muito mais razoável seria prescindir de variáveis globais:
#include <iostream>
using namespace std;
double valor;
/** Devolve o quadrado do argumento.
@pre V.
@post
quadradoDe
=valor
2.*/
double elevaAoQuadrado(double valor)
{
return valor * valor;
}
int main()
{
cout << "Introduza os coeficientes a, b e c (ax^2 + bx + c): ";
double a, b, c;
cin >> a >> b >> c;
double discriminante = quadrado(b) + 4 * a * c;
if(discriminante < 0) {
cerr << "Polinómio sem raízes reais." << endl;
return 1;
}
double r1 = (-b - sqrt(disciminante)) / 2 / a;
double r2 = (-b + sqrt(disciminante)) / 2 / a;
cout << "As raízes são " << r1 << " e " << r2 << '.' << endl;
}
É importante realizar, no entanto, que ocasionalmente (leia-se raramente) é
útil recorrer a variáveis globais (e.g., cin
, cout
e
cerr
são variáveis globais). Mas pense muitas vezes antes
de as utilizar.
Por vezes há más soluções que se espalham como boatos. Por exemplo, não faz qualquer sentido escrever uma função para devolver uma cadeia de caracteres com a mesma dimensão da cadeia passada como argumento mas preenchida com hífens:
/**
Cria e devolve a palavra escondida com base na palavra adivinhar.
@pre V.
@post pala '-' com o mesmo de número de letras da palavra a adivinhar.
*/
string palavraEscondida(string palavra)
{
string palavra_escondida;
for(string::size_type i = 0; i != palavra.length(); ++i)
palavra_escondida += '-';
return tracos;
}
Nem tão pouco faz sentido continuar a usar uma função como o mesmo
objectivo embora com o corpo fazendo uso de um dos construtores do tipo string
:
/**
Cria e devolve a palavra escondida com base na palavra adivinhar.
@pre V.
@post pala '-' com o mesmo de número de letras da palavra a adivinhar.
*/
string palavraEscondida(string palavra)
{
string palavra_escondida(palavra.length(), '-');
return tracos;
}
ou mesmo
/**
Cria e devolve a palavra escondida com base na palavra adivinhar.
@pre V.
@post pala '-' com o mesmo de número de letras da palavra a adivinhar.
*/
string palavraEscondida(string palavra)
{
return string(palavra.length(), '-');
}
O melhor mesmo seria usar este tipo de inicialização directamente na definição da variável, sem definir qualquer função para o efeito.
Abaixo segue-se um conjunto genérico de (maus) exemplos e respectivas correcções. Embora estes erros digam maioritariamente respeito a grafismo e nomenclatura, apresentam-se numa secção à parte para que um correcção integral possa ser apresentada.
Exemplo 1:
/**
Esta rotina vai devolver uma letra minúscula. Se necessário, vai converteruma letra maiúscula em minúscula.
*/
char conversão(char letra)
{
if(isupper(letra))
{
letra=tolower(letra);
return letra;
}
else
return letra;
}
Diagnóstico:
=
e os respectivos operandos.Correcção:
/**
Devolve a versão minúscula do caractere dado.@pre V.
@post
versãoMinúsculaDe
= versão minúscula decaractere
.*/
char versãoMinúsculaDe(char caractere)
{
return tolower(caractere);
}
Mas pode-se perguntar simplesmente qual a utilidade de semelhante função,
uma vez que devolve exactamente o mesmo que a função tolower()
pré-existente...
Exemplo 2:
//
mostra o estado do jogo
void progresso(string alfabeto,string palavra,int falhanços)
{
cout<<endl;
cout<<"estado do jogo"<<endl;
cout<<endl;
cout<<endl;
cout<<"palavra : "<<palavra<<endl;
cout<<endl;
cout<<"letras disponíveis : "<<alfabeto<<endl;
cout<<endl;
cout<<"falhanços disponíveis : "<<falhanços<<endl;
}
Diagnóstico:
cout
.<<
dos respectivos
operandos..Correcção:
/**
Mostra o estado do jogo.
@pre V.
@post Ecrã contém estado actual do jogo.
*/
void mostraEstadoDoJogo(string letras_disponíveis, string palavra_adivinhada,
int número_de_falhanços_disponíveis)
{
cout << endl
<< "Estado do jogo"<<endl
<< endl
<< endl
<< "palavra adivinhada: "<< palavra_adivinhada << endl
<< endl
<< "letras disponíveis: "<< letras_disponívei << endl
<< endl
<< "falhanços disponíveis: "<< número_de_falhanços_disponíveis << endl;
}
Exemplo 3:
//
Esta rotina vai verificar se a letra escolhida já foi ou não inserida anteriormente.
bool letra_no_abc(string abc , char letra)
{
int comprimento=abc.length();
for(int b=0;b!=comprimento; ++b)
{
if(abc[b]==letra)
{
return true;
break;
}
}
return false;
}
Diagnóstico:
break
é sempre inútil após uma instrução
return
, pois a última termina imediatamente a função, e por
conseguinte dos os ciclos em execução.Correcção:
/**
Devolve booleano que indica se a cadeia dada contém o caractere dado.
@pre V.
@post contém = (E j : 0 <= j <
cadeia
.size
() :cadeia
[j] ==caractere
).*/
bool contém(string cadeia, char caractere)
{
for(string::size_type i = 0; i != cadeia.size(); ++i)
if(cadeia[i] == caractere)
return true;
return false;
}
Exemplo 4:
int main()
variavel introduzida no menu "jogo do enforcado"
{
char jogaousai; //enquanto a escolha for diferente de 'q', é devolvido
while (jogaousai != 'q')/*
o menu principal
*/
...
{
cin >> jogaousai;
...
}
}
Diagnóstico:
Correcção:
int main()
...
{
char opção_do_utilizador;
do {
cin >> opção_do_utilizador;
...
} while(opção_do_utilizador != 's');
}