Cap. 22 - Colecciones de objetos
22.1) Composición
A estas alturas ya hemos visto varios casos de composición.
- Uno de los primeros ejemplos fue usar un llamado a un método como parte de una expresión.
- Otro ejemplo es la estructura anidada de sentencias; podemos poner una sentencia if dentro de un loop while, que a su vez puede estar dentro de otra sentencia if, y así sucesivamente.
Habiendo visto este patrón, y habiendo ya aprendido sobre listas y objetos, no deberíamos sorprendernos al enteranos de que podemos crear listas de objetos. También podemos crear objetos que contienen listas (como atributos); podemos crear listas que contienen listas; podemos crear objetos que contienen objetos, etc.
En este capítulo y en el siguiente veremos algunos ejemplos de estas combinaciones, utilizando el objeto Carta como ejemplo.
22.2) Objetos CARTA
Si no estás familiarizado con las cartas de la baraja inglesa, te recomendamos que consigas un mazo, de lo contrario este capítulo no va a tener mucho sentido.
- Hay 52 cartas en un mazo de cartas inglesas, cada una de las cuales corresponde a uno de 4 palos y uno de 13 valores posibles.
- Los palos son Picas, Corazones, Diamantes y Tréboles (en orden descendente si se trata del juego del bridge).
- Los valores son As, 2, 3, 4, 5, 6, 7, 8, 9, 10, J (Jack), Q (Queen) y K (King). En algunos juegos se considera al As por encima del Rey en rango, en otros por debajo del 2.
Si queremos definir un nuevo objeto que represente una carta de esta baraja, es obvio que los dos atributos deberían ser palo y valor. No es obvio qué tipo de atributos deberían ser.
- Una posibilidad es utilizar strings que contengan palabras como "Tréboles" para los palos o "Reina" para los valores.
- Un problema con esta implementación es que no sería fácil comparar cartas para ver cuál tiene un valor mayor o un palo más importante.
La alternativa es usar enteros para codificar valores y palos.
- Aquí "codificar" no se refiere a crear una especie de código secreto, como suele entenderse en la vida cotidiana.
- Para un informático, "codificar" se refiere a definir una correspondencia entre una secuencia de números y los elementos que representan. Por ejemplo:
Picas --> 3 Corazones --> 2 Diamantes --> 1 Tréboles --> 0
- Una obvia propiedad de esta correspondencia es que los palos mapean a enteros en orden, para que podamos comparar palos comparando enteros.
El mapeo para valores es bastante obvio. Cada uno de los valores numéricos mapea a su entero correspondiente, y para las figuras:
Jack --> 11 Queen --> 12 King --> 13
La razón por la que estamos usando notación matemática para estas correspondencias es que no forman parte del programa Python.
- Son parte del diseño del programa, pero nunca aparecen explícitamente en el código.
La definición de la clase Carta se ve así:
class Carta: def __init__(self, palo=0, valor=0): self.palo = palo self.valor = valor
Como de costumbre, nuestro método de inicialización tiene un parámetro opcional para cada atributo.
Para crear algunos objetos, que representen por ejemplo al 3 de Tréboles y al 11 de Diamantes, usamos estos comandos:
tres_de_treboles = Carta(0, 3) carta1 = Carta(1, 11)
En el primer caso anterior, por ejemplo, el primer argumento 0 representa el palo Tréboles.
Salva este código para uso posterior
En el próximo capítulo asumiremos que tenemos guardada la clase Carta, y también la clase Mazo que definiremos a continuación, en un archivo llamado Cartas.py
22.3) Atributos de clase y el método __str__
Para imprimir objetos Carta de un modo que la gente pueda leer fácilmente, queremos mapear los códigos enteros en palabras. Una forma natural de hacerlo es con listas de strings. Asignamos estas listas a atributos de clase en las primeras líneas de la definición de la clase:
class Carta: palos = ["Tréboles", "Diamantes", "Corazones", "Picas"] valores = ["cero", "As", "2", "3", "4", "5", "6", "7", "8", "9", "10", "Sota", "Reina", "Rey"] def __init__(self, palo=0, valor=0): self.palo = palo self.valor = valor def __str__(self): return self.valores[self.valor] + " de " + self.palos[self.palo]
Un atributo de clase se define fuera de cualquier método, y puede ser accedido por cualquiera de los métodos de la clase.
Dentro de __str__, podemos usar palos y valores para mapear los valores numéricos de palo y valor a strings.
- Por ejemplo, la expresión self.palos[self.palo] significa utiliza el atributo palo del objeto self como un índice en el atributo de clase llamado palos, para obtener el string apropiado.
La razón por la que tenemos un "cero" como primer elemento en valores es para actuar como el elemento 0 de la lista, que nunca va a ser usado. Los únicos valores válidos van de 1 a 13.
- Este valor desperdiciado no era estrictamente necesario. Podríamos haber empezado poniendo el As en 0, pero era menos confuso que el valor de índice 2 correspondiera al 2, el de índice 3 al 3, etc.
Con los métodos que implementamos hasta aquí, podemos crear e imprimir cartas:
>>> carta1 = Carta(1, 11) >>> print(carta1) Sota de Diamantes
Los atributos de clase como palos son compartidos por todos los objetos Carta. La ventaja de esto es que podemos utilizar cualquier objeto Carta para acceder a dichos atributos:
>>> carta2 = Carta(1, 3) >>> print(carta2) 3 de Diamantes >>> print(carta2.palos[1]) Diamantes
Dado que toda instancia de Carta referencia al mismo atributo de clase, tenemos una típica situación de uso de alias.
- La desventaja es que si modificamos un atributo de clase, afectará a todas las instancias de la clase.
Por ejemplo, si decidimos que la Sota de Diamantes debería ser realmente Sota de Ballenas Revoltosas, podríamos hacer esto:
>>> carta1.palos[1] = "Ballenas Revoltosas" >>> print(carta1) Sota de Ballenas Revoltosas
El problema es que todas las cartas de Diamantes se habrán convertido en cartas de Ballenas Revoltosas:
>>> print(carta2) 3 de Ballenas Revoltosas
En general, no es buena idea andar modificando atributos de clase. (Sorprende incluso que en Python sea posible hacerlo desde un objeto o en tiempo de ejecución!)
22.4) Comparando cartas
Para tipos primitivos, hay 6 operadores relacionales (<, >, ==, !=, <=, >=) que comparan valores y determinan si uno es mayor, menor o igual a otro. Si queremos que nuestros propios tipos sean comparables utilizando la sintaxis de estos operadores relacionales, debemos definir en nuestra clase seis métodos especiales correspondientes a los mismos.
Nos gustaría comenzar con un método simple llamado cmp que engloba la lógica del ordenamiento.
- Por convención, un método de comparación toma dos argumentos, self y otro, y retorna 1 si el primer objeto es mayor, -1 si el segundo objeto es mayor, y 0 si son iguales.
Algunos tipos son completamente ordenados, lo que significa que podemos comparar cualquier par de elementos y decir cuál es más grande.
- Por ejemplo, los enteros y los números float son completamente ordenados.
Otros tipos son desordenados, lo que significa que no hay una forma razonable de decir que uno es mayor que el otro
- Por ejemplo, las frutas son desordenadas, por lo cual no podemos comparar manzanas con naranjas, u ordenar razonablemente una colección de imágenes o de teléfonos celulares.
Las cartas de la baraja son parcialmente ordenadas, lo que significa que a veces podemos comparar cartas y a veces no.
- Por ejemplo, sabemos que el 3 de Tréboles es mayor que el 2 de Tréboles, y que el 3 de Diamantes es mayor que el 3 de Tréboles.
- Pero ¿cuál es mejor? ¿El 3 de Tréboles o el 2 de Diamantes? Uno tiene un valor mayor, pero el otro tiene un palo mayor.
Para hacer a las cartas comparables, tenemos que decidir qué es más importante: palo o valor. Honestamente, la elección es arbitraria.
- Para elegir algo concreto, diremos que el palo es más importante, teniendo en cuenta que un mazo de cartas nuevo viene ordenado con los Tréboles arriba, los Diamantes luego, etc.
Habiendo decidido lo anterior podemos escribir cmp:
def cmp(self, otro): # Comparo los palos if self.palo > otro.palo: return 1 if self.palo < otro.palo: return -1 # Los palos son iguales... comparo los valores if self.valor > otro.valor: return 1 if self.valor < otro.valor: return -1 # Los valores son los mismos... es un empate return 0
En este ordenamiento, los ases se consideran menores que los 2s.
Ahora podemos definir los 6 métodos especiales que implementan la sobrecarga de los operadores relacionales:
def __eq__(self, otro): return self.cmp(otro) == 0 def __le__(self, otro): return self.cmp(otro) <= 0 def __ge__(self, otro): return self.cmp(otro) >= 0 def __gt__(self, otro): return self.cmp(otro) > 0 def __lt__(self, otro): return self.cmp(otro) < 0 def __ne__(self, otro): return self.cmp(otro) != 0
Con esto ya implementado, los operadores relacionales se pueden usar para comparar cartas:
>>> carta1 = Carta(1, 11) >>> carta2 = Carta(1, 3) >>> carta3 = Carta(1, 11) >>> carta1 < carta2 False >>> carta1 == carta3 True
22.5) Mazos
Ahora que tenemos objetos que representan cartas, el siguiente paso lógico es definir una clase que represente a un Mazo.
- Por supuesto, un mazo está hecho de cartas, así que cada mazo va a contener a una lista de cartas como atributo.
- Muchos juegos de cartas van a necesitar al menos dos mazos: un mazo rojo y un mazo azul.
Esta es la definición de clase para Mazo. El método de inicialización crea el atributo cartas y genera el paquete estándar de 52 cartas:
class Mazo: def __init__(self): self.cartas = [] for palo in range(4): for valor in range(1, 14): self.cartas.append(Carta(palo, valor))
La forma más sencilla de poblar el mazo es con un loop anidado.
- El loop externo enumera los palos de 0 a 3. El loop interno enumera los valores de 1 a 13.
- Como el loop externo itera 4 veces y el interno 13 veces, se ejecuta el cuerpo del loop interno 52 veces (13 veces 4).
- Cada iteración crea una nueva instancia de Carta con el palo y valor actual y lo agrega al final de la lista cartas (método append).
Con esto ya disponible, podemos instanciar algunos mazos:
mazo_rojo = Mazo() mazo_azul = Mazo()
22.6) Imprimiendo el mazo
Como siempre, cuando definimos un nuevo tipo queremos un método que imprima el contenido de una instancia.
- Para imprimir un Mazo, recorremos la lista e imprimimos cada Carta:
class Mazo: . . . def print_mazo(self): for carta in self.cartas: print(carta)
Aquí, y desde ahora, indicaremos con 3 puntos suspensivos (. . .) que estamos omitiendo los demás métodos en la clase.
Como alternativa a print_mazo, podríamos escribir un método __str__ para la clase Mazo.
- La ventaja de __str__ es que es más flexible. En vez de sólo imprimir los contenidos del objeto, genera una representación en forma de string que otras partes del programa pueden manipular antes de imprimir, o guardar para uso posterior.
Aquí hay una versión de __str__ que retorna un string que representa a un Mazo.
- Para darle un poco de gracia (visualmente) arregla las cartas en una cascada en que cada una es indentada un espacio más que la anterior.
class Mazo: . . . def __str__(self): s = "" for i in range(len(self.cartas)): s = s + " " * i + str(self.cartas[i]) + "\n" return s
Este ejemplo muestra varias características interesantes.
- Primero, en vez de recorrer self.cartas y asignar cada carta a una variable, usamos i como variable del loop y como índice dentro de la lista de cartas.
- Segundo, usamos el operador de multiplicación de strings para indentar cada carta un espacio más que la anterior. La expresión " " * i produce un número de espacios igual al valor actual de i
- Tercero, en vez de usar el comando print para imprimir cartas, usamos la función str. Pasar un objeto como argumento a str es equivalente a llamar al método __str__ del objeto.
- Finalmente, estamos usando la variable s como un acumulador. Al principio s es el string vacío. En cada iteración del loop, un nuevo string es generado y concatenado con el viejo valor de s para obtener un nuevo valor. Cuando termina el loop, s contiene la representación completa del Mazo como un string, la cual se ve así:
>>> mazo_rojo = Mazo() >>> print(mazo_rojo) As de Tréboles 2 de Tréboles 3 de Tréboles 4 de Tréboles 5 de Tréboles 6 de Tréboles 7 de Tréboles 8 de Tréboles 9 de Tréboles 10 de Tréboles Sota de Tréboles Reina de Tréboles Rey de Tréboles As de Diamantes 2 de Diamantes 3 de Diamantes 4 de Diamantes 5 de Diamantes 6 de Diamantes 7 de Diamantes 8 de Diamantes 9 de Diamantes 10 de Diamantes Sota de Diamantes Reina de Diamantes Rey de Diamantes As de Corazones 2 de Corazones 3 de Corazones 4 de Corazones 5 de Corazones 6 de Corazones 7 de Corazones 8 de Corazones 9 de Corazones 10 de Corazones Sota de Corazones Reina de Corazones Rey de Corazones As de Picas 2 de Picas 3 de Picas 4 de Picas 5 de Picas 6 de Picas 7 de Picas 8 de Picas 9 de Picas 10 de Picas Sota de Picas Reina de Picas Rey de Picas
Incluso si el resultado aparece en 52 líneas, no es más que un largo string que contiene muchas nuevas líneas.
22.7) Barajando el mazo
Si un mazo está perfectamente barajado, entonces cualquier carta tiene la misma probabilidad de aparecer en cualquier parte del mazo, y cualquier lugar en el mazo tiene la misma probabilidad de contener una carta.
Para mezclar el mazo, usaremos la función randrange del módulo random.
- Dados dos argumentos enteros, a y b, randrange elige un entero aleatorio en el rango a <= x < b.
- Como el borde superior es estrictamente menor que b, podemos usar el largo de una lista como segundo parámetro, y tenemos garantía de obtener un índice legal.
- Por ejemplo, si rng ya fue instanciado como una fuente de números aleatorios, esta expresión elige el índice de un mazo de cartas aleatorio:
rng.randrange(0, len(self.cartas))
Una forma fácil de mezclar el mazo es ir recorriendo la lista de cartas e ir intercambiando cada una con otra elegida aleatoriamente.
- Es posible que la carta sea intercambiada consigo misma, pero eso está bien.
- De hecho, si impidiéramos esa opción, el orden obtenido ya no hubiera sido completamente aleatorio.
- (Esta última afirmación es matemáticamente discutible - para empezar es discutible que este método produzca un mazo realmente aleatorio)
class Mazo: . . . def mezclar(self): import random rng = random.Random() # Crear un generador aleatorio (random generator) cantidad_cartas = len(self.cartas) for i in range(cantidad_cartas): j = rng.randrange(i, cantidad_cartas) (self.cartas[i], self.cartas[j]) = (self.cartas[j], self.cartas[i])
En vez de asumir que hay 52 cartas en el mazo, tomamos el tamaño de la lista cartas y lo guardamos en cantidad_cartas.
Para cada carta en el mazo, elegimos una carta aleatoria de entre las cartas que todavía no han sido mezcladas. Luego intercambiamos la carta actual (i) con la elegida (j).
- Para intercambiar las cartas usamos una asignación de tuplas:
(self.cartas[i], self.cartas[j]) = (self.cartas[j], self.cartas[i])
Si bien este es un buen método de mezclado, un objeto generador de números aleatorios tiene también un método shuffle (mezclar) que puede mezclar elementos de una lista directamente.
- Así que podemos reescribir la función anterior para usar el método shuffle antes mencionado, así:
class Mazo: . . . def mezclar(self): import random rng = random.Random() # Crear un generador aleatorio (random generator) rng.shuffle(self.cartas) # Usar su método shuffle (mezclar)
22.8) Tomar cartas del mazo y repartirlas
Otro método que sería útil para la clase Mazo es remover, que toma una carta como parámetro, la quita, y retorna True si la carta estaba en el mazo y False si no estaba:
class Mazo: . . . def remover(self, carta): if carta in self.cartas: self.cartas.remove(carta) return True else: return False
Podríamos probar esta función con un llamado así:
>>> mazo_rojo = Mazo() >>> mazo_rojo.remover(Carta(2, 7)) True >>> print(mazo_rojo) As de Tréboles 2 de Tréboles ... # Aparecen todas las cartas menos el 7 de Diamantes, que hemos removido del mazo
El operador in retorna True si el primer operando está en el segundo.
- Si el primer operando es un objeto, Python usa el método __eq__ del objeto para determinar igualdad con items de la lista.
- Como el método __eq__ que implementamos en la clase Carta chequea igualdad profunda, nuestro método remover también estará chequeando igualdad profunda.
Para repartir cartas, queremos quitar y devolver la carta que está más arriba en el mazo. El método pop de listas nos da una forma conveniente de hacerlo:
class Mazo: . . . def pop(self): return self.cartas.pop()
En realidad, pop quita la última carta de la lista, por lo cual estamos repartiendo del fondo del mazo.
Una operación más que es verosímil que necesitemos es la función vacio, que devuelve True si el mazo no contiene cartas.
class Mazo: . . . def vacio(self): return self.cartas == []
22.9) Glosario
- codificar, atributo de clase, acumulador
22.10) Ejercicios
1) Modificar cmp para que los Ases sean considerados más fuertes que los Reyes. (hacerlo)