Resolução de Problemas Usando a Linguagem C

Neste documento apresentam-se sugestões e regras que o autor julga fazerem sentido mas que estão, em grande medida, sujeitas a discussão. O bom senso ditará quais aplicar e quais (e quando) por em causa. O documento está dividido em três secções. A primeira é sobre resolução de problemas em geral. A segunda sobre resolução de problemas usando linguagens de programação. A terceira sobre resolução de problemas usando a linguagem C. Deve se lido em sequência. O estilo é propositadamente sintético.

Ah! E o documento ainda não está pronto...


Alguns conselhos aos alunos

  1. Acompanhe a matéria: não deixe tudo para o fim.
  2. Organize o seu tempo: divida em troços as horas "livres" que tem (inclua parte do fim-de-semana!); cada troço serve para estudar/trabalhar sobre uma cadeira específica.
  3. Programar é resolver problemas: para o que precisa de conhecimentos sólidos sobre as ferramentas que vai utilizar (a linguagem de programação), mas também, e sobretudo, de criatividade, inteligência, bom senso e pragmatismo.
  4. Pratique: exercitará a sua inteligência e desenvolverá a sua criatividade e pragmatismo (bom senso espero que já tenha a dose suficiente).
  5. Faça muitos exercícios: encontrará muitos na bibliografia fornecida (se precisar de mais fale com os docentes).
  6. Só fazendo se aprende: é uma máxima que se aplica perfeitamente a programação.
  7. É bom sinal ter dúvidas: (os docentes também têm muitas) use as aulas de dúvidas para as esclarecer!

1. Abordagens para a resolução de problemas

1.1 Conselhos sobre estratégia

No que segue abaixo "ferramenta" é qualquer instrumento, real ou abstracto, que pode ser usado para atingir resultados definidos. No caso da programação, "ferramenta" pode significar uma classe, um objecto, um módulo, uma função, uma estrutura de dados, um pedaço de código, uma macro (em C), um algoritmo, um programa, etc.

  1. Use a abordagem descendente de resolução de problemas.
  2. Identificou uma ferramenta como útil para o seu problema?

    Isto é, quando justificável, misture um pouco de abordagem ascendente na abordagem descendente (ver acetatos de Programação I).

  3. Não pense na forma como uma ferramenta foi implementada ao usá-la: a implementação é irrelevante. O que importa é o que cada ferramenta se compromete a fazer (qual o seu "contrato"). A isto se chama capacidade de abstracção.
  4. Teste imediatamente o que for desenvolvendo:
  5. Não altere as partes/ferramentas testadas.
  6. Teste de novo as partes/ferramentas que tiver de alterar.

1.2 Encapsulamento das ferramentas

Resolver um problema usando a abordagem ascendente corresponde a complementar ("artilhar") com novas ferramentas (estruturar de dados, funções, módulos, etc.) a "caixa de ferramentas" que possui (a linguagem em uso e respectivas bibliotecas) antes de passar à resolução propriamente dita, com as ferramentas disponíveis..

Encapsular significa ocultar os pormenores de implementação duma ferramenta do seu utilizador. O encapsulamento também é conhecido por ocultação de dados ou informação.

  1. Antes de construir uma ferramenta pense bem na sua futura utilização: para que servirá a ferramenta? como se usará?
  2. Construa as ferramentas o mais opacas possível: afinal, você para saber as horas tem de ver o mecanismo do relógio?
  3. Impossibilite o acesso aos pormenores das ferramentas a quem as utiliza: isto é, esconda o mecanismo do relógio numa caixa.
  4. Obrigue a que a utilização das ferramentas se faça exclusivamente através de interfaces bem estabelecidas: só admita interações com o relógio 1. olhando o seu mostrador, 2. rodando o botão de acerto das horas.
  5. Fixe as interfaces das ferramentas e os respectivos "contratos" duma vez por todas: não mude o método de acerto do relógio ou o efeito de rodar o respectivo manípulo, nem a maneira de ler as horas no mostrador (imagine as consequências para o utilizador!).

Na metáfora do relógio você tem um papel aparentemente ambíguo: umas vezes é o construtor (2., 3. e 4.) e outras o utilizador (1.). Esta duplicidade de papeis acompanhá-lo-á sempre ao longo da sua vida de programador: as ferramentas que constroi num dia utiliza no outro. Não esqueça, no entanto, que jamais trabalhará sozinho: fará sempre parte duma equipa. Outros humanos utilizarão as ferramentas que construir. Lembre-se sempre disso. Proteja-se dos erros dos outros ou dos seus próprios, agindo em cada instante como construtor ou como utilizador, conforme apropriado. Não misture os papeis.

A ocultação de dados ou de informação (como sugerido acima) tem várias vantagens:

  1. Protecção do utilizador - o utilizador só pode manipular as ferramentas através das interfaces, logo não pode nunca "fazer asneira": você não tem necessidade de mexer no delicado mecanismo interior do relógio para saber as horas, mesmo que tenha sido você a construí-lo.
  2. Facilidade de depuração - os problemas são responsabilidade de quem desenvolveu a ferramenta (as consequências ocorrem perto do local onde existem os erros ou avarias): se o relógio atrasa ou adianta, o problema é no mecanismo, logo do construtor e não do utilizador (qual deles é você?).
  3. Facilidade de manutenção e correcção - correcções de avarias erros na ferramenta e/ou alterações da sua implementação não têm qualquer consequência para quem a utiliza: ao voltar do relojoeiro depois duma avaria ou erro de fabrico, o mostrador continua a usar-se para ver as horas e o manípulo para as acertar.
  4. O hábito de ocultar os dados (encapsular) ao programar em C,  facilita a evolução para linguagens mais recentes, tais como o Java, o C++ ou a Ada, que dispõem de mecanismos sofisticados para o fazer.

2. Programação

Na Secção 1 falou-se de técnicas de resolução de problemas. Não se especificou a índole dos problemas a resolver nem os recursos a usar na sua resolução. Assim, os conselhos dados são aplicáveis a (quase) qualquer tipo de problemas, em (quase) qualquer circunstância, com (quase) quaisquer recursos.

Nesta secção ser-se-á um pouco mais específico: os recursos a usar são um computador e uma linguagem de programação e os problemas são os que se podem resolver com estes recursos (a classe de problemas resolúvel com um computador e uma linguagem é bastante maior do que parece: lembre-se que ainda está em discussão se tais recursos são suficientes para emular a inteligência humana). Os exemplos serão dados na linguagem C, embora os conselhos sejam aplicáveis a qualquer linguagem de programação, ou melhor, a qualquer linguagem de programação imperativa, conceito que aprenderá mais tarde. Algumas notas, identificadas com [OO], referem-se unicamente a linguagens orientadas para objectos, sendo nesse caso as linguagens Java ou C++ usadas para exemplificar.

Muitos alunos não têm dificuldade em perceber o problema a resolver nem em propor uma boa solução. A maior dificuldade está em formalizar a solução numa linguagem não natural: a linguagem de programação. Lembre-se que, numa linguagem de programação (pelo menos nas imperativas e procedurais):

  1. Tem de especificar todos os pormenores: não conte com a inteligência do computador (é muito limitada...).
  2. Não há ambiguidades: tudo tem um significado preciso (bom ... quase tudo...).
  3. Só a prática nos permite "começar a pensar na linguagem de programação": normalmente pensa em português (julgo); no fim do curso pode ser que pense em C, ou noutra linguagem de programação mais evoluída e/ou expressiva.

2.1 Estratégia

  1. Seja disciplinado e rigoroso: não programe "a direito" ou "meia bola e força", deixando para o fim a organização e o "alindamento".
  2. Planeie, pense, só depois implemente: qual é a probabilidade de um macaco fechado numa sala com papel e lapiz escrever "Os Lusíadas"?
  3. Comente o código enquanto programa e não no fim: é enquanto se programa que mais facilmente se identificam as partes mais difíceis do código.
  4. Estruture o mais cedo possível: evitará erros e acabará por perder menos tempo.
  5. Escreva código claro mesmo que um pouco ineficiente: é melhor do que escrever código indecifrável hiper-eficiente.
  6. Não tente optimizar o seu código todo: é comum os programas passarem 99% do tempo em apenas 1% do código!
  7. Optimize escolhendo um melhor algoritmo: não vai ganhar nada de substancial usando "truques sujos".

2.2 Estruturação

  1. Escreva funções ([OO] métodos) curtas: cada função deve ter até dez linhas (o máximo serão cerca de 60 linhas, mas este valor é já prenunciador de má estruturação).
  2. Não escreva funções ([OO] métodos) quilométricas que serão "partidas quando houver tempo": nunca vai haver tempo.
  3. Distinga procedimentos de funções ([OO] no caso dos métodos a distinção também faz sentido).
  4. Funções calculam qualquer coisa, o ênfase está naquilo que devolvem.

    Exemplo: double sin(double)

  5. Procedimentos fazem qualquer coisa, o ênfase está no que fazem e não no que devolvem (se é que devolvem alguma coisa).

    Exemplo: int fscanf(FILE *, const char *, ...)

  6. Evite funções que alterem os seus argumentos.
  7. Evite procedimentos que devolvam valores (excepto para assinalar condições de erro ou quando for a forma mais simples de afectar o código que chama a função, v.g. em int fgetc(FILE *)).
  8. Não use variáveis globais: todos os procedimentos que afectam variáveis globais são difíceis de perceber, pois o nome da variável global afectada não é usado na chamada ao procedimento.
  9. Há excepções válidas à regra anterior, mas são raras.
  10. Não use recursividade senão quando todas as condições seguintes se verificarem:
    1. A sua utilização conduz a um aumento considerável da clareza e simplicidade do código:
      • Sim: percorrer uma árvore binária balanceada.
      • Não: percorrer uma lista ou calcular o factorial (soluções iterativas são simples).
    2. As sequências de chamadas recursivas têm fim garantido e não forçado:
      • Sim: percorrer árvore binária.
      • Não: sistema de menus (em vez disso usar ciclos do while).
    3. O número de chamadas recursivas encadeadas não excede algumas dezenas no pior caso:
      • Sim: percorrer árvore binária balanceada.
      • Não: percorrer árvore binária não balanceada (porquê?).
    4. O número total de chamadas à função recursiva é pequeno:

2.3 Estilo

Uma linguagem serve para comunicar. Uma linguagem de programação serve para comunicar, por ordem de importância:

  1. com outros programadores (ou com o docente da cadeira);
  2. consigo próprio...
  3. ...e com o computador (compilador).

O ênfase deve ser posto na legibilidade e clareza:

  1. Use "frases" curtas.
  2. Faça o código legível em português.
  3. Seja criativo no conteúdo e não na forma.
  4. Seja consistente no seu estilo.

A clareza dum programa depende de dois factores que, ironicamente, são irrelevantes para o compilador: os nomes dos objectos usados e a disposição do texto.

2.3.1 Nomes

Use nomes apropriados para variáveis, funções e procedimentos ([OO] e/ou métodos), tipos, etc. Nas regras gerais abaixo, uma "contracção" consiste, normalmente, na eliminação de artigos, conjunções e preposições. Tenha, no entanto, cuidado com as contrações e abreviações: será que outros, ou você próprio daqui a uns meses, as percebem? Por complementos entende-se os complementos directo e indirecto duma frase.

Faltam tipos, estruturas, classes, etc.

2.3.2 Disposição do texto.

Indentação: será para indicar frases subordinadas?

Não usar mais do que três ou quatro níveis de indentação. Indício de má estruturação de funções.

Recomendar um de dois estilos de indentação.

Recomendar espaços em branco entre operadores e operandos. Mas não entre operandos e () ou [].

Recomendar linhas em branco onde clarificar o código. Sempre entre declarações e intruções. Excepto nas trocas e casos semelhantes.

Linhas em branco antes de comentários de bloco.

2.2.3 Comentários

Falar dos vários estilos de comentário. Dizer que funcionam: como prólogos ou introduções, e como apartes ou explicações ou parênteses (embebidos).

  1. Escreva código tão claro que não precise de comentários embebidos.

Falar dos contratos da função: pressupostos nos argumentos -> resultados (pré-condições e pós-condições). Comentários com essa preocupação.

2.2.4 Exemplo

Suponha o seguinte programa. Que faz ele?

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    int n, d;
} fr;

int mdc(int v1, int v2)
{
    while(v2 != 0) {
        int aux = v2;
        v2 = v1 % v2;
        v1 = aux;
    }
    return v1 == 0 ? 1 : v1;
}

fr red(fr v)
{
    int div = mdc(v.n, v.d);
    v.n /= div;
    v.d /= div;
    return v;
}

fr le(void)
{
    fr v;
    if(scanf("%d/%d", &v.n, &v.d) != 2)
        exit(EXIT_FAILURE);
    return v;
}

void escr(fr v)
{
    printf("%d/%d", v.n, v.d);
}

int main(void)
{
    fr v = le();
    v = red(v);
    escr(v);
    return EXIT_SUCCESS;
}

Teve dificuldade em seguir? Experimente a versão seguinte:

#include <stdio.h>
#include <stdlib.h>
typedef struct {
    int numerador, denominador;
} fraccao;
/*
 * Pressupostos: Nenhum.
 * Resultado: Devolve o maximoDivisorComum dos argumentos (se
 *            forem ambos nulos devolve 1).
 */ 
int maximoDivisorComum(int valor1, int valor2)
{
    while(valor2 != 0) {
        int valorAuxiliar = valor2;
        valor2 = valor1 % valor2;
        valor1 = valorAuxiliar;
    }
    return valor1 == 0 ? 1 : valor1;
}
/*
 * Pressupostos: Nenhum.
 * Resultado: Devolve a versao reduzida da fraccao passada
 *            como argumento.
 */
fraccao reduzida(fraccao valor)
{
    int divisor = maximoDivisorComum(valor.numerador,
                                     valor.denominador);
    valor.numerador /= divisor;
    valor.denominador /= divisor;
    return valor;
}
/*
 * Pressupostos: Tem de existir dois inteiros separados por
 *               '/' na entrada. De outra forma o programa aborta.
 * Resultado: Devolve uma fraccao lida da entrada.
 */
fraccao le(void)
{
    fraccao valor;
    if(scanf("%d/%d", &valor.numerador, &valor.denominador) != 2)
        exit(EXIT_FAILURE);
    return valor;
}
/*
 * Pressupostos: Nenhum.
 * Resultado: Aparece no ecra a representacao da fraccao passada
 *            como argumento.
 */
void escreve(fraccao valor)
{
    printf("%d/%d", valor.numerador, valor.denominador);
}
/*
 * Pressupostos: Tem de existir dois inteiros separados por
 *               '/' na entrada. De outra forma o programa aborta.
 * Resultado: Aparece no ecra a representacao reduzida duma
 *            fraccao dada pelo utilizador (lida da entrada).
 */
int main(void)
{
    fraccao valor = le();
    valor = reduzida(valor);
    escreve(valor);
    return EXIT_SUCCESS;
}

Mais fácil, não? Isso deve-se a que, apesar de estruturadas da mesma forma, a primeira versão não segue as orientações dadas neste documento, enquanto a segunda segue.


3. Programação em C

A completar..


Página concebida e mantida por Eng. Manuel Menezes de Sequeira (última actualização 2006/07/07)
Copyright © 1996-2001 ISCTE