Cap. 21 - Más P.O.O. (Programación Orientada a Objetos)
21.1) MiTiempo
Como otro ejemplo de un tipo definido por el usuario, definiremos una clase llamada MiTiempo que lleve la cuenta de la hora del día. Le daremos un método __init__ para asegurar que toda instancia sea creada con los atributos y la inicialización correctos. La definición de la clase se verá así:
class MiTiempo: def __init__(self, hrs=0, mins=0, segs=0): """ Crea un objeto MyTime inicializado a hrs, mins, segs """ self.horas = hrs self.minutos = mins self.segundos = segs
Podemos instanciar un nuevo objeto MiTiempo:
t1 = MiTiempo(11, 59, 30)
El diagrama de estado del objeto se ve así:
Dejamos como ejercicio al lector que agregue un método __str__ para que los objetos MiTiempo se puedan imprimir a sí mismos decentemente.
- Solución:
def __str__(self): return "Tiempo: {0:02d}:{1:02d}:{2:02d}".format(self.horas, self.minutos, self.segundos)
21.2) Funciones puras
En las próximas secciones escribiremos dos versiones de una función sumar_tiempo que calcula la suma de dos objetos MiTiempo.
- Estos dos ejemplos nos permitirán estudiar dos tipos de funciones: funciones puras y modificadores.
La siguiente es una versión básica de sumar_tiempo:
def sumar_tiempo(t1, t2): h = t1.horas + t2.horas m = t1.minutos + t2.minutos s = t1.segundos + t2.segundos suma_t = MiTiempo(h, m, s) return suma_t
La función crea un nuevo objeto MiTiempo y retorna una referencia al nuevo objeto. Se trata de una función pura porque no modifica a ninguno de los objetos que le son pasados como parámetros y no tiene efectos laterales, como actualizar variables globales, mostrar un valor o recibir input del usuario.
Aquí hay un ejemplo de uso de esta función. Crearemos dos objetos MiTiempo: tiempo_actual, que contiene la hora actual; y tiempo_pan que contiene la cantidad de tiempo que le lleva a un panadero hacer un pan. Luego utilizaremos la función sumar_tiempo para deducir a qué hora estará pronto el pan.
>>> hora_actual = MiTiempo(9, 14, 30) >>> tiempo_pan = MiTiempo(3, 35, 0) >>> hora_pronto = sumar_tiempo(hora_actual, tiempo_pan) >>> print(hora_pronto) Tiempo: 12:49:30
El output de este programa es 12:49:30, que es correcto. Por otro lado, hay casos en que el resultado no será correcto. ¿Puedes pensar en alguno?
- El problema es que la función no trata con los casos en que el número de segundos o minutos pasa de 60.
- Cuando esto ocurre, debemos mover los segundos extra a la columna de los minutos, o los minutos extra a la de las horas.
Aquí hay una mejor versión de la función:
def sumar_tiempo(t1, t2): h = t1.horas + t2.horas m = t1.minutos + t2.minutos s = t1.segundos + t2.segundos if s >= 60: s -= 60 m += 1 if m >= 60: m -= 60 h += 1 suma_t = MiTiempo(h, m, s) return suma_t
Esta función está poniéndose grande, y todavía no funciona para todos los casos. A continuación vamos a sugerir un modo de implementarla alternativo, que produce mejor código.
21.3) Modificadores
Hay ocasiones en que es útil para una función modificar uno o más de los objetos que recibe como parámetros. Habitualmente, el llamador mantiene una referencia a los objetos que pasa, por lo cual cualquier cambio que la función le será visible. Las funciones que operan de este modo se llaman modificadores.
La función incrementar, que agregar cierta cantidad de segundos a un objeto MiTiempo, se escribiría con más naturalidad como un modificador. Un primer esbozo de la función se vería así:
def incrementar(t, segs): t.segundos += segs if t.segundos >= 60: t.segundos -= 60 t.minutos += 1 if t.minutos >= 60: t.minutos -= 60 t.horas += 1
La primera línea implementa la operación básica; el resto maneja los casos especiales de los que hablamos antes.
¿Es esta función correcta? ¿Qué pasa si el parámetro segs es mucho más grande de 60? En este caso, no es suficiente sumar 1 minuto; debemos seguir haciéndolo hasta que segundos quede por debajo de 60. Una solución es reemplazar las sentencias if con sentencias while:
def incrementar(t, segs): t.segundos += segs while t.segundos >= 60: t.segundos -= 60 t.minutos += 1 while t.minutos >= 60: t.minutos -= 60 t.horas += 1
Esta versión es correcta ahora, al menos cuando segs no es negativa, y cuando las horas no pasan de 23, pero sigue no es la mejor solución posible.
21.4) Convirtiendo INCREMENTAR en un método
Una vez más, los programadores de POO preferirán poner funciones que trabajan con objetos MiTiempo dentro de la clase MiTiempo, así que convirtamos a incrementar en un método.
- Para ahorrar espacio no copiaremos aquí los métodos que teníamos definidos previamente, pero deberías mantenerlos en tu versión:
class MiTiempo: # Definiciones de métodos anteriores aquí def incrementar(self, segundos): self.segundos += segundos while self.segundos >= 60: self.segundos -= 60 self.minutos += 1 while self.minutos >= 60: self.minutos -= 60 self.horas += 1
La transformación es puramente mecánica - movimos la definición dentro de la definición de la clase y (esto es opcional) cambiamos el nombre del primer parámetro a self, para seguir las convenciones de estilo de Python.
Ahora podemos llamar a incrementar usando la sintaxis con la que se llaman métodos:
>>> hora_actual.incrementar(500)
Una vez más, el objeto en que se llama el método es asignado al primer parámetro, self. El segundo parámetro, segundos, es el que recibe el valor 500.
21.5) Una intuición del tipo Ajá!
Con frecuencia una intuición de alto nivel del problema puede simplificar muchísimo el trabajo de programación.
En este caso, la intuición es que un objeto MiTiempo es realmente un número de tres dígitos en base 60! El componente segundos es la columna de las unidades, el componente minutos es la columna de las sesentenas, y el componente horas es la columna de los 3600s.
Cuando escribimos sumar_tiempo e incrementar, estábamos haciendo suma en base 60, que es la razón por la cual teníamos que llevarnos uno a la columna siguiente cuando pasábamos de 60.
Esta observación sugiere otra forma de encarar el problema completo: podemos convertir un objeto MiTiempo en un número único y sacar ventaja del hecho de que la computadora sabe cómo hacer cuentas con números. El siguiente método se agrega a la clase MiTiempo para convertir cualquier instancia en la cantidad correspondiente de segundos.
class MiTiempo: # Definiciones de métodos anteriores aquí def a_segundos(self): """ Retorna el número de segundos representados por esta instancia """ return self.horas * 3600 + self.minutos * 60 + self.segundos
Todo lo que necesitamos ahora es poder convertir de un entero a un objeto MiTiempo. Suponiendo que tenemos tsegs segundos, lo podemos hacer mediante algunas divisiones enteras y módulos, así:
horas = tsegs // 3600 segundos_sobrantes = tsegs % 3600 minutos = segundos_sobrantes // 60 segundos = segundos_sobrantes % 60
Tendrás que pensar un poco para convencerte de que esta técnica para convertir de una base a la otra es correcta.
En POO queremos poner juntos los datos y las operaciones que se refieren a ellos. Así que querríamos tener esta lógica dentro de la clase MiTiempo. La mejor solución es reescribir la clase inicializadora para que pueda manejar valores iniciales de segundos o minutos que estén fuera de los valores normalizados.
- Un tiempo normalizado sería algo como 3 horas, 12 minutos y 30 segundos. El mismo tiempo, no normalizado, podría ser 3 horas, 70 minutos y 150 segundos.
Escribamos entonces un inicializador más poderoso para MiTiempo:
class MiTiempo: # . . . def __init__(self, hrs=0, mins=0, segs=0): """ Crea un objeto MyTime inicializado a hrs, mins, segs. Los valores de mins y segs pueden estar fuera del rango 0-59, pero el objeto MiTiempo resultante estará normalizado. """ # Calcular la cantidad total de segundos a representar segundos_totales = hrs*3600 + mins*60 + segs self.horas = segundos_totales // 3600 # Dividir en h, m, s segundos_restantes = segundos_totales % 3600 self.minutos = segundos_restantes // 60 self.segundos = segundos_restantes % 60
Ahora podemos reescribir sumar_tiempo así:
def sumar_tiempo(t1, t2): segs = t1.a_segundos() + t2.a_segundos() return MiTiempo(0, 0, segs)
Esta versión es mucho más corta que la original, y es mucho más fácil de razonar sobre ella y demostrar que es correcta.
21.6) Generalización
En cierto modo, convertir de base 60 a base 10 y viceversa es más difícil que sólo lidiar con tiempos. La conversión de bases es muy abstracta, mientras que nuestra intención para pensar sobre el tiempo es mejor.
Pero si tenemos la visión suficiente como para tratar cómodamente tiempos como números de base 60 y hacemos el esfuerzo de escribir las conversiones, obtenemos un programa que es más corto, fácil de leer y corregir, y más confiable.
También es más fácil agregarle nuevas funcionalidades a futuro. Por ejemplo, para restar dos objetos MiTiempo, lo cual equivale a encontrar la duración que transcurre entre los mismos, lo que haríamos espontáneamente sería implementar una substracción con "préstamo". Usando las funciones de conversión nos evitaríamos ese trabajo y obtendríamos un código más fácil de leer, comprender y corregir.
Concluimos con cierta ironía que en ocasiones hacer que un problema sea más difícil (o más general) permitirá simplificar la programación, pues habrá menos casos especiales y menos oportunidades de error.
Especialización vs. Generalización
Los desarrolladores de software suelen enfrascarse en especializar sus tipos, mientras que los matemáticos van por el camino opuesto y tratan de generalizarlo todo.
¿Qué queremos decir con esto?
Si le pedimos a un matemático que resuelva un problema que tiene que ver con días de la semana, días del siglo, juegos de cartas, tiempo o dominós, lo más probable es que observen que todos esos objetos pueden representarse con enteros. Las cartas de un mazo, por ejemplo, pueden numerarse 0 a 51. Los días de un siglo pueden enumerarse. Los matemáticos dirán: "Estas cosas son numerables - a cada elemento puede asignársele un número único (y podemos a partir del número determinar cuál era el objeto original). Entonces démosles números, y luego pensemos el asunto como si fuera un problema de enteros. Afortunadamente, tenemos poderosas técnicas y una buena comprensión de los enteros, y por lo tanto nuestra abstracción - el modo en que abordamos y simplificamos estos problemas - es tratar de reducir estos problemas a problemas sobre números enteros."
Los teóricos y desarrolladores informáticos tendemos a hacer lo opuesto. Argumentaremos que hay muchas operaciones entre enteros que simplemente no significan nada para los dominós o los días de un siglo. Así que definiremos más bien tipos especializados, como MiTiempo, porque podemos restringir, controlar y especializar las operaciones que son posibles en dicho tipo. La POO es especialmente popular porque nos da una forma práctica y poderosa de empaquetar métodos y datos especializados en un nuevo tipo.
Ambos métodos son poderosas herramientas para resolver problemas. Muchas veces puede ayudar el tratar de pensar los problemas desde ambos puntos de vista - "Qué pasaría si tratara de reducir todo a unos pocos tipos primitivos? " vs. "Qué pasaría si esta cosa tuviera su propio tipo especializado?"
21.7) Otro ejemplo
La función después debería comparar dos tiempos, y decirnos si el primero es estrictamente posterior al segundo, es decir:
>>> t1 = MiTiempo(10, 55, 12) >>> t2 = MiTiempo(10, 48, 22) >>> despues(t1, t2) # Es t1 posterior a t2? True
Esto es un poco más complicado porque opera sobre dos objetos MiTiempo, no sólo uno. Pero preferimos escribirlo como un método de todas formas - en este caso, un método sobre el primer argumento:
def despues(self, t2): """ Retorna True si es estrictamente mayor que el tiempo t2 """ if self.horas > t2.horas: return True if self.horas < t2.horas: return False if self.minutos > t2.minutos: return True if self.minutos < t2.minutos: return False if self.segundos > t2.segundos: return True return False
Llamamos a este método sobre un objeto y le pasamos el segundo como argumento:
if hora_actual.despues(hora_pronto): print("El pan estará pronto antes de comenzarlo!")
Casi podemos leer la llamada en español. Si la hora actual es posterior a la hora en que estará pronto, entonces...
La lógica de las sentencias if merece una atención especial aquí. Las líneas 11-18 del método despues sólo van a alcanzarse si los dos campos horas son iguales. Del mismo modo, el test que compara los segundos en la línea 16 sólo se alcanzará si ambos tiempos tienen los mismos valores de horas y minutos.
¿Podríamos simplificar esta función utilizando nuestra intuición "Ajá!", convirtiendo ambas horas en enteros para compararlas? Sí, y con resultados espectaculares!
def despues(self, time2): """ Retorna True si es estrictamente mayor que el tiempo t2 """ return self.a_segundos() > t2.a_segundos()
Esta es una gran forma de implementar este método: si queremos decir si el primer tiempo es posterior al segundo, convertimos a ambos en enteros y comparamos los enteros.
21.8) Sobrecarga (overloading) de operadores
Algunos lenguajes, incluido Python, permiten dar distintos significados al mismo operador cuando se aplica a tipos diferentes.
- Por ejemplo, + tiene significados muy distintos en Python según se aplique a enteros o strings.
Esta capacidad se llama sobrecarga de operadores. Es muy útil cuando los programadores pueden sobrecargar los operadores para sus propios tipos.
- Por ejemplo, para sobreescribir el operador adición +, podemos proveer un método __add__
def __add__(self, otro): return MiTiempo(0, 0, self.a_segundos() + otro.a_segundos())
Como de costumbre, el primer parámetro es el objeto sobe el cual el método es invocado. El segundo parámetro es llamado otro muy convenientemente para distinguirlo de self (uno mismo).
- Para sumar dos objetos MiTiempo, creamos y retornamos un nuevo objeto MiTiempo que contiene su suma.
Ahora, si aplicamos el operador + a dos objetos MiTiempo, Python invoca el método __add__ que acabamos de escribir:
>>> t1 = MiTiempo(1, 15, 42) >>> t2 = MiTiempo(3, 50, 30) >>> t3 = t1 + t2 >>> print(t3) Tiempo: 05:06:12
La expresión t1 + t2 es equivalente a t1.__add__(t2), pero obviamente es mucho más elegante.
- Como ejercicio, agrega el método __sub__(self, otro) que sobrecargue el operador de la resta, y pruébalo. (hacerlo)
Para el próximo par de ejercicios volveremos a la clase Punto que habíamos definido en el primer capítulo sobre POO, y sobrecargaremos algunos de sus operadores.
- Primero, la adición de dos puntos que consiste en sumar sus respectivas coordenadas (x, y), así:
class Punto: # Código anterior def __add__(self, otro): return Punto(self.x + otro.x, self.y + otro.y)
Hay muchas formas de sobreescribir el comportamiento del operador de la multiplicación: se puede definir un método __mul__, o un __rmul__, o ambos.
- Si el operando a la izquierda de * es un Punto, Python llamará a __mul__, que asume que el segundo operando también es un Punto. Computaremos el producto punto de dos puntos, definido según las reglas del álgebra lineal:
- Si el operando a la izquierda de * es de un tipo primitivo y el operador de la derecha es un Punto, Python llamará a __rmul__, que implementa la multiplicación por un escalar:
- El resultado es un nuevo Punto cuyas coordenadas son múltiplos de las coordenadas originales. Si otro es un tipo que no puede ser multiplicado por un número float, entonces __rmul__ producirá un error.
def __mul__(self, otro): return self.x * otro.x + self.y * otro.y
def __rmul__(self, otro): return Punto(otro * self.x, otro * self.y)
Este ejemplo muestra en funcionamiento ambos tipos de multiplicaciones:
>>> p1 = Punto(3, 4) >>> p2 = Punto(5, 7) >>> print(p1 * p2) 43 >>> print(2 * p2) (10, 14)
Qué pasa si intentamos evaluar p2 * 2? Como el primer parámetro es un Punto, Python llama a __mul__ con 2 como segundo argumento. Dentro de __mul__, el programa trata de acceder a la coordenada x de otro, lo cual falla porque un entero no tiene atributos:
>>> print(p2 * 2) AttributeError: 'int' object has no attribute 'x'
Desafortunadamente, el mensaje de error no es muy claro. Este ejemplo muestra algunas de las dificultades de la programación orientada a objetos (POO). A veces es incluso complicado comprender cuál es el código que se está ejecutando.
21.9) Polimorfismo
La mayor parte de los métodos que hemos escrito sólo funcionan para un tipo específico. Cuando creamos un nuevo objeto, escribimos métodos que operan en ese tipo.
Pero hay ciertas operaciones que nos gustaría aplicar a varios tipos, como las operaciones aritméticas en las secciones previas. Si varios tipos soportan el mismo conjunto de operaciones, sería útil poder escribir un método que funcionase en cualquiera de esos tipos.
Por ejemplo, la operación multisumar (que es común en álgebra lineal) toma tres parámetros; multiplica los dos primeros y luego suma el tercero. La podemos escribir en Python así:
def multisumar(x, y, z): return x * y + z
Esta función va a funcionar para cualquier tipo de valor x e y que puedan multiplicarse y cualquier valor de z que pueda sumarse a dicho producto.
La podemos llamar con valores numéricos:
>>> multisumar(3, 2, 1) 7
O con Puntos:
>>> p1 = Punto(3, 4) >>> p2 = Punto(5, 7) >>> print(multisumar(2, p1, p2)) (11, 15) >>> print(multisumar(p1, p2, 3)) 46
En el primer caso, el Punto es multiplicado por un escalar y luego sumado a otro Punto. En el segundo caso, el producto punto produce un valor numérico, por lo cual el tercer parámetro ha de ser también un número.
Una función como ésta, que puede tomar argumentos de distintos tipos se llama polimórfica.
Como segundo ejemplo, consideremos la función derecho_y_revés, que imprime una lista dos veces, primero al derecho y luego al revés:
def derecho_y_reves(frente): import copy reves = copy.copy(frente) reves.reverse() print(str(frente) + str(reves))
Como el método reverse es un modificador, hicimos una copia de la lista antes de revertirla. De este modo la función no modifica la lista que recibió como parámetro.
Aquí hay un ejemplo de llamado de derecho_y_reves:
>>> mi_lista = [1, 2, 3, 4] >>> derecho_y_reves(mi_lista) [1, 2, 3, 4][4, 3, 2, 1]
Por supuesto, implementamos esta función pensando en listas, así que no es sorprendente que funcione con ellas. Lo sorprendente sería que la pudiéramos aplicar a Punto.
Para saber si una función puede ser aplicada a un nuevo tipo, aplicamos la regla fundamental del polimorfismo de Python, que se llama la regla de escritura del pato (duck typing rule): Si todas las operaciones dentro de la función se pueden aplicar al tipo, la función puede aplicarse al tipo. Las operaciones en la función derecho_y_reves incluyen copy, reverse y print.
No todos los lenguajes de programación definen polimorfismo de esta manera. Busca duck typing (escritura de pato) y trata de comprender por qué esta forma de hacerlo tiene ese nombre.
- Respuesta: en cualquier área de la programación el "testeo del pato" consiste en aplicar el principio del pato para determinar si cierto tipo de objeto serviría para cierto propósito. El testeo del pato se basa en el famoso dicho en idioma inglés: "si camina como un pato y hace cuac como un pato, entonces debe ser un pato". Por lo tanto, si aplicamos esta regla a decidir si un objeto puede ser considerado o no como de cierto tipo, el criterio sería así: se puede considerar que un objeto tiene cierto tipo si tiene métodos y propiedades requeridos por dicho tipo ("si camina como un pato y hace cuac como un pato...").
El método copy funciona en cualquier objeto, y ya escribimos un método __str__ para objetos Punto, por lo cual sabemos que print funciona también. Sólo nos falta un método reverse en la clase Punto:
# El nombre de este método debe ser "reverse" para poder ser utilizado polimórficamente # (si utilizamos otro nombre para el método, funcionará igual, pero perderemos el polimorfismo) def reverse(self): (self.x , self.y) = (self.y, self.x)
Con esto pronto, podemos pasar Puntos a la función derecho_y_reves:
>>> p = Punto(3, 4) >>> derecho_y_reves(p) (3, 4)(4, 3)
El polimorfismo más interesante es el que ocurre no intencionalmente, cuando descubrimos que una función que hemos escrito puede ser aplicada a un tipo para el que nunca la tuvimos en mente.
21.10) Glosario
- producto punto, multiplicación por un escalar, normalización/normalizado
- estilo de programación funcional (un estilo de programación en que la mayor parte de las funciones son puras)
- modificador (función o método que cambia uno o varios de los objetos que recibe como parámetros; suelen ser void, es decir, no retornan un valor)
- función pura (función que no modifica ninguno de los objetos que recibe como parámetros; la mayor parte son fructíferas en vez de void)
- sobrecarga de operadores (extensión de operadores built-in (+, -, *, >, <, etc.) para que hagan cosas distintas para distintos tipos de argumentos)
- función polimórfica (función que puede operar sobre más de un tipo. Sutil distinción: en la sobrecarga, tenemos diferentes funciones con el mismo nombre, operando sobre distintos tipos, mientras que en el polimorfismo tenemos a una sola función que puede operar sobre varios tipos: no son varias funciones con el mismo nombre (sobrecarga), sino una sola función que ejecuta el mismo código pero sobre distintos tipos (polimorfismo))
21.11) Ejercicios
1) Escribir una función booleana entre que tome dos objetos MiTiempo como argumentos (t1 y t2) y devuelva True si el objeto llamador cae entre esos dos. (hacerlo)
- Asumir que t1 <= t2 y hacer el test cerrado por la izquierda y abierto por la derecha, es decir, se devuelve True si t1 <= objeto <= t2.
- Implementarla como un método de la clase MiTiempo.
2) Sobreescribir los operadores necesarios de tal forma que en vez de escribir: (hacerlo)
Si t1.despues(t2): ...
- Podamos usar el más conveniente:
Si t1 > t2: ...
3) Reescribir incrementar como un método que utilice nuestra intuición "Ajá!" (hacerlo)
4) Crear casos de testeo para el método incrementar. (hacerlo)
- Considerar específicamente el caso en que el número de segundos que hay que sumar al tiempo es negativo. Corregir incrementar para que maneje este caso si ya no lo está haciendo.
- (Para no complicar demasiado el problema, se puede asumir que no se van a restar más segundos de los que ya hay en el objeto tiempo).
5) ¿Puede el tiempo físico ser negativo, o debe el tiempo siempre moverse en la dirección positiva? Algunos físicos piensan que esta pregunta no es tan tonta como suena. Ve qué puedes encontrar en la web sobre este asunto. (hacerlo)