Cap. 15 - Clases y objetos

15.1) Programación orientada a objetos

Python es un lenguaje de programación orientado a objetos, lo que significa que provee herramientas que soportan la programación orientada a objetos (POO).

La programación orientada a objetos tiene sus raíces en los años 1960s, pero fue sólo a mediados de los 1980s que se convirtió en el principal paradigma de programación utilizado en la creación de nuevo software. Fue desarrollada como una forma de manejar el creciente tamaño y complejidad de los sistemas de software, y hacer más fácil el modificar esos grandes sistemas a medida que pasara el tiempo.

Hasta ahora, casi todos los programas que hemos escrito han usado el paradigma conocido como programación procedural.

En la programación orientada a objetos, en cambio, el foco está en la creación de objetos que contienen tanto datos como funcionalidades.

Habitualmente, cada definición de objeto se corresponde con un objeto o concepto del mundo real, y las funciones que operan en dicho objeto se corresponden con las formas en que los objetos del mundo real interactúan.

15.2) Tipos de datos compuestos definidos por el usuario

Ya hemos visto clases como str, int, float y Turtle. Estamos prontos ahora para crear nuestra propia clase definida por el usuario: el Punto.

Consideremos el concepto de punto en matemáticas. En dos dimensiones, un punto es un par de números (coordenadas) que se tratan colectivamente como un objeto único. Los puntos suelen escribirse entre paréntesis con una coma que separa las coordenadas. Por ejemplo, (0, 0) representa el origen y (x, y) representa el punto que está x unidades a la derecha e y unidades arriba de origen.

Algunas de las operaciones típicas que uno asocia con un punto son calcular la distancia de un punto al origen, o a otro punto, o encontrar el punto medio de dos puntos, o responder si un punto cae o no dentro de un rectángulo o de un círculo dados. Veremos pronto cómo podemos responder a estas preguntas y combinarlas con la información que tengamos disponible.

Una forma natural de representar un punto en Python es con un par de números. La pregunta, entonces, es cómo agrupar ese par de números en un objeto compuesto. La solución fácil y obvia sería utilizar una tupla, y para algunas aplicaciones esa sería una buena elección.

Otra alternativa es definir una nueva clase. Este enfoque requerirá un poco más de esfuerzo, pero tiene ventajas que pronto serán evidentes. Queremos que cada uno de nuestros puntos tengan un atributo x y un atributo y, así que nuestra primer definición de clase se ve así:

class Punto:
    """ La clase Punto representa y maneja pares (x, y) de coordenadas. """

    def __init__(self):
        """ Crea un nuevo punto en el origen """
        self.x = 0
        self.y = 0

Las reglas de sintaxis para definir una clase son similares a las de otras sentencias compuestas.

Si la primera línea después del header de la clase es un string, se convierte en el docstring de la clase, y será reconocido por varias herramientas.

Cada clase debe tener un método con el nombre especial __init__.

Así que utilicemos nuestra nueva clase Punto ahora:

p = Punto()         # Instancia un objeto de tipo Punto
q = Punto()         # Crea un segundo punto

print(p.x, p.y, q.x, q.y)  # Cada objeto punto tiene su propia x y su propia y

Este programa imprime:

0 0 0 0

Esto debería resultarnos conocido - ya habíamos usado clases para crear más de un objeto.

from turtle import Turtle

tess = Turtle()     # Instancia objetos de tipo Turtle
alex = Turtle()

Las variables p y q son referencias asignadas a dos nuevos objetos de tipo Punto. Una función como Turtle o Punto que crea una nueva instancia de objeto se llama constructor, y toda clase provee automáticamente un constructor que es llamado con el mismo nombre de la clase.

Es útil imaginar la clase como una fábrica para crear objetos. La clase misma no es una instancia de un punto, pero contiene la maquinaria necesaria para crear instancias de puntos.

El proceso combinado de "crear un nuevo objeto" y "hacer que sus atributos se inicialicen con los valores por defecto de fábrica" es lo que llamamos instanciación (de objetos).

15.3) Atributos

Como ocurre con los objetos del mundo real, las instancias de objetos tienen atributos y métodos.

Podemos modificar los atributos de una instancia usando la notación punto:

>>> p.x = 3
>>> p.y = 4

Ambos módulos e instancias crean sus propios espacios de nombres, y la sintaxis para acceder a los nombres contenidos en cada uno, llamados atributos, es la misma.

El diagrama de estados siguiente muestra el resultado de estas asignaciones:

La variable p se refiere a un objeto Punto, el cual contiene dos atributos. Cada atributo se refiere a un número.

Podemos acceder al valor de un atributo usando la misma sintaxis:

>>> print(p.y)
4
>>> x = p.x
>>> print(x)
3

La expresión p.x significa "ve al objeto al que se refiere p y toma el valor de su x".

El propósito de la notación punto es permitirnos indicar con precisión a qué variable nos estamos refiriendo exactamente.

Podemos utilizar la notación punto como parte de cualquier expresión, así que las siguientes sentencias son válidas:

print("(x={0}, y={1})".format(p.x, p.y))
distancia_al_origen_al_cuadrado = p.x * p.x + p.y * p.y

La primera línea muestra (x = 3, y = 4). La segunda línea calcula el valor 25.

15.4) Mejorando nuestro inicializador

Si quisiéramos crear un punto en la posición (7, 6), actualmente necesitaríamos 3 líneas de código:

p = Point()
p.x = 7
p.y = 6

Podemos hacer más general a nuestro constructor de clase si pasamos parámetros extra al método __init__, como se muestra en este ejemplo:

class Punto:
    """ La clase Punto representa y maneja pares (x, y) de coordenadas. """

    def __init__(self, x=0, y=0):
        """ Crea un nuevo punto de coordenadas x, y """
        self.x = x
        self.y = y

# Otras sentencias fuera de la clase se pueden escribir aquí

Los parámetros x e y son ambos opcionales. Si el llamador no pasa los argumentos correspondientes, tomarán los valores por defecto 0. Aquí tenemos nuestra clase mejorada en acción:

>>> p = Punto(4, 2)
>>> q = Punto(6, 3)
>>> r = Punto()       # r representa el origen (0, 0)
>>> print(p.x, q.y, r.x)
4 3 0

Una puntualización muy técnica...

Si nos ponemos quisquillosos, deberíamos argumentar que el docstring de __init__ es incorrecto. El método __init__ no crea el objeto (es decir, no aparta memoria para él), simplemente inicializa el objeto a sus valores por defecto de fábrica después de que éste ya ha sido creado.

Pero herramientas como PyScripter y similares tratan a la instanciación como una sola cosa en que ambos pasos - creación e inicialización - ocurren al mismo tiempo, y eligen mostrar el docstring del inicializador como la tooltip que guía al programador a la hora de llamar al constructor de clase.

Por lo tanto, escribimos el docstring que va a tener más sentido para el programador que esté usando nuestra clase Punto, cuando le sea mostrado como un pop-up:

15.5) Agregando otros métodos a nuestra clase

La gran ventaja de usar una clase como Punto en vez de una simple tupla (6, 7) se ve ahora con claridad. Podemos agregar métodos a la clase Punto que son operaciones sensibles sobre puntos, pero que no necesariamente serían apropiadas para otras tuplas como (25, 12) que podrían representar, digamos, un día y un mes, es decir, el día de Navidad. Así, calcular la distancia desde el origen es útil para puntos, pero no tiene sentido para datos del tipo (día, mes). Para datos del tipo (día, mes) nos interesarán otra clase de operaciones, tal vez encontrar en qué día de la semana cayó ese par (día, mes) en 2020.

Crear una clase como Punto brinda una gran cantidad de "poder organizador" a nuestros programas, así como a nuestra forma de pensar. Podemos agrupar las operaciones relevantes, y las clases de datos a las cuales aplican, y cada instancia de la clase puede tener su propio estado.

Un método se comporta como una función pero es invocado en una instancia específica, por ejemplo tess.right(90). Como los atributos de datos, los métodos se acceden mediante la notación punto.

Agreguemos otro método, distancia_al_origen, para ver mejor cómo funciona:

class Punto:
    """ La clase Punto representa y maneja pares (x, y) de coordenadas. """

    def __init__(self, x=0, y=0):
        """ Crea un nuevo punto de coordenadas x, y """
        self.x = x
        self.y = y

    def distancia_al_origen(self):
        """ Calcular mi distancia hasta el origen """
        return ((self.x ** 2) + (self.y ** 2)) ** 0.5

Creemos ahora algunas instancias de puntos, miremos a sus atributos, y llamemos nuestro nuevo método sobre ellas. (Debemos primero correr el programa, para hacer a la clase Punto visible para el intérprete).

>>> p = Punto(3, 4)
>>> p.x
3
>>> p.y
4
>>> p.distancia_al_origen()
5.0
>>> q = Punto(5, 12)
>>> q.x
5
>>> q.y
12
>>> q.distancia_al_origen()
13.0
>>> r = Point()
>>> r.x
0
>>> r.y
0
>>> r.distancia_al_origen()
0.0

Cuando definimos un método, el primer parámetro se refiere a la instancia que estamos manejando. Como ya hemos observado, es tradicional llamar a este parámetro self.

Observar que el llamador a distancia_al_origen no provee explícitamente un argumento que corresponda al parámetro self - eso lo hace Python internamente, sin nuestra intervención.

15.6) Instancias de argumentos y parámetros

Podemos pasar un argumento como parámetro del modo usual. Ya lo vimos en algunos de los ejemplos con tortugas, cuando pasamos el objeto turtle a algunas funciones como dibujar_barra en el capítulo 5 (Condicionales), para que la función pudiese controlar y usar la instancia turtle que le pasásemos.

Tener en cuenta que nuestra variable sólo contiene una referencia al objeto, así que pasarle tess a una función crea un alias: tanto el llamador como la función llamada tienen ahora una referencia a la misma tortuga!

Aquí hay una función sencilla que recibe un Punto como parámetro:

def print_punto(pt):
    print("({0}, {1})".format(pt.x, pt.y))

La función print_punto toma un punto como argumento y formatea el output en cualquier forma que elijamos.

15.7) Convirtiendo una instancia en un string

Muchos lenguajes de programación orientada a objetos probablemente no harían lo que acabamos de hacer con print_punto. Cuando trabajamos con clases y objetos, la alternativa recomendada es agregar un nuevo método a la clase. Y no nos gustan métodos charlatanes que incluyan llamados a la función print. Una mejor aproximación es tener un método que permita que cada instancia produzca una representación en formato string de sí misma. Llamémosla inicialmente to_string (por su significado en inglés). Tenemos:

class Punto:
    # ...

    def to_string(self):
        return "({0}, {1})".format(self.x, self.y)

Ahora podemos decir:

>>> p = Punto(3, 4)
>>> print(p.to_string())
(3, 4)

¿Pero no tenemos ya un convertidor de tipos str que puede convertir un objeto a string? Sí! ¿Y no usa print automáticamente dicho convertidor cuando imprime cosas? Sí, otra vez!

>>> str(p)
'<__main__.Punto object at 0x000001A2F5338160>'
>>> print(p)
<__main__.Punto object at 0x000001A2F5338160>

Python tiene un truco ingenioso bajo la manga para resolver esto. Si llamamos __str__ a nuestro nuevo método (en vez de to_string), el intérprete Python utilizará nuestro código cada vez que necesite convertir un Punto a un string. Hagámoslo otra vez, así:

class Punto:
    # ...

    def __str__(self):
        return "({0}, {1})".format(self.x, self.y)

Y ahora las cosas se ven muy bien:

>>> str(p)		# Python utiliza ahora el método __str__ que hemos escrito
'(3, 4)'
>>> print(p)
(3, 4)

15.8) Instancias como valores de retorno

Las funciones y métodos pueden retornar instancias. Por ejemplo, dados dos objetos Punto, encontrar su punto medio. Primero escribamos esto como una función común y corriente:

def punto_medio(p1, p2):
    """ Devuelve el punto medio entre los puntos p1 y p2 """
    mx = (p1.x + p2.x)/2
    my = (p1.y + p2.y)/2
    return Punto(mx, my)

La función crea y retorna un nuevo objeto Punto.

>>> p = Punto(3, 4)
>>> q = Punto(5, 12)
>>> r = punto_medio(p, q)
>>> print(r)
(4.0, 8.0)

Ahora implementemos esto como un método. Supongamos que tenemos un objeto Punto, y queremos encontrar el punto medio entre él y otro Punto.

class Punto:
    # ...

    def punto_medio(self, otro):
        """ Retorna el punto medio entre sí mismo (self) y el otro punto """
        mx = (self.x + otro.x)/2
        my = (self.y + otro.y)/2
        return Punto(mx, my)

Este método es idéntico a la función que escribimos antes, salvo por el hecho de que cambiaron algunos nombres. Se usaría así:

>>> p = Punto(3, 4)
>>> q = Punto(5, 12)
>>> r = p.punto_medio(q)
>>> print(r)
(4.0, 8.0)

Aunque el ejemplo anterior asigna cada punto a una variable, esto no es estrictamente necesario. Así como los llamados a funciones se pueden combinar, también los llamados a métodos e instanciaciones de objetos se pueden también componer. Esto nos lleva a una versión alternativa, que no usa variables:

>>> print(Punto(3, 4).punto_medio(Punto(5, 12)))
(4.0, 8.0)

15.9) Un cambio de perspectiva

La sintaxis para un llamado normal a función, imprimir_hora(hora_actual) da a entender que la función es el agente activo. Es como si dijera algo así: "Oye, imprimir_hora, aquí te paso algo para que imprimas!"

En programación orientada a objetos, en cambio, los objetos son los considerados como agentes activos. Un llamado a hora_actual.imprimir_hora() dice: "Oye, hora_actual, por favor, imprímete a tí mismo!"

En nuestra primera introducción a las tortugas, en el capítulo 3, utilizamos un estilo de programación orientado a objetos, escribiendo por ejemplo tess.forward(100), que es como pedirle a la tortuga misma que avance la cantidad solicitada de pasos.

Este cambio de perspectiva puede verse más prolijo, pero no necesariamente se ve a primera vista que también es mucho más útil. Lo cierto es que en muchos casos transferir la responsabilidad de las funciones a los objetos hace posible escribir métodos más versátiles, y hace más fácil el mantenimiento y la reutilización del código.

La ventaja más importante de la programación orientada a objetos es que hace coincidir nuestro modelo mental del problema más precisamente con lo que es nuestra experiencia cotidiana del mundo real.

La funcionalidad de los objetos de la vida real tiende a estar ligada a esos mismos objetos. La programación OOP nos permite imitar eso en nuestros propios programas.

15.10) Los objetos pueden tener estado

Los objetos son más útiles cuando podemos mantener en ellos un cierto estado que cambia de tiempo en tiempo. Consideremos un objeto turtle. Su estado consiste en cosas como su posición, la dirección en la que apunta, su color y su forma. Un método como left(90) modifica el estado de su dirección en que apunta; forward cambia su posición, etc.

Si tenemos un objeto cuenta_bancaria, un componente principal de su estado sería su balance actual, y tal vez un registro de todas las transacciones pasadas. Los métodos de este objeto nos permitirían consultar su balance actual, depositar nuevos fondos o hacer un pago. El método para hacer un pago recibiría un monto y una descripción, para que ésta pudiera ser agregada al registro de transacciones. También necesitaríamos un método para ver el registro de transacciones.

15.11) Glosario

15.12) Ejercicios

1) Reescribir la función distancia del capítulo 6, titulado Funciones fructíferas, para que tome dos Puntos como parámetros en vez de 4 números. (hacerlo)

 

2) Agregar un método reflejar_x a la clase Punto que retorne un nuevo Punto, el cual sea la reflexión del punto original respecto al eje Ox. Por ejemplo, Point(3, 5).reflejar_x() es el punto (3, -5). (hacerlo)

 

3) Agregar un método pendiente_desde_origen que retorne la pendiente de la recta que une el origen con el punto. Por ejemplo, (hacerlo)

>>> Punto(4, 10).pendiente_desde_origen()
2.5

 

4) La ecuación de una línea recta es "y = mx + n". Los coeficientes m y n describen completamente a la línea. Escribe un método en la clase Punto tal que si a una instancia de Punto se le pasa otro Punto, compute la ecuación de la línea recta que une a ambos puntos. Debe retornar los dos coeficientes como una tupla con dos valores. Por ejemplo, (hacerlo)

>>> print(Punto(4, 11).ecuacion_recta_hasta(Punto(6, 15)))
>>> (2, 3)

 

5) Dados cuatro puntos que pertenecen a una circunferencia, encontrar el centro de la misma. ¿Cuándo fallará esta función? (hacerlo)

 

6) Crear una nueva clase, SMS_archivo. La clase va a instanciar objetos SMS_archivo, de modo similar a como la bandeja de entrada o la bandeja de salida de un teléfono celular

mi_bandeja_entrada = SMS_archivo()

Este archivo puede guardar múltiples mensajes SMS (es decir su estado interno será simplemente una lista de mensajes). Cada mensaje se representará con una tupla:

(visto, desde_numero, hora_llegada, texto_del_SMS)

El objeto mi_bandeja_entrada debería proveer los siguientes métodos:

mi_bandeja_entrada.agregar_nuevo(desde_numero, hora_llegada, texto_del_SMS)
  # Crear una nueva tupla SMS, la inserta después de otros mensajes en el archivo.
  # Al crearse el mensaje, se setea el argumento visto a False

mi_bandeja_entrada.cantidad()
  # Retorna el número de mensajes SMS en mi_bandeja_entrada

mi_bandeja_entrada.get_indices_no_leidos()
  # Retorna una lista de índices de todos los SMS que todavía no fueron vistos

mi_bandeja_entrada.get_mensaje(i)
  # Retorna (desde_numero, hora_llegada, texto_del_SMS) del mensaje[i]
  # También cambia su estado a "visto"
  # Si no hay mensaje en la posición i, retorna None

mi_bandeja_entrada.borrar(i)     # Borra el mensaje de índice i
mi_bandeja_entrada.vaciar()       # Borra todos los mensajes de la bandeja de entrada

Escribe la clase, crea un objeto de tipo SMS_archivo, escribe tests para estos métodos e implementa los métodos.(hacerlo)