Quantcast
Channel: Take it easy
Viewing all articles
Browse latest Browse all 19

El diablo está en los detalles, Python edition

$
0
0

No es un secreto que Ducksboard está implementado en Python. Hemos dado alguna que otra charla sobre el producto, y en ellas hablamos de la elección de tecnologías y otras hierbas. El decantarnos por Python no nos llevó demasiado trabajo a Jan y a mí. Es un lenguaje que conocíamos bien (Flumotion está escrito en Python, y de ahí veníamos), y nos parecía razonablemente decente (no es Lisp, pero tampoco es PHP) y adaptado a las necesidades del proyecto. En alguna de esas charlas nos han preguntado que por qué no usamos Javascript y Node.js, o Ruby, o Erlang, o Go, o cualquier otro lenguaje / plataforma.

Bien, esa pregunta tiene su jugo. Justificar el uso de una tecnología frente a otras puede derivar rapidamente en un flamewar de libro, lo que suele aportar poco y poner a la gente de mal humor. Como la política, ¡o el fútbol! Mal rollo. Y aquí no quiero malos rollos, soy un tío positivo. Así pues, he optado por no responder atacando las flaquezas de otros lenguajes, sino subrayando algunas de las pequeñas cosas que me hacen feliz en Python. Vamos, que paso de explicar la ponzoña de lenguaje que es Javascript, y en cambio prefiero centrarme en esos detallitos que hacen que Python mole bastante más. Sin acritud.

Como la mayoría sabréis a estas alturas, Python es un lenguaje orientado a objetos de tipado dinámico. El intérprete oficial, CPython, está disponible para todos los sistemas operativos decentes. En Python prima la legibilidad, y los principios de diseño recogidos en su famoso Zen reman en esa dirección. Al igual que Ruby (que sería una suerte de primo), ha adoptado una serie de conceptos molones y útiles de los lenguajes funcionales, más concretamente de Lisp y su manejo de listas. Tiene una librería estándar bien completita, y la popularidad del lenguaje ha derivado en un exteeenso ecosistema de utilidades. Bien.

De todas las cosas chulas que ofrece Python, que no son pocas, he escogido unas cuantas que me parecen especialmente útiles. No son exclusivas de Python, pero a día de hoy se me haría difícil sentirme productivo sin herramientas equivalentes en otro lenguaje. No dejan de ser detalles del lenguaje, construcciones o librerías totalmente opcionales, pero cuyo uso mide el “pythonismo” del código. Porque sí, amigos, se puede escribir código nada “pythonico” en Python, pero entonces, ¿dónde está la gracia? Hay que sacarle el jugo a las herramientas, y de eso va este post, al menos para gente no puesta en Python.

List comprehensions

Las listas por comprensión (el nombre en castellano es bien horrible, sí) son centrales en la transformación de listas en Python. Concretamente, y tirando de lenguaje funcional, son equivalentes a las operaciones de mapeo y filtrado de listas. La idea es usar sintaxis en vez de funciones para efectuar esas operaciones sobre una lista, siempre con el fin de facilitar la lectura y comprensión de código. Pero vayamos por partes. ¿Qué es eso del mapeo y del filtrado? Y, si me apuras… ¿qué es una lista en Python?

La lista es una de las estructuras de datos básicas en Python. El concepto es bien simple: es un contenedor de objetos. Vamos, un array ordenado de objectos de cualquier tipo, sin más. Un ejemplo de lista:

foo = [2, 'hola', 5.67, 2, [1, 2]]

La lista foo contiene dos enteros (’2′ en ambos casos), una cadena de texto, un número decimal (float en Python) e incluso otra lista formada por dos enteros (‘[1, 2]‘). Una lista puede contener objetos de cualquier tipo, el orden siempre se mantiene, y un mismo elemento puede incluirse múltiples veces. Las listas son modificables.

¿En qué consisten las operaciones de mapeo y filtrado de listas? Mapear es aplicar una función a todos los elementos de una lista, y retornar una nueva lista con los resultados. Filtrar es crear una nueva lista donde sólo se incluyan los elementos de la lista original que cumplan una condición. Un ejemplillo con una lista de enteros:

foo = [1, 2, 3, 4, 5, 6]

def double(x):
    return x * 2

foo_doubled = map(double, foo)
# foo_doubled es [2, 4, 6, 8, 10, 12]

def is_even(x):
    return (x % 2) == 0

foo_evens = filter(is_even, foo)
# foo_evens es [2, 4, 6]
  • La función map aplica a cada elemento de la lista foo la función double, que lo multiplica por 2. El resultado, foo_doubled, es simplemente [2, 4, 6, 8, 10, 12].
  • La función filter aplica a cada elemento de foo la función is_even, que comprueba si un número es par. Sólo los valores que retornan un valor positivo para dicha función se incluyen en la nueva lista foo_evens, esto es [2, 4, 6].

¿Y si queremos obtener una lista de los dobles de los valores pares? O sea, ¿combinar ambas funciones? Bien, no hay problema, ya que tanto map como filter retornan nuevas listas:

foo_doubled_evens = map(double, filter(is_even, foo))

foo_doubled_evens, como cabe esperar, es [4, 8, 12].

Todo muy bien, map y filter molan todo, y pueden combinarse. Pero acabamos teniendo funciones que llaman a otras funciones, y con una lista de parámetros que siguen un orden concreto que hay que consultar si no recuerdas. Siendo el trabajo con listas tan central como lo es en Python, sería genial no tener que andar invocando funciones anidadas (no es el lenguaje más eficiente en ese aspecto) y recordando órdenes de parámetros de varias de ellas. Si además puedo escribir menos código, premio gordo. Molaría tener algo más… “pythonico”, más cercano al lenguaje natural. Veamos como se expresa esta última combinación como comprensión:

foo_doubled_evens = [x * 2 for x in foo if x % 2 == 0]

Bueno, esto es un cambio. Por lo pronto, no hay invocación de funciones por ninguna parte. Sólo sintaxis, que el intérprete convertirá en un bucle en código nativo de ejecución más rápida que la solución anterior. Además no he tenido que definir ninguna función para calcular dobles o comprobar pares, puedo aplicar transformaciones o chequeos a cada elemento dentro de la comprensión directamente. En cuanto a la sintaxis… ¿realmente es sencilla y cercana al lenguaje natural? Tratemos de leer la comprensión: multiplica x por 2 para cada x en la lista foo si el módulo de dividir x por 2 es 0 (vamos, si es par). Pues no es muy difícil de leer, no.

En definitiva, las comprensiones son una manera eficiente y muy legible de aplicar transformaciones a listas. Por descontado, no es necesario mapear y el filtrar en cada ocasión, otras compresiones válidas serían:

foo_doubled = [x * 2 for x in foo]           # equivalente a map
foo_evens = [x for x in foo if x % 2 == 0]   # equivalente a filter

Si escribes código Python y no aparecen unas cuantas de éstas, algo estás haciendo mal :)

Generadores

Esto de las listas está la mar de chulo, las cosas como son. Son cómodas y muy flexibles, el caballo de batalla de las estructuras de datos en Python. ¿Pero qué pasa cuando la cantidad de datos que queremos que almacenen crece a lo bestia, en plan MUY grande? Pues que el consumo de memoria de la aplicación sube y sube mientras le deje el sistema operativo. Si además esa lista se genera a partir de otro recurso (otra lista vía comprensión, un fichero en disco, un bucle que genera valores, …) tendremos que esperar a que ese recurso genere todo el contenido para poder empezar a trabajar con la lista. Nos quedaremos bloqueados esperando que se “llene”.

Para muestra, un botón. Midamos lo que tarda el intérprete de Python en darnos el primer valor de una lista gorda generada con la función range. range genera una lista de valores consecutivos desde 0 hasta tanto como le digas. A ver cuánto tarda con números gordos:

range(100000)[0]       # 1.68 ms en mi máquina
range(1000000)[0]      # 21.3 ms
range(10000000)[0]     # 211 ms
range(100000000)[0]    # tarda un huevo y acabo matando el intérprete

¿Qué pasa aquí? Pues que range tiene que generar la lista entera de valores antes de que podamos acceder al primero de ellos. La lista es cada vez más grande, así que necesita cada vez más tiempo y memoria para generarla, sin más. Eso es una buena mierda, si me permiten la expresión, porque yo sólo quiero acceder al primer valor (no es un ejemplo muy útil, no, pero ilustra la idea). Pero atención, probemos lo mismo con la función xrange, que hace lo mismo que range pero no retorna una lista, sino un generador (y ahora explico lo que es eso):

xrange(100000)[0]                 # 336 ns (n, sí, de nano)
xrange(1000000)[0]                # 331 ns
xrange(10000000)[0]               # 341 ns
xrange(100000000)[0]              # 328 ns
xrange(1000000000000000000)[0]    # 337 ns

¡Todos tardan lo mismo! Da igual como sea de grande el rango de valores que he pedido, siempre tardo lo mismo (casi nada, además) en obtener el primer valor. ¿Qué es eso de un generador que tanto mola? Pues no lo puedo explicar sin introducir antes el concepto de iterador, pero tranquilos, no va ser doloroso. Un iterador es simplemente un objecto en Python sobre el que se puede iterar, esto es recorrer todos los valores que contiene en un bucle. Una lista es un iterador, porque puedo recorrer sus valores en un bucle, sin ir más lejos. Pues un generador es un iterador que sólo genera el próximo valor de la iteración cuando es necesario accederlo, nunca antes. Y no se ha entendido nada, pero ahora lo aclaro.

Voy a crear una función generadora (vamos, una función que devuelve un generador, como xrange) y voy a iterar sobre sus valores:

def generator():
    i = 0               # empezamos a iterar con el valor 0
    while True:         # ¡un iterador infinito!
        yield i         # esta es la magia
        i = i + 1       # incrementamos i para la siguiente iteración

my_gen = generator()    # creamos el generador invocando la función

my_gen.next()           # 0 (next pide el siguiente valor de un iterador)
my_gen.next()           # 1
my_gen.next()           # 2
...
my_gen.next()           # 4815162342... y hasta el infinito

Bien, a ver qué ha pasado aquí. Creamos una función todo loca que contiene un bucle infinito (while True nunca jamás termina por sí mismo), pero al invocarla más tarde, en vez de tirarse la infinidad en ejecución, crea un iterador (un objeto que ofrece el método next para poder iterar sobre sus valores). Que no cunda el pánico: todo esto lo causa el “statement” yield (¿hay una palabra decente en castellano para statement?). Cuando el intérprete ve que la función usa yield para retornar valores, sabe que ha de crear un generador. ¿Y qué hace yield para que esto funcione así?

Lo que hace yield es retornar un valor y “congelar” la ejecución de la función. Retorna el control a la sección de código que la ha invocado y la función se queda parada en ese punto hasta que vuelva a ser invocada. Con la nueva invocación la función retoma su flujo de ejecución, mantiene el contexto y sigue tan feliz como si nada hubiese pasado. Los lectores avezados se habrán dado cuenta de que eso es (en parte) lo que hace una corrutina, y así es. Gracias a yield podemos implementar iteradores “lazy”.

En nuestro ejemplo iniciamos un bucle infinito, pero al retornar el primer valor la función congela su ejecución. La siguiente vez que se ejecuta (a causa del next que se invoca sobre el generador) continúa su ejecución, así que incrementa i e inicia una nueva iteración del bucle, que consiste en retornar ese nuevo valor y congelar la ejecución de nuevo. Y así hasta siempre jamás, mientras sigamos llamando a next. Y ese es el tema.

Con generadores se pueden hacer cosas realmente geniales, os aconsejo muy fervientemente echar un ojo a Generator Tricks for Systems Programmers para ver ejemplos realmente molones. Tiene una continuación, A Curious Course on Coroutines and Concurrency, donde construye cosas mucho más complejas sobre los mismos principios, pero ya es un tema más advanced :)

Y ya que hemos echado un ojo a las comprensiones de listas, no puedo dejarme en el tintero una de las maneras más chulas de crear generadores (no siempre hay que hacerlo con funciones): las expresiones generadoras. El concepto es muy simple, si en vez de usar [ y ] usamos ( y ) en una comprensión, crearemos un generador y no una lista.

[x for x in algo_que_genera_valores]      # esto crea una lista
(x for x in algo_que_genera_valores)      # esto crea un generador

Había más, pero…

Se me ha ido la mano, y esto tiene ya más de 2000 palabros, lo que lo convierte en un buen ladrillo. Además, en mi experiencia, los artículos más técnicos en este blog tiene poca acogida, así que mejor no me arriesgo a escribir el doble para que me lean la mitad :)

Si te ha gustado y quieres conocer más cosillas chulas de Python, como decoradores, el módulo de itertools y otras hierbas, por favor deja un comentario pidiendo una segunda parte. Sólo si lo pedís unos pocos me curro otras 2000 palabras, que esto lleva un rato xD.

¡Gracias por leer!



Viewing all articles
Browse latest Browse all 19

Trending Articles