Cap. 16 - Clases y objetos II

16.1) Rectángulos

Digamos que queremos implementar una clase para representar rectángulos en el plano XY. La pregunta es, ¿qué información debemos proveer para especificar dicho rectángulo? Para mantener las cosas simples, asumiremos que el rectángulo está orientado vertical u horizontalmente, es decir nunca en ángulos oblicuos.

Hay unas pocas posibilidades:

Una elección convencional es especificar la esquina superior izquierda del rectángulo, y su tamaño.

De modo similar a como hicimos con la clase Punto en el capítulo anterior, definiremos una clase Rectangulo, y le implementaremos un inicializador y un método conversor de strings:

class Rectangulo:
    """ Clase para crear y manejar objetos rectángulo """

    def __init__(self, esq, anc, alt):
        """ Inicializa un rectángulo en la posición dada por esquina, con cierto ancho y alto """
        self.esquina = esq
        self.ancho = anc
        self.alto = alt

    def __str__(self):
        return  "({0}, {1}, {2})".format(self.esquina, self.ancho, self.alto)

caja = Rectangulo(Punto(0, 0), 100, 200)
bomba = Rectangulo(Punto(100, 80), 5, 10)    # En un juego de video
print("caja: ", caja)
print("bomba: ", bomba)

Para especificar la esquina superior izquierda, hemos embebido un objeto Punto dentro de nuestro nuevo objeto Rectangulo. Creamos dos nuevos objetos Rectangulo y luego los imprimimos, lo que produce el siguiente output:

caja:  ((0, 0), 100, 200)
bomba:  ((100, 80), 5, 10)

El operador punto compone. La expresión caja.esquina.x significa "ve al objeto al que se refiere caja, selecciona su atributo llamado esquina, y ve a ese objeto y selecciona su atributo llamado x".

La figura muestra el estado de este objeto:

16.2) Los objetos son mutables

Podemos cambiar el estado de un objeto haciendo una asignación a uno de sus atributos.

caja.ancho += 50
caja.alto += 100

Por supuesto, sería preferible tener un método que permita encapsular estas acciones dentro de la clase. Y aprovechemos para agregar también un método que cambie la posición del rectángulo:

class Rectangulo:
   # ...

    def crecer(self, delta_ancho, delta_alto):
        """ Crecer (o achicar) este objeto según sus deltas """
        self.ancho += delta_ancho
        self.alto += delta_alto

    def mover(self, dx, dy):
        """ Move este objeto según los deltas (dx y dy) """
        self.esquina.x += dx
        self.esquina.y += dy

Probemos los nuevos métodos:

>>> r = Rectangulo(Punto(10, 5), 100, 50)
>>> print(r)
((10, 5), 100, 50)
>>> r.crecer(25, -10)
>>> print(r)
((10, 5), 125, 40)
>>> r.mover(-10, 10)
>>> print(r)
((0, 15), 125, 40)

16.3) Igualdad (Sameness)

El significado de la palabra "igual" parece muy claro hasta que nos ponemos a pensar en él, y entonces nos damos cuenta que implica más cosas de las que se ven a primera vista.

Por ejemplo, si decimos que "Alicia y Bernardo tienen el mismo auto", queremos decir que el auto de ambos son de la misma marca y modelo, pero queda claro que se trata de dos autos diferentes. Si en cambio decimos "Alicia y Bernardo tienen la misma madre", significamos que la madre de ambos es una sola y la misma persona.

Cuando hablamos de objetos, hay una ambigüedad similar. Por ejemplo, si dos objetos Punto son el mismo, ¿significa esto que contienen los mismos datos (coordenadas) o que son realmente un solo y el mismo objeto?

Hemos visto el operador is en la lección sobre las listas, en que hablamos de los alias: nos permite saber si dos referencias se refieren al mismo objeto.

>>> p1 = Punto(3, 4)
>>> p2 = Punto(3, 4)
>>> p1 is p2
False

Incluso si p1 y p2 contienen las mismas coordenadas, no son el mismo objeto. Si en cambio asignamos p1 a p3, entonces las dos variables son alias de un único objeto:

>>> p3 = p1
>>> p1 is p3
True

Este tipo de igualdad se llama igualdad superficial porque sólo compara las referencias, no los contenidos de los objetos.

Para comparar los contenidos de los objetos - igualdad profunda - debemos escribir una función llamada mismas_coordenadas:

def mismas_coordenadas(p1, p2):
    return (p1.x == p2.x) and (p1.y == p2.y)

Ahora si creamos dos objetos distintos que contengan los mismos datos, podemos usar mismas_coordenadas para comprobar si representan puntos con las mismas coordenadas.

>>> p1 = Punto(3, 4)
>>> p2 = Punto(3, 4)
>>> mismas_coordenadas(p1, p2)
True

Por supuesto, si las dos variables se refieren al mismo objeto, tendrán ambas igualdad superficial e igualdad profunda.

Cuidado con el ==

"Cuando yo uso una palabra", decía Humpty Dumpty, en un tono un tanto desdeñoso, "quiere decir lo que yo quiero que quiera decir - ni más ni menos". Alicia en el País de las Maravillas

Python tiene la característica muy peculiar de que permite al diseñador de una clase definir lo que operaciones como == o < deberían significar.

  • (Ya vimos cómo podemos controlar cómo nuestros objetos pueden convertirse a strings, así que ya hemos visto algo similar!)

Veremos el tema con más detalle más adelante, lo importante aquí es observar que según el caso el implementador de la clase elegirá utilizar una semántica de igualdad superficial, y en otros una de igualdad profunda, como se ve en el siguiente experimento:

p = Punto(4, 2)
s = Punto(4, 2)
print("== entre Puntos retorna", p == s)
# Por defecto, == entre objetos Punto hace un test de igualdad superficial

a = [2,3]
b = [2,3]
print("== entre listas retorna",  a == b)
# Por defecto, == entre listas hace un test de igualdad profundo

Lo que da el siguiente output:

== entre Puntos retorna False
== entre listas retorna True

Observamos que incluso cuando las dos listas (o tuplas, etc.) son objetos distintos con diferentes direcciones de memoria, para listas el operador == hace un chequeo de igualdad profunda, mientras que en el caso de la clase Punto hace un chequeo superficial de igualdad.

16.4) Copia

El uso de alias puede hacer que un programa sea difícil de leer porque cambios hechos en un lugar pueden provocar cambios inesperados en otro lugar. Es difícil llevar la cuenta de todas las variables que podrían estarse refiriendo al mismo objeto.

La copia de objetos es con frecuencia una alternativa recomendable al uso de alias. El módulo copy contiene una función copy que permite duplicar un objeto:

>>> import copy
>>> p1 = Punto(3, 4)
>>> p2 = copy.copy(p1)
>>> p1 is p2
False
>>> mismas_coordenadas(p1, p2)
True

Una vez importado el módulo copy, podemos utilizar la función copy para crear un nuevo Punto. Los puntos p1 y p2 no son el mismo punto, pero contienen datos idénticos.

Para copiar un objeto simple como Point, que no contiene objetos embebidos, copy es suficiente. Esto se conoce como copia superficial.

Para algo como Rectangulo, que contiene una referencias a un Punto, copy no hace lo que esperaríamos. Copia la referencia al objeto Punto, así que ahora el viejo Rectangulo y el nuevo se refieren al mismo Punto.

Si creamos una caja, b1, del modo habitual y hacemos una copia, b2 usando copy, el diagrama de estados resultantes será así:

Esto muy probablemente no es lo que esperábamos. En este caso, si llamamos a crecer en uno de los objetos Rectangulo, el otro no se vería afectado, pero si llamamos a mover en cualquiera de ellos, ese llamado afectará al otro. Un comportamiento así es confuso y muy proclive a provocar errores. La copia superficial ha creado un alias al Punto que representa la esquina.

Afortunadamente, el módulo copy contiene una función llamada deepcopy que copia no sólo el objeto sino también sus objetos embebidos. No sorprenderá saber que una operación de este tipo se llama copia profunda.

>>> b2 = copy.deepcopy(b1)

Ahora b2 y b1 son objetos completamente separados.

16.5) Glosario

16.6) Ejercicios

1) Agregar un método area a la clase Rectangulo que devuelva el área de cualquier instancia: (hacerlo)

r = Rectangulo(Punto(0, 0), 10, 5)
test(r.area() == 50)

 

2) Escribir un método permetro en la clase Rectangulo que devuelva el perímetro de cualquier instancia de rectángulo: (hacerlo)

r = Rectangulo(Punto(0, 0), 10, 5)
test(r.perimetro() == 30)

 

3) Escribir un método flipar en la clase Rectangulo que intercambio el alto y el ancho de cualquier instancia de rectángulo: (hacerlo)

r = Rectangulo(Punto(100, 50), 10, 5)
test(r.ancho == 10 and r.alto == 5)
r.flip()
test(r.ancho == 5 and r.alto == 10)

 

4) Escribir un nuevo método en la clase Rectangulo que revise si un Punto cae dentro del rectángulo. Para este ejercicio, asumir que rectángulo en (0, 0) con ancho 10 y alto 5 tiene bordes abiertos en los extremos correspondientes a ancho y alto. Es decir, se extiende según las x en el intervalo [0, 10), en el cual 0 está incluido pero 10 está excluido, y en el intervalo [0, 5) en el eje de las y.

Debería pasar estos tests: (hacerlo)

r = Rectangulo(Punto(0, 0), 10, 5)
test(r.contiene(Punto(0, 0)))
test(r.contiene(Punto(3, 3)))
test(not r.contiene(Punto(3, 7)))
test(not r.contiene(Punto(3, 5)))
test(r.contiene(Punto(3, 4.99999)))
test(not r.contiene(Punto(-3, -3)))

 

5) En juegos, solemos ubicar un cuadro delimitador (bounding box, o hitbox) alrededor de nuestros sprites.

Podemos entonces hacer detección de colisiones entre objetos del juego, por ejemplo bombas y naves espaciales, revisando si sus rectángulos delimitadores se superponen o no.

Escribe una función para determinar si dos rectángulos colisionan. (hacerlo)