Saltar para o conteúdo
Topos Uranos

Resumo

As duas aulas anteriores ensinaram o que é uma função e como entram e saem os seus dados; esta ensina a decidir que funções escrever. O método é o refinamento sucessivo: escreve-se um main breve que chama funções ainda inexistentes, cada uma com o seu contrato, e desce-se a cada uma até chegar a partes que se escrevem em poucas linhas. A aula fixa os critérios de uma boa interface, a regra “uma função, uma tarefa” com os sinais que denunciam a sua violação, e a sobrecarga, que dá um mesmo nome à mesma tarefa sobre tipos distintos. Com esses critérios, reescreve os algoritmos da unidade 4, que viviam dentro de main, e testa-os separadamente. O conceito que unifica tudo é a decomposição descendente: um programa bem projetado lê-se como a sua própria especificação.

Objetivos de aprendizagem

  1. Decompor um problema em subproblemas por refinamento sucessivo e escrever a árvore da decomposição.
  2. Projetar a interface de cada função (nome, parâmetros, retorno e contrato) antes de escrever o seu corpo.
  3. Reescrever um programa monolítico da unidade 4 como um conjunto de funções coesas, e testar cada uma separadamente com um main de teste.
  4. Avaliar um projeto segundo a regra “uma função, uma tarefa” e a ausência de efeitos ocultos.
  5. Sobrecarregar uma função, prever que versão cada chamada escolhe e explicar por que uma chamada resulta ambígua.

Refinamento sucessivo

Do enunciado a um main de poucas linhas

Os programas da unidade 4 foram escritos inteiros dentro de main. Enquanto cabem numa tela, isso se tolera; ora, um programa de duzentas linhas escrito assim exige ter tudo na cabeça ao mesmo tempo, e só admite o teste do conjunto. O refinamento sucessivo, ou projeto descendente, inverte a ordem do trabalho: primeiro escreve-se o que o programa faz, na linguagem do problema, como uma sequência de chamadas a funções que ainda não existem; depois toma-se cada uma dessas funções como um problema novo, menor, e repete-se o procedimento até chegar a funções tão simples que o seu corpo se escreve sem decompor nada.

O problema a seguir serve de exemplo. Um inteiro n≥1n \geq 1 é perfeito se a soma dos seus divisores próprios (os positivos distintos de nn) é igual a nn, deficiente se é menor e abundante se é maior: 28=1+2+4+7+1428 = 1 + 2 + 4 + 7 + 14 é perfeito; 8, com 1+2+4=71 + 2 + 4 = 7, é deficiente; 12, com 1+2+3+4+6=161 + 2 + 3 + 4 + 6 = 16, abundante. Quer-se um programa que leia nn e escreva a soma dos seus divisores próprios e a sua categoria. O primeiro nível do projeto é main, e escreve-se antes de tudo:

Programa em C++
#include <cstdint>#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​// Soma dos divisores positivos de n.// Pré-condição: n >= 1.// Pós-condição: devolve a soma de todos os divisores positivos de n, incluído n.std::int64_t divisorSum(int n);​// Categoria de n segundo a soma dos seus divisores próprios.// Pré-condição: n >= 1.// Pós-condição: devolve deficient, perfect ou abundant conforme a soma seja menor, igual ou maior que n.Kind classify(int n);​// Nome de uma categoria, para escrevê-lo.// Pré-condição: nenhuma.// Pós-condição: devolve «deficiente», «perfeito» ou «abundante».std::string kindName(Kind kind);​int main(){    int n{};    std::cin >> n;    std::println("Divisores próprios de {}: somam {}", n, divisorSum(n) - n);    std::println("{} é {}", n, kindName(classify(n)));}
Mensagens do ligador
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':main.cpp:27:(.text+0x22a): undefined reference to `divisorSum(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x3fd): undefined reference to `classify(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x40d): undefined reference to `kindName[abi:cxx11](Kind)'collect2: error: ld returned 1 exit status

O programa compila sem nenhum aviso, porque cada chamada concorda com a sua declaração, que é a única coisa que o compilador verifica, como ensinou a aula Funções. O que falha é a ligação: o ligador procura as definições e não as encontra (“undefined reference”, referência indefinida; o acréscimo [abi:cxx11] é uma marca interna do GCC para as funções que devolvem std::string). Esse estado é exatamente o do projeto neste ponto: as interfaces estão decididas, os corpos não. Se isto é assim, main já se lê como a especificação do programa, e as três declarações são as tarefas pendentes, cada uma menor que o problema original.

A árvore da decomposição

O segundo nível desce a cada declaração. kindName é uma decisão de três casos. classify apoia-se em divisorSum: um número é perfeito quando a soma de todos os seus divisores é 2n2n. E divisorSum é o laço dos divisores em pares (d,n/d)(d, n/d) da aula Algoritmos numéricos, que para em n\sqrt{n}. O resultado desenha-se como uma árvore: a raiz é o problema completo; os filhos de cada nó, os subproblemas que o resolvem; as folhas, operações que a linguagem já oferece.

ExemploA árvore do classificador de inteiros

A figura desdobra a árvore nível por nível, na ordem em que foi projetada.

Demonstração

  1. O primeiro nível é o problema inteiro, a cargo de main, que ainda não se decompõe.

  2. O segundo nível é o main de cinco operações: ler, somar, classificar, nomear e escrever. Cada nó é uma chamada; as da biblioteca já existem, as demais estão declaradas e pendentes.

  3. O terceiro nível decompõe classify, que não faz mais que chamar divisorSum e comparar. A mesma função aparece duas vezes na árvore, e escreve-se uma só: a reutilização vê-se na árvore antes de escrever código.

  4. O último nível são as folhas: o teste de divisibilidade e o limite dos pares, operações da linguagem. Ao chegar a elas, o refinamento termina, porque não resta nada por decompor.

Com a árvore completa, escrevem-se as definições de baixo para cima, de modo que cada função se apoia em outras já escritas:

Programa em C++
#include <cstdint>#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​// Soma dos divisores positivos de n.// Pré-condição: n >= 1.// Pós-condição: devolve a soma de todos os divisores positivos de n, incluído n.std::int64_t divisorSum(int n){    std::int64_t sum{0};    for (int d{1}; d <= n / d; ++d) {        if (n % d == 0) {            sum += d;            if (d != n / d) {                sum += n / d;            }        }    }    return sum;}​// Categoria de n segundo a soma dos seus divisores próprios.// Pré-condição: n >= 1.// Pós-condição: devolve deficient, perfect ou abundant conforme a soma seja menor, igual ou maior que n.Kind classify(int n){    const std::int64_t proper{divisorSum(n) - n};    if (proper < n) {        return Kind::deficient;    }    if (proper == n) {        return Kind::perfect;    }    return Kind::abundant;}​// Nome de uma categoria, para escrevê-lo.// Pré-condição: nenhuma.// Pós-condição: devolve «deficiente», «perfeito» ou «abundante».std::string kindName(Kind kind){    if (kind == Kind::deficient) {        return "deficiente";    }    if (kind == Kind::perfect) {        return "perfeito";    }    return "abundante";}​int main(){    int n{};    std::cin >> n;    std::println("Divisores próprios de {}: somam {}", n, divisorSum(n) - n);    std::println("{} é {}", n, kindName(classify(n)));}
Entrada do programa
28
Saída
Divisores próprios de 28: somam 2828 é perfeito

Três traços do resultado devem-se ao método. Primeiro, main não mudou ao escrever o que está abaixo. Segundo, a soma devolve-se como std::int64_t (de <cstdint>, o inteiro de 64 bits que a aula Recursão também usa), porque para nn próximo do máximo de int a soma dos seus divisores pode excedê-lo, e decidi-lo na interface protege todos os que chamam. Terceiro, cada função verifica-se separadamente: divisorSum(28) deve dar 56, divisorSum(1) deve dar 1 e divisorSum(36) deve dar 91, onde o par (6,6)(6, 6) se conta uma só vez; a última parte da aula converte essas verificações num programa.