Erros mais frequentes na resolução do Trabalho Final (entrega intermédia)

Sumário

Introdução

Este documento apresenta os erros mais comuns cometidos nas resoluções do Trabalho Final (entrega intermédia).  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 emularem 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.

Expressão escrita

É comum ouvir-se entre os alunos e encontrar nos relatórios dos trabalhos 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.

O verbo "retornar" tem pelo menos duas acepções diferentes.  Significa "voltar ao ponto de partida", quando é usado de forma intransitiva, e significa "restituir", quando é usado de forma transitiva.  Ora, retornar, na primeira acepção indicada, é exactamente o que sucede no final de uma rotina, seja ela função ou procedimento, pois o fluxo de execução do programa volta ao ponto de partida, onde a rotina foi invocada.  

Por outro lado, o verbo "devolver" significa (embora não seja a acepção mais comum) "dizer em resposta".  Ora devolver, nesta acepção, é exactamente o que sucede no final de uma função, mas não no final de um procedimento.  Assim, deve-se dizer que:

  1. um procedimento, ao terminar, retorna (ao ponto onde foi invocado);
  2. uma função, ao terminar, retorna (ao ponto onde foi invocada), devolvendo o valor calculado.

Relação entre conceitos e ferramentas do C++

É 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 /* ... */
Comentários (de linha e de bloco).
/// ... ou /** ... */
Documentação (em linha ou em bloco).

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.

Modularização

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.

4.1  Repetições

É comum surgirem rotinas que são muito parecidas, indiciando que poderiam ser transformadas numa só sem qualquer aumento de complexidade se se passar um argumento adicional.  Por exemplo:

void desenhaCelulaBrancaEm(Posicao const& posicao)
{
    Posicao posicao_da_celula_no_ecra(
...);

    ecra << cor_das_celulas_brancas;
    ecra << posicao_da_celula_no_ecra;

    for(int i = 0; i != dimensao_das_celulas_no_ecra.numeroDeLinhas(); ++i)
        ecra << parado << largura(7) << ' ' << baixaCursor;
}

void desenhaCelulaPretaEm(Posicao const& posicao)
{
    Posicao posicao_da_celula_no_ecra(
...);

    ecra << cor_das_celulas_pretas;
    ecra << posicao_da_celula_no_ecra;

    for(int i = 0; i != dimensao_das_celulas_no_ecra.numeroDeLinhas(); ++i)
        ecra << parado << largura(7) << ' ' << baixaCursor;
}

Pode-se escrever um único procedimento acrescentando um parâmetro para representar a cor da célula (admitindo que está definido um tipo enumerado Cor, com valores preto e branco):

void desenhaCelulaEmCom(Posicao const& posicao, Cor const cor)
{
    Posicao posicao_da_celula_no_ecra(
...);

    ecra << (cor == preta ? cor_das_celulas_pretas : cor_das_celulas_brancas);
    ecra << posicao_da_celula_no_ecra;

    for(int i = 0; i != dimensao_das_celulas_no_ecra.numeroDeLinhas(); ++i)
        ecra << parado << largura(7) << ' ' << baixaCursor;
}

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.  Note-se ainda que é má ideia levar a ânsia de juntar rotinas numa só demasiado longe.

4.2  Dois-em-um

Veja-se a seguinte definição:

void colocaPecas(char pecas[8][8])
{
    for(int i = 0; i != 8; ++i) {
        for(int j = 0; j != 8; ++j) {
            if (((i+j) % 2 != 0) and i < 3 ) {
                ecra << cursor(i*altura_quadrado+2+altura_quadrado/2,
                               j*largura_quadrado+2+largura_quadrado/2);
                ecra << preta << largura(3) << " ";
                pecas[i][j] = 'P';
            } else if (((i+j) % 2 != 0) and i > 4) {
                ecra << cursor(i*altura_quadrado+2+altura_quadrado/2,
                               j*largura_quadrado+2+largura_quadrado/2);
                ecra << branca << largura(3) << " ";
                pecas[i][j] = 'B';
            } else if (((i+j) % 2 != 0) and (i > 2 and i < 5))
                pecas[i][j] = 'L';
        }
    }
}

Este procedimento não se limita a colocar as peças, como o nome sugere.  Coloca as peças e desenha-as no ecrã.  Logo, dever-se-ia chamar colocaPecasEDesenhaAsNoEcra().  Como um módulo deve ter uma função bem definida, a utilização da conjunção "e" no seu nome indicia claramente que procedimento deveria ser partido em dois.  Um para colocar as peças no tabuleiro, e outro para desenhar as peças no ecrã.  A rotinas com duas funções bem (?) definidas costuma chamar-se rotinas "dois em um".

Note-se finalmente que o código acima sofre de outros problemas.  Ver mais abaixo outras críticas a este código.

Convenções gráficas

5.1  Grafismo de nomes de entidades

É 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 das seguintes convenções para os nomes das entidades:

5.2  Indentação e espaços

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.

Nomenclatura

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 (e.g., o aluno), elementos da sua equipa (e.g., 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> const& vector) e bool denominadorDe(Racional const& 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 const& racional), onde a resposta à pergunta sugerida seria "Esta função devolve o denominador de um racional".  O sintagma nominal "um Racional" desta resposta 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 const inteiro, Vector<int> const& 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> const& vector, int const 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 const 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 const& racional), onde foi necessário fazer um ajustamento na ordem das palavras.  No caso das classes, a ordem presume-se dada à instância através da qual o procedimento foi invocado.  Por exemplo, 

FilaDeBI fila_de_BI; 

...

fila_de_BI.tiraPrimeiroItem();

Deve-se ler "fila de bilhetes de identidade, tira o (teu) primeiro item!".

6.1  Alguns maus exemplos

Que significa cor1 em

Ecra::ObjectoCor const cor1(branco, preto);

Quando a constante for usada no código dificilmente se conseguirá adivinhar a que entidade diz respeito a cor.  Talvez cor_das_células_pretas fosse um melhor nome. 

O nome m na definição

bool m[8][8] = {}; 

não diz nada.  Talvez células_ocupadas fosse um melhor nome.

Expressões idiomáticas em C++

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(células_ocupadas[linha][coluna] == true)
   
...

if(células_ocupadas[linha][coluna] != false)
   
...

ou

if(células_ocupadas[linha][coluna] != true)
   
...

if(células_ocupadas[linha][coluna] == false)
   
...

Este código não está incorrecto, mas a forma idiomática de o escrever é

if(células_ocupadas[linha][coluna])
   
...

ou

if(not células_ocupadas[linha][coluna])

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 célula na posição (linha, coluna) está ocupada, então..."

ou

"a célula na posição (linha, coluna) não está ocupada, então..."

em vez da leitura pouco natural e verbosa das primeiras versões, que se lêem como

"se a célula na posição (linha, coluna) está ocupada for verdadeiro, então..."

"se a célula na posição (linha, coluna) está ocupada não for falso, então..."

ou

"se a célula na posição (linha, coluna) está ocupada for falso, então..."

"se a célula na posição (linha, coluna) está ocupada não for verdadeiro, 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(células_ocupadas[linha][coluna])
    return true;
else
    return false;

ou

if(células_ocupadas[linha][coluna])
    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 células_ocupadas[linha][coluna];

ou

return not células_ocupadas[linha][coluna];

Neste caso o argumento da 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 célula na posição (linha, coluna) está ocupada'."

ou

"Retorna devolvendo a veracidade da afirmação 'a célula na posição (linha, coluna) não está ocupada'."

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.

Controlo de fluxo

8.1  Invocação em cascata

É frequente encontrarem-se exemplos de código em que a invocação das rotinas se faz em cascata quando deveria ser feita em sequência.  Por exemplo, o código

void movimentaCursor(...)
{
   
...
}

void desenhaPeças(...)
{
   
...

    movimentaCursor(...);
}

void colocaPeças(...)
{
   
...

    desenhaPeças();
}

void desenhaTabuleiro(...)
{
   
...

    colocaPeças(...);
}

int main()
{
    desenhaTabuleiro(
...);
}

dever-se-ia na realidade escrever

void movimentaCursor(...)
{
   
...
}

void desenhaPeças(...)
{
   
...
}

void colocaPeças(...)
{
   
...
}

void desenhaTabuleiro(...)
{
   
...
}

int main()
{
    desenhaTabuleiro(
...);
    colocaPeças(
...);
    desenhaPeças(
...);
    movimentaCursor(
...);
}

8.2  Ciclos e instruções de iteração

Um erro que surgiu com alguma frequência foi o de não se usarem ciclos onde eles deveriam ser usados.  Por exemplo, o código

typedef vector<bool> LinhaDeBool;

typedef vector<LinhaDeBool> MatrizDeBool; 

MatrizDeBool 
    células_ocupadas(dimensão_do_tabuleiro.númeroDeLinhas(),
                     LinhaDeBool(dimensão_do_tabuleiro.númeroDeColunas(),
                                 false));

...

células_ocupadas[5][0] = true;
células_ocupadas[5][2] = true;
células_ocupadas[5][4] = true;
células_ocupadas[5][6] = true;
células_ocupadas[6][1] = true;
células_ocupadas[6][3] = true;
células_ocupadas[6][5] = true;
células_ocupadas[6][7] = true;
células_ocupadas[7][0] = true;
células_ocupadas[7][2] = true;
células_ocupadas[7][4] = true;
células_ocupadas[7][6] = true;

...

deveria na realidade ser escrito

typedef MatrizDeBool::size_type Linha;

typedef LinhaDeBool::size_type Coluna;

for(Linha linha = células_ocupadas.size() - número_de_linhas_inicialmente_ocupadas;
    linha != células_ocupadas.size(); ++linha)
    for(Coluna coluna = 0; coluna != células_ocupadas[linha].size(); ++coluna)
        if(éPreta(Posicao(linha, coluna))
            células_ocupadas[linha][coluna] = true; 

Documentação

Poucas resoluções continham comentários, completos e correctos, de documentação dos módulos existentes no programa.  A documentação de um módulo deve conter:

  1. Uma breve descrição em português vernáculo da funcionalidade do módulo.
  2. O contrato do módulo:
    1. A pré-condição do módulo.
    2. A condição objectivo do módulo.

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 const& cadeia, char const caractere)
{
   
...
}

Ver também a secção seguinte.

10  Programação por contrato: instruções de asserção

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:

  1. A que se devem as violações do contrato, quando ocorrem?
  2. Que deve acontecer quando o contrato é violado?

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 o programador (que se presume ser uma pessoa 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>

using namespace std;

/**
Devolve a raiz quadrada do argumento.
   
@pre 0 <= valor.
   
@post raizDe2 aproximadamente igual a valor. */
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, pois se assumiu que o programa lida apenas com número reais.  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.2.

Claramente o erro não é do programador enquanto produtor de raizDe(), mas enquanto seu consumidor.  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>
#include <cassert>

using namespace std;

/**
Devolve a raiz quadrada do argumento.
   
@pre 0 <= valor.
   
@post raizDe2 aproximadamente igual a 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);
    }

    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 ele 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>
#include <cassert>
#include <limits>
#include <cmath>

using namespace std;

/**
Devolve a raiz quadrada do argumento.
   
@pre 0 <= valor.
   
@post |raizDe2 - 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>
#include <cassert>
#include <limits>
#include <cmath>

using namespace std;

/**
Devolve a raiz quadrada do argumento.
   
@pre 0 <= valor.
   
@post |raizDe2 - 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()?

  1. Do ponto de vista do programa como produto acabado ambas as asserções são desnecessárias (a primeira é fácil de ver porquê, a segunda implicaria uma demonstração formal do bom funcionamento da função).
  2. Mas um programa nunca é um produto acabado.  Está sujeito a correcções, actualizações, etc.  Durante essas operações, o programador pode introduzir novos erros no programa, que é conveniente serem assinalados convenientemente.

11  Variáveis globais e fundamentalismo

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():

  1. Onde está a variável valor?
  2. O que faz ?  Eleva ao quadrado o quê?

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;

/** Devolve o quadrado do argumento.
   
@pre V.
   
@post quadradoDe = valor2. */
double quadradoDe(double const 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 = quadradoDe(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 muito, mas mesmo muito raramente) é útil recorrer a variáveis globais (e.g., cin, cout e cerr são variáveis globais).  Mas pense muitas vezes (mesmo muitas) antes de as utilizar.

No caso do trabalho final, foram vários os casos de código com variáveis globais, de que um exemplo é:

#include <...>

int matriz[8][8];

Posicao coordenadas_iniciais;
Posicao coordenadas_finais;

...

int main()
{
   
...
}

A primeira variável, para além de ter um nome absurdo e um tipo pouco apropriado (bastaria um booleano), deveria ser local.  As duas últimas deveriam, provavelmente, ser constantes.

12  Soluções comuns: boatos

Por vezes há más soluções que se espalham como boatos.  No caso do trabalho final foi um estranha passagem por referência, que corresponde a uma atribuição errada de responsabilidades às rotinas:

void desenhaLinhaDeCelulasComPrimeiraClara(int &linha)
{
    for(int coluna = 0; 
        coluna != dimensaoDoTabuleiro.numeroDeColunas() / 2; ++coluna)
        ecra << cor_das_celulas_claras << largura(7) << ' '
             << cor_das_celulas_escuras << largura(7) << ' ';

    ++linha;
}

Para além de esta rotina corresponder a uma má modularização do problema (o desenho faz-se por linha do ecrã e não do tabuleiro, não há rotina para desenhar uma célula, etc.),  há algo de muito estranho aqui...  Para que serve o parâmetro linha?  Porque é incrementado?  Afinal, que faz esta rotina para além de desenhar uma linha de células em que a primeira célula é clara?  A verdade é que o parâmetro não deveria existir, pois não é usado na rotina para fazer a sua verdadeira função.  A variável a que corresponde no ponto de invocação deveria ser incrementada nesse local, e não dentro desta rotina.

Da mesma forma, surgiram muitas passagens de argumentos por referência não-constante desnecessárias e perigosas.  Para dar apenas um exemplo:

void desenhaPecas(vector<vector<bool> >& posicoes_ocupadas) 

   
...
}

Neste caso o vector pode ser passado por referência, mas constante, uma vez que o procedimento não o altera nem o deve alterar:

void desenhaPecas(vector<vector<bool> > const& posicoes_ocupadas) 

   
...
}

13  Números mágicos

A utilização de números mágicos, i.e., de valores literais de significado obscuro ao longo do código, é muito má ideia.  É claramente preferível a utilização de constantes, pois o seu nome clarifica as intenções do programador.  Por exemplo,

char letra = 'A';

for(int coluna = 0; coluna != 56; coluna += 7) {
    ecra << cursor(0, 7 + coluna) << letra;

    ++letra;
}

Este exemplo também demonstra uma confusão frequente nos programas apresentados entre coordenadas do tabuleiro e coordenadas do ecrã.  Este assunto é tratado na próxima secção.

14  Coordenadas: ecrã ou tabuleiro?

Todo o programa se pode escrever de uma forma muito mais clara e genérica se se usarem sempre que possível coordenadas do tabuleiro de damas, e não coordenadas do ecrã.  O código acima, para desenho da legenda superior do tabuleiro, poderia ser escrito como:

Posicao const origem_do_tabuleiro_no_ecra(...);

Dimensao const dimensao_das_celulas_no_ecra(...);

Dimensao const posicao_relativa_das_legendas(-3, -2);

...

Posicao const origem_da_legenda_do_topo = origem_do_tabuleiro_no_ecra +
    Dimensao(posicao_relativa_das_legendas.numeroDeLinhas(), 0);

int largura_das_celulas_no_ecra = dimensao_das_celulas_no_ecra.numeroDeColunas();

ecra << origem_da_legenda_do_topo;

for(int coluna = 0; coluna != dimensao_do_tabuleiro.numeroDeColunas(); ++coluna)
    ecra << largura(largura_das_celulas_no_ecra) << ao_centro 
         << char('A' + coluna);

Note-se que este código permite alterar com facilidade:

  1. O número de células no tabuleiro.
  2. A posição do tabuleiro no ecrã.
  3. A posição relativa da legenda relativamente à posição do tabuleiro no ecrã.
  4. A dimensão das células no ecrã.
  5. O alinhamento usado para a colocação das letras da legenda.