Cap. 23 - Herencia
23.1) Herencia
El concepto que más frecuentemente se asocia con la programación orientada a objetos es el de herencia.
- Herencia es la habilidad de definir una nueva clase que es una versión modificada de una clase existente.
La ventaja principal de esta funcionalidad es que puedes agregar métodos a la nueva clase sin modificar la original.
- Se llama herencia porque la nueva clase hereda todos los métodos de la clase existente.
- Extendiendo la metáfora, a la clase original se la llama a veces la clase padre. Y la nueva clase es la clase hijo o a veces una subclase.
La herencia es una funcionalidad poderosa.
- Algunos programas que serían complicados sin herencia pueden escribirse concisamente y con mucha sencillez gracias a ella.
- También, la herencia puede facilitar la reutilización de código, ya que se puede personalizar el comportamiento de las clases padre sin tener que modificarlas.
- En algunos casos, la estructura de la herencia refleja la estructura natural del problema, lo que facilita la comprensión del programa.
Por otro lado, la herencia puede dificultar la comprensión del código de un programa.
- Cuando se llama a un método, a veces no queda claro dónde uno puede encontrar su definición. El código relevante puede estar repartido entre varios módulos.
- También, muchas de las cosas que pueden hacerse con herencia pueden hacerse con la misma elegancia (e incluso más) sin ella.
- En conclusión: si la estructura natural del problema no se presta en sí misma a la herencia, este estilo de programación puede causar más problemas que soluciones.
En este capítulo mostraremos el uso de herencia como parte de un programa que juega al juego Old Maid (similar al Culo Sucio de la baraja española).
- Parte de nuestro objetivo será escribir código que podamos reutilizar para implementar más adelante otros juegos de cartas.
23.2) Una mano de cartas
En casi todos los juegos de cartas es necesario poder representar una mano de cartas.
- Una mano es similar a un mazo: ambas constan de un conjunto de cartas, y requieren operaciones como agregar y quitar cartas.
- También debemos tener la habilidad de mezclar tanto los mazos como las manos de cartas.
Una mano también es diferente que un mazo.
- Dependiendo del juego del que se trate, tendremos que realizar algunas operaciones sobre las manos que no tienen sentido para el mazo.
- Por ejemplo, en el poker tenemos que clasificar una mano (color, escalera, etc.) o compararla con otra mano.
- En bridge necesitamos calcular el puntaje de una mano para hacer una subasta.
Esta situación sugiere el uso de la herencia. Si Mano es una subclase de Mazo, tendrá todos los métodos de Mazo y se le pueden agregar nuevos métodos.
En este capítulo agregaremos código a nuestro archivo Cartas.py del capítulo anterior (L23_cartas.py).
- En la definición de clase, el nombre de la clase padre aparece entre paréntesis:
class Mano(Mazo): pass
Esta sentencia indica que la nueva clase Mano hereda de la clase Mazo ya existente.
El constructor de Mano inicializa los atributos para la mano, que son nombre y cartas.
- El string nombre identifica esta mano, probablemente por el nombre del jugador al que pertenece. El nombre es un parámetro opcional con un string vacío como valor por defecto.
- La lista cartas corresponde a las cartas que forman parte de la mano (es una lista de cartas). Se inicializa con una lista vacía.
class Mano(Mazo): def __init__(self, nombre=""): self.cartas = [] self.nombre = nombre
En casi todo juego de cartas es necesario tomar y dejar cartas para agregarlas y quitarlas de una mano.
- Quitar cartas de una mano ya está contemplado, porque Mano hereda la función remover de Mazo.
- Pero tenemos que implementar una función agregar:
class Mano(Mazo): . . . def add(self, carta): self.cartas.append(carta)
Como siempre, los 3 puntos suspensivos indican que hemos omitido otros métodos de la clase. El método append de lista agrega una nueva carta al final de la lista de cartas.
23.3) Repartiendo cartas
Ahora que tenemos una clase Mano, queremos repartir cartas del Mazo en varias manos.
- No es inmediatamente obvio si este método debería ir en la clase Mazo o en la clase Mano.
- Sin embargo, como opera sobre un mazo único y sobre (posiblemente) varias manos, es más natural asociarlo al Mazo.
El método repartir debería ser suficientemente general, ya que los distintos juegos tienen diversas formas de repartir.
- Podemos querer desde repartir todo el mazo de una vez hasta dar una sola carta a cada mano.
El método repartir toma dos parámetros, una lista (o tupla) de manos y el número total de cartas a repartir.
- Si no hay suficientes cartas en el mazo, el método reparte todas las cartas que tiene disponibles y se detiene.
class Mazo: . . . def esta_vacio(self): return self.cartas == [] def repartir(self, manos, cantidad_cartas=999): cantidad_manos = len(manos) for i in range(cantidad_cartas): if self.esta_vacio(): break # Break si no quedan cartas carta = self.pop() # Tomar la carta de arriba mano = manos[i % cantidad_manos] # De quién es el próximo turno? mano.add(carta) # Agregar la carta a la mano
El segundo parámetro, cantidad_cartas, es opcional: el valor por defecto es un número muy grande, lo que implica que se reparten todas las cartas del mazo.
La variable i del loop va desde 0 hasta cantidad_cartas - 1. En cada iteración se quita una carta del mazo mediante el método pop, que elimina y devuelve el último elemento de la lista.
El operador módulo (%) nos permite repartir cartas en ronda (una carta por vez a cada mano).
- Cuando i es igual a la cantidad de manos en la lista (o es un múltiplo), la expresión i % cantidad_manos vuelve al inicio de la lista (índice 0).
23.4) Imprimiendo una mano
Para imprimir el contenido de una mano, podemos aprovechar el método __str__ heredado de Mazo. Por ejemplo:
>>> mazo = Mazo() >>> mazo.mezclar() >>> mano = Mano("Franco") >>> mazo.repartir([mano], 5) >>> print(mano) 7 de Picas 9 de Tréboles 7 de Tréboles 5 de Tréboles Sota de Diamantes
No es una gran mano, pero podría dar pie a una escalera de tréboles.
Si bien es conveniente heredar los métodos existentes, hay información adicional en un objeto Mano que también podemos querer imprimir.
- Para hacerlo, podemos implementar un método __str__ en la clase Mano que sobreescriba el de la clase Mazo:
class Mano(Mazo): . . . def __str__(self): s = "Mano " + self.nombre if self.esta_vacio(): s += " está vacía\n" else: s += " contiene\n" return s + Mazo.__str__(self)
Al principio, s es un string que identifica la mano.
- Si ésta está vacía, el programa agrega las palabras " está vacía" y devuelve s.
- En caso contrario, el programa agrega la palabra " contiene" y la representación como string de la mano, llamando al método __str__ de la clase Mazo.
Puede parecer extraño enviar a self (que se refiere a una mano) al método de la clase Mazo, pero debemos recordar que definimos una Mano como un tipo de Mazo.
- Los objetos Mano pueden hacer todo lo que los Mazo pueden hacer, así que es legal enviarle una Mano a un método de Mazo.
En general, siempre es legal usar una instancia de una subclase en lugar de una instancia de una clase ancestro.
23.5) La clase JuegoCartas
La clase JuegoCartas se ocupa de algunos aspectos centrales comunes a casi todos los juegos de cartas, como crear un mazo y mezclarlo.
class JuegoCartas: def __init__(self): self.mazo = Mazo() self.mazo.mezclar()
Este es el primer ejemplo que vemos de un método de inicialización que hace una computación importante, más allá de la mera asignación de valores a los atributos.
Para implementar juegos específicos, podemos heredar de JuegoCartas y agregar funcionalidades al nuevo juego. Como ejemplo, escribiremos una simulación de la Solterona.
- Observación: este juego inglés, Old Maid, que aquí traducimos por Solterona, corresponde al que en español se conoce como Culo Sucio o As de Oro.
El objetivo de la Solterona es deshacerte de las cartas que tienes en tu mano. Lo haces mediante coincidencias de valor y palo.
- Por ejemplo, el 4 de Tréboles hace un match con el 4 de Picas ya que ambos palos son negros.
- La sota de Corazones hace un match con la sota de Diamantes ya que ambos son rojos.
Al comenzar el juego, la Reina de Tréboles es removida del mazo, por lo cual la Reina de Picas no tiene correspondiente.
- Las cincuenta y un cartas restantes se reparten a los jugadores en ronda.
- Después del reparto, cada jugador descarta todos los pares de cartas que le sea posible.
Cuando ya no se pueden hacer pares, comienza el juego. Por turno, cada jugador toma una carta (sin verla) de su vecino sentado a la izquierda (el primero que todavía tenga cartas).
- Si la carta elegida hace par con alguna de las que el jugador tiene en la mano, puede descartar el par. En caso contrario, la carta queda agregada a su mano.
- Tarde o temprano todos los pares posibles serán descartados, dejando sólo a un jugador (el perdedor) con una carta en la mano: la Reina de Picas.
En nuestra simulación computarizada del juego, la computadora jugará todas las manos.
- Desafortunadamente, algunos matices del juego real se pierden.
- En una partida real, el jugador que tiene la Reina de Picas tratará de hacer que su vecino tome esa carta, tal vez mostrándola un poco de más, o un poco de menos, o del modo que sea.
- En cambio, en nuestra versión la computadora tomará una carta del vecino completamente al azar.
23.6) La clase ManoSolterona (OldMaidHand)
Una mano para jugar La Solterona requiere ciertas capacidades que van más allá de las de una mano normal.
- Definiremos una nueva clase, ManoSolterona, que heredará de Mano y aportará un método adicional llamado descartar_pares:
class ManoSolterona(Mano): def descartar_pares(self): cuenta = 0 cartas_iniciales = self.cartas[:] for carta in cartas_iniciales: match = Carta(3 - carta.palo, carta.valor) if match in self.cartas: self.cartas.remove(carta) self.cartas.remove(match) print("Mano {0}: {1} se corresponde con {2}" .format(self.nombre, carta, match)) cuenta += 1 return cuenta
Comenzamos por hacer una copia de la lista de cartas, para poder ir recorriendo la copia al mismo tiempo que vamos eliminando cartas de la lista original.
- Esto es porque como vamos modificando self.cartas durante el loop, no sería buena idea utilizarla para controlar la recorrida.
- Python (y lo mismo cualquier otro lenguaje de programación) podría confundirse bastante si tratara de recorrer una lista que está cambiando!
Para cada carta en la mano, averiguamos primero cuál sería su carta correspondiente (match).
- La carta correspondiente tiene el mismo valor y el otro palo del mismo color.
- La expresión 3 - carta.palo convierte un Tréboles (palo 0) en Picas (palo 3) y Diamantes (palo 1) en Corazones (palo 2). Puedes verificar tú mismo que al revés también funciona.
- Si la carta correspondiente (match) está también en la mano, ambas cartas son quitadas de la misma.
El siguiente ejemplo muestra cómo se usa descartar_pares:
>>> juego = JuegoCartas() >>> mano = ManoSolterona("Franco") >>> juego.mazo.repartir([mano], 13) >>> print(mano) Mano Franco contiene 7 de Diamantes 6 de Picas 2 de Picas Sota de Corazones Sota de Tréboles Sota de Diamantes Rey de Picas Rey de Tréboles 5 de Diamantes 10 de Corazones Reina de Corazones 5 de Picas 3 de Diamantes >>> mano.descartar_pares() Mano Franco: Sota de Corazones se corresponde con Sota de Diamantes Mano Franco: Rey de Picas se corresponde con Rey de Tréboles 2 >>> print(mano) Mano Franco contiene 7 de Diamantes 6 de Picas 2 de Picas Sota de Tréboles 5 de Diamantes 10 de Corazones Reina de Corazones 5 de Picas 3 de Diamantes
Observar que la clase ManoSolterona no tiene método __init__ (no le hemos implementado uno).
- Es que lo hereda automáticamente de la clase Mano (e implementarlo es opcional, sólo necesario si quisiéramos modificar el método del ancestro).
23.7) La clase JuegoSolterona (OldMaidGame)
Ahora podemos ocuparnos del juego propiamente dicho.
- JuegoSolterona será una subclase de JuegoCartas con un nuevo método llamado jugar que recibe una lista de jugadores como parámetro.
- Como __init__ es heredado de JuegoCartas, un nuevo objeto JuegoSolterona contiene automáticamente un mazo mezclado:
class JuegoSolterona(JuegoCartas): def jugar(self, nombres): # Eliminar Reina de Tréboles self.mazo.remover(Carta(0,12)) # Crear una mano para cada jugador self.manos = [] for nombre in nombres: self.manos.append(ManoSolterona(nombre)) # Repartir las cartas self.mazo.repartir(self.manos) print("---------- Las cartas fueron repartidas") self.print_manos() # Eliminar pares coincidentes iniciales matches = self.descartar_todos_los_pares() print("---------- Descartados los pares inicialies, comienza el juego") self.print_manos() # Jugar hasta que las 50 cartas se hayan descartado turno = 0 cantidad_manos = len(self.manos) while matches < 25: matches += self.jugar_un_turno(turno) turno = (turno + 1) % cantidad_manos print("---------- Fin del Juego") self.print_manos()
Dejamos como ejercicio la implementación de print_manos().
- Respuesta: implementación de print_manos:
def print_manos(self): for mano in self.manos: print(mano)
Algunos de los pasos del juego fueron separados en métodos. Por ejemplo descartar_todos_los_pares recorre la lista de manos y llama a descartar_pares para cada una:
class JuegoSolterona(JuegoCartas): . . . def descartar_todos_los_pares(self): cantidad = 0 for mano in self.manos: cantidad += mano.descartar_pares() return cantidad
La variable cantidad es un acumulador que lleva la cuenta de la cantidad de pares descartados en cada mano. Cuando hemos recorrido todas las manos, se retorna el valor total (cantidad).
Cuando la cantidad total de pares descartados (matches) llega a 25, sabemos que 50 cartas han sido quitadas del mazo y por lo tanto sólo queda una carta: el juego ha terminado.
La variable turno indica qué jugador tiene el turno de jugar.
- Comienza valiendo 0 y se incrementa de a uno. Cuando alcanza cantidad_manos el operador módulo lo baja nuevamente a 0.
El método jugar_un_turno recibe un parámetro que indica de quién es el turno. El valor de retorno es la cantidad de pares descartados durante este turno.
class JuegoSolterona(JuegoCartas): . . . def jugar_un_turno(self, i): if self.manos[i].esta_vacio(): return 0 vecino = self.encontrar_vecino(i) carta_tomada = self.manos[vecino].pop() self.manos[i].add(carta_tomada) print("Mano", self.manos[i].nombre, "tomó", carta_tomada) cantidad = self.manos[i].descartar_pares() self.manos[i].mezclar() return cantidad
Si la mano de un jugador está vacía, ese jugador ya no puede jugar, por lo que no hace nada y retorna 0.
En caso contrario, un turno consiste en encontrar al primer jugador a la izquierda que tenga cartas, tomar una carta del mismo, y chequear si hay pares que se puedan descartar ahora en mi mano.
- Antes de retornar, las cartas en la mano son mezcladas de forma que la elección del siguiente jugador sea aleatoria.
El método encontrar_vecino comienza con el jugador que está inmediatamente a la izquierda y continúa circulando hasta encontrar al primer jugador que todavía tenga cartas:
class JuegoSolterona(JuegoCartas): . . . def encontrar_vecino(self, i): cantidad_manos = len(self.manos) for proximo in range(1,cantidad_manos): vecino = (i + proximo) % cantidad_manos if not self.manos[vecino].esta_vacio(): return vecino
Si encontrar_vecino llegara a recorrer todo el círculo de jugadores sin encontrar vecino, retornaría None y provocaría un error en algún paso posterior del programa.
- Afortunadamente, se puede probar que eso nunca ocurrirá (siempre y cuando el fin del juego se detecte correctamente)
- La razón: la única forma de no encontrar vecinos con cartas es que sólo un jugador tenga cartas, y es fácil probar que en tal caso esa carta sería la Solterona y el juego ya habría terminado.
El siguiente output es el de una corrida completa del juego para tres jugadores:
>>> juego = JuegoSolterona() >>> juego.jugar(["Quico", "Chavo", "Chilindrina"]) ---------- Las cartas fueron repartidas Mano Quico contiene 8 de Diamantes 4 de Tréboles 8 de Tréboles 4 de Diamantes Reina de Diamantes Rey de Picas Rey de Corazones 7 de Diamantes 6 de Tréboles 10 de Tréboles Rey de Tréboles 6 de Corazones As de Diamantes 8 de Picas 2 de Diamantes 4 de Corazones 7 de Corazones Mano Chavo contiene 5 de Diamantes 7 de Tréboles 4 de Picas 10 de Corazones 3 de Tréboles 10 de Diamantes 8 de Corazones 5 de Corazones Rey de Diamantes 6 de Picas 3 de Corazones Sota de Tréboles As de Tréboles Reina de Corazones 9 de Tréboles 5 de Picas Sota de Corazones Mano Chilindrina contiene 6 de Diamantes Sota de Diamantes 7 de Picas 9 de Diamantes 3 de Picas 2 de Picas As de Corazones 9 de Corazones Sota de Picas 2 de Tréboles 3 de Diamantes 10 de Picas 2 de Corazones 5 de Tréboles 9 de Picas Reina de Picas As de Picas Mano Quico: 8 de Tréboles se corresponde con 8 de Picas Mano Quico: 4 de Diamantes se corresponde con 4 de Corazones Mano Quico: Rey de Picas se corresponde con Rey de Tréboles Mano Quico: 7 de Diamantes se corresponde con 7 de Corazones Mano Chavo: 5 de Diamantes se corresponde con 5 de Corazones Mano Chavo: 10 de Corazones se corresponde con 10 de Diamantes Mano Chilindrina: 9 de Diamantes se corresponde con 9 de Corazones Mano Chilindrina: 2 de Picas se corresponde con 2 de Tréboles ---------- Descartados los pares inicialies, comienza el juego Mano Quico contiene 8 de Diamantes 4 de Tréboles Reina de Diamantes Rey de Corazones 6 de Tréboles 10 de Tréboles 6 de Corazones As de Diamantes 2 de Diamantes Mano Chavo contiene 7 de Tréboles 4 de Picas 3 de Tréboles 8 de Corazones Rey de Diamantes 6 de Picas 3 de Corazones Sota de Tréboles As de Tréboles Reina de Corazones 9 de Tréboles 5 de Picas Sota de Corazones Mano Chilindrina contiene 6 de Diamantes Sota de Diamantes 7 de Picas 3 de Picas As de Corazones Sota de Picas 3 de Diamantes 10 de Picas 2 de Corazones 5 de Tréboles 9 de Picas Reina de Picas As de Picas Mano Quico tomó Sota de Corazones Mano Chavo tomó As de Picas Mano Chavo: As de Tréboles se corresponde con As de Picas Mano Chilindrina tomó 10 de Tréboles Mano Chilindrina: 10 de Picas se corresponde con 10 de Tréboles Mano Quico tomó 5 de Picas Mano Chavo tomó 9 de Picas Mano Chavo: 9 de Tréboles se corresponde con 9 de Picas Mano Chilindrina tomó 8 de Diamantes Mano Quico tomó 8 de Corazones Mano Chavo tomó 2 de Corazones Mano Chilindrina tomó 6 de Corazones Mano Chilindrina: 6 de Diamantes se corresponde con 6 de Corazones Mano Quico tomó 6 de Picas Mano Quico: 6 de Tréboles se corresponde con 6 de Picas Mano Chavo tomó As de Corazones Mano Chilindrina tomó 4 de Tréboles Mano Quico tomó 3 de Corazones Mano Chavo tomó 3 de Picas Mano Chavo: 3 de Tréboles se corresponde con 3 de Picas Mano Chilindrina tomó 2 de Diamantes Mano Quico tomó Rey de Diamantes Mano Quico: Rey de Corazones se corresponde con Rey de Diamantes Mano Chavo tomó 5 de Tréboles Mano Chilindrina tomó 5 de Picas Mano Quico tomó 7 de Tréboles Mano Chavo tomó 2 de Diamantes Mano Chavo: 2 de Corazones se corresponde con 2 de Diamantes Mano Chilindrina tomó 3 de Corazones Mano Chilindrina: 3 de Diamantes se corresponde con 3 de Corazones Mano Quico tomó Reina de Corazones Mano Quico: Reina de Diamantes se corresponde con Reina de Corazones Mano Chavo tomó Reina de Picas Mano Chilindrina tomó As de Diamantes Mano Quico tomó Reina de Picas Mano Chavo tomó 4 de Tréboles Mano Chavo: 4 de Picas se corresponde con 4 de Tréboles Mano Chilindrina tomó Reina de Picas Mano Quico tomó As de Corazones Mano Chavo tomó Sota de Diamantes Mano Chilindrina tomó 8 de Corazones Mano Chilindrina: 8 de Diamantes se corresponde con 8 de Corazones Mano Quico tomó 5 de Tréboles Mano Chavo tomó As de Diamantes Mano Chilindrina tomó 5 de Tréboles Mano Chilindrina: 5 de Picas se corresponde con 5 de Tréboles Mano Quico tomó Sota de Diamantes Mano Quico: Sota de Corazones se corresponde con Sota de Diamantes Mano Chavo tomó 7 de Picas Mano Chilindrina tomó 7 de Tréboles Mano Quico tomó As de Diamantes Mano Quico: As de Corazones se corresponde con As de Diamantes Mano Chavo tomó Sota de Picas Mano Chavo: Sota de Tréboles se corresponde con Sota de Picas Mano Chilindrina tomó 7 de Picas Mano Chilindrina: 7 de Tréboles se corresponde con 7 de Picas ---------- Fin del Juego Mano Quico está vacía Mano Chavo está vacía Mano Chilindrina contiene Reina de Picas
Así que perdió la Chilindrina.
23.8) Glosario
- herencia, clase padre/ancestro, subclase
23.9) Ejercicios
1) Agregar un método print_manos a la clase JuegoSolterona que recorra self.manos e imprima cada mano.
2) Define un nuevo tipo de Turtle (tortuga), TurtleGTX, que venga con las siguientes funcionalidades extra:
- Puede saltar a cierta distancia, y tiene un odómetro que lleva la cuenta de qué tan lejos se ha movido la tortuga desde que salió de la línea de producción.
- (La clase padre tiene varios sinónimos como fd, forward, back, backward y bk: para este ejercicio enfócate en poner esta funcionalidad dentro del método forward)
- Piensa con cuidado cómo contar la distancia si se le pide a la tortuga que avance (forward) una cantidad negativa.
- (No quisiéramos comprar una tortuga de segunda mano cuyo odómetro nos engañara porque el dueño anterior solía moverse en reversa. Puedes probarlo con un auto de verdad, y fijarte si el odómetro cuenta hacia arriba o hacia abajo cuando se va en reversa)
- (La respuesta es que el odómetro siempre cuenta hacia arriba, porque lo que se recorra se recorre, sea para donde sea: o sea que un movimiento en reversa ha de contarse positivo)
3) Luego de viajar cierta distancia al azar, tu tortuga debería detenerse por una rueda pinchada.
- Cuando esto ocurra, lanza una excepción donde sea que forward sea llamada.
- También deberás proveer un método cambiar_rueda que permita corregir el pinchazo.