Saltar al contenido
Topos Uranos

Resumen

Las dos clases anteriores enseñaron qué es una función y cómo entran y salen sus datos; esta enseña a decidir qué funciones escribir. El método es el refinamiento sucesivo: se escribe un main breve que llama a funciones todavía inexistentes, cada una con su contrato, y se desciende a cada una hasta llegar a partes que se escriben en pocas líneas. La clase fija los criterios de una buena interfaz, la regla «una función, una tarea» con las señales que la delatan, y la sobrecarga, que da un mismo nombre a la misma tarea sobre tipos distintos. Con esos criterios reescribe los algoritmos de la unidad 4, que vivían dentro de main, y los prueba por separado. El concepto que unifica todo es la descomposición descendente: un programa bien diseñado se lee como su propia especificación.

Objetivos de aprendizaje

  1. Descomponer un problema en subproblemas por refinamiento sucesivo y escribir el árbol de la descomposición.
  2. Diseñar la interfaz de cada función (nombre, parámetros, retorno y contrato) antes de escribir su cuerpo.
  3. Reescribir un programa monolítico de la unidad 4 como un conjunto de funciones cohesivas, y probar cada una por separado con un main de prueba.
  4. Evaluar un diseño según la regla «una función, una tarea» y la ausencia de efectos ocultos.
  5. Sobrecargar una función, predecir qué versión elige cada llamada y explicar por qué una llamada resulta ambigua.

Refinamiento sucesivo

Del enunciado a un main de pocas líneas

Los programas de la unidad 4 se escribieron enteros dentro de main. Mientras caben en una pantalla, eso se tolera; ahora bien, un programa de doscientas líneas escrito así exige tenerlo todo en la cabeza a la vez, y solo admite la prueba del conjunto. El refinamiento sucesivo, o diseño descendente, invierte el orden del trabajo: primero se escribe lo que el programa hace, en el lenguaje del problema, como una secuencia de llamadas a funciones que todavía no existen; después se toma cada una de esas funciones como un problema nuevo, más pequeño, y se repite el procedimiento hasta llegar a funciones tan simples que su cuerpo se escribe sin descomponer nada.

El problema siguiente sirve de ejemplo. Un entero n≥1n \geq 1 es perfecto si la suma de sus divisores propios (los positivos distintos de nn) es igual a nn, deficiente si es menor y abundante si es mayor: 28=1+2+4+7+1428 = 1 + 2 + 4 + 7 + 14 es perfecto; 8, con 1+2+4=71 + 2 + 4 = 7, es deficiente; 12, con 1+2+3+4+6=161 + 2 + 3 + 4 + 6 = 16, abundante. Se quiere un programa que lea nn y escriba la suma de sus divisores propios y su clase. El primer nivel del diseño es main, y se escribe antes que nada:

Programa en C++
#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​long long divisorSum(int n);Kind classify(int n);std::string kindName(Kind kind);​int main(){    int n{};    std::cin >> n;    std::println("Divisores propios de {}: suman {}", n, divisorSum(n) - n);    std::println("{} es {}", n, kindName(classify(n)));}
Mensajes del enlazador
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':main.cpp:15:(.text+0x22a): undefined reference to `divisorSum(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:16:(.text+0x3fd): undefined reference to `classify(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:16:(.text+0x40d): undefined reference to `kindName[abi:cxx11](Kind)'collect2: error: ld returned 1 exit status

El programa se compila sin ningún aviso, porque cada llamada concuerda con su declaración, que es lo único que el compilador comprueba, como enseñó la clase Funciones. Lo que falla es el enlace: el enlazador busca las definiciones y no las encuentra («undefined reference», referencia sin definir; el añadido [abi:cxx11] es una marca interna de GCC para las funciones que devuelven std::string). Ese estado es exactamente el del diseño en este punto: las interfaces están decididas, los cuerpos no. Si esto es así, main ya se lee como la especificación del programa, y las tres declaraciones son las tareas pendientes, cada una más pequeña que el problema original.

El árbol de la descomposición

El segundo nivel desciende a cada declaración. kindName es una decisión de tres casos. classify se apoya en divisorSum: un número es perfecto cuando la suma de todos sus divisores es 2n2n. Y divisorSum es el bucle de los divisores en pares (d,n/d)(d, n/d) de la clase Algoritmos numéricos, que se detiene en n\sqrt{n}. El resultado se dibuja como un árbol: la raíz es el problema completo; los hijos de cada nodo, los subproblemas que lo resuelven; las hojas, operaciones que el lenguaje ya ofrece.

EjemploEl árbol del clasificador de enteros

La figura despliega el árbol nivel por nivel, en el orden en que se diseñó.

Demostración

  1. El primer nivel es el problema entero, a cargo de main, que todavía no se descompone.

  2. El segundo nivel es el main de cinco líneas: leer, sumar, clasificar, nombrar y escribir. Cada nodo es una llamada; las de la biblioteca ya existen, las demás están declaradas y pendientes.

  3. El tercer nivel descompone classify, que no hace más que llamar a divisorSum y comparar. La misma función aparece dos veces en el árbol, y se escribe una sola: la reutilización se ve en el árbol antes de escribir código.

  4. El último nivel son las hojas: la prueba de divisibilidad y el límite de los pares, operaciones del lenguaje. Al llegar a ellas el refinamiento termina, porque no queda nada por descomponer.

Con el árbol completo, se escriben las definiciones de abajo arriba, de modo que cada función se apoya en otras ya escritas:

Programa en C++
#include <iostream>#include <print>#include <string>​enum class Kind { deficient, perfect, abundant };​// Precondición: n >= 1.// Poscondición: devuelve la suma de todos los divisores positivos de n, incluido n.long long divisorSum(int n){    long long 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;}​// Precondición: n >= 1.Kind classify(int n){    const long long proper{divisorSum(n) - n};    if (proper < n) {        return Kind::deficient;    }    if (proper == n) {        return Kind::perfect;    }    return Kind::abundant;}​std::string kindName(Kind kind){    if (kind == Kind::deficient) {        return "deficiente";    }    if (kind == Kind::perfect) {        return "perfecto";    }    return "abundante";}​int main(){    int n{};    std::cin >> n;    std::println("Divisores propios de {}: suman {}", n, divisorSum(n) - n);    std::println("{} es {}", n, kindName(classify(n)));}
Entrada del programa
28
Salida
Divisores propios de 28: suman 2828 es perfecto

Tres rasgos del resultado se deben al método. Primero, main no cambió al escribir lo de abajo. Segundo, la suma se devuelve como long long, porque para nn cercano al máximo de int la suma de sus divisores puede excederlo, y decidirlo en la interfaz protege a todos los que llaman. Tercero, cada función se comprueba por separado: divisorSum(28) debe dar 56, divisorSum(1) debe dar 1 y divisorSum(36) debe dar 91, donde el par (6,6)(6, 6) se cuenta una sola vez; la última sección de la clase convierte esas comprobaciones en un programa.