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
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? ¿Qué es una clase?
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.
- 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.
- 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__().
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): |
- Línea 2: Las clases pueden (y deberían) tener docstrings, tal y como sucede con los módulos y funciones.
- 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.
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''' |
- 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.
- Línea 3: La variable fib se refiere ahora a un objeto que es instancia de la clase Fib.
- 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.
- 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.
En el siguiente código:
class Fib:
def __init__(self, max):
self.max = max |
- 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: |
- Línea 3: self.max se crea en el método __init__(), por ser el primero que se llama.
- 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 |
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 |
- Línea 1: Para poder construir un iterador desde cero fib necesita ser una clase, no una función.
- 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.
- 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__().
- 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.
- 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.
- 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.
- El bucle for llama a Fib(1000), que retorna un objeto que es instancia de la clase Fib. Llamémoslo fib_inst.
- En secreto, pero de forma inteligente, el bucle for llama a iter(fib_inst), que retorna un objeto iterador. Vamos a llamar a este objeto fib_iter. En este caso, fib_iter == fib_inst, porque el método fib_inst.__iter__() retorna self, pero el bucle for no lo sabe, ni le importa.
- Para recorrer el bucle a través del iterador, el bucle for llama a next(fib_iter), que, a su vez, llama a fib_iter.__next__(), el método __next__() del objeto fib_iter, que realiza los cálculos necesarios y devuelve el siguiente elemento de la serie de fibonacci. El bucle for toma este valor y lo asigna a n, luego ejecuta el cuerpo del bucle para ese valor de n.
- ¿Cómo sabe el bucle for cuando parar? ¡Me alegro de que lo preguntes! Cuando next(fib_iter) eleva la excepción StopIteration el bucle for la captura finaliza sin error (Cualquier otra excepción se elevaría normalmente). ¿Y dónde has visto que se lanze esta excepción StopIteration? En el método __next__() ¡Por supuesto!
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 = [] |
- 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).
- 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' |
- Línea 4: Cada instancia de la clase hereda el atributo rules_filename con el valor definido para la clase.
- Línea 8: La modificación del valor de la variable en una instancia no afecta al valor de las otras instancias...
- 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.
- 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.
- 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 |
- Línea 1: El método __iter__() se llamará cada vez que alguien --digamos un bucle for-- llame a iter(rules).
- 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 |
- 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.
- 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.
- 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
.
.
. |
- 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...).
- 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.
- 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
.
.
. |
- 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.
- 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:
- Cuando el módulo es importado crea una única instancia de la clase LazyRules, que denominamos rules, que abre el fichero de patrones pero no lo lee.
- Cuando pedimos la primera pareja de funciones de búsqueda y sustitución, busca en la caché pero está vacía. Por lo que lee una línea del fichero de patrones, construye la pareja de funciones de búsqueda y sustitución para ellas y las guarda en la caché (además de retornarlas).
- Digamos, por simplicidad, que la primera regla coincidió con la búsqueda. Si es así no se busca nada más y no se lee nada más del fichero de patrones.
- Además, por continuar con el argumento, supón que el programa que está usando este objeto llama a la función plural() de nuevo para pasar al plural una palabra diferente. El bucle for de la función plural() llamará a la función iter(rules), que resetea el índice de la caché pero no resetea el fichero abierto.
- La primera vez en esta nueva iteración, el bucle for pedirá el valor de rules, que llama al método __next__(). Esta vez, sin embargo, la caché tiene ya una pareja de funciones de búsqueda y sustitución, la correspondiente a los patrones de la primera línea del fichero de patrones, puesto que fueron construidas y cacheadas al generar el plural de la palabra anterior y por eso están en la caché. El índice de la caché se incrementa y no se toca el fichero abierto.
- Vamos a decir, en aras de continuar el argumento, que esta vez la primera regla no coincide, por lo que el bucle for da otra vuelta y pide otro valor de la variable rules. Por lo que se invoca por segunda vez al método __next__(). Esta vez la caché está agotada --solamente contenía un elemento, y estamos solicitando un segundo-- por lo que el método __next__() continúa. Se lee otra línea del fichero abierto, se construyen las funciones de búsqueda y sustitución de los patrones y se introducen en la caché.
- Este proceso de lectura, construcción y caché continua mientras las reglas del fichero no coincidan con la palabra que estamos intentando poner en plural. Si se llega a encontrar una coincidencia antes del final del fichero, se utiliza y termina, con el fichero aún abierto. El puntero del fichero permanecerá dondequiera que se parase la lectura, a la espera de la siguiente sentencia readline(). Mientras tanto, la caché ha ido ocupándose con más elementos y si se volviera a intentar poner en plural a otra palabra, se probará primero con los elementos de la caché antes de intentar leer la siguiente línea del fichero de patrones.
Hemos alcanzado el nirvana de la generación de plurales.
- 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).
- 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.
- 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.
José Miguel González Aguilera
2016-08-18