Apuntev20190710 PDF
Apuntev20190710 PDF
Apuntev20190710 PDF
CC1002 Introducción a la
Programación
Estos apuntes del curso CC1002 Introducción a la Programación están basados en los libros How to
Design Programs, MIT Press, de M. Felleisen et al., y Objects First with Java - A Practical Introduction
using BlueJ, Fifth Edition, Prentice Hall, de David J. Barnes y Michael Kölling. La distribución de
estos apuntes está limitada al cuerpo docente y a los alumnos del curso CC1002. ESTÁ PROHIBIDO
COMPARTIR O REDISTRIBUIR ESTOS APUNTES FUERA DE LA COMUNIDAD DEL CURSO
CC1002.
Índice
2 Funciones 10
2.1 Variables y funciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.1.1 Definición de funciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.1.2 Indentación y subordinación de instrucciones . . . . . . . . . . . . . . . . . . . . 11
2.1.3 Alcance de una variable . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.2 Problemas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
2.3 Un poco más sobre errores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.3.1 Errores de ejecución . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.3.2 Errores de indentación . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
3 Receta de Diseño 16
3.1 Entender el propósito de la función . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3.2 Dar ejemplos de uso de la función . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3.3 Probar la función . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3.4 Especificar el cuerpo de la función . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
4 Módulos y Programas 20
4.1 Descomponer un programa en funciones . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
4.2 Módulos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
4.3 Programas interactivos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
2
ÍNDICE 3
6 Recursión 40
6.1 Potencias, factoriales y sucesiones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
6.2 Torres de Hanoi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
6.3 Copo de nieve de Koch . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
6.4 Receta de diseño para la recursión . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 48
8 Datos Compuestos 56
8.1 Estructuras (structs) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56
8.2 Receta de diseño para estructuras . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
8.2.1 Diseñar estructuras . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
8.2.2 Plantilla . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
8.2.3 Cuerpo de la función . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
10 Abstracción Funcional 81
10.1 Similitudes en definiciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
10.2 Similitudes en definición de datos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86
10.3 Formalizar la abstracción a partir de ejemplos . . . . . . . . . . . . . . . . . . . . . . . . 87
10.3.1 Comparación . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
10.3.2 Abstracción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
10.3.3 Test . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
10.3.4 Contrato . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
10.3.5 Formulando contratos generales . . . . . . . . . . . . . . . . . . . . . . . . . . . . 90
10.4 Otro ejemplo de función abstracta . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
10.5 Funciones anónimas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
ÍNDICE 4
11 Mutación 96
11.1 Memoria para funciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
11.2 Diseñar funciones con memoria . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
11.2.1 La necesidad de tener memoria . . . . . . . . . . . . . . . . . . . . . . . . . . . . 98
11.2.2 Memoria y variables de estado . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
11.2.3 Funciones que inicializan memoria . . . . . . . . . . . . . . . . . . . . . . . . . . 100
11.2.4 Funciones que cambian la memoria . . . . . . . . . . . . . . . . . . . . . . . . . . 101
11.3 Estructuras mutables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
11.4 Diseñar funciones que modifican estructuras . . . . . . . . . . . . . . . . . . . . . . . . . 106
11.4.1 ¿Por qué mutar estructuras? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
11.4.2 Receta de diseño estructural y con mutación . . . . . . . . . . . . . . . . . . . . 107
14 Depuración 122
14.1 ¿Qué es la depuración? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
14.2 El proceso de depuración . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
14.3 Depurar programas con el método cientı́fico . . . . . . . . . . . . . . . . . . . . . . . . . 123
1
Capı́tulo 1
Estas notas pretenden ser un complemento a las cátedras dictadas en el contexto del curso CC1002
Introducción a la Programación, obligatorio para alumnos de Primer Año del Plan Común de Ingenierı́a
y Ciencias, dictado por la Facultad de Ciencias Fı́sicas y Matemáticas de la Universidad de Chile.
El objetivo principal de este curso no es formar programadores, sino desarrollar en los alumnos
una base común en razonamiento algorı́tmico y lógico, ası́ como una capacidad de modelamiento y
abstracción, necesarios para trabajar una habilidad general en la resolución de problemas. Estos
problemas no necesariamente estarán acotados en el contexto de las Ciencias de la Computación, sino
en el ámbito cotidiano y de la Ingenierı́a y Ciencias en general.
Los computadores son máquinas con un gran poder de cálculo y procesamiento. De hecho, los
computadores fueron diseñados y construidos para poder realizar operaciones matemáticas muchı́simo
más rápido que un ser humano. Sin embargo, es un ser humano el que le tiene que decir al computador,
de alguna forma, qué operaciones debe realizar. A esto se le denomina “programar”. En este capı́tulo
se introducirán los conceptos de algoritmo y programa, y se estudiará cómo programar el computador
para que evalúe expresiones simples con distintos tipos de datos básicos.
Tal como vimos anteriormente, un algoritmo es la representación natural, paso a paso, de cómo
podemos resolver un problema. Esto generalmente se conoce como una técnica de diseño top-down
2
1.2. ¿QUÉ ES UN PROGRAMA? 3
Veamos a continuación un ejemplo: supongamos que queremos cocinar un huevo frito para
acompañar un almuerzo. La definición del algoritmo que resuelve este problema serı́a:
1. Encender un fósforo
2. Con el fósforo, prender un quemador en la cocina
3. Colocar la sartén sobre el quemador de la cocina
4. Poner unas gotas de aceite sobre la sartén
5. Tomar un huevo y quebrarlo
6. Colocar el huevo quebrado sobre la sartén
7. Esperar hasta que el huevo esté listo
Para comenzar a trabajar con Python utilizaremos su intérprete. El intérprete de Python es una
aplicación que lee expresiones que se escriben, las evalúa y luego imprime en pantalla el resultado
obtenido.
Es importante recalcar que utilizaremos en este curso a Python como una herramienta y no un fin.
No hay que olvidar que el objetivo es aprender a resolver problemas, utilizando un computador como
apoyo para realizar esta tarea.
Con los tipos de datos explicados anteriormente, podemos realizar operaciones entre ellos utilizando
operadores especı́ficos en cada caso. Ası́, para datos numéricos (enteros y reales), podemos usar los
operadores de suma (+), resta (-), multiplicación (*) y división (/). La prioridad de estos operadores
es la misma usada en álgebra: primero se evalúan los operadores multiplicativos de izquierda a derecha
según orden de aparición (* y /), y luego los aditivos (+, -). En el caso de querer imponer una
evaluación en particular que no siga el orden preestablecido, podemos indicarlo utilizando paréntesis.
Por ejemplo:
1 >>> 3 + 5
2 -> 8
3 >>> 3 + 2 * 5
4 -> 13
5 >>> (3 + 2) * 5
6 -> 25
Nota: La secuencia de caracteres >>> significa que usamos el intérprete de Python. La lı́nea
siguiente, con la secuencia de caracteres -> indica la respuesta del intérprete, tal como Python la
evalúa.
En Python, se definen dos operadores adicionales para operaciones matemáticas recurrentes: elevar
a potencia (**) y calcular el resto de una división entera (%). La prioridad del operador % es la misma
que la de los operadores multiplicativos, mientras que la del operador ** es mayor. Ası́:
1 >>> 2 ** 3
2 -> 8
3 >>> 4 ** 0.5
4 -> 2.0
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
1.4. PROGRAMAS SIMPLES 5
5 >>> 10 % 3
6 -> 1
Para operar con valores de tipo texto, en Python utilizamos generalmente dos operadores: si
queremos unir (concatenar) dos cadenas de texto, lo indicamos con el operador +; por otro lado, si
queremos repetir una cadena de texto, lo indicamos con el operador *. Por ejemplo:
1 >>> 1 / 2
2 -> 0
En efecto, dado que 1 y 2 son valores de tipo entero, la división entre ellos también lo será (y,
por ende, se transforma el valor obtenido al tipo entero). Si queremos calcular el valor real de dicha
operación, debemos forzar a que al menos uno de los dos operandos sea real. Ası́:
1.4.2 Variables
Una variable es el nombre que se le da a un valor o a una expresión. El valor de una variable está
dado por la definición más reciente del nombre. Para evaluar una expresión con variables, usamos una
semántica por sustitución, esto es, reemplazamos en la expresión los valores asociados al nombre por
aquellos que están definidos por la variable.
Para crear variables en un programa podemos utilizar cualquier letra del alfabeto, o bien, una
combinación de letras, números y el sı́mbolo siempre que el primer carácter no sea un número. Para
asignar una variable a una expresión (o al resultado de ésta), utilizamos el operador =.
1 >>> a = 8
2 >>> b = 12
3 >>> c = a * b
En este ejemplo notamos que queremos calcular el área de un rectángulo, pero los nombres de las
variables no son los indicados. En este caso, las variables a, b y c pueden representar cualquier cosa.
Serı́a mucho más adecuado escribir:
1 >>> ancho = 8
2 >>> largo = 12
3 >>> area = ancho * largo
1.5 Errores
Hasta ahora hemos visto únicamente expresiones correctas, tal que al evaluarlas obtenemos un
resultado. ¿Qué es lo que imprime el intérprete en este caso?
1 >>> dia = 13
2 >>> mes = ’ agosto ’
3 >>> ’ Hoy es ’ + dia + ’ de ’ + mes
1 >>> dia = 13
2 >>> mes = ’ agosto ’
3 >>> ’ Hoy es ’ + str ( dia ) + ’ de ’ + mes
4 -> ’ Hoy es 13 de agosto ’
Existen distintos tipos de errores. Es útil conocerlos para no cometerlos, y eventualmente para
saber cómo corregirlos.
1 >>> numero = 15
2 >>> antecesor = ( numero - 1))
1 >>> lado1 = 15
2 >>> area = lado1 * lado2
1 >>> dia = 13
2 >>> mes = ’ agosto ’
3 >>> ’ Hoy es ’ + dia + ’ de ’ + mes
Lamentablemente, el intérprete no nos indicará cuándo o en qué lı́nea se producen estos errores,
por lo que la mejor manera de enfrentarlos es evitarlos y seguir una metodologı́a limpia y robusta que
nos permita asegurar a cabalidad que lo que estamos escribiendo efectivamente es lo que esperamos
que el computador ejecute. Por ejemplo:
1 >>> numero = 15
2 >>> doble = 3 * numero
3 >>> doble # esperariamos 30 , luego debe haber algun error en el codigo
4 -> 45
Funciones1
La clase anterior vimos cómo crear expresiones sencillas que operan con números. El objetivo de esta
clase es ir un paso más allá y desarrollar pequeños trozos de código que implementen operaciones como
un conjunto. Al igual que en matemática, en computación llamamos función a una estructura que
recibe valores de entrada y genera un valor de salida.
areaCirculo = 3.14 · r2
Ası́, si tenemos un cı́rculo cuyo radio tiene valor igual a 5, sabemos que sustituyendo la variable
“r ” con este valor, obtenemos su área:
1 Traducido al español y adaptado de: M. Felleisen et al.: How to Design Programs, MIT Press. Disponible en:
www.htdp.org
10
2.1. VARIABLES Y FUNCIONES 11
La segunda lı́nea define la salida de la función. La palabra clave return indica el fin de la función,
y la expresión que la sucede será evaluada y retornada como la salida.
Para utilizar una función, debemos invocarla con el valor de sus argumentos. En nuestro ejemplo,
para calcular el área de un cı́rculo con radio igual a 5, invocamos a la función de la siguiente manera:
Para esto, debemos crear una función que reciba dos argumentos, el radio del cı́rculo externo y el
radio del cı́rculo interno, calcule el área de ambos cı́rculos y finalmente los reste. Luego, la función
que calcula el área de un anillo queda definida de la siguiente manera:
1 >>> areaAnillo (5 , 3)
2 -> 50.24
En efecto, esta evaluación corresponde a:
areaAnillo(5, 3) → areaCirculo(5) - areaCirculo(3)
→ 3.14 * 5 ** 2 - 3.14 * 3 ** 2
→ 3.14 * 25 - 3.14 * 9
→ 50.24
separado del margen izquierdo por una tabulación. Esto indica que la segunda lı́nea está subordinada a
la función, lo que se traduce que esta instrucción es parte de la ella. Ası́, si una función está compuesta
por muchas instrucciones, cada lı́nea debe esta indentada al menos una tabulación hacia la derecha
más que la lı́nea que indica el nombre y argumentos de la función. Notemos entonces que la función
de nuestro ejemplo puede ser reescrita de la siguiente manera:
Imaginemos que declaramos una variable a. Si una función realiza alguna operación que requiere
de esta variable, el valor utilizado será aquel que contiene la variable. Por ejemplo:
1 >>> a = 100
2 >>> def sumaValorA ( x ):
3 ... return x + a
4 >>> sumaValorA (1)
5 -> 101
Sin embargo, una variable puede ser redefinida dentro de una función. En este caso, cada vez que
se deba evaluar una expresión dentro de la función que necesite de esta variable, el valor a considerar
será aquel definido dentro de la función misma. Además, la redefinición de una variable se hace de
manera local, por lo que no afectará al valor de la variable definida fuera de la función. Esto se puede
observar en el siguiente ejemplo:
1 >>> a = 100
2 >>> def sumaValorA ( x ):
3 ... a = 200
4 ... return x + a
5 >>> sumaValorA (1)
6 -> 201
7 >>> a
8 -> 100
Por otra parte, si el argumento de una función tiene el mismo nombre que una variable definida
fuera de ésta, la función evaluará sus instrucciones con el valor del argumento, pues es la variable que
está dentro de su alcance:
1 >>> a = 100
2 >>> def sumaValorA ( a ):
3 ... return 1 + a
4 >>> sumaValorA (5)
5 -> 6
Finalmente, cualquier variable definida dentro de la función no existe fuera de ésta, ya que queda
fuera de su alcance (recuerde que éste es local a la función). Por ejemplo:
2.2 Problemas
Rara vez los problemas vienen formulados de tal manera que basta con traducir una fórmula
matemática para desarrollar una función. En efecto, tı́picamente se tiene una descripción informal
sobre una situación, la que puede incluir información ambigua o simplemente poco relevante. Ası́,
la primera etapa de todo programador es extraer la información relevante de un problema y luego
traducirlo en expresiones apropiadas para poder desarrollar un bloque de código. Consideremos el
siguiente ejemplo:
“Genera S.A. le paga $4.500 por hora a todos sus ingenieros de procesos recién egresados. Un
empleado tı́picamente trabaja entre 20 y 65 horas por semana. La gerencia de informática le pide
desarrollar un programa que calcule el sueldo de un empleado a partir del número de horas trabajadas.”
En la situación anterior, la última frase es la que indica cuál es el problema que queremos resolver:
escribir un programa que determine un valor en función de otro. Más especı́ficamente, el programa
recibe como entrada un valor, la cantidad de horas trabajadas, y produce otro, el sueldo de un
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
2.3. UN POCO MÁS SOBRE ERRORES 14
empleado en pesos. La primera oración implica cómo se debe calcular el resultado, pero no lo especifica
explı́citamente. Ahora bien, en este ejemplo particular, esta operación no requiere mayor esfuerzo: si
un empleado trabaja una cantidad h de horas, su sueldo será: $4.500 · h.
Ahora que tenemos una expresión para modelar el problema, simplemente creamos una función en
Python para calcular los valores:
1 def sueldo ( h ):
2 return 4500 * h
En este caso, la función se llama sueldo, recibe un parámetro h representando a la cantidad de
horas trabajadas, y devuelve 4500 * h, que corresponde al dinero que gana un empleado de la empresa
al haber trabajado h horas.
1 >>> 1 / 0
Otra manera de obtener este tipo de error es invocando una función con un número equivocado de
argumentos. Por ejemplo, si utilizamos la función areaCirculo con dos argumentos en vez de uno,
recibiremos un mensaje de error que lo indica:
Imagine que se desea definir una función que calcule el área de un cuadrado dado el largo de uno
de sus lados. Una implementación de esta función podrı́a ser de la siguiente manera:
Receta de Diseño1
En el capı́tulo anterior vimos que el desarrollo de una función requiere varios pasos. Necesitamos
saber qué es lo relevante en el enunciado del problema y qué podemos ignorar. Además, necesitamos
saber qué es lo que la función recibirá como parámetros, y cómo relaciona estos parámetros con
la salida esperada. Además, debemos saber, o averiguar, si Python provee operaciones básicas para
manejar la información que necesitamos trabajar en la función. Si no, deberı́amos desarrollar funciones
auxiliares que implementen dichas operaciones. Finalmente, una vez que tengamos desarrollada la
función, necesitamos verificar si efectivamente realiza el cálculo esperado (para el cual efectivamente
implementamos la función). Esto puede evidenciar errores de sintaxis, errores de ejecución, o incluso
errores de diseño.
Para trabajar apropiadamente, lo mejor es seguir una receta de diseño, esto es, una descripción
paso a paso de qué es lo que tenemos que hacer y en qué orden. Basándonos en lo que hemos visto
hasta ahora, el desarrollo de un programa requiere al menos las siguientes cuatro actividades:
En las siguientes secciones estudiaremos en detalle cada una de estas cuatro actividades.
www.htdp.org
16
3.1. ENTENDER EL PROPÓSITO DE LA FUNCIÓN 17
El contrato consiste en dos partes: la primera, a la izquierda de los dos puntos especifica el nombre
de la función; la segunda, a la derecha de los dos puntos, especifica qué tipo de datos consume y qué es
lo que produce. Los tipos de valores de entrada se separan de los de salida por una flecha. En el caso
de nuestro ejemplo el tipo de datos que consume es de tipo numérico, es decir, puede ser de tipo entero
(int) o real (float), por lo que lo representamos con la palabra num. El valor que se producetambién
es de tipo numérico, dado que es de tipo entero si es que ambos datos de entrada son enteros, o es de
tipo real si es que al menos uno de los datos de entrada es un número real. En general, representaremos
los tipos de datos que conocemos hasta el momento como sigue (se irán agregando otros a lo largo del
curso):
Por ejemplo, para la función areaAnillo del capı́tulo anterior, su contrato es:
Una vez que tenemos especificado el contrato, podemos agregar el encabezado de la función. Éste
reformula el nombre de la función y le da a cada argumento un nombre distintivo. Estos nombres
son variables y se denominan los parámetros de la función. Miremos con más detalle el contrato y el
encabezado de la función areaRectangulo:
Finalmente, usando el contrato y los parámetros, debemos formular un propósito para la función,
esto es, un comentario breve sobre qué es lo que la función calcula. Para la mayorı́a de nuestras
funciones, basta con escribir una o dos lı́neas; en la medida que vayamos desarrollando funciones y
programas cada vez más grandes, podremos llegar a necesitar agregar más información para explicar el
propósito de una función. Ası́, hasta el momento llevamos lo siguiente en la especificación de nuestra
función:
En cualquier función que desarrollemos, nos debemos asegurar que al menos calcula efectivamente
el valor esperado para los ejemplos definidos en el encabezado. Para facilitar el testeo, podemos hacer
uso del comando assert de Python para definir un caso de uso y compararlo con el valor esperado.
Ası́, por ejemplo, si queremos probar que un valor calculado de la función es igual a uno que
calculamos manualmente, podemos proceder como sigue:
1 assert areaRectangulo (5 , 3) == 15
En este caso, le indicamos a Python que evalúe la aplicación de la función areaRectangulo con los
parámetros 5 y 3, y verifique si el resultado obtenido es efectivamente 15. Si ese es el caso, la función
se dice que pasa el test. En caso contrario, Python lanza un error y es entonces indicio que debemos
verificar con detalle nuestra función. Por ejemplo:
combinaciones de valores de entrada para pasar como parámetros. Sin embargo, el testeo es una
estrategia muy potente para verificar errores de sintaxis o de diseño en la función.
Para los casos en los que la función no pasa un test, debemos poner especial atención a los ejemplos
que especificamos en el encabezado. En efecto, es posible que los ejemplos estén incorrectos, que la
función tenga algún tipo de error, o incluso que tanto los ejemplos como la función tengan errores.
En cualquier caso, deberı́amos volver a revisar la definición de la función siguiendo los cuatro pasos
anteriores.
Notemos que podemos formular el cuerpo de la función únicamente si entendemos cómo la función
calcula el valor de salida a partir del conjunto de valores de entrada. Ası́, si la relación entre las
entradas y la salida están dadas por una fórmula matemática, basta con traducir esta expresión a
Python. Si por el contrario nos enfrentamos a un problema verbal, debemos construir previamente la
secuencia de pasos necesaria para formular la expresión.
En nuestro ejemplo, para resolver el problema basta con evaluar la expresión largo * ancho para
obtener el área del rectángulo. Ası́, la traducción en Python de este proceso serı́a:
Módulos y Programas1
En general, un programa consta no sólo de una, sino de muchas definiciones de funciones. Por ejemplo,
si retomamos el ejemplo del anillo que vimos en el capı́tulo 2, tenemos dos funciones: una para calcular
el área de un cı́rculo (areaCirculo) y una para calcular el área del anillo propiamente tal (areaAnillo).
En otras palabras, dado que la función areaAnillo retorna el valor que queremos en nuestro programa,
decimos que es la función principal. De igual manera, dado que la función areaCirculo apoya a la
función principal, decimos que es una función auxiliar.
El uso de funciones auxiliares hace que el diseño de programas sea más manejable, y deja finalmente
al código más limpio y entendible de leer. Por ejemplo, consideremos las siguientes dos versiones para
la función areaAnillo:
Para un programa pequeño como el que hemos visto en el ejemplo, las diferencias entre ambos estilos
de diseño de funciones son menores, aun cuando bastante significativas. Sin embargo, para programas
o funciones más grandes, el usar funciones auxiliares no se vuelve una opción, sino una necesidad. Esto
es, cada vez que se nos pida escribir un programa, debemos considerar el descomponerlo en funciones,
y éstas a su vez descomponerlas en funciones auxiliares hasta que cada una de ellas resuelva UNO Y
SÓLO UN SUBPROBLEMA particular.
1 Parte de este texto fue traducido al español y adaptado de: M. Felleisen et al.: How to Design Programs, MIT Press.
20
4.1. DESCOMPONER UN PROGRAMA EN FUNCIONES 21
“Una importante cadena de cines de Santiago tiene completa libertad en fijar los precios de las
entradas. Claramente, mientras más cara sea la entrada, menos personas estarán dispuestas a pagar
por ellas. En un reciente estudio de mercado, se determinó que hay una relación entre el precio al que
se venden las entradas y la cantidad de espectadores promedio: a un precio de $5.000 por entrada, 120
personas van a ver la pelı́cula; al reducir $500 en el precio de la entrada, los espectadores aumentan
en 15. Desafortunadamente, mientras más personas ocupan la sala para ver la pelı́cula, más se debe
gastar en limpieza y mantenimiento general. Para reproducir una pelı́cula, el cine gasta $180.000.
Asimismo, se gastan en promedio $40 por espectador por conceptos de limpieza y mantenimiento. El
gerente del cine le encarga determinar cuál es la relación exacta entre las ganancias y el precio de las
entradas para poder decidir a qué precio se debe vender cada entrada para maximizar las ganancias
totales.”
Si leemos el problema, está clara cuál es la tarea que nos piden. Sin embargo, no resulta del todo
evidente el cómo hacerlo. Lo único de lo que podemos estar seguros es que varias cantidades dependen
entre sı́.
Cuando nos vemos enfrentados a estas situaciones, lo mejor es identificar las dependencias y ver
las relaciones una por una:
Los gastos están formados por dos ı́temes: un gasto fijo ($180.000) y un gasto variable que
depende del número de espectadores.
Finalmente, el enunciado del problema también especifica cómo el número de espectadores
depende del precio de las entradas.
Definamos, pues, una función por cada una de estas dependencias; después de todo, las funciones
precisamente calculan cómo distintos valores dependen de otros. Siguiendo la receta de diseño que
presentamos en el capı́tulo anterior, comenzaremos definiendo los contratos, encabezados y propósitos
para cada una de las funciones:
Antes de escribir cualquier lı́nea de código siga la receta de diseño para cada función:
formule el contrato, encabezado y propósito de la función, plantee ejemplos de uso
relevantes y formule casos de prueba para verificar que su función se comportará
correctamente.
Una vez escritas las formulaciones básicas de las funciones y al haber calculado a mano una serie
de ejemplos de cálculo, podemos reemplazar los puntos suspensivos ... por expresiones de Python.
En efecto, la función ganancias calcula su resultado como la diferencia entre los resultados arrojados
por las funciones ingresos y gastos, tal como lo sugiere el enunciado del problema y el análisis de
las dependencias que hicimos anteriormente. El cálculo de cada una de estas funciones depende del
precio de las entradas (precioEntrada), que es lo que indicamos como parámetro de las funciones.
Para calcular los ingresos, primero calculamos el número de espectadores para precioEntrada y lo
multiplicamos por precioEntrada. De igual manera, para calcular los gastos sumamos el costo fijo
al costo variable, que corresponde al producto entre el número de espectadores y 40. Finalmente, el
cálculo del número de espectadores también se sigue del enunciado del problema: podemos suponer
una relación lineal entre el número de espectadores y el valor de la entrada. Ası́, 120 espectadores están
dispuestos a pagar $5.000, mientras que cada $500 que se rebajen del precio, vendrán 15 espectadores
más.
Si bien es cierto que podrı́amos haber escrito directamente la expresión para calcular el número
de espectadores en todas las funciones, esto es altamente desventajoso en el caso de querer modificar
una parte en la definición de la función. De igual manera, el código resultante serı́a completamente
ilegible. Ası́, formulamos la siguiente regla que debemos seguir junto con la receta de diseño:
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
4.2. MÓDULOS 23
De igual manera, en ocasiones podemos encontrarnos con valores que se repiten varias veces en
una misma función o programa. Claramente, si queremos modificar su valor, no nos gustarı́a tener
que modificarlo en cada una de las lı́neas en que aparece. Luego, lo recomendable es que sigamos una
definición de variable, en la que asociamos un identificador con un valor (de la misma manera que
a una variable le asociamos el resultado de una expresión). Por ejemplo, podemos asociarle el valor
3.14 a una variable de nombre PI para referirnos al valor de π en todas las lı́neas que necesitemos en
nuestro programa. Ası́:
PI = 3.14
Luego, cada vez que nos refiramos a PI, el intérprete reemplazará el valor por 3.14.
El usar nombres para las constantes hace más entendible el código para identificar dónde se
reemplazan distintos valores. De igual manera, el programa se vuelve más mantenible en el caso
de necesitar modificar el valor de la constante: sólo lo cambiamos en la lı́nea en que hacemos la
definición, y este cambio se propaga hacia abajo cada vez que se llama al identificador. En caso
contrario, deberı́amos modificar a mano cada una de las lı́neas en que escribimos directamente el valor.
4.2 Módulos
La programación modular es una técnica de diseño que separa las funciones de un programa en
módulos, los cuales definen una finalidad única y contienen todo lo necesario, código fuente y variables,
para cumplirla. Conceptualmente, un módulo representa una separación de intereses, mejorando la
mantenibilidad de un software ya que se fuerzan lı́mites lógicos entre sus componentes. Ası́, dada una
segmentación clara de las funcionalidades de un programa, es más fácil la búsqueda e identificación de
errores.
Hasta el momento, solo hemos escrito programas en el intérprete de Python, por lo que no podemos
reutilizar el código que hemos generado hasta el momento. Para guardar código en Python, lo debemos
hacer en archivos con extensión .py. Ası́, basta con abrir un editor de texto (como por ejemplo el bloc
de notas), copiar las funciones que deseamos almacenar y guardar el archivo con un nombre adecuado
y extensión .py. Es importante destacar que existen muchas herramientas que destacan las palabras
claves de Python con diferentes colores, haciendo más claro el proceso de escribir código.
Imaginemos ahora que queremos calcular el perı́metro y el área de un triángulo dado el largo de
sus lados. Primero debemos definir la función perimetro que recibe tres parámetros:
4 def perimetro (a ,b , c ):
5 return a + b + c
6 # Test
7 assert perimetro (2 , 3 , 2) == 7
Dado que esta función pertenece a lo que se esperarı́a fueran las funcionalidades disponibles de un
triángulo, crearemos un módulo que la almacene, cuyo nombre será triangulo. Ası́, abriremos un
archivo con nombre triangulo.py y copiaremos nuestra función dentro de él.
Luego, solo nos queda definir la función de área. Sabemos que el área de un triángulo puede
calcularse en función de su semiperı́metro, representado por p, que no es más que la mitad del perı́metro
de un triángulo. La relación entre área y semiperı́metro de un triángulo de lados a, b y c está dada
por la siguiente fórmula:
p
A = p ∗ (p − a) ∗ (p − b) ∗ (p − c)
Para traducir esta fórmula en una función ejecutable necesitamos la función raı́z cuadrada, que
está incluida en el módulo math de Python. Para importar un módulo externo debemos incluir la
siguiente lı́nea en nuestro módulo triángulo: import math, que literalmente significa importar un
módulo externo para ser usado en un programa. Para utilizar una función de un módulo, la notación a
usar es modulo.funcion(...). Luego, si la función raı́z cuadrada del módulo math de Python se llama
sqrt y toma un parámetro, podemos definir la función area de un triángulo de la siguiente manera:
1 import math
2
3 # perimetro : num num num -> num
4 # calcula el perimetro de un triangulo de lados a , b , y c
5 # ejemplo : perimetro (2 , 3 , 2) devuelve 7
6 def perimetro (a ,b , c ):
7 return a + b + c
8
9 # Test
10 assert perimetro (2 , 3 , 2) == 7
11
12
13 # area : num num num -> float
14 # calcula el area de un triangulo de lados a ,b , y c
I
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
4.3. PROGRAMAS INTERACTIVOS 25
Para pedir datos al usuario, Python provee dos funciones: input y raw_input. La primera de ellas,
input, recibe como parámetro un mensaje de tipo texto para el usuario y recupera el dato ingresado.
Esto lo podemos ver en el siguiente ejemplo, en el cual se le pide al usuario ingresar un número:
La otra función para ingresar datos disponible en Python, raw_input, tiene un comportamiento
similar, con la excepción de que todo valor ingresado se almacenará con tipo texto. Esto se ve en el
siguiente código:
Ası́, primero que nada, debemos importar el módulo que creamos con la palabra clave import:
1 import triangulo
Luego, debemos preguntar por el largo de cada lado del triángulo y almacenarlos en variables cuyos
nombres sean representativos, como se muestra a continuación:
1 import triangulo
2
3 print ’ Calcular el area y perimetro de un triangulo ’
4 l1 = input ( ’ Ingrese el largo del primer lado ’)
5 l2 = input ( ’ Ingrese el largo del segundo lado ’)
6 l3 = input ( ’ Ingrese el largo del tercer lado ’)
7
8 print ’ El perimetro del triangulo es ’ , triangulo . perimetro ( l1 , l2 , l3 )
9 print ’ El area del triangulo es ’ , triangulo . area ( l1 , l2 , l3 )
Ahora que tenemos listo nuestro programa, podemos guardarlo en un archivo .py y ejecutarlo cada
vez que necesitemos calcular el área y perı́metro de un triángulo cualquiera (suponiendo que los valores
entregados corresponden a un triángulo válido).
Para terminar, una manera alternativa de importar una función de un módulo es ocupar la
instrucción:
Expresiones y Funciones
Condicionales1
En general, los programas deben trabajar con distintos datos en distintas situaciones. Por ejemplo, un
videojuego puede tener que determinar cuál es la velocidad de un objeto en un rango determinado, o
bien cuál es su posición en pantalla. Para un programa de control de maquinaria, una condición puede
describir en qué casos una válvula se debe abrir. Para manejar condiciones en nuestros programas,
necesitamos una manera de saber si esta condición será verdadera o falsa. Ası́, necesitamos una
nueva clase de valores, los que, por convención, llamamos valores booleanos (o valores de verdad). En
este capı́tulo veremos los valores de tipo booleano, expresiones que se evalúan a valores booleanos, y
expresiones que calculan valores dependiendo del valor de verdad de una evaluación.
“Genera S.A. le paga $4.500 por hora a todos sus ingenieros de procesos recién egresados. Un empleado
tı́picamente trabaja entre 20 y 65 horas por semana. La gerencia de informática le pide desarrollar un
programa que calcule el sueldo de un empleado a partir del número de horas trabajadas si este valor
está dentro del rango apropiado.”
Las palabras en cursiva resaltan qué es lo nuevo respecto al problema que presentamos en el capı́tulo
de Funciones. Esta nueva restricción implica que el programa debe manipular al valor de entrada de
una manera si tiene una forma especı́fica, y de otra manera si no. En otras palabras, de la misma
manera que las personas toman decisiones a partir de ciertas condiciones, los programas deben ser
capaces de operar de manera condicional.
Las condiciones no deberı́an ser nada nuevo para nosotros. En matemática, hablamos de
proposiciones verdaderas y falsas, las que efectivamente describen condiciones. Por ejemplo, un número
puede ser igual a, menor que, o mayor que otro número. Ası́, si x e y son números, podemos plantear
las siguientes tres proposiciones acerca de x e y:
1. x = y: “x es igual a y”;
29
5.1. VALORES BOOLEANOS 30
Para cualquier par de números (reales), una y sólo una de estas tres proposiciones es verdadera.
Por ejemplo, si x = 4 y y = 5, entonces la segunda proposición es verdadera y las otras son falsas. Si
x = 5 y y = 4, entonces la tercera es verdadera y las otras son falsas. En general, una proposición es
verdadera para ciertos valores de variables y falsa para otros.
Además de determinar si una proposición atómica es verdadera o falsa en algún caso, a veces
resulta importante determinar si la combinación de distintas proposiciones resulta verdadera o falsa.
Consideremos las tres proposiciones anteriores, las que podemos combinar, por ejemplo, de distintas
maneras:
1. x = y y x < y y x > y;
2. x = y o x < y o x > y;
3. x = y o x < y.
La primera proposición compuesta es siempre falsa, pues dado cualquier par de números (reales)
para x e y, dos de las tres proposiciones atómicas son falsas. La segunda proposición compuesta es,
sin embargo, siempre verdadera para cualquier par de números (reales) x e y. Finalmente, la tercera
proposición compuesta es verdadera para ciertos valores y falsa para otros. Por ejemplo, es verdadera
para x = 4 y y = 4, y para x = 4 y y = 5, mientras que es falsa si x = 5 y y = 3.
Al igual que en matemática, Python provee comandos especı́ficos para representar el valor de verdad
de proposiciones atómicas, para representar estas proposiciones, para combinarlas y para evaluarlas.
Ası́, el valor lógico verdadero es True, y el valor falso se representa por False. Si una proposición
relaciona dos números, esto lo podemos representar usando operadores relacionales, tales como: ==
(igualdad), > (mayor que), y < (menor que).
Traduciendo en Python las tres proposiciones matemáticas que definimos inicialmente, tendrı́amos
lo siguiente:
1. x == y: “x es igual a y”;
Además de los operadores anteriores, Python provee como operadores relacionales: <= (menor o
igual que), >= (mayor o igual que), y != (distinto de).
Una expresión de Python que compara números tiene un resultado, al igual que cualquier otra
expresión de Python. El resultado, sin embargo, es True o False, y no un número. Ası́, cuando una
proposición atómica entre dos números es verdadera, en Python se evalúa a True. Por ejemplo:
1 >>> 4 < 5
2 -> True
De igual manera, una proposición falsa se evalúa a False:
1 >>> 4 == 5
2 -> False
Para expresar condiciones compuestas en Python usaremos tres conectores lógicos: and (conjunción
lógica: “y”), or (disyunción lógica: “o”) y not (negación: “no”). Por ejemplo, supongamos que
queremos combinar las proposiciones atómicas x == y y y < z, de tal manera que la proposición
compuesta sea verdadera cuando ambas condiciones sean verdaderas. En Python escribirı́amos:
1 x == y and y < z
para expresar esta relación. De igual manera, si queremos formular una proposición compuesta que
sea verdadera cuando (al menos) una de las proposiciones sea verdadera, escribimos:
1 x == y or y < z
Finalmente, si escribimos algo como:
1 not x == y
lo que estamos indicando es que deseamos que la negación de la proposición sea verdadera.
Las condiciones compuestas, al igual que las condiciones atómicas, se evalúan a True o False.
Consideremos por ejemplo la siguiente condición compuesta: 5 == 5 and 5 < 6. Note que está
formada por dos proposiciones atómicas: 5 == 5 y 5 < 6. Ambas se evalúan a True, y luego, la
evaluación de la compuesta se evalúa a: True and True, que da como resultado True de acuerdo a las
reglas de la lógica proposicional. Las reglas de evaluación para or y not siguen el mismo patrón.
En las siguientes secciones veremos por qué es necesario formular condiciones para programar y
explicaremos cómo hacerlo.
De igual manera, si queremos operar con condiciones más complejas sobre números, un primer paso
puede ser determinar los rangos de definición en la recta numérica, y luego definir una función sobre
los valores que se pueden tomar en el (los) intervalo(s). Por ejemplo, la siguiente función determina si
un número está entre 5 o 6, o bien es mayor que 10:
Esto es, cualquier número entre 5 y 6 sin incluirlos, o bien, cualquier número mayor o igual que 10.
En este caso, desarrollamos una condición compuesta componiendo los distintos trozos que definen al
intervalo donde la función se debe evaluar a verdadero.
5.3 Condiciones
Imaginemos que queremos crear un programa que juegue al cachipún con el usuario, pero que siempre
le gane, independiente de lo que éste le entregue. Esto significa que debemos diseñar e implementar
un programa que, dada una jugada del usuario, entregue la jugada que le gana según las reglas
del cachipún. Para esto, en las dos secciones siguientes veremos las expresiones condicionales y las
instrucciones que provee Python para crear funciones con este tipo de expresiones.
En particular para Python, la traducción de estas expresiones está dada de la siguiente manera:
1 if pregunta :
2 respuesta
Al ser ejecutada, se verifica si el resultado de la evaluación de la pregunta es verdadero o falso. En
Python, esto quiere decir si el valor evaluado es igual a True o False. Por ejemplo, imaginemos que
tenemos dos variables de tipo numérico y queremos saber si son iguales. Si las variables se llaman x e
y, podemos mostrar en pantalla al usuario si es que esta condición es verdadera:
1 if x == y :
2 print ’ Son iguales ! ’
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
5.3. CONDICIONES 33
Una expresión condicional puede estar compuesta de más de una pregunta asociada a una respuesta.
En el ejemplo anterior, podrı́amos además decir cuál de las dos variables representa al número mayor.
Ası́, las expresiones condicionales también pueden ser de la forma:
En Python, para modelar este tipo de expresiones podemos utilizar las instrucciones elif y else,
como se muestra a continuación:
1 if pregunta :
2 respuesta
3 elif pregunta :
4 respuesta
5 ...
6 elif pregunta :
7 respuesta
O:
1 if pregunta :
2 respuesta
3 elif pregunta :
4 respuesta
5 ...
6 else :
7 respuesta
Al igual que en las expresiones condicionales, los tres puntos indican que las expresiones if pueden
tener más de una condición. Las expresiones condicionales, como ya hemos visto, se componen de dos
expresiones pregunta y una respuesta. La pregunta es una expresión condicional que al ser evaluada
siempre debe entregar un valor booleano, y la respuesta es una expresión que sólo será evaluada si es
que la condición asociada a esta se cumple.
1 if x == y :
2 print ’ Son iguales ! ’
3 elif x > y :
4 print ’x es mayor que y ’
5 elif y > x :
6 print ’y es mayor que x ’
O:
1 if x == y :
2 print ’ Son iguales ! ’
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
5.3. CONDICIONES 34
3 elif x > y :
4 print ’x es mayor que y ’
5 else :
6 print ’y es mayor que x ’
Cuando se evalúa una expresión condicional completa, esto se hace en orden, evaluando cada
pregunta, o condición, una por una. Si una pregunta se evalúa como verdadero, entonces la respuesta
asociada a esa pregunta se evaluará y será el resultado de la expresión condicional completa. Si no
es ası́, entonces se continuará con la evaluación de la siguiente pregunta y ası́ sucesivamente hasta
que alguna de las condiciones se cumpla. Esto quiere decir que para el ejemplo anterior, primero se
evaluará la primera pregunta (x == y) y si esta se cumple, se mostrará en consola el mensaje ’Son
iguales!’. Si es que no se cumple, entonces seguirá con la siguiente instrucción elif y evaluará su
pregunta asociada, x > y, imprimiendo en pantalla si es que esta condición se cumple. Si no, evaluará
la última pregunta e imprimirá el mensaje.
Aunque las expresiones del ejemplo anterior tienen una sintaxis algo diferente, ambas son
equivalentes. Podemos notar que la expresión de la derecha está formada solamente con instrucciones
if y elif, lo que significa que se evalúan las tres condiciones posibles de nuestro ejemplo de manera
explı́cita. Mientras que la expresión de la derecha utiliza la instrucción else, la cual indica que su
respuesta será evaluada sólo si ninguna de las preguntas anteriores se evalúa como verdadera.
Volvamos a nuestro ejemplo del cachipún en el cual el usuario siempre pierde. Para diseñar un
programa que determine la jugada ganadora dada una entrada del usuario, debemos identificar las tres
situaciones posibles, resumidas a continuación:
Luego, el programa completo consta de tres partes principales: (i) pedir al usuario la jugada a
ingresar; (ii) identificar la jugada que le ganará a la ingresada por el jugador humano; y por último
(iii) mostrarla en pantalla. La segunda parte estará definida en una función que, dada una entrada,
entregue como resultado la jugada ganadora. Ası́, siguiendo la receta de diseño, debemos, primero que
todo, escribir su contrato y formular su propósito:
1 # jaliscoCachipun : str -> str
2 # entrega la jugada ganadora del cachipun dada una entrada valida
3 def jaliscoCachipun ( jugada ):
4 ...
Luego, debemos agregar un ejemplo de la función:
Ahora que nuestra función está completa, podemos usarla para jugar con el usuario:
indentación más que la cláusula if están subordinadas a esta, y se dice que forman un bloque de
código. Un bloque de código sólo será evaluado si es que condición asociada se cumple.
1 >>> if x == y :
2 ... print ’ son iguales ! ’
Para funciones numéricas, una buena estrategia es dibujar una recta numérica e identificar los
intervalos correspondientes a la situación particular a estudiar. Imaginemos que queremos implementar
un programa que retorne el saludo correspondiente a la hora del dı́a. Ası́, si son más de las 1 de la
mañana y menos de las 12 de la tarde, el programa responderá ‘Buenos dı́as!’; si menos de las 21 horas,
el mensaje será ‘Buenas tardes!’; y si es más de las 21, entonces el programa deseará las buenas noches.
Consideremos el contrato de esta función:
Para nuestra función saludo, deberı́amos usar 1, 12, y 21 como casos de borde. Además,
deberı́amos escoger números como 8, 16, y 22 para probar el comportamiento al interior de cada
uno de los tres intervalos.
1 if (...):
2 ...
3 elif (...):
4 ...
5 elif (...):
6 ...
Luego formulamos las condiciones para describir cada una de las situaciones. Las condiciones
son proposiciones sobre los parámetros de la función, expresados con operadores relacionales o con
funciones hechas por nosotros mismos.
Las lı́neas de nuestro ejemplo se completa para traducirse en las siguientes tres condiciones:
Agregando estas condiciones a la función, tenemos una mejor aproximación de la definición final:
cumple.
En nuestro ejemplo, los resultados son especificados directamente del enunciado del problema.
Estos son ’Buenos dias!’, ’Buenas tardes!’, y ’Buenas noches!’. En ejemplos más complejos,
debe ser el programador quien determina la expresión la respuesta de cada condición, puesto que no
siempre están descritas de manera tan explı́cita. Estas se pueden contruir siguiendo los pasos de la
receta de diseño que hemos aprendido hasta ahora.
1 def saludo ( hora ):
2 if (1 <= hora ) and ( hora < 12):
3 return ’ Buenos dias ! ’
4 elif (12 <= hora ) and ( hora < 21):
5 return ’ Buenas tardes ! ’
6 elif (21 <= hora ):
7 return ’ Buenas noches ! ’
hora <= 12
Más aún, sabemos que las expresiones if son evaluadas secuencialmente. Esto es, cuando la segunda
condición es evaluada, la primera ya debe haber producido False. Por lo tanto sabemos que en la
segunda condicional la cantidad no es menor o igual a 12, lo que implica que su componente izquierda
es innecesaria. La definición completa y simplificada de la función saludo se describe como sigue:
¿Serı́a correcto el ejemplo? En realidad, no lo es. Si revisan los intervalos en la figura, se van a
dar cuenta que el programa devuelve un saludo equivocado para la 12, entre otros problemas. ¡Esto
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
5.5. DISEÑAR FUNCIONES CONDICIONALES 39
Recursión
Muchas veces nos tocará enfrentarnos con definiciones que dependen de sı́ mismas. En particular, en
programación se habla de funciones y estructuras recursivas cuando su definición depende de la misma
definición de éstas. En este capı́tulo veremos un par de ejemplos de funciones recursivas.
24 = 2 · 24−1
= 2 · 23
= 2 · (2 · 22 )
= 2 · (2 · (2 · 21 ))
= 2 · (2 · (2 · (2 · 20 )))
= 2 · (2 · (2 · (2 · 1)))
= 16
Una función que se define en términos de sı́ misma es llamada función recursiva.
La segunda parte de la definición, a la cual llamaremos caso recursivo, es la que hace uso de su
propia definición para continuar la evaluación hasta llegar al caso base.
40
6.1. POTENCIAS, FACTORIALES Y SUCESIONES 41
n si 0 ≤ n ≤ 1
Fn =
Fn−1 + Fn−2 si n > 1
La implementación en Python de una función que calcula el enésimo número de Fibonacci es la
siguiente:
1 # fibonacci : int -> int
2 # calcula el n - esimo numero de la sucesion de fibonacci
3 # ejemplo : fibonacci (7) devuelve 13
4 def fibonacci ( n ):
5 if n < 2:
6 # caso base
7 return n
8 else :
9 # caso recursivo
10 return fibonacci ( n - 1) + fibonacci ( n - 2)
11
12 # Test
13 assert fibonacci (0) == 0
14 assert fibonacci (1) == 1
15 assert fibonacci (7) == 13
Las Torres de Hanoi es el nombre de un puzzle matemático que consiste en mover todos los discos
de una vara a otra, bajo ciertas restricciones. El juego consta de una plataforma con tres varas y n
discos puestos en orden decreciente de tamaño en una de ellas. El objetivo del juego es mover todos
los discos de una vara a la otra, de forma que al final se mantenga el mismo orden.
Nos interesa saber cuántos movimientos son necesarios para resolver el juego.
Solución
La clave para resolver el puzzle no está en determinar cuáles son los movimientos a realizar, sino en
que el juego puede ser descompuesto en instancias más pequeñas. En el caso de las Torres de Hanoi,
el problema está en mover n discos. Por lo tanto, veamos una forma de resolver el problema de forma
de tener que resolver el juego con n − 1 discos, y volvamos a aplicar el procedimiento hasta mover
todos los discos.
El objetivo del juego es mover la pila completa de una vara a la otra. Por lo que, inicialmente, lo
único a lo que podemos apuntar a lograr es a trasladar el disco más grande de su vara a otra, y no nos
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
6.2. TORRES DE HANOI 43
queda otra opción que mover todos los discos restantes de su vara a otra.
Supongamos que ya tenemos una función hanoi(n) que nos dice cuántos movimientos hay que
realizar para mover n discos de una vara a otra. Esa función es la que queremos definir, ¡pero al
mismo tiempo la necesitamos para resolver el problema! En el ejemplo de la figura necesitamos 15
movimientos para resolver el puzzle.
Para mover el disco más grande de una vara a otra, necesitamos mover los n − 1 discos anteriores
a otra vara, lo cual nos toma hanoi(n-1) movimientos.
Luego, debemos mover el disco más grande de su vara a la desocupada, esto nos toma 1
movimiento.
A continuación, debemos volver a mover los n − 1 discos restantes para que queden encima del
disco grande que acabamos de mover. Esto nuevamente nos toma hanoi(n-1) movimientos.
En total, necesitamos 2× hanoi(n-1) +1 movimientos para n discos.
¿Cuál es el caso base? Si tenemos 1 disco, sólo debemos moverlo de su vara a la otra para completar
el juego.
Figura 6.1: Mover los n − 1 primeros discos, recursivamente hacia la segunda vara.
Ahora que entendimos el propósito y la solución del juego, podemos escribirla en Python:
Figura 6.3: Volver a mover los primeros n − 1 discos, recursivamente hacia la tercera vara.
12
13 # Test
14 assert hanoi (1) == 1
15 assert hanoi (4) == 15
16 assert hanoi (5) == 31
Se puede apreciar que la solución de Hanoi sigue un patrón especial. De hecho, la solución a la
ecuación de recurrencia h(n) = 2 · h(n − 1) + 1 es h(n) = 2n − 1, por lo que si hubiéramos llegado a
ese resultado, podrı́amos utilizar la función potencia para resolver el problema.
El objetivo es describir el contorno de la figura hasta cierto nivel (puesto que el perı́metro de la
figura final es infinito). Para esto, es necesario describir un poco más en detalle la figura.
¿Cómo podemos describir su contorno de manera recursiva? No es difı́cil observar que al generar
la figura, al estar parados en algún triángulo de alguna iteración, a 1/3 del lado de distancia de un
vértice comenzamos a generar ¡la misma figura! El caso base lo debemos definir nosotros, puesto que
sin él, la figura se irá generando indefinidamente.
Para programar nuestro copo de nieve en Python, utilizaremos el módulo Turtle que viene incluido
en el lenguaje. El módulo Turtle provee varias funciones que dibujan en la pantalla. Conceptualmente,
se trata de una tortuga robótica que se mueve en la pantalla marcando una lı́nea a su paso. Algunas
de las funciones provistas son:
turtle.forward(size): Se mueve size pixeles en su dirección.
turtle.left(angle), turtle.right(angle): La tortuga gira a la izquierda o a la derecha,
respectivamente, dependiendo de su sentido, en angle grados.
turtle.speed(speed): Se establece la velocidad de la torturga. El parámetro speed = 0 indica
que se mueve a la máxima velocidad.
turtle.done(): Se le indica que se han terminado las instrucciones para la tortuga.
Con estas funciones podemos indicarle cómo dibujar un fractal. Sin importar dónde comencemos,
debemos dibujar un triángulo equilátero y al avanzar 1/3 de su lado, se dibuja un fractal nuevamente.
La Figura 6.6 muestra cómo deberı́a quedar nuestra implementación.
Analicemos el proceso de dibujar el copo de nieve. Observe que el copo de nieve se trata de dibujar
un triángulo equilátero, por lo que podemos dividir el problema en dibujar sólo un lado (puesto que
los otros dos son iguales, salvo por el ángulo de donde viene). Supongamos que tenemos una función
snowflakeque dibuja los tres lados. Cada lado debe ser dibujado usando la curva de Koch.
La función que dibuja cada lado la llamaremos koch, que representará la curva de Koch. Esta
función debe dibujar cada lado del trı́angulo actual. Esta función deberı́a recibir el lado del triángulo
inicial y el caso base, es decir, el tamaño mı́nimo a partir del cual no debe continuar la recursión.
Llamemos a estos parámetros size y min_size, respectivamente.
Una implementación posible es la siguiente:
Sin embargo, tiene un error. Si dibujamos esta curva tres veces y creamos el triángulo con
snowflake, resultará en lo que se puede apreciar en la Figura 6.7. Al separar nuestra función en
dos, una que dibuja un lado y la otra que usa la primera para dibujar los tres lados, hemos perdido
información. En particular, los vértices de los triángulos que se forman indirectamente al juntar las
tres curvas (que son iguales al primero, al ser equiláteros del mismo tamaño) no generan sub-triángulos
y no se forma la figura del copo de nieve. Para esto debemos generar más sub-triángulos incluso en
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
6.3. COPO DE NIEVE DE KOCH 47
esos vértices.
Figura 6.7: Primer intento del copo de nieve, juntando las tres curvas de Koch de la primera
implementación.
Para solucionar este problema, modifiquemos nuestro algoritmo para que sólo dibuje una lı́nea recta
cuando llegamos al lı́mite del tamaño:
1 import turtle
2
3 # koch : int int -> None
I
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
6.4. RECETA DE DISEÑO PARA LA RECURSIÓN 48
Tal como vimos en aquel capı́tulo, es necesario probar que la función que definamos cumpla con el
contrato estipulado. Estas pruebas deben asegurar con suficiente certeza de que la función cumple su
objetivo de acuerdo a los parámetros ingresados. En este punto el contrato es muy importante, ya que
especifica los tipos de datos y sus dominios que serán considerados dentro de la función. No es factible
probar todos los posibles parámetros de una función, pero el contrato disminuye considerablemente
estas opciones. De los casos restantes, debemos escoger sólo los casos más representativos.
1 assert potencia (2 , 4) == 16
2 assert potencia (1.5 , 3) == 3.375
3 assert potencia (10 , 3) == 1000
Sin embargo, tal como definimos potencia, tanto en código como matemáticamente, hay casos
llamados de borde, es decir, extremos dentro del dominio de datos que acepta, que serı́an relevantes
50
7.1. AFIRMACIONES (ASSERTIONS) 51
de usar en los tests. En el caso de la potencia, la definición cambia de acuerdo al valor del exponente,
por lo que el caso de borde serı́a un buen test para nuestra función:
1 assert True
2 assert 10 < 12
3 assert a == b and ( c < d or a < b )
Cuando la condición se evalúa a True, la afirmación no hace nada y el programa puede continuar.
Cuando la condición evalúa a False (es decir, si no se cumple), la afirmación arroja un error y el
programa termina:
El uso de distintos operadores lógicos para assert nos ayuda a escribir mejores tests. Observe que
el uso de más operadores no es una caracterı́stica especial de la afirmación, sino que corresponde a que
la condición que se pasa es una expresión, y en ella se puede usar cualquier operador booleano.
En particular, podemos utilizar operadores no sólo de igualdad, sino también de comparación (< o
>, etc). Por ejemplo, suponga que tenemos una función que calcula aleatoriamente un número entre 1
y 10. ¿Cómo hacemos un test para eso, si cada evaluación de la función resultará en un valor distinto?
En este caso, la observación clave está en que no nos importa qué valor tome la función, sino el rango
de valores. Por ejemplo:
1 import random
2 # escogeAlAzar : -> int
3 # Devuelve un numero al azar entre 1 y 10
4 def escogeAlAzar ():
5 # random . random () retorna un numero al azar entre 0 y 1
6 return int (10 * random . random ()) + 1
7
8 # Test
9 assert escogeAlAzar () <= 10
10 assert escogeAlAzar () >= 1
Si nuestra función retorna de un tipo booleano, no es necesario indicar la igualdad. Si tenemos una
función esPar que retorna True si su argumento es un entero par, y False si no, puede ser probada
de la siguiente forma:
Estos números están sujetos a errores de precisión. Es decir, algunas operaciones no serán exactas
debido a que no existe una representación para cada número posible. Puede intentar este ejemplo en
el intérprete:
(a0 + e0 ) · (a1 + e1 ) ≈ a0 a1 + a0 e1 + a1 e0
Con esto en mente, ¿cómo podemos estar seguros de que una función que manipule números de
punto flotante está correcta con respecto a nuestras pruebas? Para esto utilizamos una tolerancia que
denotaremos ε, que nos servirá para comparar dentro de un rango de valores.
Implementemos la función distancia euclidiana que calcula la distancia entre dos puntos dados por
sus coordenadas, x0 , y0 , x1 , y1 .
5 dx = ( x1 - x0 )
6 dy = ( y1 - y0 )
7 return ( dx ** 2 + dy ** 2) ** 0.5
Consideremos las siguientes expresiones:
1 # Tests
2 tolerancia = 0.0001
3 assert cerca ( distancia (0 , 0 , 4 , 0) , 4.0 , tolerancia )
4 assert cerca ( distancia (0 , 1 , 1 , 0) , 1.4142 , tolerancia )
Vamos a diseñar una función recursiva para calcular la raı́z cuadrada de un número:
√
Los argumentos de la función recursiva son: el número positivo x, una estimación de x, y
un nivel de precisión epsilon. Para verificar que x es un número positivo utilizaremos una
precondición, que implica agregar un test dentro de la función. En Python esto se puede
implementar con assert.
Caso base: si el cuadrado de la estimación está a distancia epsilon de x, se retorna el valor de
la estimación. Para esto, utilizaremos la función cerca.
Caso recursivo: Se calcula una mejor estimación de acuerdo al método de Heron, y se realiza el
llamado recursivo.
La función recursiva se llamará heron r y será una función auxiliar a la√función heron, que hará
el primer llamado a la función √recursiva con una estimación inicial de x. Nota: el elegir como
estimación un valor cercano a x hace que el programa termine más rápido, pero usar un valor
genérico como 1 también funciona.
Siguiendo la receta de diseño, la función para calcular la raı́z cuadrada usando el método de Heron
queda como sigue:
55
Capı́tulo 8
Datos Compuestos1
Hasta el momento hemos visto únicamente cómo operar con valores simples (esto es, con números,
con strings, y con valores lógicos). Sin embargo, en computación es recurrente el tener que manipular
valores compuestos que corresponden a alguna combinación sobre estos valores simples. Por ejemplo,
supongamos que queremos calcular la suma entre dos fracciones dadas.
Es claro que una primera alternativa para resolver este problema es definir una función sobre cuatro
valores enteros, y operar sobre ellos como es usual en matemática:
a c ad + bc
+ =
b d bd
Ası́ pues, la función que permite resolver este problema es:
1 Parte de este capı́tulo fue traducido al español y adaptado de: M. Felleisen et al.: How to Design Programs, MIT
56
8.2. RECETA DE DISEÑO PARA ESTRUCTURAS 57
Para trabajar con estructuras en este curso, disponemos del módulo estructura.py que contiene
las definiciones básicas para poder crear estructuras.
1 import estructura
2 estructura . crear ( " nombre " , " atributo1 atributo2 ... atributoN " )
En el ejemplo anterior, importamos el módulo estructura y utilizamos la instrucción crear para
crear una nueva estructura. Notemos que este comando recibe dos parámetros: el nombre de la
estructura, y un texto con los distintos atributos que la representan, cada uno separado por un espacio
simple.
Ası́, por ejemplo, para crear una estructura que represente a una fracción, proseguimos de la
siguiente manera:
1 import estructura
2
3 estructura . crear ( " fraccion " , " numerador denominador " )
Finalmente, notemos que podemos acceder directamente a los distintos atributos que definen a la
estructura:
1 >>> a = fraccion (1 , 2)
2 -> fraccion ( numerador =1 , denominador =2)
3 >>> a . numerador
4 -> 1
5 >>> a . denominador
6 -> 2
En la siguiente sección veremos en detalle cómo diseñar apropiadamente una estructura. Luego,
completaremos nuestro ejemplo inicial para ver cómo crear funciones que manipulen estructuras.
En primer lugar, debemos reconocer desde el planteamiento del problema cuáles son las estructuras
que vamos a necesitar. En efecto, la regla a seguir es que cada vez que necesitemos operar sobre un
conjunto de valores relacionados entre sı́, debemos escribir una estructura que los encapsule. Si no
utilizamos estructuras, lo más probable es que perdamos rápidamente la pista de qué valor pertenece
a qué elemento del programa, especialmente cuando tenemos funciones grandes que procesan mucha
información.
En segundo lugar, se deben usar estructuras para organizar las funciones. Para ello, utilizaremos
una plantilla que la acoplaremos a la receta de diseño que ya conocemos. De esta manera, debemos
asegurarnos en todo momento que la definición de la estructura efectivamente se corresponde con la
plantilla. Los pasos a incorporar en la nueva receta de diseño son:
8.2.2 Plantilla
Una función que opera sobre datos compuestos, por lo general opera sobre las componentes de las
estructuras que recibe como entrada. Para poder recordar claramente cuáles son estas componentes,
debemos diseñar una plantilla. Ası́, una plantilla es un encabezado y un cuerpo de función que lista
todas las posibles combinaciones de expresiones que se pueden generar con las entradas de la función.
En otras palabras, una plantilla expresa lo que sabemos sobre las entradas, pero aún no nos dice nada
sobre cómo va a ser la salida de la función. Luego, utilizamos la plantilla para cualquier función que
consuma los mismos tipos de parámetros.
1 import estructura
2
3
4 # Diseno de la estructura
5 # fraccion : numerador ( int ) denominador ( int )
6 estructura . crear ( " fraccion " , " numerador denominador " )
7
8 # Contrato
9 # sumaFracciones : fraccion fraccion -> fraccion
10
11 # Proposito
12 # crear una nueva fraccion que corresponda a la suma
13 # de dos fracciones f1 y f2
14
15 # Ejemplo :
16 # sumaFracciones ( fraccion (1 , 2) , fraccion (3 , 4))
17 # devuelve fraccion (10 , 8)
18
19
20 # Plantilla
21 # def f u n c i o n C o n F r a c c i o n e s ( fraccion1 , fraccion2 ):
22 # ... fraccion1 . numerador ... fraccion2 . numerador ...
23 # ... fraccion1 . numerador ... fraccion2 . denominador ...
I
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
8.2. RECETA DE DISEÑO PARA ESTRUCTURAS 59
Tal como lo vimos en el capı́tulo anterior, una de las maneras para representar información compuesta
es usando estructuras. En efecto, las estructuras son útiles cuando sabemos cuántos datos queremos
combinar. Sin embargo, en muchos otros casos, no sabemos cuántas cosas queremos enumerar, y
entonces formamos una lista. Una lista puede tener un largo arbitrario, esto es, contiene una cantidad
finita pero indeterminada de elementos.
9.1 Listas
Para el manejo de listas, ocuparemos el módulo lista.py, implementado para efectos de este curso.
Para poder ocuparlo, primero hay que importar todas sus funciones:
Ası́, en una lista distinguimos dos campos en su estructura: el valor y la lista siguiente. El
campo valor puede ser de cualquier tipo (básico o compuesto), mientras que el campo siguiente es
precisamente una lista, tal como los eslabones de una cadena. La definición de la estructura para listas
está incluida en el módulo lista.py, note en particular en el contrato de la estructura su definición
recursiva:
1 # Diseno de la estructura
2 # lista : valor ( cualquier tipo ) siguiente ( lista )
3 estructura . crear ( " lista " , " valor siguiente " )
Para crear una lista nueva, el módulo provee la función crearLista que recibe dos parámetros:
el valor del primer elemento de la lista y el resto de la lista. Veamos un ejemplo: supongamos que
queremos formar una lista con los planetas del sistema solar. Primero comenzamos con un eslabón de
la lista que sólo contiene a Mercurio:
1 Traducido al español y adaptado de: M. Felleisen et al.: How to Design Programs, MIT Press. Disponible en:
www.htdp.org
60
9.1. LISTAS 61
crearLista("Mercurio", listaVacia)
En toda lista distinguimos dos elementos: la cabeza y la cola. La cabeza de una lista es el valor
que está al frente de la lista (es decir, el primer valor disponible). La cola de una lista es todo lo que
va encadenado a la cabeza. Ası́, en nuestro último ejemplo, la cabeza de la lista es el string "Tierra",
mientras que la cola es la lista formada por el eslabón (Venus, (Mercurio, (listaVacia))). En
efecto, estos elementos son proporcionados por el módulo:
Supongamos ahora que nos dan una lista de números. Una de las primeras cosas que nos gustarı́a
hacer es sumar los números de esta lista. Más concretamente, supongamos que estamos únicamente
interesados en listas de tres números. Ası́, una listaDeTresNumeros es una lista que se puede crear
con la instrucción crearLista(x, crearLista(y, crearLista(z, listaVacia))), donde x, y, z
son tres números.
Escribamos el contrato, el propósito, el encabezado y ejemplos para una función que sume estos
números siguiendo la receta de diseño:
Sin embargo, al definir el cuerpo de la función nos topamos con un problema. Una lista de la
manera en que la hemos construido es una estructura. Ası́, deberı́ampos proveer una plantilla con
las distintas alternativas que se pueden elegir para construir las expresiones. Por desgracia, aún no
sabemos cómo seleccionar los elementos de una lista. Por otro lado, recordemos que a través de la
función cabeza podemos acceder al primer elemento de una lista, mientras que la función cola nos
entrega el resto. Veamos más en detalle qué nos devuelven las combinaciones de estas operaciones:
Luego, utilizando las funciones cabeza y cola disponibles en el módulo para manejar listas,
podemos escribir la plantilla para sumaTres:
De la sección anterior vimos que representar una lista de juguetes es simple: basta con crear una
lista de strings, encadenando los eslabones uno por uno. La secuencia para formar la lista podrı́a ser,
por ejemplo:
listaVacia
crearLista(pelota, listaVacia)
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
9.3. PROCESAR LISTAS DE LARGO ARBITRARIO 63
Sin embargo, para una tienda real, esta lista de seguro contendrá muchos más elementos, y la
lista crecerá y disminuirá a lo largo del tiempo. Lo cierto es que en ningún caso podremos decir
por adelantado cuántos elementos distintos contendrá la lista. Luego, si quisiéramos desarrollar una
función que consuma tales listas, no podremos simplemente decir que la lista de entrada tendrá uno,
dos o tres elementos.
En otras palabras, necesitamos una definición de datos que precisamente describa la clase de
listas que contenga una cantidad arbitraria de elementos (por ejemplo, strings). Desafortunadamente,
hasta ahora sólo hemos visto definiciones de datos que tienen un tamaño fijo, que tienen un número
determinado de componentes, o listas con una cantidad delimitada de elementos.
Note que todos los ejemplos que hemos desarrollado hasta ahora siguen un patrón: comenzamos
con una lista vacı́a, y empezamos a encadenar elementos uno a continuación del otro. Notemos que
podemos abstraer esta idea y plantear la siguiente definición de datos:
Dado que ya tenemos una descripción rigurosa de qué es lo que se espera de esta función, sigamos
la receta de diseño para escribir su contrato, encabezado y propósito:
1 >>> hayPelotas ( crearLista ( " arco " , crearLista ( " flecha " , \
2 crearLista ( " muneca " , listaVacia ))))
3 -> False
4 >>> hayPelotas ( crearLista ( " soldadito " , crearLista ( " pelota " , \
5 crearLista ( " oso " , listaVacia ))))
6 -> True
El paso siguiente es diseñar la plantilla de la función que se corresponda con la definición del tipo
de datos. En este caso, dado que la definición de una lista de strings tiene dos cláusulas (lista vacı́a o
una lista de strings), la plantilla se debe escribir usando un bloque condicional if:
Ahora que disponemos de la plantilla para la función, podemos escribir el cuerpo de la misma.
Para ello, abordaremos separadamente cada una de las dos ramas de la condición que se definen en la
plantilla:
1. Si la lista está vacı́a, debemos retornar el valor False (pues el string no puede estar en una lista
vacı́a).
2. Si no, debemos preguntar si la cabeza corresponde al string buscado. En este caso, surgen dos
alternativas:
4 else :
5 if cabeza ( unaLista ) == " pelota " :
6 return True
7 else :
8 return hayPelotas ( cola ( unaLista ))
Plantilla: Se formula con expresiones condicionales. Debe haber una condición por cada cláusula
en la definición de datos recursiva, escribiendo expresiones adecuadas en todas las condiciones
que procesen datos compuestos.
Ejemplo: la plantilla para una función que procesa una lista de simbolos seria
1 # def procesarLista ( unaLista ):
2 # if vacia ( unaLista ):
3 # ...
4 # else :
5 # ... cabeza ( unaLista )
6 # ... procesarLista ( cola ( unaLista )) ...
Cuerpo de la función: Se empieza por los casos base (aquellos que no tienen llamados recursivos).
Luego, se continúa con los casos recursivos. Debemos recordar cuáles de las expresiones en la
plantilla se calculan, y suponemos que para los llamados recursivos la función retorna el valor
esperado (hipótesis de inducción). Finalmente, se combinan los valores obtenidos de los llamados
recursivos dependiendo del problema (sumarlos, calcular el mı́nimo, calcular el máximo, etc.).
Ejemplo: supongamos que queremos implementar la función cuantos, que calcula cuántos
sı́mbolos contiene la lista de simbolos. Siguiendo la receta, tenemos que para el caso base (lista
vacia) la respuesta es 0. Para el caso recursivo, tenemos que la lista contiene un sı́mbolo (la
cabeza) más los simbolos que contenga el resto de la lista, es decir, basta con sumar 1 al resultado
del llamado recursivo para obtener el valor final.
Combinar valores: El paso de combinar valores puede consistir en una expresión a evaluar (como
en el ejemplo anterior), o puede requerir preguntar algo sobre el primer objeto en la lista (en cuyo
caso, puede ser necesaria una condición if anidada), o puede requerir definir funciones auxiliares
(por ejemplo, si se quiere calcular el mı́nimo de la lista).
Llamemos a esta función listaSueldos, tal que recibe una lista de cuántas horas los empleados
de la compañı́a han trabajado, y devuelva una lista de los sueldos semanales por cada uno de ellos.
Es claro que estas dos listas se pueden representar por listas de enteros. Dado que ya disponemos de
una definición de datos para la entrada y la salida (modificando levemente la definición que vimos en
la sección anterior), podemos comenzar inmediatamente a desarrollar la función.
1. Si listaHoras está vacı́a, entonces tenemos que devolver una lista vacı́a.
2. Si no, creamos una lista donde la cabeza corresponde a aplicar la función sueldo a la cabeza de
listaHoras, y la encadenamos a la lista que se forma al aplicar recursivamente la función con
la cola de listaHoras.
Afortunadamente, recordemos que los elementos de una lista no tienen por qué ser únicamente
atómicos (es decir, números, strings o valores lógicos). En efecto, podemos diseñar estructuras y
agregarlas sin problemas a cualquier lista que definamos. Ası́ pues, intentemos ir un paso más allá y
hagamos el inventario de la jugueterı́a un poco más realista. Comenzaremos entonces por diseñar la
estructura que va a representar a la definición de un nuevo tipo de datos especı́fico para este problema:
un registro de inventario.
Definiremos un registro como una estructura compuesta de un campo de tipo texto para almacenar
el nombre del producto, y de un campo de tipo numérico para almacenar el valor de dicho producto.
Ası́ pues, diseñemos la estructura:
Más aún, podemos definir una colección de registros para almacenar toda la información que
disponemos. A esto nos referiremos en este problema como inventario:
1 # inventario : [ registro ]*
2 # inventario es una lista de registros de largo indeterminado
Es decir, un inventario está compuesto de:
1. Una lista vacı́a: listaVacia, o bien
Ahora, para hacer las cosas más interesantes, podemos implementar una función que calcule la
suma total de los precios que están registrados en el inventario (es decir, calcular el valor total de los
productos de la jugueterı́a). En primer lugar, debemos definir el contrato, el propósito, ejemplos, y el
encabezado de la función:
Finalmente, para definir el cuerpo de la función, debemos considerar cada una de las ramas en la
condición de manera independiente. En primer lugar, si el inventario está vacı́o, naturalmente la suma
total será 0. Si no, debemos tomar el elemento de la cabeza, acceder al campo precio del registro, y
luego sumarlo al resultado que arroje la llamada recursiva de la función sobre la cola del inventario.
1 import estructura
2
3 # Diseno de la estructura
4 # lista : valor ( any = cualquier tipo ) siguiente ( lista )
5 estructura . crear ( " lista " , " valor siguiente " )
6
7 # identificador para listas vacias
8 listaVacia = None
9
10 # crearLista : any lista -> lista
11 # devuelve una lista cuya cabeza es valor
12 # y la cola es resto
13 def crearLista ( valor , resto ):
14 return lista ( valor , resto )
15
16 # cabeza : lista -> any
17 # devuelve la cabeza de una lista ( un valor )
18 def cabeza ( lista ):
19 return lista . valor
20
21 # cola : lista -> lista
I
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
9.7. OTRAS DEFINICIONES DE DATOS RECURSIVAS 70
Veamos un ejemplo práctico. Tı́picamente, los médicos utilizan árboles genealógicos para investigar
patrones de ocurrencia hereditarios en una familia. Por ejemplo, un árbol genealógico podrı́a servir
para ver el seguimiento en los colores de ojos en una familia. ¿Cómo podrı́amos representar esta
estructura en el computador?
Una forma de mantener este árbol genealógico puede ser agregar un nodo cada vez que un niño
nace. Desde este nodo, podemos dibujar conexiones al nodo del padre y al nodo de la madre, lo que
nos indica que estos nodos están relacionados entre sı́. Naturalmente, no dibujamos conexiones para
los nodos que no conocemos sus ancestros o descendientes. Más aún, podemos agregar a cada nodo
más información relacionada con la persona en cuestión: su nombre, color de ojos, color de pelo, etc.
El ejemplo de la Figura 9.1 nos muestra un árbol genealógico de ancestros de una familia. Esto es,
se indica la relación entre los nodos desde los hijos hacia sus padres. Ası́, Andrés es el hijo de Carlos
y Beatriz, tiene ojos pardos y nació en 1950. De manera similar, Gustavo nació en 1996, tiene ojos
pardos y es hijo de Eva y Federico. Para representar a un nodo en el árbol combinamos en un único
dato compuesto: el nombre, el año de nacimiento, el color de ojos, quién es el padre y quién es la
madre.
1 # hijo : padre ( hijo ) madre ( hijo ) nombre ( str ) nacimiento ( int ) ojos ( str )
2 estructura . crear ( " hijo " , " padre madre nombre nacimiento ojos " )
En otras palabras, un hijo se construye a partir de cinco elementos, donde:
1. padre y madre son: (a) vacı́os (listaVacia), o bien, (b) otro hijo; nombre y ojos son strings;
nacimiento es un entero.
Esta definición es especial en dos puntos. Primero, es una definición de datos recursiva que involucra
estructuras. Segundo, la definición de datos tiene una sola cláusula y menciona dos alternativas para
el primer y segundo componente. Esto viola la receta de diseño vista en este capı́tulo para definiciones
de datos recursivas.
Sin embargo, notemos que podemos resolver este problema definiendo simplemente una colección
de nodos en un árbol genealógico (nodoAG) de la siguiente manera:
1 # nodoAG : padre ( nodoAG ) madre ( nodoAG ) nombre ( str ) nacimiento ( int ) ojos ( str )
donde la estructura puede ser:
1. vacı́a (nodoAGVacio), o bien
2. otro nodoAG
Notemos que, a diferencia del caso anterior, un nodoAG no especifica explı́citamente cuáles son los
tipos de cada una de sus componentes, sino que es recursiva (un nodoAG se define en términos de sı́
mismo). Ası́, esta nueva definición sı́ satisface la convención que impusimos de tener al menos dos
cláusulas: una no recursiva y una recursiva.
Veamos cómo podemos crear el árbol del ejemplo anterior. Primero, tenemos que crear cada uno
de los nodos:
Por otro lado, tenemos que definir los nodos más profundos en términos de otros nodos que hay
que referenciar en el camino. Esto puede resultar bastante engorroso, por lo que lo más sencillo es
introducir una definición de variable por cada nodo, y luego utilizar esta variable para referirnos a los
distintos componentes del árbol.
1 # Primera generacion :
2 carlos = nodoAG ( nodoAGVacio , nodoAGVacio , " Carlos " , 1926 , " verdes " )
3 beatriz = nodoAG ( nodoAGVacio , nodoAGVacio , " Beatriz " , 1926 , " verdes " )
4
5 # Segunda generacion :
6 andres = nodoAG ( carlos , beatriz , " Andres " , 1950 , " pardos " )
7 david = nodoAG ( carlos , beatriz , " David " , 1955 , " marron " )
8 eva = nodoAG ( carlos , beatriz , " Eva " , 1965 , " azules " )
9 federico = nodoAG ( nodoAGVacio , nodoAGVacio , " Federico " , 1966 , " marron " )
10
11 # Tercera generacion :
12 gustavo = nodoAG ( federico , eva , " Gustavo " , 1996 , " pardos " )
Ahora que ya contamos con una descripción de cómo podemos manipular árboles genealógicos, nos
gustarı́a poder definir funciones sobre ellos. Consideremos de manera genérica una función del tipo:
Veamos ahora un ejemplo concreto. Diseñemos una función que determine si alguien en la familia
tiene ojos azules. Como ya deberı́a resultar natural, sigamos la receta de diseño:
La plantilla de la función es similar a la que vimos anteriormente, salvo que ahora nos referimos
especı́ficamente a la función ancestroOjosAzules. Como siempre, usaremos la plantilla para guiar el
diseño del cuerpo de la función:
1. En primer lugar, si unAG está vacı́o, entonces naturalmente nadie puede tener los ojos azules en
ese árbol (pues no hay nadie), y la evaluación da False.
2. Por otro lado, la segunda cláusula de la plantilla tiene varias expresiones, que debemos revisar
una a una:
(a) ancestroOjosAzules(unAG.padre) verifica si alguien en el árbol del padre del nodo tiene
ojos azules;
(b) ancestroOjosAzules(unAG.madre) verifica si alguien en el árbol de la madre del nodo tiene
ojos azules;
(c) unAG.nombre recupera el nombre del nodo;
(d) unAG.nacimiento recupera el año de nacimiento del nodo;
(e) unAG.ojos recupera el color de ojos del nodo;
Nuestra misión es ahora utilizar apropiadamente estos valores. Claramente, si el nodo tiene el string
"azules" en el campo ojos, entonces el resultado de la función debe ser True. En caso contrario, la
función debe producir True sólo si en el árbol del padre o de la madre alguien tiene los ojos azules.
Este último caso nos indica que debemos verificar recursivamente sobre los componentes del árbol para
determinar el color de ojos.
12 return True
13 # si se llega este punto , es porque
14 # ningun ancestro tenia ojos azules
15 return False
16 # Tests
17 assert not a nc es tr oO jo sA zu le s ( carlos )
18 assert not a nc es tr oO jo sA zu le s ( beatriz )
19 assert a nc es tr oO jo sA zu le s ( eva )
20 assert not a nc es tr oO jo sA zu le s ( david )
21 assert a nc es tr oO jo sA zu le s ( gustavo )
No es difı́cil darse cuenta que para representar este tipo de árboles en el computador necesitamos
una estructura de datos diferente al que usamos para el árbol genealógico de ancestros. Esta vez, los
nodos van a incluir información sobre los hijos (que a priori no sabemos cuántos son) en lugar de los
dos padres.
Notemos que ambas estructuras se refieren mutuamente: para definir a un padre necesitamos una
lista de hijos, y para definir a una lista de hijos necesitamos un padre. Ası́, la única forma que estas
dos definiciones hagan sentido es que se introduzcan simultáneamente:
1 import estructura
2
3 # padre : hijos ( listaHijos ) nombre ( str ) nacimiento ( int ) ojos ( str )
4 estructura . crear ( " padre " , " hijos nombre nacimiento ojos " )
5
6 # listaHijos : lista ( padre )
7 # listaHijos es una lista de padres de largo indeterminado
Traduzcamos ahora el árbol descendente que presentamos más arriba. Naturalmente, antes de
poder crear una estructura de tipo padre debemos definir todos los nodos que van a representar a los
hijos. Al igual que en la sección anterior, esta definición puede volverse rápidamente muy engorrosa,
por lo que lo mejor es introducir variables para llamar a las distintas referencias.
Veamos la generación más joven: tenemos que Gustavo es hijo de Federico y de Eva. Luego, lo
primero que debemos hacer es crear un nodo para Gustavo:
Creemos ahora el nodo que representa a Federico: tiene como único hijo a Gustavo (que ya definimos
previamente). Luego:
Notemos que en este caso utilizamos la función crearLista provista por el módulo lista.py, que
precisamente permite crear una lista a partir de los valores que recibe como parámetro (en este caso,
una estructura de tipo listaHijos).
1 # Nietos :
2 gustavo = padre ( listaVacia , " Gustavo " , 1996 , " pardos " )
3 # Padres :
4 hijosFedericoEva = crearLista ( gustavo , listaVacia )
5 federico = padre ( hijosFedericoEva , " Federico " , 1966 , " marron " )
6 eva = padre ( hijosFedericoEva , " Eva " , 1965 , " azules " )
7 david = padre ( listaVacia , " David " , 1955 , " marron " )
8 andres = padre ( listaVacia , " Andres " , 1950 , " pardos " )
9 # Abuelos :
10 h ijo sB ea tr iz Ca rl os =
crearLista ( andres , crearLista ( david , crearLista ( eva , listaVacia )))
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
9.8. DEFINICIONES MUTUAMENTE RECURSIVAS 76
11 beatriz = padre ( hijosBeatrizCarlos , " Beatriz " , 1926 , " verdes " )
12 carlos = padre ( hijosBeatrizCarlos , " Carlos " , 1926 , " verdes " )
Veamos ahora el desarrollo de la función descendienteOjosAzules, que verifica dentro del árbol
si hay algún descendiente que tenga los ojos de color azul. Naturalmente, recibe como parámetro una
estructura de tipo padre y devuelve un valor de tipo booleano.
1 # Tests
2 assert not d e s c e n d i e n t e O j o s A z u l e s ( gustavo )
3 assert d e s c e n d i e n t e O j o s A z u l e s ( eva )
4 assert d e s c e n d i e n t e O j o s A z u l e s ( beatriz )
Veamos cómo definir la plantilla. Dado que la estructura padre tiene cuatro componentes, entonces
debemos desagregar cuatro expresiones para poner en la plantilla. Además, como la función no
recibe más parámetros, los selectores de la plantilla serán precisamente las cuatro expresiones que
se desprenden de las componentes de la estructura:
1 def d e s c e n d i e n t e O j o s A z u l e s ( unPadre ):
2 if unPadre . ojos == " azules " :
3 return True
4 else :
5 # ... unPadre . hijos ...
6 # ... unPadre . nombre ...
7 # ... unPadre . nacimiento ...
Dado que ni el campo nombre ni el campo nacimiento nos aportan información relevante respecto
al color de ojos de una persona, los descartamos. Ası́, nos queda finalmente un único selector:
listaHijos, una estructura que extrae la lista de hijos asociada a un padre dado.
1 def d e s c e n d i e n t e O j o s A z u l e s ( unPadre ):
2 if unPadre . ojos == " azules " :
3 return True
4 else :
5 return hijoOjosAzules ( unPadre . hijos )
Ocupémonos ahora de la función hijoOjosAzules. Como es natural, veamos primero algunos tests:
1 # Tests
2 assert not hijoOjosAzules ( hijosFedericoEva ) # [ Gustavo ]
3 assert hijoOjosAzules ( h ij os Be at ri zC ar lo s ) # [ Andres , David , Eva ]
En el ejemplo, Gustavo no tiene ojos azules y no tiene registrado ningún descendiente. Luego, la
función deberı́a arrojar el valor False para la lista formada por Gustavo. Por otro lado, Eva tiene
ojos azules, por lo que el evaluar la función con la lista [andres, david, eva] deberı́a arrojar True.
Dado que el parámetro que recibe la función es una lista, la plantilla a utilizar es la estándar:
4 def d e s c e n d i e n t e O j o s A z u l e s ( unPadre ):
5 if unPadre . ojos == " azules " :
6 return True
7 else :
8 return hijoOjosAzules ( unPadre . hijos )
9
10 # hijoOjosAzules : listaHijos -> bool
11 # determina si alguna de las estructuras
12 # en listaHijos tiene ojos azules
13 def hijoOjosAzules ( unaLista ):
14 if vacia ( unaLista ):
15 return False
16 else :
17 if d e s c e n d i e n t e O j o s A z u l e s ( cabeza ( unaLista )):
18 return True
19 else :
20 return hijoOjosAzules ( cola ( unaLista ))
Suponga que se debe implementar una estructura que permita almacenar los datos de personas. En
este contexto, un árbol binario es similar a un árbol genealógico, pero ahora utilizaremos la siguiente
estructura para sus nodos:
Nos va a resultar útil contar con una función que nos permita crear nuevos nodos y otra función
que nos diga si un árbol binario es vacı́o:
6
7 # vacio : nodo -> bool
8 # devuelve True si el ABB esta vacio
9 def vacio ( nodo ):
10 return nodo == nodoVacio
Veamos como crear dos árboles binarios con la definición de nodo propuesta:
1 unArbol = crearNodo (15 , " Juan " , nodoVacio , crearNodo (24 , " Ivan " ,
nodoVacio , nodoVacio ))
2 otroArbol = crearNodo (15 , " Juan " , crearNodo (87 , " Hector " ,
nodoVacio , nodoVacio ) , nodoVacio )
Ahora introduciremos un tipo especial de árbol binario. Un árbol de búsqueda binaria (ABB) es un
árbol binario que cumple con una caracterı́stica de orden con respecto a uno de los valores almacenados
(en nuestro ejemplo, el RUT): para todo nodo del árbol se cumple que su valor asociado es mayor que
los valores de todos los nodos del lado izquierdo (árbol binario correspondiente a izq), y es menor que
los valores de todos los nodos del árbol derecho (árbol binario correspondiente a der). De acuerdo a
esta definición, el árbol unArbol serı́a un ABB, pero el árbol otroArbol no lo es. Formalmente, un
ABB para nuestro ejemplo se define como:
Para ejemplificar el uso de un ABB, diseñaremos una función que nos permita encontrar en un
ABB el nombre asociado a un RUT especı́fico. La plantilla de la función serı́a la siguiente:
Para el caso base, adoptaremos la convención que si el ABB es vacı́o no hay un nombre asociado al
RUT buscado, y la función retornará ”” (texto vacı́o). Para el caso recursivo, vamos a requerir de una
expresión condicional: verificamos si el RUT del nodo corresponde al RUT buscado. Si esto es cierto,
la función retorna el nombre guardado en el nodo. En caso contrario, la búsqueda en el ABB debe
proseguir en forma recursiva, pero la pregunta es, ¿se debe seguir buscando hacia el lado izquierdo o
hacia el lado derecho? En este punto es en donde sacamos ventaja que la estructura es un ABB: si el
RUT buscado es menor que el RUT del nodo, necesariamente debe estar hacia el lado izquierdo del
ABB (sino, vioları́a las restricciones impuestas a un ABB); en caso contrario, si el RUT buscado es
mayor que el RUT del nodo, necesariamente debe estar hacia el lado derecho del ABB.
Una vez que hemos entendido bien el procedimiento de búsqueda, procedemos a implementarlo:
Abstracción Funcional1
Las repeticiones son la causa de muchos errores de programa. Por lo tanto, los buenos
programadores tratan de evitar las repeticiones lo más posible. Cuando desarrollamos un conjunto de
funciones, especialmente funciones derivadas de una misma plantilla, pronto aprendemos a encontrar
similitudes. Una vez desarrolladas, es tiempo de revisarlas para eliminar las repeticiones lo más posible.
Puesto de otra manera, un conjunto de funciones es como un ensayo o una novela u otro tipo de pieza
escrita: el primer borrador es sólo un borrador. Es un sufrimiento para otros el tener que leerlos. Dado
que las funciones son leı́das por muchas otras personas y porque las funciones reales son modificadas
después de leerse, debemos aprender a “editar” funciones.
81
10.1. SIMILITUDES EN DEFINICIONES 82
Veamos las dos funciones anteriores: ambas consumen una lista de strings (nombres de juguetes)
y buscan un juguete en particular. La función de la izquierda busca una pelota y la de la derecha
busca un auto en una lista de strings (unaLista). Las dos funciones son casi indistinguibles. Cada una
consume una lista de strings; cada cuerpo de la funcion consiste en una expresión condicional con dos
cláusulas. Cada una produce False si la entrada es vacı́a; cada una consiste en una segunda expresión
condicional anidada para determinar si el primer item es el que se busca. La única diferencia es el
string que se usa en la comparación de la expresión condicional anidada: hayPelotas usa "pelota" y
hayAutos usa "auto".
Los buenos programadores son demasiado perezosos para definir muchas funciones que están
estrechamente relacionadas. En lugar de eso, definen una sola función que puede buscar tanto "pelota"
como "auto" en una lista de juguetes. Esa función más general debe consumir un dato adicional, el
string que estamos buscando, pero por lo demás es igual que las dos funciones originales.
El proceso de combinar dos funciones relacionadas en una sola función se denomina abstracción
funcional. Definir versiones abstractas de funciones es sumamente beneficioso. El primero de estos
beneficios es que la función abstracta puede realizar muchas tareas diferentes. En nuestro ejemplo,
contiene puede buscar muchos strings deferentes en vez de solamente uno particular.
# inferiores: lista(num) num -> lista(num) # superiores: lista(num) num -> lista(num)
# Construye una lista de aquellos numeros # Construye una lista de aquellos numeros
# de unaLista que sean inferiores a n # de unaLista que sean superiores a n
# ejemplo: inferiores(crearLista(1, # ejemplo: superiores(crearLista(2,
# crearLista(2, listaVacia)), 2) # crearLista(4, listaVacia)), 2)
# devuelve (1, listaVacia) # devuelve (4, listaVacia)
def inferiores(unaLista, n): def superiores(unaLista, n):
if vacia(unaLista): if vacia(unaLista):
return listaVacia return listaVacia
else: else:
if cabeza(unaLista) < n: if cabeza(unaLista) > n:
return crearLista(cabeza(unaLista), return crearLista(cabeza(unaLista),
inferiores(cola(unaLista), n)) superiores(cola(unaLista), n))
else: else:
return inferiores(cola(unaLista), n) return superiores(cola(unaLista), n)
En el caso de hayPelotas y hayAutos la abstracción es sencilla. Sin embargo hay casos más
interesantes, como son las dos funciones anteriores. La función de la izquierda consume una lista de
números y un número, y produce una lista de todos aquellos números de la lista que son inferiores
a ese número; la función de la derecha produce una lista con todos aquellos números que están por
encima de ese número.
escribir contratos para funciones como filtro, puesto que no sabemos aún como hacerlo. Discutiremos
este problema en las secciones que siguen.
Veamos cómo trabaja filtro en un ejemplo. Claramente, si la lista que se entrega como parámetro
es listaVacia, el resultado también será listaVacia, sin importar cuáles ean los otro argumentos:
22 if True :
23 return crearLista ( cabeza ( crearLista (4 , listaVacia )) , \
24 filtro ( menorQue , cola ( crearLista (4 , listaVacia )) , 5))
25 else :
26 return filtro ( menorQue , cola ( crearLista (4 , listaVacia )) , 5)
27 = crearLista (4 , filtro ( menorQue , cola ( crearLista (4 , listaVacia )) , 5))
28 = crearLista (4 , filtro ( menorQue , listaVacia , 5))
29 = crearLista (4 , listaVacia )
30 = (4 , listaVacia )
Nuestro ejemplo final es la aplicación de filtro a una lista de dos elementos:
5 if x > y :
6 return x
7 else :
8 return y
9
10 # listaMasLarga : lista lista -> numero
11 # Devuelve el largo de la lista mas larga , si ambas son vacias
12 # devuelve -1
13 # Ejemplo : listaMasLarga ( crearLista (5 , listaVacia ) , listaVacia ) -> 1
14 def listaMasLarga (x , y ):
15 if vacia ( x ) and vacia ( y ):
16 return -1
17 else :
18 return maximo ( len lista ( x ) , len lista ( y ))
• listaVacia • listaVacia
• crearLista(n, l): donde n es un • crearLista(n, l): donde n es un RI y l
número y l es una lista de números es una lista de RI
Ambas definen un tipo de listas. La de la izquierda es la definición de un dato que representa una lista
de números; la de la derecha describe una lista de registros de inventario (RI), que representaremos
con una estructura de dato compuesto. La definición de la estructura está dada como sigue:
1 import estructura
2 estructura . crear ( " ri " , " nombre precio " )
Dada la similitud entre la definición de ambas estructuras de datos recursivas, las funciones que
consumen elementos de estas clases también son similares. Miremos el siguiente ejemplo. La función
de la izquierda es inferiores, la cual filtra números de una lista de números. La función de la derecha
es inf-ri, que extrae todos aquellos registros de inventario de una lista cuyo precio esté por debajo
de un cierto número. Excepto por el nombre de la función, la cual es arbitraria, ambas definiciones
difieren solo en un punto: el operador relacional.
# inferiores : lista(num) num -> lista(num) # inf-ri : lista(RI) num -> lista(RI)
# Construye una lista de aquellos numeros # Construye una lista de aquellos registros
# de unaLista que sean inferiores a n # en unaLista con precio inferior a n
# ejemplo: inferiores(crearLista(1, # ej.: inf-ri(crearLista(ri("ri1", 1),
# crearLista(2, listaVacia)), 2) # crearLista(ri("ri2", 2), listaVacia)), 2)
# devuelve (1, listaVacia) # devuelve (ri("ri1", 1), listaVacia)
def inferiores(unaLista, n): def inf-ri(unaLista, n):
if vacia(unaLista): if vacia(unaLista):
return listaVacia return listaVacia
else: else:
if cabeza(unaLista) < n: if cabeza(unaLista) <ri n:
return crearLista(cabeza(unaLista), return crearLista(cabeza(unaLista),
inferiores(cola(unaLista), n)) inf-ri(cola(unaLista), n))
else: else:
return inferiores(cola(unaLista), n) return inf-ri(cola(unaLista), n)
Si abstraemos las dos funciones, obviamente obtenemos la función filtro. Sin embargo, podemos
escribir inf-ri en términos de filtro:
No nos deberı́a sorprender encontar otros usos para filtro puesto que ya argumentamos que la
abstracción funcional fomenta el reuso de funciones para distintos propósitos. Acá podemos ver que
filtro no solamente filtra lista de números, sino que cosas arbitrarias. Esto se cumple siempre y
cuando podamos definir una función que compare estas cosas arbitrarias con números.
En efecto, todo lo que necesitamos es una función que pueda comparar elementos de una lista
con elementos que pasamos a filtro como el segundo argumento. Acá presentamos una función que
extrae todos los elementos con el mismo nombre de una lista de registros de inventario:
10.3.1 Comparación
Cuando encontramos dos definiciones de funciones que son casi idénticas, salvo algunas pocas
diferencias en los nombres, se comparan las funciones y se marcan las diferencias, encerrándolas en
una caja. Si las cajas sólo contienen valores, se puede hacer la abstracción. Veamos un ejemplo:
Ambas funciones aplican una función a cada valor de la lista. Se distinguen en un solo punto: la
función que aplican (CaF y RIaString, respectivamente), que están marcadas en cajas. Ambos cajas
contienen valores funcionales, por lo que se puede realizar la abstracción.
10.3.2 Abstracción
A continuación, se reemplazan los contenidos de los pares de cajas correspondiente con nuevos
identificadores, y se agregan dichos nombres a los parámetros. Por ejemplo, si hay tres pares de
cajas se necesitan tres identificadores nuevos. Ambas definiciones deben ser ahora idénticas, excepto
por el nombre de la función. Para obtener la abstracción, se reemplazan sistemáticamente los nombres
de función por uno nuevo.
Note que se reemplazaron los nombres en la cajas con f, y se agregó f como parámetro de
ambas funciones. Ahora se reemplazan convertirCF y nombres con un nuevo nombre de función,
obteniéndose la función abstracta mapa:
Usamos el nombre mapa para la función resultante en nuestro ejemplo, dado que es el nombre
tradicional en los lenguajes de programación que tiene esta función especı́fica.
10.3.3 Test
Ahora debemos validar que la nueva función es una abstracción correcta de la funciones originales.
La definición misma de abstracción sugiere que se puede validar definiendo las funciones originales en
términos de la función abstracta, probando las nuevas versiones con los ejemplos de test originales. En
la mayorı́a de los casos, esto se puede realizar en forma directa. Supongamos que la función abstracta
se llama f-abstracta, y suponga que una de las funciones originales se llama f-original que recibe
un argumento, llamémosle valor. Si f-original difiere de las otras funciones originales en el uso de
un valor, entonces se define la siguiente función:
Para asegurarse que estas dos definiciones son equivalentes a las originales, y de paso mostrar que
mapa es una abstracción correcta, se aplican estas dos funciones a los ejemplos especificados en los
contratos originales de convertirCF y nombres.
10.3.4 Contrato
Para hacer la abstracción útil, debemos formular un contrato adecuado. Si los valores en las cajas de la
abstracción (paso dos de esta receta) son funciones, como en el ejemplo, el contrato requiere definirlas,
para lo cual utilizaremos tipos de datos con flechas. Adicionalmente, para obtener un contrato flexible,
debemos definir y usar definiciones de datos paramétricas y formular un tipo paramétrico.
Veamos como hacer el contrato para la función mapa de nuestro ejemplo. Por una parte, si vemos
a mapa como una abstracción de convertirCF, el contrato podrı́a definirse como:
Por el contrario, si vemos a mapa como una abstracción de nombres, el contrato quedarı́a como
sigue:
El primer contrato serı́a inútil en el segundo caso, y viceversa. Para acomodar ambos casos, es
necesario entender qué es lo que hace mapa y luego definir el contrato. Mirando a la definición de mapa,
podemos ver que aplica su primer argumento (una función) a cada elemento de su segundo argumento
(una lista). Esto implica que la función debe “consumir” el tipo de dato que la lista contenga. Por
ejemplo, sabemos que f tiene el contrato:
# f : X -> ???
si unaLista contiene datos de tipo X. Más aún, mapa crea una lista con el resultado de aplicar f a cada
elemento. Por lo tanto, si f produce datos de tipo Y entonces mapa produce una lista de datos de tipo
Y. Al agregar todo esto el contrato queda como sigue:
Este contrato indica que mapa produce una lista de datos de tipo Y a partir de una lista de datos
de tipo X y una función de X a Y, independiente de lo que signifique X e Y.
Una vez que tenemos la abstracción de dos o más funciones, debemos verificar si hay otros usos
para la función abstracta. En muchos casos, una función abstracta es útil en una amplia gama de
contextos que van más alla de lo que uno inicialmente habı́a anticipado. Esto hace que las funciones
sean mucho más fáciles de leer, entender y mantener. Por ejemplo, ahora podemos usar la función
mapa cada vez que necesitemos una función que produzca una lista nueva a partir del procesamiento
de los elementos de una lista ya existente.
Al comparar los contratos, se observa que difieren en dos lugares. A la izquierda de -> se tiene
lista(num) y lista(RI), a la derecha se tiene codigolista(num) y lista(str). Considere el segundo
paso de la receta de abstracción. Los contratos quedan como:
Es fácil comprobar que, reemplazando X con num e Y con num, se obtiene el contrato para la función
convertirCF. Veamos otro ejemplo con los contratos de las funciones inferiores e inf-ri:
Los contratos se diferencian en dos partes: la lista consumida y la lista producida. Como ya hemos
visto, las funciones de la segunda etapa del proceso de abstracción reciben un argumento adicional:
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
10.4. OTRO EJEMPLO DE FUNCIÓN ABSTRACTA 91
El argumento añadido es una función, que en el primer caso recibe un num y en el segundo un RI.
Comparando ambos contratos, se observa que num y RI ocupan la misma posición en el contrato, y
podemos entonces reemplazarlos por una variable. Al hacer esto, el contrato, que corresponde al de la
función filtro queda como:
Observando los nuevos contratos, vemos que ahora es posible reemplazar num por otra variable Y,
con lo que obtenemos el contrato final:
El resultado del primer argumento debe ser bool (no puede ser reemplazado por una variable),
dado que es utilizado en una condición. Por lo tanto, éste es el contrato más abstracto posible para la
función filtro.
Resumiendo, para encontrar contratos generales se requiere comparar los contratos de los ejemplos
que tengamos para crear abstracciones. Reemplazando valores distitnos en posiciones correspondientes
por variables, una a la vez, se logra hacer un contrato más genérico en forma gradual. Para validar
que la generalización del contrato es correcta, se debe verificar que el contrato describe correctamente
las instancias especı́ficas de las funciones originales.
Estos problemas implican procesar los elementos de la lista para obtener un único valor. Esto se
puede abstraer a una función que llamaremos fold (“reducir”), que recibe una lista, un valor inicial y
una función de dos argumentos, procesa los elementos de la lista y devuelve un único valor. La función
toma el valor inicial que se pasó como parámetro y el primer valor de la lista, se invoca la función de
dos argumentos y ésta retorna un valor. Dicho valor se ocupa para procesar el segundo valor de la
lista usando la función de dos argumentos, y asi sucesivamente hasta que se haya procesado la lista
completa. Si la lista está vacı́a (caso base), se retorna el valor inicial. El valor que retorna la última
invocación a la función de dos argumentos es el valor que devuelve fold.
Por ejemplo, para obtener la suma de los valores de una lista, se puede programar de la siguiente
forma utilizando fold:
1 # Funcion de dos argumentos requerida
2 def sumar (x , y ):
3 return x + y
4
5 # sumar Valore sLista : lista -> num
6 # suma los valores dentro de la lista y devuelve el resultado
7 # ejemplo : si unaLista = lista (10 , lista (20 , lista (30 , listaVacia )))
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
10.5. FUNCIONES ANÓNIMAS 92
13 # Tests
14 valores = lista (1 , lista (2 , lista (3 , lista (4 , listaVacia ))))
15 assert fold ( lambda x , y : x + y , 0 , valores ) == 10
16 valores = lista ( " pedro " , lista ( " juan " , lista ( " diego " , listaVacia )))
17 assert fold ( lambda x , y : x + y , " " , valores ) == " pedrojuandiego "
95
Capı́tulo 11
Mutación1
Sin embargo, muchos programas deben recordar algo sobre sus aplicaciones anteriores. Recuerde
que un programa tı́picamente consiste en muchas funciones. En el pasado siempre hemos supuesto que
existe una función principal, y que todas las otras funciones auxiliares son invisibles al usuario. En
algunos casos, un usuario puede esperar más de un servicio o funcionalidad a partir de un programa,
y cada servicio es mejor implementarlo como una función. Cuando un programa provee más de una
función como un servicio al usuario, es común que, por conveniencia o porque agregamos alguna interfaz
de usuario, las funciones deban tener memoria.
Como este punto es difı́cil de comprender, estudiaremos un ejemplo sencillo: manejar números
de teléfono en una agenda o libreta de direcciones. Un programa de libreta de direcciones provee
usualmente al menos dos servicios:
Estos dos servicios corresponden a dos funciones. Además, introducimos una definición de una
estructura para mantener la asociación nombre-número:
1 import estructura
2 from lista import *
3
4 # registro : nombre ( str ) numero ( int )
5 estructura . crear ( " registro " , " nombre numero " )
6 l i b r e t a D e D i r e c c i o n e s = crearLista ( registro ( " Maria " , 1) ,\
7 crearLista ( registro ( " Pedro " , 2) , listaVacia ))
8
9 # buscar : lista ( registro ) str -> num or False
10 # busca nombre en libreta y devuelve el numero correspondiente
11 # si no encuentra nombre , retorna False
1 Parte de este capı́tulo fue traducido al español y adaptado de: M. Felleisen et al.: How to Design Programs, MIT
En el pasado, la única forma de lograr el mismo efecto es que el usuario hubiera editado en el
codigo de nuestro programa la definición de libretaDeDirecciones. Sin embargo, no queremos que
los usuarios tengan que editar nuestros programas para obtener el resultado deseado. De hecho, ellos
no deberı́an tener acceso al código de nuestros programas. Por lo tanto, estamos forzados a proveer
una interfaz con una función que permita tales cambios.
1 import estructura
2 from lista import *
3
4 # registro : nombre ( str ) numero ( int )
5 estructura . crear ( " registro " , " nombre numero " )
6 l i b r e t a D e D i r e c c i o n e s = listaVacia
7
8 # agregarALibreta : str num -> None
9 # agrega nombre y numero a l i b r e t a D e D i r e c c i o n e s
10 def agregarALibreta ( nombre , numero ):
11 global l i b r e t a D e D i r e c c i o n e s
12 l i b r e t a D e D i r e c c i o n e s = crearLista ( registro ( nombre , numero ) ,\
13 libretaDeDirecciones )
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
11.2. DISEÑAR FUNCIONES CON MEMORIA 98
14
15 # buscar : lista ( registro ) str -> num or False
16 # busca nombre en libreta y devuelve el numero correspondiente
17 # si no encuentra nombre , retorna False
18 def buscar ( libreta , nombre ):
19 if vacia ( libreta ):
20 return False
21 elif cabeza ( libreta ). nombre == nombre :
22 return cabeza ( libreta ). numero
23 else :
24 return buscar ( cola ( libreta ) , nombre )
La necesidad por el primer paso es obvia. Una vez que sabemos que un programa requiere memoria,
debemos realizar un análisis de los datos para la memoria de éste. Es decir, debemos determinar qué
clase de datos se pondrán y se sacarán de ahı́. Finalmente, debemos diseñar cuidadosamente las
funciones para el programa que van a cambiar la memoria.
Volvamos al ejemplo de la libreta de direcciones. Vimos cómo una función agrega registros a la
libreta de direcciones y otra busca nombres en ella. Claramente, el uso del “servicio de adición” afecta
los usos posteriores del “servicio de búsqueda”, y por lo tanto requiere memoria. De hecho, la memoria
en este caso corresponde a un objeto fı́sico natural: la libreta de direcciones que la gente guardaba
antes de la existencia de dispositivos electrónicos.
La segunda clase de memoria también tiene ejemplos clásicos. Uno de ellos es el tı́pico contador.
Cada vez que se aplica la función producirá un número distinto:
Figura 11.1: Organización de programas con memoria, para el ejemplo de la libreta de direcciones.
1 contador = 0
2 # cuenta : None -> int
3 # En la invocacion i devuelve i
4 def cuenta ():
5 global contador
6 contador = contador + 1
7 return contador
Cada vez que se aplique la función, devolverá un valor distinto. En este caso la mutación o
modificación de la memoria se hace al modificar la variable contador. En Python es necesario usar
la instrucción global antes de la variable que se quiere modificar. Si no se incluye la instrucción, el
intérprete supondrá que se está definiendo una nueva variable. El ejemplo de arriba no funcionarı́a sin
esta instrucción, dado que contador aún no ha sido definida.
Cada servicio en un programa corresponde a una función que puede usar funciones auxiliares. Un
servicio que cambia la memoria de un programa es implementado con una función que usa la asignación
(=) en alguna o algunas de las variables de estado. Para entender cómo una función deberı́a modificar
una variable de estado, necesitamos saber qué tipo de valores puede representar esta variable y cuál
es su propósito. En otras palabras, debemos definir un contrato y un propósito para las variables de
estado de la misma manera en que desarrollamos contratos para definir una función.
Volvamos nuevamente al ejemplo de la libreta de direcciones. Ésta tiene una variable de estado:
libretaDeDirecciones. Su propósito es representar una lista de registros, donde cada uno de éstos
consiste en dos campos: un nombre y un número. Para documentar que libretaDeDirecciones puede
representar sólo una lista de esta naturaleza, debemos agregar un contrato, tal como sigue:
1 import estructura
2 from lista import *
3
4 # registro : nombre ( str ) numero ( int )
5 estructura . crear ( " registro " , " nombre numero " )
6
7 # l i b r e t a D e D i r e c c i o n e s : [ registro ]*
8 # para mantener los pares de nombres y numeros de telefono
9 l i b r e t a D e D i r e c c i o n e s = listaVacia
Por la definición del contrato, es posible usar listaVacia como el valor inicial de
libretaDeDirecciones.
Del contrato de la variable de estado, podemos concluir que la siguiente asignación no tiene sentido:
1 libretaDeDirecciones = 5
La instrucción anterior asigna 5 a libretaDeDirecciones, lo cual no es una lista. Por lo tanto, la
expresión viola el contrato. Pero la instrucción
1 l i b r e t a D e D i r e c c i o n e s = listaVacia
es válida, porque vuelve a asignar a libretaDeDirecciones su valor inicial. A continuación, otra
asignación:
Revisemos las etapas más básicas de la receta de diseño y de cómo podemos acomodar los efectos
en las variables de estado:
Análisis de Datos:
Las funciones que afectan el estado de las variables pueden consumir y (posiblemente) producir
datos. Por lo tanto, aún necesitamos analizar cómo representar la información y, si es necesario,
introducir definiciones de estructuras y de datos.
que las funciones ahora tienen efectos, necesitamos ejemplos que los ilustren.
1 # si l i b r e t a D e D i r e c c i o n e s es lista vacia y
2 # evaluamos agregarALibreta (" Maria " , 1) ,
3 # entonces l i b r e t a D e D i r e c c i o n e s es lista ( registro (" Maria " , 1) ,
4 # listaVacia )
5
6 # si l i b r e t a D e D i r e c c i o n e s es lista ( registro (" Claudio " , 5) ,
7 # listaVacia ) y evaluamos agregarALibreta (" Gonzalo " , 42) ,
8 # entonces l i b r e t a D e D i r e c c i o n e s es lista ( registro (" Gonzalo " , 42) ,
9 # lista ( registro (" Claudio " , 5") , listaVacia ))
10
11 # si l i b r e t a D e D i r e c c i o n e s es lista ( registro1 , lista ( registro2 ,
12 # lista (... , listaVacia ))...) , y evaluamos
13 # agregarALibreta (" Benjamin " , 1729) , entonces
14 # l i b r e t a D e D i r e c c i o n e s es lista ( registro (" Benjamin " , 1729) ,
15 # lista ( registro1 , lista ( registro2 , lista (... , listaVacia )))...)
El lenguaje de los ejemplos involucran palabras de naturaleza temporal. Esto no deberı́a ser
sorprendente, ya que las asignaciones enfatizan la noción de tiempo en la programación.
La Plantilla:
La plantilla de una función que cambia el estado de las variables es igual que en una función
ordinaria, pero el cuerpo deberı́a contener expresiones de asignación (=) sobre variables de estado
para especificar que las variables de estado están siendo modificadas. En Python se utiliza la
instrucción global para identificar una variable de estado:
1 def f u n c i o n Q u e C a m b i a E s t a d o (x , y , z ):
2 global variableDeEstado
3 variableDeEstado = ...
En ocasiones, también pueden haber condiciones de por medio. Por ejemplo, se puede querer
cambiar el estado de una variable dependiendo del valor de un parámetro o de otra variable de
estado.
El cuerpo de la función:
Como siempre, el desarrollo de la función completa requiere de un entendimiento sólido de los
ejemplos (cómo son calculados) y de la plantilla. Para las funciones con efectos, la expresión
de asignación es el paso más demandante. En algunos casos, el lado derecho de la asignación
involucra sólo operaciones primitivas, los parámetros de la función y la variable de estado (o
varias de ellas). En otros casos, lo mejor es escribir una función auxiliar (sin efecto) que consuma
el valor actual de la variable de estado y los parámetros de la función, y que produzca un nuevo
valor para la variable de estado.
Testing:
Es una tarea complicada verificar que las funciones en estos casos tengan el efecto deseado.
Hay dos maneras de testear funciones con efectos. Primero, podemos asignar la variable de
estado a un estado deseado, aplicar la función, y luego verificar que la función tiene el efecto
deseado y el resultado esperado.
1 contador = 3
2 siguiente = cuenta ()
3 assert siguiente == 4
1 l i b r e t a D e D i r e c c i o n e s = listaVacia
2 agregarALibreta ( " Benjamin " , 1729)
3 assert lista ( registro ( " Benjamin " , 1729) , listaVacia ) \
4 == l i b r e t a D e D i r e c c i o n e s
En este test sólo verificamos que "Benjamin" y 1729 hayan sido agregados a una lista vacı́a.
En la segunda forma de testear, podemos capturar el estado de una variable de estado antes de
que sea testada, luego aplicar la función que cambia la memoria y finalmente conducir a los tests
apropiados. Considere la siguiente expresión:
1 libretaActual = l i b r e t a D e D i r e c c i o n e s
2 agregarALibreta ( " John " , 10)
3 assert lista ( registro ( " John " , 10) , libretaActual ) \
4 == l i b r e t a D e D i r e c c i o n e s
Para conducir a pruebas sobre funciones con efectos, especialmente a pruebas del segundo tipo,
es útil de abstraer la expresión del test en una función:
Usando esta función, ahora podemos fácilmente testear agregarALibreta varias veces y
asegurarnos que cada vez el efecto es el adecuado:
Reuso futuro:
Una vez que tenemos un programa completo y testeado, deberı́amos recordar qué calcula y cuáles
son sus efectos. Sin embargo, no necesitamos saber cómo funciona. Si nos encontramos con una
situación donde se necesita el mismo cálculo y los mismos efectos, podemos rehusar el programa
como si fuera una operación primitiva. En presencia de efectos, es mucho más difı́cil reusar una
función que en el mundo de los programas algebraicos o sin efectos, es decir, utilizando sólo
programación funcional.
1 import estructura
2 from lista import *
3
4 # registro : nombre ( str ) numero ( int )
5 estructura . crear ( " registro " , " nombre numero " )
6
7 # Variables de estado :
8 # l i b r e t a D e D i r e c c i o n e s : [ registro ]*
9 # para mantener los pares de nombres y numeros de telefono
10 l i b r e t a D e D i r e c c i o n e s = listaVacia
11
12 # agregarALibreta : str num -> None
13 # proposito : la funcion siempre produce None
14 # efecto : agregar registro ( nombre , numero ) al principio de
15 # libretaDeDirecciones
16
17 # Encabezado :
18 # def agregarALibreta ( nombre , numero ): ...
19
20 # Ejemplos :
21 # si l i b r e t a D e D i r e c c i o n e s es lista vacia y
22 # evaluamos agregarALibreta (" Maria " , 1) ,
23 # entonces l i b r e t a D e D i r e c c i o n e s es
24 # lista ( registro (" Maria " , 1) , listaVacia )
25
26 # si l i b r e t a D e D i r e c c i o n e s es lista ( registro (" Claudio " , 5) , \
27 # listaVacia ) y evaluamos agregarALibreta (" Gonzalo " , 42) ,
28 # entonces l i b r e t a D e D i r e c c i o n e s es lista ( registro (" Gonzalo " , 42) ,
29 # lista ( registro (" Claudio " , 5") , listaVacia ))
30
31 # Plantilla : omitida
32
33 # Definicion :
34 def agregarALibreta ( nombre , telefono ):
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
11.3. ESTRUCTURAS MUTABLES 105
35 global l i b r e t a D e D i r e c c i o n e s
36 l i b r e t a D e D i r e c c i o n e s = lista ( registro ( nombre , telefono ) , \
37 libretaDeDirecciones )
38
39 # Tests :
40 # t e s t L i b r e t a D e D i r e c c i o n e s : str num -> bool
41 # para determinar si agregarALibreta tiene el efecto deseado
42 # sobre l i b r e t a D e D i r e c c i o n e s y no mas que eso
43 # efecto : el mismo que agregarALibreta ( nombre , numero )
44 def t e s t L i b r e t a D e D i r e c c i o n e s ( nombre , numero ):
45 libretaActual = l i b r e t a D e D i r e c c i o n e s
46 agregarALibreta ( nombre , numero )
47 return lista ( registro ( nombre , numero ) , libretaActual ) \
48 == l i b r e t a D e D i r e c c i o n e s
49
50 assert t e s t L i b r e t a D e D i r e c c i o n e s ( " Claudio " , 5)
51 assert t e s t L i b r e t a D e D i r e c c i o n e s ( " Maria " , 1)
52 assert t e s t L i b r e t a D e D i r e c c i o n e s ( " Gonzalo " , 42)
53 assert t e s t L i b r e t a D e D i r e c c i o n e s ( " John " , 10)
1 >>> estructura . mutable ( " artista " , " nombre instrumento " )
2 >>> p = artista ( " Michael Weikath " , " guitar " )
3 >>> q = p
4 >>> p . instrumento = " bass "
5 >>> q . instrumento
6 " bass "
Este ejemplo difiere del primero en dos formas. Primero, define a q como p. Segundo, la última
expresión se hace sobre q y no sobre p.
Lo que acabamos de ver es el efecto de compartir el efecto de una asignación, lo que significa que
una modificación de una estructura afecta al programa en más de un lugar. El efecto de compartir
también es visible dentro de listas, como muestra el siguiente ejemplo:
1 >>> estructura . crear ( " lista " , " val sig " )
2 >>> estructura . mutable ( " artista " , " nombre instrumento " )
3 >>> unaLista = lista ( artista ( " Michael Weikath " , " guitar " ) , None )
4 >>> ( unaLista . val ). artista = " Roland Grapow "
5 >>> ( unaLista . val ). artista
6 " Roland Grapow "
Finalmente, los efectos pueden ser compartidos entre los elementos de diferente listas:
1 >>> estructura . crear ( " lista " , " val sig " )
2 >>> estructura . mutable ( " artista " , " nombre instrumento " )
3 >>> q = lista ( artista ( " Michael " , " guitar " ) , None )
4 >>> r = lista ( q . val , lista (( q . val ). instrumento , None ))
5 >>> ( q . val ). instrumento = ’ vocals ’
6 >>> ( r . val ). instrumento
7 " vocals "
La nueva definición introduce la variable r, una lista con dos elementos. Como r contiene al
artista como primer elemento y como el campo instrumento del artista es "vocals", el resultado
es "vocals". Sin embargo, el programa todavı́a tiene conocimiento de "guitar" en alguna parte.
¿Puede encontrar dónde?
En resumen, la asignación sobre estructuras mutables nos da bastante poder. No sólo podemos
crear nuevas estructuras y revelar sus contenidos, sino que también podemos cambiar sus contenidos,
mientras que las estructuras se mantienen igual. Ahora tenemos que comprender qué significa esto
para el diseño de programas.
Sin embargo, en otras ocasiones crear una nueva estructura no corresponde a la intuición. Suponga
que queremos darle un aumento a alguien. La única forma de lograr esto hasta el momento era crear
un nuevo registro de personal que contuviera toda la información anterior y la nueva información del
salario. O, suponga que alguien se cambió de casa y tiene un nuevo número de teléfono, y nos gustarı́a
actualizar nuestra lista de contactos. Tal como el programa que cambia el nivel de salario de una
persona, el programa que actualiza la lista de contactos crearı́a un nuevo registro. En la realidad, sin
embargo, no crearı́amos un nuevo registro de personal ni una nueva entrada en la lista de contactos.
En vez de eso, corregirı́amos el registro actual en ambos casos. Un programa deberı́a ser capaz de
hacer la misma acción correctiva, y con mutación, podemos desarrollar tales programas.
Suponga que nos dan una estructura y una definición de datos para registros de personal:
1 import estructura
2
3 # Un registro de empleado ( RE ) es una estructura
4 # RE (n , d , s )
5 # donde n es un string , d es un string y s es un entero
6
7 # RE : nombre ( str ) direccion ( str ) salario ( int )
8 estructura . mutable ( " RE " , " nombre direccion salario " )
Una función que consume uno de estos registros se basa en la siguiente plantilla:
1 def f u nc i o nP a r aE m p le a d o ( re ):
2 ... re . nombre ...
3 ... re . direccion ...
4 ... re . salario ...
Considere una función para subir el salario a un empleado:
¿Qué pasa si los campos de una estructura son, a su vez, estructuras? Suponga que queremos
simular un juego de cartas. Cada carta tiene dos caracterı́sticas importantes: su tipo y su valor. La
colección de cartas de un jugador se llama mano. Vamos a suponer que la mano de un jugador nunca
está vacı́a, esto es, siempre tiene al menos una carta en su mano.
Una mano consiste de una estructura mano con valor, tipo y siguiente como campos. El campo
siguiente puede contener dos tipos de valores: vacı́o (None), que indica que no hay más cartas, y una
estructura mano, que contiene las cartas restantes. De una perspectiva más general, una mano es una
lista de cartas, pero sólo la última contiene vacı́o (None) como valor en siguiente.
Al principio, un jugador no tiene cartas. Al sacar la primera se creará su mano. Las otras cartas
son puestas en la mano existente cuando sean necesarias. Esto llama a dos funciones: una para crear la
mano y otra para insertar una carta en la mano. Debido a que la mano existe sólo una vez y corresponde
a un objeto fı́sico, es natural pensar que la segunda función es la que modifica un valor existente en
vez de crear uno nuevo. Por ahora vamos a aceptar esta premisa y explorar sus consecuencias.
Esta especificación dice que la función tiene un valor invisible como resultado que se comunica con
el resto del programa sólo por medio de sus efectos.
1 mano0 = crearMano (13 , " trebol " , crearMano (1 , " diamante " , \
2 crearMano (2 , " corazones " , None )))
Dado que el valor y el tipo pasados a agregarAlFinal son valores atómicos, la plantilla debe
estar basada en la definición de los datos de tipo mano:
El siguiente paso a considerar es cómo la función deberı́a afectar a unaMano en cada cláusula:
1. En el primer caso, el campo siguiente de unaMano es vacı́o (None). En ese caso, podemos
modificar el campo siguiente de forma que contenga la nueva carta:
unaMano.siguiente = crearMano(v, t)
Recuerde que la nueva mano creada contiene vacı́o (None) en su campo siguiente.
2. En el segundo caso, la recursión natural agrega una nueva carta la final de unaMano. De hecho,
debido a que la unaMano dada no es la última en la cadena, la recursión natural resuelve el
problema.
Estructuras Indexadas
En este capı́tulo se estudiarán estructuras de datos indexadas, que permiten acceder a los valores de
los datos almacenados en ellas a través de un ı́ndice. Algunas de estas estructuras son mutables, pero
otras no. También se estudiarán instrucciones que permiten iterar sobre los valores de estas estructuras
indexadas.
12.1 Arreglos
Un arreglo es una estructura de datos mutable que consiste en una secuencia contigua de un número
fijo de elementos homogéneos almacenados en la memoria. En la siguiente figura se muestra un arreglo
de enteros con diez valores:
Para acceder a un elemento del arreglo se utiliza un ı́ndice que identifica a cada elemento de manera
única. Los ı́ndices son números enteros correlativos y, en la mayorı́a de los lenguajes de programación,
comienzan desde cero. Por lo tanto, si el arreglo contiene n elementos el ı́ndice del último elemento
del arreglo es n − 1. Una ventaja que tienen los arreglos es que el costo de acceso de un elemento del
arreglo es constante, es decir no hay diferencias de costo entre accesar el primer, el último o cualquier
elemento del arreglo, lo cual es muy eficiente. La desventaja es que es necesario definir a priori el
tamaño del arreglo, lo cual puede generar mucha pérdida de espacio en memoria si se definen arreglos
muy grandes para contener conjuntos pequeños de elementos.
Python posee una biblioteca para crear y manipular arreglos de variables numéricas, pero en vez
de esta estructura estudiaremos otra que denominaremos listas de Python. Estas listas son mucho
más flexibles que los arreglos, permiten guardar cualquier tipo de dato dentro de éstas (como las listas
recursivas que ya conocen) y además proveen de varias funciones útiles.
111
12.2. LISTAS DE PYTHON 112
Las listas son estructuras mutables, por lo que se pueden modificar los valores almacenados en sus
casilleros:
Note que para una lista de largo n, la función range(n) retorna la lista [0, 1, 2, ..., n-1],
que corresponden a los ı́ndices válidos para acceder a los casilleros de la lista.
En el ejemplo anterior, la instrucción del ciclo se ejecuta cinco veces, una vez por cada asignación
realizada a la variable valor.
Note que es posible obtener el mismo resultado anterior si es que a la variable se le asigna un valor
de una lista retornada por range, y luego dicho valor se ocupa como indice para acceder a los distintos
casilleros de la lista:
La instrucción while se puede utilizar para iterar sobre los valores de una lista de la siguiente
forma:
Un diccionario guarda pares llave:valor en un orden arbitrario. Las llaves deben ser únicas,
de forma de poder identificar cada valor almacenado en el diccionario de forma unı́voca. Para crear
diccionarios, se utiliza la siguiente sintaxis:
Archivos de Texto
En este capı́tulo veremos cómo crear y ocupar archivos de texto para guardar información en disco.
117
13.2. EJEMPLOS DE USO 118
1 # segunda solucion
2 # leer y mostrar lineas de archivo
3 lector = open ( " lineas . txt " ," r " )
4 # leer todas las lineas del archivo
5 for linea in lector :
6 # mostrar string ( salvo ultimo caracter )
7 print linea [0: -1]
8 # cerrar archivo
9 lector . close ()
Depuración1
Depurar es el proceso de entender por qué un programa no está funcionando. Después de darse
cuenta que el programa no funciona (con el testing), queremos entender por qué el programa no
funciona como esperábamos. Después de entender la fuente del error, viene el paso de arreglar el error,
pero esto es usualmente mas fácil que entender por qué el programa falló.
Testear y depurar son cosas muy diferentes. Cuando testeamos, estamos comparando la entrada y
la salida de un programa con su especificación. Cuando depuramos, estamos estudiando los eventos
que hicieron surgir un error.
Hay dos tipos principales de testing: el test unitario y el test de integración. El test unitario prueba
una pieza simple de un programa, idealmente aislada del resto del programa. Éste es el tipo de testing
que hemos estudiado hasta ahora. El otro tipo de testing es el test de integración, que prueba un
programa grande completo. Al probar un programa completo pueden surgir nuevos errores, incluso
cuando hemos testeado bien cada función en el programa individualmente. Esto es porque al unir las
funciones en un programa grande, es mas probable que encontremos casos excepcionales que no hemos
incluido en nuestros tests. Cuando esto ocurre, tenemos que depurar nuestro programa.
Depurar es una habilidad que se aprende, nadie lo hace bien la primera vez, pero es una de las
caracterı́sticas que diferencia un buen programador de otros. Usualmente, aprender a depurar es un
proceso lento, y toma tiempo hasta que uno puede hacer mucho progreso muy rápido. Un aspecto
positivo de la depuración es que no sólo se aplica en programación, sino que esta habilidad se puede
transferir a otras áreas. Depurar es un proceso de ingenierı́a que se puede ocupar en muchos sistemas
complejos, como por ejemplo en experimentos de laboratorio.
Existen dos herramientas básicas para hacer depuración: la instrucción print y la lectura de
código. Ser capaz de entender qué hace exactamente el código (no lo que uno piensa que hace) es
probablemente la habilidad más importante para hacer depuración.Por esto, la instrucción print
puede ser útil para conocer, por ejemplo, el valor actual de una variable de estado, y si ésta se modifica
correctamente después de alguna instrucción o invocación a función. Una alternativa a la instrucción
1 Parte de este capı́tulo fue traducido al español y adaptado de: Eric Grimson and John Guttag, 6.00 Introduction
to Computer Science and Programming, Fall 2008. (Massachusetts Institute of Technology: MIT OpenCourseWare).
http://ocw.mit.edu (accessed 11 04, 2011). License: Creative Commons Attribution-Noncommercial-Share Alike.
122
14.2. EL PROCESO DE DEPURACIÓN 123
print es la instruccion assert, que nos permite parar el programa cuando algo inesperado ocurre.
Hay herramientas más avanzadas para realizar este tipo de procedimiento de depuración, llamadas
depuradores (en inglés debuggers); IDLE tiene uno. Estas herramientas son un poco más complejas,
ası́ que no vamos a verlas, pero a los interesados les recomendamos encarecidamente que las investiguen.
Lo más importante que uno tiene que recordar cuando depura es ser sistemático. Esto es lo que
separa buenos depuradores de malos depuradores: los buenos depuradores han encontrado una manera
sistemática de buscar errores en programas. Lo que hacen es reducir el espacio de búsqueda en el
programa en donde puede estar el error, hasta encontrar la fuente de la error. Visto de esta manera,
depurar es un proceso de búsqueda en el código de un programa. De la misma manera que cuando una
busca una valor en una lista, uno no toma elementos al azar, sino que hace un recorrido sistemático de
la lista. Desfortunadamente, esta búsqueda al azar es lo que hace mucha gente cuando están buscando
errores.
La segunda pregunta es: ¿Será este error parte de una familia de errores? La idea de esta pregunta
es que uno no quiere arreglar un defecto nada más, sino que quiere asegurarse que el programa está
libre de errores. Si un error proviene, por ejemplo, de un manejo equivocado de la mutación en listas,
conviene verificar todas las ocurrencias de manejo de listas en el programa, para ver si el mismo error
está presente en otros contextos. Más que arreglar sólo un defecto, uno tiene que detenerse y verificar
si el defecto ocurre más de una vez, arreglando todas las instancias del error de una vez, y no empezar
la búsqueda de cero cuando un error parecido aparece más adelante. Esto también es parte de ser
sistemático en la búsqueda de errores.
La última pregunta es ¿cómo localizar y corregir el error? Para esto se usa el método cientı́fico.
El método cientı́fico empieza estudiando los datos disponibles. En este caso, los datos disponibles
son los resultados de los tests del programa. Estos son los resultados equivocados, pero también los
resultados correctos. Esto es porque el programa puede funcionar en algunos casos y en otros no; a
veces, entender por qué el program funcionó en un caso y no en el otro permite entender la causa del
defecto, basado en la diferencia entre el caso correcto y el caso equivocado.
La otra gran pieza de información es el texto del programa. Cuando estudien el programa, recuerden
que no lo entienden, porque si lo hubieran entendido de verdad el programa no tendrı́a un defecto.
Entonces, hay que leer el program con un ojo crı́tico.
Mientras uno estudia los datos, el método cientı́fico nos hace formular una hipótesis consistente
con los datos. No sólo una parte de los datos; todos los datos. Basado en esta hipótesis, se diseña un
experimento repetible.
que saber cuál es el resultado esperado. Uno tiene tı́picamente una hipótesis, y la hipótesis dice que el
resultado a obtener tiene que ser un resultado X. Si el resultado no es X, se comprobó que la hipótesis
es falsa. Este es el punto donde mucha gente falla en la depuración: no se toman el tiempo de pensar
qué es lo que tendrı́a que devolver el programa, o sea, de formar una hipótesis. Si éste es el caso, no
están siendo sistemáticos al interpretar los resultados del experimento. Antes de ejecutar cualquier
test, tienen que saber que esperan que realice el programa.
Otra cosa que impide la reproducibilidad son las interacciones con el usuario. Si un programa
reacciona a interacciones del usuario, no va a ser reproducible. En este caso, hay que hacer que estas
interacciones sean definidas en una pequeña parte del programa, de tal manera que la mayor parte del
programa sea testeable y depurable de una manera reproducible.
El primero es de encontrar la entrada más pequeña posible que conduzca al error. En muchos
casos, un programa se va a ejecutar por mucho tiempo antes de encontrar un error. Esto es poco
práctico, ası́ que hay que tratar de reducir el problema para encontrarlo de manera más rápida.
Por ejemplo, si un juego de palabras no funciona con palabras de 12 letras, podemos probar
como funciona con palabras de 3 letras. Si la misma falla ocurre con 3 letras en vez de 12, esto
simplifica mucho la resolución del problema, ya que el espacio de búsqueda se reduce bastante.
El proceso usual es de tratar de reducir la entrada paso a paso: primero con 11 letras, despues
10, etc., hasta que el programa empeza a funcionar de nuevo.
El segundo objetivo es encontrar la parte del programa que es la más probable de ser “culpable”.
En ambos casos, es recomendable hacer una búsqueda binaria. La idea es que tratamos de
descartar la mitad de los datos, o la mitad del programa a cada paso. Esto permite encontrar
el lugar del problema rápidamente, incluso si el programa o los datos son grandes. En el caso
de un programa, la idea es de ocupar la instrucción print para imprimir valores intermedios
del programa, y determinar si están correctos. La búsqueda binaria en este caso nos va a hacer
empezar en la mitad del programa. Si no encontramos el error, esto quiere decir que se ubica en
la segunda mitad del programa. Si lo encontramos, quiere decir que está ubicado en la primera
mitad. Después seguimos buscando en cuartos del programa, y asi sucesivamente, hasta llegar a
la lı́nea que contiene el error. Con este proceso, a cada paso descartamos la mitad del programa
como ”inocente” del error.
En resumen, depurar programas es una tarea compleja que requiere ser sistemático y entender
bien qué es lo que hace el programa. De esta forma, será posible encontrar el error para corregirlo.
Programar es una tarea compleja y suceptible a muchos errores, incluso los mejores programadores
cometen errores al escrbir programas, por lo que es muy importante para todo programador el practicar
la habilidad de depuración.
125
Capı́tulo 15
Objetos y Clases
Hasta ahora en el curso hemos visto dos paradigmas de programación: un paradigma funcional, en
donde los problemas se modelan como funciones que toman datos de entrada y retornan un valor simple
o compuesto que sólo depende de la entrada, y un paradigma imperativo, en donde hay estructuras
que actúan como memoria y por lo tanto los resultados retornados por las funciones no sólo dependen
de los valores de los parámetros de la función, sino que también dependen del estado actual de estas
estructuras. A partir de este capı́tulo se estudiará un tercer paradigma de programación, conocido
como programación orientada al objeto. En este paradigma de programación se utilizan dos conceptos
fundamentales: objetos y clases. Éstos forman la base de toda la programación en lenguajes orientados
a objetos.
Un objeto es un modelo computacional de un ente o concepto que posee ciertos atributos y con el cual
podemos realizar ciertas operaciones. Hasta el momento, hemos visto datos complejos (structs) que
nos permiten modelar los atributos del concepto, y definimos funciones sobre éstos para implementar
las distintas operaciones posibles. En cambio, en la programación orientada a objetos, los objetos
contienen tanto los datos asociados a éstos como las operaciones que se pueden realizar con ellos. Una
clase permite describir en forma abstracta los atributos y operaciones del concepto modelado, que
luego se instancia en un objeto. En este capı́tulo estudiaremos como crear objetos y como interactuar
con ellos, dejando para el próximo capı́tulo cómo se define una clase en Python.
Con este pequeño código lo que hemos hecho ha sido crear un objeto de la clase Automovil.
Nuestro automóvil también puede moverse, acelerar, frenar, etc. Los objetos tienen
comportamiento, siguiendo nuestro modelo de la realidad. Supongamos que nuestros objetos de la
clase Automovil tienen distintas funciones, o métodos, que realizan acciones sobre el objeto:
En el ejemplo anterior, supongamos que nuestro automóvil entrega el nivel de gasolina actual tras
un tiempo en movimiento:
1 >>> unAutomovil . acelerar (). acelerar (). frenar (). acelerar (). frenar ()
Esto es posible dado que al llamar a acelerar o frenar, el resultado de la llamada retorna la
misma instancia de unAutomovil, a la cual se puede volver a llamar a los mismos métodos.
durante 30 segundos, podemos esperar que su nivel de gasolina disminuya. Como dijimos al comienzo,
si queremos representar un automóvil por su color, su velocidad, etc. estamos diferenciándolos. Pero
también podemos cambiar estos valores (como la gasolina). Estos valores constituyen el estado del
objeto. Tal como en las estructuras mutables, el estado de un objeto también puede cambiar. Veamos
un par de ejemplos.
Si queremos crear un automóvil de cierto color y cierta velocidad máxima, podrı́amos hacerlo al
momento de instanciar el objeto:
Definición de Clases
En este capı́tulo estudiaremos cómo definir una clase en Python, cómo definir los campos de la
clase, cómo definir la construcción de un objeto, y cómo definir métodos en la clase. Como ejemplo,
implementaremos una clase que nos permita crear objetos para manejar fracciones (como en el capı́tulo
de datos compuestos).
16.1 Clase
Para definir una clase en Pyhon se utiliza la instrucción class. En esta instrucción se debe señalar el
nombre que tendrá la clase. Por convención, los nombres de las clases comienzan con mayúscula. En
este punto, y como parte de la receta de diseño, señalaremos los campos (también llamados variables de
instancia) que tendrá la clase y sus tipos. Veremos una primera versión de nuesta clase para manejar
fracciones, que denominaremos FraccionV1:
1 # Campos :
2 # numerador : int
3 # denominador : int
4 class FraccionV1 :
16.1.1 Campos
Los campos de una clase son variables de estado que nos permiten almacenar información sobre los
objetos de dicha clase. Para la clase FraccionV1 necesitamos al menos dos campos: uno para almacenar
el numerador de la fracción, y otro para almacenar el denominador de la fracción. Al ser variables de
estado, su valor se puede modificar haciendo la asignación correspondiente al valor nuevo.
16.2 Constructor
El constructor es el primer método que uno debe definir en una clase. Este método se ejecuta cada
vez que se crea un nuevo objeto de la clase. Usualmente, en este método es en donde se definen los
campos de la clase y sus valores iniciales. Para la clase FraccionV1, el constructor es el siguiente:
1 # Constructor
2 def __init__ ( self , numerador = 0 , denominador = 1):
3 # Inicializacion de campos
4 self . numerador = numerador
5 self . denominador = denominador
129
16.3. MÉTODOS 130
En Python, el método constructor siempre tiene el nombre __init__, y sólo se puede definir un
constructor por clase. Todo método de una clase en Python (incluyendo al constructor) tiene como
primer parámetro la palabra clave self, que es una referencia al objeto que se está creando, aunque
cuando uno crea un objeto no coloca nada para dicho parámetro. Note que el constructor para la
clase FraccionV1 recibe además dos parámetros, el numerador y el denominador. Si el usuario no los
especı́fica al crear el objeto, se especifica que esas variables tendrán los valores 0 y 1 por default. Por
ejemplo:
1 >>> f = FraccionV1 (1 , 2) # crea la fraccion 1/2
2 >>> f = FraccionV1 () # crea la fraccion 0/1
Dentro del constructor se definen e inicializan las dos variables de instacias: self.numerador y
self.denominador. Note que es necesario anteponer self. cada vez que se desee accesar o modificar
dichos campos, sino Python interpreta que el programador se está refiriendo a variables locales del
método. Es usual definir e inicializar todos los campos dentro del constructor, aunque es posible
agregar campos a la clase posteriormente, definiendo nuevas variables de estado en otros métodos de
la clase.
16.3 Métodos
Los métodos de una clase se definen igual que las funciones en Python, con la diferencia que se definen
dentro del contexto de una clase y deben tener como primer parámetro la referencia self. Note que
este primer parámetro self no es parte del contrato del método. Por ejemplo, definamos un método
para la clase FraccionV1 que nos permita sumar dos fracciones:
1 # suma : FraccionV1 -> FraccionV1
2 # devuelve la suma de la fraccion con otra fraccion
3 def suma ( self , fraccion ):
4 num = self . numerador * fraccion . denominador + \
5 fraccion . numerador * self . denominador
6 den = self . denominador * fraccion . denominador
7 return FraccionV1 ( num , den )
El método es muy similar a la función suma que implementamos en el capı́tulo de datos compuestos.
Note self corresponde al objeto que invoca al método suma y fraccion corresponde al objeto que se
pasó por parámetro al método. Por ejemplo, en el siguiente código:
1 f1 = FraccionV1 (1 , 2)
2 f2 = FraccionV1 (5 , 6)
3 f3 = f1 . suma ( f2 )
el objeto f1 corresponde a self en el método suma, y el objeto f2 corresponde al parámetro fraccion
en dicho método.
Por ejemplo, veamos un método mutador que permite simplificar una fracción. Para esto, se debe
modificar tanto el numerador como el denominador de la fracción, dividiendo ambos valores por el
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
16.4. RECETA DE DISEÑO DE CLASES 131
máximo común divisor. Suponiendo que disponemos de la función mcd(x,y) que calcula el máximo
común divisor entre dos números enteros x e y, podemos implementar el método simplificar de la
siguiente forma:
1 # simplificar : None -> None
2 # efecto : simplifica la fraccion , puede modificar los
3 # valores de los campos numerador y denominador
4 def simplificar ( self ):
5 valor = mcd ( self . numerador , self . denominador )
6 if valor > 1:
7 self . numerador = self . numerador / valor
8 self . denominador = self . denominador / valor
Es muy tı́pico definir en una clase métodos accesores para obtener el valor de los distintos campos,
y métodos mutadores para asignarles un nuevo valor. Por convención, los nombres de los métodos
accesores comienzan con get, y los mutadores comienzan con set. Para nuesta clase FraccionV1, los
métodos correspondientes son los siguientes:
1 # getNumerador : None -> int
2 # devuelve el valor del campo numerador
3 def getNumerador ( self ):
4 return self . numerador
5
6 # getDenominador : None -> int
7 # devuelve el valor del campo denominador
8 def getDenominador ( self ):
9 return self . denominador
10
11 # setNumerador : int -> None
12 # efecto : modifica el valor del campo numerador
13 def setNumerador ( self , numerador ):
14 self . numerador = numerador
15
16 # setDenominador : int -> None
17 # efecto : modifica el valor del campo denominador
18 def setDenominador ( self , denominador ):
19 self . denominador = denominador
Antes de definir la clase, se identifican los campos que tendrá y sus tipos correspondientes.
La definición de los métodos sigue las reglas habituales de la receta de diseño para funciones,
pero los cuerpos de los métodos y los tests correspondientes quedan pendientes.
Una vez terminada la definición de métodos, fuera de la clase se implementan los tests para
todos los métodos. Esto es ası́ porque es necesario crear objetos de la clase con los cuales invocar
los objetos, y dependiendo de los valores de los campos de cada objeto se puede determinar la
respuesta esperada a cada método.
Finalmente, se implementan los cuerpos de los métodos, y luego se ejecutan los tests. Se corrigen
los errores detectados en los tests, y se itera nuevamente hasta que todos los tests sean exitosos.
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
16.4. RECETA DE DISEÑO DE CLASES 132
1 # Campos :
2 # numerador : int
3 # denominador : int
4 class FraccionV1 :
5
6 # Constructor
7 def __init__ ( self , numerador = 0 , denominador = 1):
8 # Inicializacion de campos
9 self . numerador = numerador
10 self . denominador = denominador
11
12 # getNumerador : None -> int
13 # devuelve el valor del campo numerador
14 def getNumerador ( self ):
15 return self . numerador
16
17 # getDenominador : None -> int
18 # devuelve el valor del campo denominador
19 def getDenominador ( self ):
20 return self . denominador
21
22 # setNumerador : int -> None
23 # efecto : modifica el valor del campo numerador
24 def setNumerador ( self , numerador ):
25 self . numerador = numerador
26
27 # setDenominador : int -> None
28 # efecto : modifica el valor del campo denominador
29 def setDenominador ( self , denominador ):
30 self . denominador = denominador
31
32 # toString : None -> str
33 # devuelve un string con la fraccion
34 def toString ( self ):
35 return str ( self . numerador ) + " / " + str ( self . denominador )
36
37 # suma : FraccionV1 -> FraccionV1
38 # devuelve la suma de la fraccion con otra fraccion
39 def suma ( self , fraccion ):
40 num = self . numerador * fraccion . denominador + \
41 fraccion . numerador * self . denominador
42 den = self . denominador * fraccion . denominador
43 return FraccionV1 ( num , den )
44
45 # mcd : int int -> int
I
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
16.4. RECETA DE DISEÑO DE CLASES 133
en donde el resultado se almacena en un nuevo objeto de la misma clase. Como detalle adicional, para
evitar que un usuario fuera de la clase pueda modificar los campos de un objeto, en Python se pueden
defir con nombres que comiencen con los caracteres __ (dos caracteres de guión bajo), y esto los hace
inaccesibles fuera de la clase (si uno intenta modificarlos, Python arroja el error AttributeError). La
implementación de la clase FraccionV2, que sólo utiliza métodos accesores es la siguiente:
1 # Campos :
2 # numerador : int
3 # denominador : int
4 class FraccionV2 :
5
6 # Constructor
7 def __init__ ( self , numerador = 0 , denominador = 1):
8 # Inicializacion de campos
9 # campos invisibles al usuario
10 self . __numerador = numerador
11 self . __denominador = denominador
12
13 # getNumerador : None -> int
14 # devuelve el valor del campo numerador
15 def getNumerador ( self ):
16 return self . __numerador
17
18 # getDenominador : None -> int
19 # devuelve el valor del campo denominador
20 def getDenominador ( self ):
21 return self . __denominador
22
23 # toString : None -> str
24 # devuelve un string con la fraccion
25 def toString ( self ):
26 return str ( self . __numerador ) + " / " + str ( self . __denominador )
27
28 # suma : FraccionV2 -> FraccionV2
29 # devuelve la suma de la fraccion con otra fraccion
30 def suma ( self , fraccion ):
31 num = self . __numerador * fraccion . __denominador + \
32 fraccion . __numerador * self . __denominador
33 den = self . __denominador * fraccion . __denominador
34 return FraccionV2 ( num , den )
35
36 # mcd : int int -> int
37 # devuelve el maximo comun divisor entre dos numeros x e y
38 # ejemplo : mcd (12 , 8) devuelve 4
39 global mcd
40 def mcd (x , y ):
41 if x == y :
42 return x
43 elif x > y :
I
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
16.4. RECETA DE DISEÑO DE CLASES 135
44 return mcd (x -y , y )
45 else :
46 return mcd (x , y - x )
47
48 # Test
49 assert mcd (12 , 8) == 4
50
51 # simplificar : None -> FraccionV2
52 # devuelve la fraccion simplificada
53 def simplificar ( self ):
54 valor = mcd ( self . __numerador , self . __denominador )
55 num = self . __numerador / valor
56 den = self . __denominador / valor
57 return FraccionV2 ( num , den )
58
59 # Tests
60 f1 = FraccionV2 (1 , 2)
61 f2 = FraccionV2 (3 , 4)
62 # Test de accesors
63 assert f1 . getNumerador () == 1
64 assert f2 . getDenominador () == 4
65 # Test de metodo suma
66 f3 = f1 . suma ( f2 )
67 assert f3 . getNumerador () == 10 and f3 . getDenominador () == 8
68 # Test de metodo toString
69 assert f3 . toString () == " 10/8 "
70 # Test de metodo simplificar
71 f4 = f3 . simplificar ()
72 assert f4 . getNumerador () == 5 and f4 . getDenominador () == 4
En capı́tulos anteriores hemos visto qué son los objetos y las clases, y cómo se implementan en Python.
En particular, discutimos las nociones de campo, constructor y métodos cuando hablamos de definición
de clases.
Ahora iremos un paso más adelante. Para construir aplicaciones interesantes, no es suficiente el
construir objetos independientes. En efecto, nos interesarı́a que los objetos puedan combinarse entre sı́,
de tal manera que juntos puedan realizar una tarea común. En este capı́tulo desarrollaremos esta idea
a través de una pequeña aplicación de ejemplo que involucra tres objetos y un conjunto de métodos
que permitan cumplir con su tarea.
Consideremos un reloj digital. Este tipo de relojes tiene una pantalla en la cual se muestran las
horas y los minutos, separados por el sı́mbolo dos puntos (:). Ası́, estos relojes son capaces de mostrar
la hora desde las 00:00 (medianoche) hasta las 23:59 (un minuto antes de medianoche).
La solución que usaremos para manejar la complejidad cuando desarrollamos programas usando
objetos es la abstracción. Dividiremos el problema en subproblemas, y luego cada subproblema en
sub-subproblemas hasta que los problemas individuales sean lo suficientemente pequeños y manejables
como para poder desarrollarlos con una clase sencilla (a esto se le denomina modularidad ). Una vez
que hayamos resuelto uno de estos subproblemas, no nos preocuparemos más de los detalles de éste,
sino que consideraremos esta solución como un elemento que podemos reutilizar en el subproblema
siguiente. Tı́picamente, a esta estrategia la llamamos dividir–y–conquistar o dividir–para–reinar.
Michael Kölling: Objects First with Java - A Practical Introduction using BlueJ, Fifth Edition, Prentice Hall.
136
17.2. DIAGRAMAS DE CLASES Y OBJETOS 137
Volvamos al ejemplo del reloj digital. Usando los conceptos de abstracción que hemos revisado, nos
gustarı́a encontrar la mejor manera de escribir una o más clases para implementarlo. Una forma de
ver el problema es considerar al reloj como una pantalla con cuatro dı́gitos (dos para las horas y dos
para los minutos). Si ahora realizamos una abstracción a más alto nivel, podemos ver al reloj como
dos entidades distintas de dos dı́gitos cada una (un par para representar las horas, y otro par para los
minutos). Ası́, un par empieza en 0, aumenta en 1 cada hora, y vuelve a 0 cuando alcanza su lı́mite
23. El otro par vuelve a 0 cuando su valor alcanza el lı́mite 59. Lo similar en el comportamiento de
estas dos entidades nos da para pensar que podemos abstraer aún más el problema, y por ende, dejar
de ver al reloj como una combinación de horas y minutos.
En efecto, podemos pensar que el reloj está formado por dos objetos que pueden mostrar valores
enteros que comienzan en 0 hasta cierto lı́mite. Este valor puede aumentar, pero, si alcanza el lı́mite,
se reinicia a 0. Ahora sı́ tenemos un nivel de abstracción apropiado que podemos representar como una
clase: un par de números programables. Ası́, para programar la pantalla del reloj, primero debemos
implementar una clase para manejar un par de números, luego darle un método para obtener su
valor, y dos métodos para asignar un valor y aumentarlo. Una vez que hayamos definido esta clase,
simplemente bastará con crear dos objetos de esta clase (cada cual con diferentes lı́mites) para ası́
construir el reloj completo.
1 # Campos :
2 # limite : int
3 # valor : int
4 class ParDeNumeros :
5 ...
Veremos más adelante los detalles de implementación de esta clase. Primero, asumamos que
podemos construir esta clase, y pensemos un poco más en cómo podemos organizar el reloj completo.
Nos gustarı́a poder construir el reloj, a partir de un objeto que tenga internamente dos pares de
números (uno para las horas y otro para los minutos). Ası́, cada uno de estos pares de números serı́a
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
17.2. DIAGRAMAS DE CLASES Y OBJETOS 138
1 # Campos :
2 # horas : ParDeNumeros
3 # minutos : ParDeNumeros
4 class Reloj :
5 ...
La estructura de objetos descrita puede visualizarse usando el siguiente diagrama de objetos. En
este diagrama apreciamos que un objeto de la clase Reloj se instancia utilizando dos objetos de la
clase ParDeNumeros.
Asimismo, la siguiente figura muestra el diagrama de clases que modela el problema del reloj digital.
Notemos que el diagrama de clases muestra únicamente dos clases, mientras que el diagrama de
objetos muestra tres objetos. Esto se debe al hecho que podemos crear más de un objeto desde una
misma clase. En este caso, creamos dos objetos ParDeNumeros desde la clase ParDeNumeros.
Estos dos diagramas ofrecen distintas vistas de la misma aplicación. El diagrama de clases muestra
la vista estática. Muestra qué es lo que tenemos al momento de escribir el programa. Ası́, tenemos dos
clases, y la flecha entre ellas indica que la clase Reloj hace uso de la clase ParDeNumeros. En otras
palabras, en el código fuente de la clase Reloj aparecerá una o más referencias a la clase ParDeNumeros.
Para comenzar el programa, crearemos un objeto de la clase Reloj. Programaremos ası́ la pantalla
del reloj digital de tal manera que cree automáticamente dos objetos ParDeNumeros. En efecto, el
diagrama de objetos muestra esta situación en tiempo de ejecución, es decir, cuando la aplicación está
corriendo. Esto también recibe el nombre de vista dinámica.
El diagrama de objetos también muestra otro detalle importante: cuando una variable almacena
un objeto, el objeto no es almacenado directamente en la variable, sino que una referencia al objeto es
almacenado en la variable. En el diagrama, la variable se representa como una caja blanca, y el objeto
es mostrado como una flecha. El objeto referenciado es almacenado fuera del objeto que referencia, y
la referencia de objetos enlaza a ambos.
Ahora analizaremos la implementación del reloj digital. Primero, debemos programar la clase
ParDeNumeros. En esta clase, notamos los dos campos que discutimos más arriba, un constructor, y
cuatro métodos. El constructor recibe el valor del lı́mite como parámetro. Ası́, por ejemplo, si recibe
24 como parámetro, el valor se reiniciará a 0 cuando se llegue a ese valor. De esta forma, el rango para
el valor que se puede almacenar en este caso va de 0 a 23. Con esto, recordemos que podemos definir
correctamente las horas y los minutos a manejar en el reloj: para las horas usamos un lı́mite de 24, y
para los minutos, un lı́mite de 60.
1 # Campos :
2 # limite : int
3 # valor : int
4 class ParDeNumeros :
5
6 # Constructor : crea un objeto que almacena dos numeros y se reinicia
7 # a cero cuando se sobrepasa el limite
8 def __init__ ( self , limite ):
9 self . limite = limite
10 self . valor = 0
11
12 # getValor : None -> int
13 # Retorna el valor actual
14 def getValor ( self ):
15 return self . valor
16
17 # setValor : int -> None
18 # efecto : Reemplaza el valor del par al nuevo valor indicado .
19 # Si el nuevo valor es menor que cero , o sobre el limite ,
20 # no hacer nada .
21 def setValor ( self , nuevoValor ):
22 if ( nuevoValor >= 0) and ( nuevoValor < self . limite ):
23 self . valor = nuevoValor
24
25 # toString : None -> str
26 # Retorna el valor almacenado en el par , esto es , un string que
27 # contiene los numeros del par ; si el valor es menor que diez , se le
1 # Campos :
2 # horas : ParDeNumeros
3 # minutos : ParDeNumeros
4 # pantalla : str
5 class Reloj :
6
7 # Constructor : crea un objeto reloj . Si no recibe parametros ,
8 # inicia el reloj a las 00:00; si no , a la hora indicada
9 def __init__ ( self , horas =0 , minutos =0):
10 self . horas = ParDeNumeros (24)
11 self . minutos = ParDeNumeros (60)
12 self . setReloj ( horas , minutos )
13
14 # tic : None -> None
15 # Se debe llamar cada minuto y hace que el reloj avance un minuto
16 def tic ( self ):
17 self . minutos . aumentar ()
18 if self . minutos . getValor () == 0:
19 self . horas . aumentar ()
20 self . a ct ua li za rP an ta ll a ()
21
22 # setReloj : int int -> None
23 # efecto : Fija la hora del reloj a la hora y minuto especificados
24 def setReloj ( self , hora , minuto ):
25 self . horas . setValor ( hora )
26 self . minutos . setValor ( minuto )
27 self . a ct ua li za rP an ta ll a ()
28
29 # getHora : None -> str
30 # Devuelve la hora actual del reloj en el formato HH : MM
31 def getHora ( self ):
32 return self . pantalla
33
34 # ac tu al iz ar Pa nt al la : None -> None
35 # efecto : Actualiza el string interno que lleva cuenta de la hora
36 # actual
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
17.3. OBJETOS QUE CREAN OBJETOS 141
Como los implementadores de la clase Reloj, debemos asegurarnos que esto realmente pase.
Para esto, simplemente escribimos código en el constructor de Reloj que crea y guarda dos objetos
de ParDeNumeros. Ya que el constructor es automáticamente llamado cuando un objeto Reloj es
creado, los objetos ParDeNumeros serán creados de manera automática también. Este es el código del
constructor de Reloj que hace este trabajo:
1 # Campos :
2 # horas : ParDeNumeros
3 # minutos : ParDeNumeros
Otros campos omitidos
1 class Reloj :
2
3 # Constructor : crea un objeto reloj . Si no recibe parametros ,
4 # inicia el reloj a las 00:00; si no , a la hora indicada
5 def __init__ ( self , horas =0 , minutos =0):
6 self . horas = ParDeNumeros (24)
7 self . minutos = ParDeNumeros (60)
8 self . setReloj ( horas , minutos )
Métodos omitidos
Cada una de estas dos lı́neas en el constructor crea un nuevo objeto ParDeNumeros y los asignan a
una variable. Como ya hemos visto, la sintaxis para crear un nuevo objeto es:
NombreDeClase(lista-de-parámetros)
Si el constructor de la clase está definido para tener parámetros, entonces deben ser suministrados
al crear el objeto. Por ejemplo, el constructor de la clase ParDeNumeros fue definido para esperar un
parámetro del tipo entero:
1 class ParDeNumeros :
2 def init ( self , limite ):
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
17.4. LLAMADAS A MÉTODOS 142
Es importante notar que en Python la definición del constructor de una clase siempre contiene
como primer parámetro self que no se considera como un argumento al momento de crear un objeto.
Ası́, para crear un objeto de la clase ParDeNumeros debemos proveer de un parámetro de tipo entero:
1 ParDeNumeros (24)
Luego, con el constructor de la clase Reloj hemos conseguido lo que querı́amos: cuando creamos
un objeto de esta clase, su constructor será ejecutado automáticamente y creará dos objetos de la clase
ParDeNumeros. Es decir, este objeto crea a su vez otros dos objetos cuando es creado y nuestra clase
Reloj está lista para ser usada.
1 self . act ua li za rP an ta ll a ()
Esta declaración es una llamada a un método. Como hemos visto hasta el momento, la clase Reloj
tiene un método con la siguiente firma:
El método tic tiene una sentencia if para comprobar que las horas también deben aumentar
cuando pasan los 60 minutos. Como parte de esta condición se llama a otro método del objeto
minutos: getValor. Este método retorna el valor actual de los minutos. Si el valor es cero, entonces
sabemos que ya han pasado 60 minutos por lo que debemos incrementar las horas. Por otra parte, si
el valor no es cero, entonces hemos terminado, puesto que no debemos aumentar las horas. Luego, la
declaración if no necesita la parte else.
Ahora debemos poder entender los tres métodos que nos restan de la clase Reloj. El método
setReloj toma dos parámetros –la hora y el minuto– y asigna el reloj con el tiempo especificado.
Mirando al cuerpo del método, podemos ver que esto lo hace llamando a los métodos setValor de
ambos objetos ParDeNumeros (uno para las horas y uno para los minutos). Luego, éste llama a
actualizarPantalla para actualizar el string de la pantalla.
El método getHora es trivial, dado que sólo retorna el string actual de la pantalla. Ya que siempre
mantenemos el string de la pantalla actualizado, es todo lo que se debe hacer ahı́.
Ahora presentamos una primera manera de probar clases sencillas; volveremos al tema de las
pruebas más adelante. La idea es que cada clase de un programa pueden tener una clase de prueba que
se encarga de: (1) crear objetos de la clase a probar, y poner estos objetos en estados que queremos
probar; (2) ejercitar la funcionalidad de dichos objetos con varias secuencias de métodos; y (3) verificar
que el comportamiento es correcto.
Vamos a ver dos ejemplos. El primero es el test de la clase ParDeNumeros, donde tenemos que
probar que los números aumentan hasta llegar al lı́mite, y que la representación textual de dichos
números siempre tiene dos caracteres. Esto se puede hacer de la siguiente manera:
1 # Para simplificar la implementacion de los tests ,
2 # este codigo se incluye en el archivo donde se
3 # encuentra la definicion de la clase ParDeNumeros
4 class TestParDeNumeros :
5
6 def __init__ ( self ):
7 # crear un objeto con estado interesante
8 self . par = ParDeNumeros (3)
9
10 def test ( self ):
11 # ejercitar funcionalidad ,
12 # y verificar el comportamiento
13 assert self . par . getValor () == 0
14 self . par . aumentar ()
15 assert self . par . getValor () == 1
16 self . par . aumentar ()
17 assert self . par . getValor () == 2
18 self . par . aumentar ()
19 assert self . par . getValor () == 0
20 self . par . aumentar ()
21 assert self . par . toString () == " 01 "
22
23 # ejecucion del test
24 test = TestParDeNumeros ()
25 test . test ()
Observe que este escenario de test es más complejo que el test de una función única, sin efecto de
borde. Seguimos con el ejemplo del test del Reloj, que aún más complejo, dado que tiene que probar
que al avanzar los minutos se cambia de minuto y de hora, según el caso. Además, la pantalla tiene
que tener el formato correcto.
1 class TestReloj :
2
3 def __init__ ( self ):
4 # crear un objeto con estado interesante
5 self . reloj = Reloj (23 ,58)
6
APUNTE DE USO INTERNO PROHIBIDA SU DISTRIBUCIÓN
17.5. TESTING DE CLASES 145