Cap. 22 - Colecciones de objetos

22.1) Composición

A estas alturas ya hemos visto varios casos de composición.

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.

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.

La alternativa es usar enteros para codificar valores y palos.

Picas        -->     3
Corazones    -->     2
Diamantes    -->     1
Tréboles     -->     0

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.

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.

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.

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.

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.

Algunos tipos son completamente ordenados, lo que significa que podemos comparar cualquier par de elementos y decir cuál es más grande.

Otros tipos son desordenados, lo que significa que no hay una forma razonable de decir que uno es mayor que el otro

Las cartas de la baraja son parcialmente ordenadas, lo que significa que a veces podemos comparar cartas y a veces no.

Para hacer a las cartas comparables, tenemos que decidir qué es más importante: palo o valor. Honestamente, la elección es arbitraria.

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.

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.

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.

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.

Aquí hay una versión de __str__ que retorna un string que representa a un Mazo.

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.

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

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.

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

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

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.

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

22.10) Ejercicios

1) Modificar cmp para que los Ases sean considerados más fuertes que los Reyes. (hacerlo)