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.

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.

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?

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.

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.

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.

Esta capacidad se llama sobrecarga de operadores. Es muy útil cuando los programadores pueden sobrecargar los operadores para sus propios tipos.

    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).

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.

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.

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.

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.

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

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)

 

2) Sobreescribir los operadores necesarios de tal forma que en vez de escribir: (hacerlo)

Si t1.despues(t2): ...

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)

 

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)