Apartados


5. Clases e iteradores

Nivel de dificultad:3 sobre 5

``El Este está al Este y el Oeste al Oeste,
y nunca ambos se encontrarán.''
--Ruyard Kipling

5.1 Inmersión

Los generadores son únicamente un caso especial de iteradores. Una función que entrega valores es una forma buena de crear un iterador sin llegar a crearlo. Déjame enseñarte lo que quiero decir.

¿Recuerdas el generador de la serie de Fibonacci? Aquí lo tienes construido como un iterador:

class Fib:
    '''iterador que genera los números de la secuencia de Fibonacci'''

def __init__(self, max): self.max = max

def __iter__(self): self.a = 0 self.b = 1 return self

def __next__(self): fib = self.a if fib > self.max: raise StopIteration self.a, self.b = self.b, self.a + self.b return fib

Vamos a ver una línea cada vez.

class Fib:

¿class? ¿Qué es una clase?

5.2 Cómo se definen clases

Python es un lenguaje orientado a objetos: Puedes definir tus propias clases de objetos, heredar de tus clases o de las preexistentes y crear instancias de las clases que has definido.

Es sencillo definir una clase en Python. Como con las funciones no existe una definición del interface separada. Simplemente define la clase y el código. Una clase en Python comienza con la palabra reservada class, seguida del nombre de la clase. Técnicamente es todo lo que se necesita puesto que no necesita heredar de ninguna otra.

class PapayaWhip:
    pass

  1. Línea 1: El nombre de esta clase es PapayaWhip y no hereda de ninguna otra. Los nombres de las clases se suelen escribir con el primer carácter en mayúsculas, CadaPalabraDeEstaForma pero esto es únicamente por convención, no es un requisito obligatorio.

  2. Línea 2: Probablemente lo hasta adivinado, pero el contenido de la clase está siempre indentado, como pasa con el código de una función, una sentencia if, un bucle for o cualquier otro bloque de código. La primera línea no indentada indica el final de la clase y se encuentra fuera de la misma.

Esta clase PapayaWhip no define ningún método o atributos, pero es correcta sintácticamente. Como necesita que exista algo en el contenido de la clase se escribe la sentencia pass. Esta palabra reservada de Python significa únicamente ``sigue adelante, no hay nada que hacer aquí''. Es una palabra reservada que no hace nada y, por ello, una buena forma de marcar un sitio cuando tienes funciones o clases a medio escribir.

La sentencia pass de Python es como una pareja vacía de llaves ({}) en Java o C.

Muchas clases heredan de otras, pero esta no. Muchas clases definen métodos, pero esta no. No hay nada que tenga que tener obligatoriamente una clase de Python, salvo el nombre. En particular, los programadores de C++ pueden encontrar extraño que las clases de Python no tengan que tener constructores y destructores explícitos. Aunque no es obligatorio, las clases de Python pueden tener algo parecido a un constructor: el método __init__().

5.2.1 El método __init__()

Este ejemplo muestra la inicialización de la clase Fib utilizando el método __init__().

class Fib:
    '''iterador que genera los números de la secuencia de Fibonacci'''

def __init__(self, max):

  1. Línea 2: Las clases pueden (y deberían) tener docstrings, tal y como sucede con los módulos y funciones.

  2. Línea 4: El método __init__() se llama de forma automática por Python inmediatamente después de que se haya creado una instancia de la clase. Es tentador --pero técnicamente incorrecto-- llamar a este método el ``constructor'' de la clase. Es tentador, porque recuerda a los constructores de C++ (por convención, el método __init__() se suele poner como el primer método de la clase), e incluso suena como uno. Es incorrecto, porque cuando se llama a este método en Python, el objeto ya ha sido construido y ya dispones de una referencia a una instancia válida de la clase (self).

El primer parámetro de todo método de una clase, incluyendo el método __init__(), siempre es una referencia al objeto actual, a la instancia actual de la clase. Por convención, este parámetro se suele llamar self. Este parámetro ocupa el rol de la palabra reservada this de C++ o Java, pero self no es una palabra reservada en Python, es simplemente una convención en para el nombre del primer parámetro de los métodos de una clase. En cualquier caso, por favor no lo llames de otra forma que no sea self; esta convención es muy fuerte y todo el mundo la usa.

En el método __init__(), self se refiere al objeto recién creado; en otros métodos de la clase se refiere a la instancia cuyo método se llamó. Aunque necesitas especificar self explícitamente cuando defines el método, no lo especificas cuando se llama. Python lo hace por ti automáticamente.

5.3 Instanciar clases

Instanciar clases en Python es inmediato. Para crear un objeto de la clase, simplemente llama a la clase como si fuese una función, pasándole los parámetros que requiera el método __init__(). El valor de retorno será el nuevo objeto.

»> import fibonacci2 
»> fib = fibonacci2.Fib(100)
»> fib
<fibonacci2.Fib object at 0x00DB8810>
»> fib.__class__
<class 'fibonacci2.Fib'>
»> fib.__doc__
'''iterador que genera los números de la secuencia de Fibonacci'''

  1. Línea 2: Se crea una instancia de la clase Fib (definida en el módulo fibonacci2) y se asigna la instancia creada a la variable fib. Se pasa un parámetro que será el parámetro max del método __init__() de la clase Fib.

  2. Línea 3: La variable fib se refiere ahora a un objeto que es instancia de la clase Fib.

  3. Línea 5: Toda instancia de una clase tiene el atributo interno __class__ que es la clase del objeto. Muchos programadores java estarán familiarizados con la clase Class, que contiene métodos como getName() y getSuperClass() para conseguir información de metadatos del objeto. En Python, esta clase de metadatos está disponible mediante el uso de atributos, pero la idea es la misma.

  4. Línea 7: Puedes acceder al docstring de la instancia como se hace con cualquier otro módulo o función. Todos los objetos que son instancia de una misma clase comparten el mismo docstring

En Python, basta con llamar a una clase como si fuese una función para crear un nuevo objeto de la clase. No existe ningún operador new como sucede en C++ o Java.

5.4 Variables de las instancias

En el siguiente código:

class Fib:
    def __init__(self, max):
        self.max = max

  1. Línea 3: ¿Qué es self.max? Es una variable de la instancia. Completamente diferente al parámetro max, que se pasa como parámetro del método. self.max es una variable del objeto creado. Lo que significa que puedes acceder a ella desde otros métodos.

class Fib:
    def __init__(self, max):
        self.max = max
    .
    .
    .
    def __next__(self):
        fib = self.a
        if fib > self.max:

  1. Línea 3: self.max se crea en el método __init__(), por ser el primero que se llama.

  2. Línea 9: ...y se utiliza en el método __next__().

Las variables de instancia son específicas de cada objeto de la clase. Por ejemplo, si creas dos objetos Fib con diferentes valores máximos cada uno de ellos recordará sus propios valores.

»> import fibonacci2
»> fib1 = fibonacci2.Fib(100)
»> fib2 = fibonacci2.Fib(200)
»> fib1.max
100
»> fib2.max
200

5.5 Un iterador para la serie de Fibonacci

Ahora estás preparado para aprender cómo construir un iterador. Un iterador es una clase que define el método __iter__().

Los tres métodos de clase, __init__, __iter__ y __next__, comienzan y terminan con un par de guiones bajos (_). ¿Por qué? No es nada mágico, pero habitualmente significa que son métodos ``especiales''. Lo único que tienen en ``especial'' estos métodos especiales es que no se llaman directamente; Python los llama cuando utilizas otra sintaxis en la clase o en una instancia de la clase.

class Fib:                 
    def __init__(self, max):
        self.max = max

def __iter__(self): self.a = 0 self.b = 1 return self

def __next__(self): fib = self.a if fib > self.max: raise StopIteration self.a, self.b = self.b, self.a + self.b return fib

  1. Línea 1: Para poder construir un iterador desde cero fib necesita ser una clase, no una función.

  2. Línea 2: Al llamar a Fib(max) se está creando un objeto que es instancia de esta clase y llamándose a su método __init__() con el parámetro max. El método __init__() guarda el valor máximo como una variable del objeto de forma que los otros métodos de la instancia puedan utilizarlo más tarde.

  3. Línea 5: El método __iter__() se llama siempre que alguien llama a iter(fib) (Como verás en un minuto, el bucle for llamará a este método automáticamente, pero tú también puedes llamarlo manualmente). Después de efectuar la inicialización necesaria de comienzo de la iteración (en este caso inicializar self.a y self.b) el método __iter__() puede retornar cualquier objeto que implemente el método __next__(). En este caso (y en la mayoría), __iter__() se limita a devolver self, puesto que la propia clase implementa el método __next__().

  4. Línea 10: El método __next__() se llama siempre que alguien llame al método next() sobre un iterador de una instancia de una clase. Esto adquirirá todo su sentido en un minuto.

  5. Línea 13: Cuando el método __next__() lanza la excepción StopIteration le está indicando a quién lo haya llamado que el iterador se ha agotado. Al contrario que la mayoría de las excepciones, no se trata de un error. es una condición normal que simplemente significa que no quedan más valores que generar. Si el llamante es un bucle for se dará cuenta de que se ha elevado esta excepción y finalizará el bucle sin que se produzca ningún error (En otras palabras, se tragará la excepción). Esta pequeña magia es el secreto clave del uso de iteradores en los bucles for.

  6. Línea 15: Para devolver el siguiente valor del iterador, el método __next__() simplemente utiliza return para devolver el valor. No se utiliza yield, que únicamente se utiliza para los generadores. Cuando se está creando un iterador desde cero, se utiliza return en el método __next__().

¿Estás ya totalmente confundido? Excelente. Veamos cómo utilizar el iterador.

»> from fibonacci2 import Fib
»> for n in Fib(1000):
...     print(n, end=' ')
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

¡Exactamente igual que en el ejemplo del generador! Es idéntico a como usabas el generador. Pero... ¿cómo?.

Existe algo de trabajo ejecutándose en los bucles for.

5.6 Un iterador para reglas de formación de plurales

Ahora es el final. Vamos a reescribir el generador de reglas de formación de plural como un iterador.

iter(f) llama a f.__iter__(). next(f) llama a f.__next__().

class LazyRules:
    rules_filename = 'plural6-rules.txt'

def __init__(self): self.pattern_file = open(self.rules_filename, encoding='utf-8') self.cache = []

def __iter__(self): self.cache_index = 0 return self

def __next__(self): self.cache_index += 1 if len(self.cache) >= self.cache_index: return self.cache[self.cache_index - 1]

if self.pattern_file.closed: raise StopIteration

line = self.pattern_file.readline() if not line: self.pattern_file.close() raise StopIteration

pattern, search, replace = line.split(None, 3) funcs = build_match_and_apply_functions( pattern, search, replace) self.cache.append(funcs) return funcs

rules = LazyRules()

Como esta clase implementa los métodos __iter__() y __next__() puede utilizarse como un iterador. Al final del código se crea una instancia de la clase y se asigna a la variable rules.

Vamos a ver la clase poco a poco.

class LazyRules:
    rules_filename = 'plural6-rules.txt'

def __init__(self): self.pattern_file = open(self.rules_filename, encoding='utf-8') self.cache = []

  1. Línea 5: Cuando instanciamos la clase LazyRules, se abre el fichero de patrones pero no se lee nada de él (Eso se hace más tarde).

  2. Línea 6: Después de abrir el fichero de patrones se inicializa la caché. Utilizarás la caché más tarde (en el método __next__()) según se lean las filas del fichero de patrones.

Antes de continuar vamos a echarle un vistazo a rules_filename. No está definida en el método __iter__(). De hecho no está definida dentro de ningún método. Está definida al nivel de la clase. Es una variable de clase y, aunque puedes acceder a ella igual que a cualquier variable de instancia (self.rules_filename), es compartida en todas las instancias de la clase LazyRules.

»> import plural6 
»> r1 = plural6.LazyRules() 
»> r2 = plural6.LazyRules() 
»> r1.rules_filename
'plural6-rules.txt'
»> r2.rules_filename 
'plural6-rules.txt' 
»> r2.rules_filename = 'r2-override.txt'
»> r2.rules_filename 
'r2-override.txt'
»> r1.rules_filename 
'plural6-rules.txt' 
»> r2.__class__.rules_filename
'plural6-rules.txt'
»> r2.__class__.rules_filename = 'papayawhip.txt'
»> r1.rules_filename 
'papayawhip.txt' 
»> r2.rules_filename
'r2-overridetxt'

  1. Línea 4: Cada instancia de la clase hereda el atributo rules_filename con el valor definido para la clase.

  2. Línea 8: La modificación del valor de la variable en una instancia no afecta al valor de las otras instancias...

  3. Línea 13: ...ni cambia el atributo de la clase. Puedes acceder al atributo de la clase (por oposición al atributo de la instancia individual) mediante el uso del atributo especial __class__ que accede a la clase.

  4. Línea 15: Si modificas el atributo de la clase, todas las instancias que heredan ese atributo (como r1 en este ejemplo) se verán afectadas.

  5. Línea 18: Todas las instancias que han modificado ese atributo, sustituyendo su valor (como r2 aquí) no se verán afectadas.

Y ahora volvamos a nuestro espectáculo.

  def __iter__(self):
        self.cache_index = 0
        return self

  1. Línea 1: El método __iter__() se llamará cada vez que alguien --digamos un bucle for-- llame a iter(rules).

  2. Línea 3: Todo método __iter__() debe devolver un iterador. En este caso devuelve self puesto que esta clase define un método __next__() que será responsable de devolver los diferentes valores durante las iteraciones.

   def __next__(self):
        .
        .
        .
        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(
            pattern, search, replace)
        self.cache.append(funcs)
        return funcs

  1. Línea 1: El método __next__() se llama cuando alguien --digamos que un bucle for-- llama a next(rules). La mejor forma de explicar este método es comenzando del final hacia atrás. Por lo que vamos a hacer eso.

  2. Línea 6: La última parte de esta función debería serte familiar. La función build_match_and_apply_functions() no ha cambiado; es igual que siempre.

  3. Línea 8: La única diferencia es que, antes de retornar el valor (que se almacena en la tupla funcs), vamos a salvarlas en self.cache.

Sigamos viendo la parte anterior...

    def __next__(self):
        .
        .
        .
        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration
        .
        .
        .

  1. Línea 5: Veamos una técnica avanzada de acceso a ficheros. El método readline() (nota: singular, no el plural readlines()) que lee exactamente una línea de un fichero abierto. Específicamente, la siguiente línea (Los objetos fichero también son iteradores...).

  2. Línea 6: Si el método readline() lee algo (quedaban líneas por leer en el fichero), la variable line no será vacía. Incluso si la línea fuese vacía, la variable contendría una cadena de un carácter '
    n'
    (el retorno de carro). Si la variable line es realmente una cadena vacía significará que no quedan líneas por leer en el fichero.

  3. Línea 8: Cuando alcanzamos el final del fichero deberíamos cerrarlo y elevar la excepción mágica StopIteration. Recuerda que llegamos a esta parte de la función porque necesitamos encontrar una función de búsqueda y otra de sustitución para la siguiente regla. La siguiente regla tiene que venir en la siguiente línea del fichero... ¡pero si no hay línea siguiente! Entonces, no tenemos que retornar ningún valor. Las iteraciones han terminado (Se acabó la fiesta...).

Si seguimos moviéndonos hacia el comienzo del método __next__()...

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

if self.pattern_file.closed: raise StopIteration . . .

  1. Línea 4: self.cache contendrá una lista con las funciones que necesitamos para buscar y aplicar las diferentes reglas (¡Al menos esto te debería resultar familiar!). self.cache_index mantiene el índice del elemento de la caché que se debe retornar. Si no hemos consumido toda la caché (si la longitud de self.cache es mayor que self.cache_index), ¡tenemos un elemento en la caché para retornar! Podemos devolver las funciones de búsqueda y sustitución de la caché en lugar de construirlas desde cero.

  2. Línea 7: Por otra parte, si no obtenemos un elemento de la caché y el fichero se ha cerrado (lo que podrá haber sucedido en la parte de más abajo del método, como se vio anteriormente) entonces ya no hay nada más que hacer. Si el fichero se ha cerrado, significa que lo hemos leído completamente --ya hemos recorrido todas las líneas del fichero de patrones y hemos construido y cacheado las funciones de búsqueda y sustitución de cada patrón. El fichero se ha agotado y la caché también, ¡Uff! ¡yo también estoy agotado! Espera un momento, casi hemos terminado.

Si lo ponemos todo junto esto es lo que sucede cuando:

Hemos alcanzado el nirvana de la generación de plurales.

  1. Coste de inicio mínimo. Lo único que se hace al realizar el import es instanciar un objeto de una clase y abrir un fichero (pero sin leer de él).

  2. Máximo rendimiento. El ejemplo anterior leería el fichero cada vez que hubiera que poner en plural una palabra. Esta versión cachea las funciones según se van construyendo y, en el peor caso, solamente leerá del fichero de patrones una única vez, no importa cuantas palabras pongas en plural.

  3. Separación de código y datos. Todos los patrones se almacenan en un fichero separado. El código es código y los datos son datos y nunca se deberán de encontrar.

¿Es realmente el nirvana? Bueno, sí y no. Hay algo que hay que tener en cuenta con el ejemplo de LazyRules: El fichero de patrones se abre en el método __init__() y permanece abierto hasta que se alcanza la regla final. Python cerrará el fichero cuando se acabe la ejecución, o después de que la última instancia de la clase LazyRules sea destruida, pero eso puede ser mucho tiempo. Si esta clase es parte de un proceso de larga duración, el intérprete de Python puede que no acabe nunca y el objeto LazyRules puede que nunca sea destruido.

Hay formas de evitar esto. En lugar de abrir el fichero durante el método __init__() y dejarlo abierto mientras lees las reglas una línea cada vez, podrías abrir el fichero, leer todas las reglas y cerrarlo inmediatamente. O podrías abrir el fichero, leer una regla, guardar la posición del fichero con el método tell(), cerrar el fichero y, más tarde, reabrirlo y utilizar el método seek() para continuar leyendo donde lo dejaste. O podrías no preocuparte de dejar el fichero abierto, como pasa en este ejemplo. Programar es diseñar, y diseñar es siempre una continua elección entre decisiones que presentan ventajas e inconvenientes. Dejar un fichero abierto demasiado tiempo puede suponer un problema; hacer el código demasiado complejo podría ser un problema. Cuál es el problema mayor depende del equipo de desarrollo, la aplicación y tu entorno de ejecución.

5.7 Lecturas recomendadas

José Miguel González Aguilera 2016-08-18