Semana 4 — Descomposición de problemas, programación modular y pila de ejecución
En las semanas anteriores resolvimos problemas escribiendo todo el código en un solo bloque: la sección algorítmica contenía prólogo, resolución y epílogo de forma secuencial. Esta estrategia funciona para programas pequeños, pero presenta tres problemas que crecen junto con la complejidad del código:
Las funciones resuelven estos tres problemas de una sola vez. Una función es un bloque de código con nombre propio que realiza una tarea específica, recibe datos de entrada (parámetros) y puede devolver un resultado. En lugar de repetir instrucciones, invocamos la función cada vez que necesitamos esa tarea.
Pensemos en una cocina profesional. El chef principal no prepara personalmente cada ingrediente de cada plato: delega tareas a cocineros especializados. Uno prepara las salsas, otro las guarniciones, otro los postres. Cada cocinero tiene una responsabilidad definida (una tarea), recibe ingredientes (parámetros) y entrega un producto terminado (valor de retorno). El chef principal «invoca» a cada especialista cuando lo necesita, sin preocuparse por cómo se elabora internamente cada preparación — solo por qué necesita y qué recibe.
Así funcionan las funciones en un programa: cada una encapsula una tarea, recibe datos, devuelve un resultado, y el programa principal las invoca sin conocer (ni necesitar conocer) sus detalles internos.
En Python, una función se define con la palabra reservada def, seguida de un nombre descriptivo, una lista de parámetros entre paréntesis y dos puntos. El cuerpo de la función está indentado y opcionalmente finaliza con una sentencia return.
Es fundamental distinguir dos términos que a menudo se confunden:
base y altura son parámetros.calcular_area(5.0, 3.0), entonces 5.0 y 3.0 son argumentos.La relación es: al invocar una función, cada argumento se asocia con el parámetro correspondiente, en orden posicional.
Definir una función no ejecuta su código; solo lo registra en memoria con un nombre. La ejecución ocurre cuando el programa invoca (o «llama») a la función por su nombre, proporcionando los argumentos necesarios.
El flujo de ejecución sigue una secuencia precisa: cuando Python encuentra la invocación calcular_area(b, h), suspende la ejecución del programa principal, transfiere el control a la función (asociando b con base y h con altura), ejecuta el cuerpo de la función, y al encontrar return, devuelve el control al punto exacto donde se realizó la invocación, sustituyendo la llamada por el valor retornado.
No todas las funciones necesitan devolver un resultado. Según su propósito, podemos distinguir dos categorías:
| Tipo | Descripción | Ejemplo |
|---|---|---|
| Con retorno return valor |
Calcula y devuelve un resultado que el invocador necesita. La función termina al ejecutar return. Se invoca típicamente como parte de una asignación o expresión. |
area = calcular_area(5, 3) |
| Sin retorno procedimiento |
Realiza una acción (imprimir, modificar un estado) sin devolver un valor explícito. En Python, devuelve None implícitamente. Se invoca como sentencia independiente. |
mostrar_resultado(area) |
print no es returnUn error muy común en principiantes es usar print() dentro de una función creyendo que eso «devuelve» el valor. No es así. print() muestra texto en pantalla; return entrega un valor al invocador. Si una función calcula un resultado que será usado después, debe usar return. Si una función solo necesita exhibir información, puede usar print(). Confundir ambas operaciones es el origen del error «mi función imprime el resultado correcto pero resultado vale None».
Python permite que una función devuelva varios valores simultáneamente, separados por comas. Internamente, Python empaqueta los valores en una tupla (un tipo de dato que estudiaremos en semanas posteriores). El invocador puede desempaquetar los valores en variables separadas:
Cuando invocamos una función con argumentos, ¿qué recibe exactamente la función? La respuesta depende del mecanismo de paso de argumentos del lenguaje. Existen dos modelos fundamentales:
| Mecanismo | Descripción | Consecuencia |
|---|---|---|
| Por valor | La función recibe una copia del valor del argumento. Cualquier modificación dentro de la función afecta solo a la copia local; el argumento original no cambia. | Seguro: la función no puede producir efectos secundarios sobre las variables del invocador. Es el comportamiento que observamos con int, float, str y bool. |
| Por referencia | La función recibe una referencia (dirección) al objeto original. Las modificaciones dentro de la función sí afectan al objeto del invocador. | Potente pero peligroso: permite que la función modifique datos compartidos. En Python, esto ocurre con objetos mutables como las listas (Semana 9). |
Técnicamente, Python utiliza un mecanismo denominado paso por asignación (pass by assignment): siempre se pasa una referencia al objeto, pero el comportamiento observable depende de si el objeto es mutable (listas, diccionarios) o inmutable (int, float, str, tuplas). Con los tipos inmutables que usamos en esta etapa del curso, el efecto práctico es idéntico al paso por valor: la función no puede modificar los datos del invocador. La Semana 7 abordará este tema en profundidad con objetos mutables.
Una de las propiedades más importantes de las funciones es el aislamiento: las variables creadas dentro de una función son invisibles desde fuera, y viceversa. Este mecanismo, denominado alcance de variables (en inglés, scope), evita interferencias accidentales entre distintas partes del programa.
| Tipo | Dónde se crea | Dónde es visible | Cuándo se destruye |
|---|---|---|---|
| Local | Dentro del cuerpo de una función (incluidos los parámetros). | Solo dentro de esa función. | Al terminar la ejecución de la función (return o fin del cuerpo). |
| Global | En el nivel principal del programa (fuera de toda función). | En todo el programa, pero solo en modo lectura desde las funciones. | Al terminar la ejecución del programa. |
Si una función define una variable local con el mismo nombre que una variable global, la variable local oculta (o «sombrea») a la global dentro del ámbito de la función. La variable global no se modifica.
El sombreado no es un error de Python, sino un comportamiento intencionado que protege al programa principal. Sin embargo, usar el mismo nombre para una variable local y una global genera confusión. La recomendación es simple: elegir nombres descriptivos y distintos para variables de distinto alcance. Si la variable global se llama total_acumulado, la variable local de una función no debería llamarse igual, a menos que se trate intencionalmente de una nueva variable independiente.
Cuando Python encuentra un nombre de variable en una expresión, busca su valor siguiendo un orden fijo conocido como la regla LEGB:
Las funciones son la herramienta técnica; la programación modular es la estrategia de diseño que las aprovecha. Modularizar un programa consiste en descomponer un problema complejo en subproblemas más simples, implementar cada uno como una función independiente, y luego coordinarlos desde un programa principal.
El diseño top-down es una estrategia donde se parte del problema completo y se lo descompone progresivamente en subproblemas más pequeños, hasta llegar a piezas lo suficientemente simples como para codificarse directamente. Es el enfoque natural de la metodología de Pólya: primero se analiza el problema general, se diseña la estructura de la solución, y luego se codifica cada parte.
Consideremos un programa que lee una temperatura en grados Celsius y genera un informe con las conversiones a Fahrenheit y Kelvin. La descomposición top-down produce el siguiente árbol:
Las funciones celsius_a_fahrenheit y celsius_a_kelvin son genuinamente independientes: cada una recibe un dato, aplica una fórmula distinta y devuelve un resultado. No comparten estado ni requieren un orden particular entre sí. Esta independencia es lo que hace que la descomposición sea auténtica: cada nodo del Nivel 2 es una función autocontenida y reutilizable. Compare esto con un caso donde los subproblemas parecen independientes pero en realidad se resuelven mejor de forma simultánea (como calcular máximo, mínimo y promedio en un único recorrido, según vimos en Semana 3).
No basta con dividir el código en funciones: la calidad de la modularización depende de cómo se realiza la división. Dos criterios complementarios guían esta decisión:
Cada función realiza una sola tarea bien definida. Todos los elementos internos (variables, operaciones) contribuyen a esa tarea. La función puede describirse con un nombre claro y una oración breve.
Indicador: si la docstring necesita la palabra «y» para describir qué hace la función, probablemente hace demasiadas cosas.
Las funciones se comunican exclusivamente a través de parámetros y valores de retorno, sin depender de variables globales ni de detalles internos de otras funciones.
Indicador: si al modificar el interior de una función se rompen otras funciones, el acoplamiento es demasiado alto.
Tomemos como ejemplo un programa monolítico de la Semana 3 (patrón de acumulación con filtrado) y transformémoslo en un diseño modular. Este proceso de mejora del código sin cambiar su comportamiento se denomina refactorización.
es_multiplo y leer_entero pueden usarse en cualquier otro programa sin modificación.La abstracción es el principio que permite usar una función sin conocer su implementación interna. Cuando invocamos len('hola'), no necesitamos saber cómo Python cuenta los caracteres internamente: solo necesitamos conocer qué hace (devuelve la longitud), qué espera recibir (una secuencia) y qué devuelve (un entero). Este acuerdo entre el invocador y la función se denomina contrato de interfaz.
El contrato de una función especifica tres elementos:
En Python, el contrato de una función se expresa mediante su docstring (cadena de documentación): un literal de cadena colocado como primera sentencia del cuerpo de la función. La docstring es accesible en tiempo de ejecución mediante el atributo __doc__ o la función help().
En el enfoque de literate programming que venimos aplicando desde la Semana 1, la sección declarativa del programa (el docstring del nivel superior) describe el problema completo. Cuando modularizamos, cada función incorpora su propia sección declarativa: su docstring, que documenta su contrato específico. De esta forma, un programa modular bien documentado se lee como una narración en dos niveles: el nivel general (qué hace el programa) y el nivel de cada función (qué hace cada parte).
Cuando un programa invoca funciones, ¿cómo sabe Python a dónde regresar después del return? ¿Cómo maneja las variables locales de varias funciones que podrían estar activas simultáneamente? La respuesta está en una estructura de datos fundamental: la pila de ejecución (en inglés, call stack).
Cada vez que se invoca una función, Python crea un stack frame (marco de pila) que contiene:
La pila opera bajo el principio LIFO (Last In, First Out): el último marco que se apila es el primero que se desapila. Esto garantiza que las funciones se completen en orden inverso al que fueron invocadas.
Sigamos paso a paso la ejecución del siguiente programa:
El siguiente simulador permite recorrer paso a paso la ejecución del programa, observando cómo se apilan y desapilan los stack frames. Use los botones para avanzar y retroceder:
cuadrado y distancia en memoria (sin ejecutar su cuerpo).
Al recorrer el simulador, preste atención a tres regularidades: (1) cada invocación apila un frame encima del anterior; (2) cada return desapila el frame del tope y entrega el valor al frame que queda debajo; (3) las variables locales de un frame son completamente independientes de las de otro, incluso si tienen el mismo nombre (como la x de las dos invocaciones a cuadrado).
La pila de ejecución no es un concepto exclusivo de Python: es una estructura de la arquitectura de computadoras que existe en la memoria RAM. En el documento Lenguajes de Máquina y Programación estudiamos que la CPU dispone de un registro especial llamado Stack Pointer (SP) que apunta a la posición actual del tope de la pila. Cada vez que se invoca una función, el SP se decrementa para «hacer espacio» al nuevo stack frame; al retornar, se incrementa para «liberar» ese espacio. Python, como lenguaje de alto nivel, gestiona esto automáticamente a través de su máquina virtual CPython, pero el mecanismo subyacente es el mismo que utiliza un programa en C o ensamblador.
Cuando un error ocurre dentro de una función, Python muestra un stack trace (traza de pila): un informe que recorre la cadena de invocaciones desde el programa principal hasta el punto exacto del error. Saber leer esta traza es una habilidad esencial de depuración.
El stack trace se lee de abajo hacia arriba. La última línea indica qué error ocurrió (TypeError). Luego, se asciende por las líneas para ver dónde ocurrió: primero en cuadrado (línea 3), que fue invocada desde distancia (línea 7), que fue invocada desde el programa principal (línea 12). Esta cadena es exactamente el contenido de la pila de ejecución en el momento del error.
Ahora que entendemos las funciones, la pila de ejecución y cómo Python gestiona las invocaciones, podemos profundizar en una pregunta que introdujimos en las primeras semanas: ¿cómo se traduce el código fuente que escribimos a instrucciones que la CPU pueda ejecutar?
| Aspecto | Compilación | Interpretación |
|---|---|---|
| Proceso | El código fuente completo se traduce una vez a código de máquina antes de ejecutarse. Se produce un archivo ejecutable. | El código fuente se traduce y ejecuta línea a línea (o sentencia a sentencia) en tiempo de ejecución. |
| Errores | Se detectan antes de ejecutar (en tiempo de compilación): el programa no se genera si hay errores sintácticos o de tipo. | Se detectan durante la ejecución: el programa se detiene al encontrar un error. |
| Velocidad de ejecución | Más rápida: el código ya está traducido a instrucciones nativas de la CPU. | Más lenta: hay una capa adicional de traducción en tiempo real. |
| Portabilidad | Menor: el ejecutable es específico para una arquitectura (x86, ARM). | Mayor: el mismo código fuente se ejecuta donde haya un intérprete instalado. |
| Ejemplos | C, C++, Rust, Go | Python (CPython), JavaScript, Ruby |
Python utiliza una estrategia intermedia. Cuando ejecutamos un programa .py, CPython (la implementación de referencia de Python) realiza dos pasos:
Este modelo híbrido combina beneficios de ambos enfoques: la compilación a bytecode detecta errores de sintaxis antes de ejecutar, y el bytecode es portable entre plataformas (solo se necesita que CPython esté instalado). La contrapartida es que la ejecución es más lenta que la de un programa compilado a código nativo.
dis: observando el bytecodePython incluye un módulo llamado dis (disassembler) que permite inspeccionar el bytecode generado para cualquier función. Esto permite verificar empíricamente cómo Python traduce nuestro código:
LOAD_FAST 0 carga el primer parámetro (a) en la pila de operandos de la PVM. LOAD_FAST 1 carga el segundo (b). BINARY_ADD toma los dos valores del tope de la pila, los suma y coloca el resultado en el tope. RETURN_VALUE devuelve ese resultado al invocador. Observe la similitud conceptual con las operaciones de la ALU que estudiamos en Arquitecturas de Computadoras: cargar operandos, operar y almacenar el resultado.
El modelo de programa que hemos utilizado desde la Semana 1 (Sección Declarativa + Sección Algorítmica con Prólogo, Resolución y Epílogo) se extiende naturalmente para incorporar funciones. La estructura de un programa modular queda:
A continuación, un programa completo que sigue el modelo modular. El problema es calcular el promedio, la nota máxima y la mínima de una secuencia de notas ingresadas por el usuario, donde el valor -1 actúa como centinela.
El programa principal ocupa apenas cinco líneas de código ejecutable (excluyendo comentarios y líneas en blanco). Todo el trabajo complejo está delegado a funciones con nombres que describen su propósito. Un lector que examine solo el programa principal puede entender qué hace el programa sin leer el cómo de cada función: esta es la esencia de la abstracción funcional.
La decisión de cuándo abstraer un bloque de código en una función no es arbitraria. Los siguientes criterios proporcionan una guía práctica:
| Criterio | Descripción | Ejemplo |
|---|---|---|
| Reutilización | El mismo bloque de código aparece (o podría aparecer) en más de un lugar. | leer_entero(mensaje) se usa cada vez que se necesita un entero del teclado. |
| Legibilidad | Un bloque complejo puede reemplazarse por una invocación con nombre descriptivo que mejora la comprensión del programa principal. | es_bisiesto(anio) es más legible que la expresión booleana completa. |
| Complejidad | Un fragmento de código supera las 10–15 líneas o maneja múltiples responsabilidades. | Dividir «validar y procesar entrada» en validar_entrada() y procesar(). |
| Testabilidad | Se necesita verificar un cálculo de forma aislada, sin ejecutar todo el programa. | es_primo(n) puede probarse con una tabla de valores conocidos. |
| Modificabilidad | Se anticipa que una parte del programa podría cambiar (requisitos, algoritmo, formato). | formatear_salida(datos) encapsula una decisión de formato que puede evolucionar. |
Si puedes ponerle a un bloque de código un nombre que lo describa mejor que el código mismo, ese bloque merece ser una función. Si el nombre más descriptivo que encuentras es hacer_cosas() o procesar_datos(), probablemente la función intenta hacer demasiado y debería dividirse.
Las funciones, la modularización y la abstracción representan un cambio cualitativo en nuestra forma de programar: pasamos de escribir secuencias lineales de instrucciones a diseñar soluciones como un conjunto de componentes colaborativos. Este cambio de paradigma —que parece puramente técnico— es en realidad una herramienta cognitiva: nos permite enfrentar problemas más complejos de los que podríamos manejar con código monolítico.
Las funciones no son un concepto exclusivo de los lenguajes de alto nivel. En el nivel de la arquitectura de computadoras, una función se implementa mediante instrucciones CALL (que apilan la dirección de retorno y saltan al código de la función) y RET (que desapilan la dirección y regresan). El Stack Pointer (SP) y el Base Pointer (BP) son registros de CPU dedicados a gestionar la pila.
Desde la perspectiva del sistema operativo, cada proceso tiene su propia pila de ejecución como parte de su espacio de memoria. El tamaño de la pila es limitado (típicamente entre 1 y 8 MB); si se apilan demasiados frames (por ejemplo, por una recursión infinita), se produce un stack overflow — un desbordamiento de pila que el SO detecta y que causa la terminación del proceso.
Funciones, Modularización y Abstracción — Semana 4
Desarrollo de Contenidos — Algoritmia y Programación — Carrera de Informática