HOWTO - Programación funcional

Autor

A. M. Kuchling

Publicación

0.32

En este documento, haremos un recorrido de las características de Python adecuadas para implementar programas en un estilo funcional. Después de una introducción de los conceptos de programación funcional, veremos las características del lenguaje como iteradores y generadores y módulos de librería relevantes como itertools y functools.

Introducción

Esta sección explica el concepto básico de programación funcional; si solo está interesado en aprender acerca de las características del lenguaje Python, pase a la siguiente sección en iteradores.

Los lenguajes de programación soportan la descomposición de problemas en muchas formas diferentes:

  • La mayoría de los lenguajes de programación son procedimentales: los programas son listas de instrucciones que le dicen a la computadora qué hacer con la entrada del programa. C, Pascal e incluso las terminales Unix son lenguajes procedimentales.

  • En los lenguajes declarativos, se escribe una especificación que describe el problema que se resolverá, y la implementación del lenguaje averigua como realizar el cálculo de forma eficiente. SQL es el lenguaje declarativo con el que probablemente esté más familiarizado; una consulta SQL describe el conjunto de datos que quiere recuperar, y el motor SQL decide si escanear tablas o usar índices, qué subcláusulas deben ejecutarse primero, etc.

  • Los programas orientados a objetos manipulan colecciones de objetos. Los objetos tienen estado interno y soportan métodos que consultan o modifican su estado interno de alguna manera. Smalltalk y Java son lenguajes orientados a objetos. C++ y Python son lenguajes que soportan la programación orientada a objetos, pero no fuerzan el uso de las características orientadas a objetos.

  • La programación funcional descompone un problema en un conjunto de funciones. Idealmente, las funciones solo reciben entradas y producen salidas, y no tienen ningún estado interno que afecte la salida producida para una entrada dada. Los lenguajes funcionales bien conocidos incluyen la familia ML (Standard ML, OCaml, y otras variantes) y Haskell.

Los diseñadores de algunos lenguajes de computadora eligen enfatizar en un enfoque particular para programar. Esto a menudo hace difícil escribir programas que usen un enfoque diferente. Otros lenguajes son lenguajes multiparadigma que soportan varios enfoques diferentes. Lisp, C++ y Python son multiparadigma; puede escribir programas o librerías que son en gran parte procedimentales, orientados a objetos o funcionales en todos estos lenguajes. En un programa grande, las diferentes secciones podrían escribirse usando diferentes enfoques; la GUI podría ser orientada a objetos mientras la lógica de procesamiento es procedimental o funcional, por ejemplo.

En un programa funcional, la entrada fluye a través de un conjunto de funciones. Cada función opera sobre su entrada y produce alguna salida. El estilo funcional desalienta las funciones con efectos secundarios que modifican el estado interno o hacen otros cambios que no son visibles en el valor de retorno de la función. Las funciones que no tienen ningún efecto secundario se llaman puramente funcionales. Evitar los efectos secundarios significa no usar estructuras de datos que se actualicen mientras se ejecuta un programa; cada salida de la función debe depender solo de su entrada.

Algunos lenguajes son demasiado estrictos y ni siquiera tienen declaraciones de asignación como a=3 o c = a + b, pero es difícil evitar todos los efectos secundarios, como imprimir en pantalla o escribir en un archivo de disco. Otro ejemplo es llamar a las funciones print() o time.sleep(), ninguna de las dos retorna un valor útil, ambas son llamadas solo por sus efectos secundarios de enviar texto a la pantalla o pausar la ejecución por un segundo.

Los programas de Python escritos en estilo funcional usualmente no irán al extremo de evitar todas las E/S o todas las asignaciones; en cambio, proveerán una interfaz aparentemente funcional pero internamente usará características no funcionales. Por ejemplo, la implementación de una función todavía usará asignaciones a variables locales, pero no modificará variables globales ni tendrá otros efectos secundarios.

La programación funcional se puede considerar lo opuesto a la programación orientada a objetos. Los objetos son pequeñas capsulas que contienen algún estado interno junto con una colección de llamadas a métodos que le permiten modificar este estado, y los programas consisten en realizar el conjunto correcto de cambios de estado. La programación funcional quiere evitar cambios de estado tanto como sea posible y trabaja con flujos de datos entre funciones. En Python podría combinar los dos enfoques para escribir funciones que reciban y retornen instancias que representen objetos en su aplicación (mensajes de e-mail, transacciones, etc.).

El diseño funcional puede parecer una restricción extraña bajo la cuál trabajar. ¿Por qué evitaría objetos y efectos secundarios? Hay ventajas teóricas y prácticas para el estilo funcional:

  • Demostrabilidad formal.

  • Modularidad.

  • Componibilidad.

  • Facilidad de depurar y probar.

Demostrabilidad formal

Un beneficio teórico es que es más fácil construir una demostración matemática de que un programa funcional es correcto.

Por un largo tiempo los investigadores se han interesado en buscar formas de demostrar matemáticamente que los programas son correctos. Esto es diferente de probar un programa sobre numerosas entradas y concluir que su salida es usualmente correcta, o leer el código fuente de un programa y concluir que el código se ve bien; en lugar de eso el objetivo es una demostración rigurosa de que un programa produce el resultado correcto para todas las entradas posibles.

La técnica utilizada para demostrar que los programas son correctos es anotar invariantes, propiedades de los datos de entrada y de las variables del programa que siempre son verdaderas. Por cada línea de código, debe mostrar que si las invariantes X e Y son verdaderas antes que la línea sea ejecutada, las invariantes ligeramente diferentes X” e Y” son verdaderas después que la línea se ejecutó. Esto continúa hasta que alcance el fin del programa, punto en el cuál las invariantes deben coincidir con las condiciones deseadas en la salida del programa.

La evitación de las asignaciones de la programación funcional surge porque las asignaciones son difíciles de manejar con esta técnica; las asignaciones pueden romper invariantes que eran verdaderas antes de la asignación sin producir nuevas invariantes que se puedan propagar hacia adelante.

Desafortunadamente, demostrar que los programas son correctos es en gran parte impráctico y no relevante al software de Python. Incluso los programas triviales requieren demostraciones que tienen varias páginas; la demostración de correctitud para un programa moderadamente complicado sería enorme, y pocos o ninguno de los programas que usa diariamente (el interprete de Python, su analizador XML, su navegador web) se podrían demostrar que son correctos. Aún si anotó o generó una demostración, entonces estaría la cuestión de verificar la demostración; quizás hay un error en ella, y equivocadamente cree que demostró que el programa es correcto.

Modularidad

Un beneficio más práctico de la programación funcional es que fuerza a romper su problema en pequeñas piezas. Como resultado los programas son más modulares. Es más fácil especificar y escribir una función pequeña que hace una cosa que una función grande que realiza una transformación complicada. Las funciones pequeñas también son más fáciles de leer y comprobar si hay errores.

Facilidad de depurar y probar

Probar y depurar un programa en estilo funcional es más fácil.

La depuración se simplifica porque las funciones generalmente son pequeñas y claramente especificadas. Cuando un programa no funciona, cada función es un punto de interfaz donde puede comprobar si los datos son correctos. Puede ver las entradas y salidas intermedias para aislar rápidamente la función que es responsable de un error.

Las pruebas son más fáciles porque cada función es un sujeto potencial para una prueba unitaria. Las funciones no dependen de un estado del sistema que necesite ser replicado antes de correr una prueba; en lugar de eso solo tiene que sintetizar la entrada correcta y comprobar que la salida coincida con las expectativas.

Componibilidad

Mientras trabaja en un programa en estilo funcional, escribirá un número de funciones con diferentes entradas y salidas. Algunas de estas funciones inevitablemente estarán especializadas en una aplicación en particular, pero otras serán útiles en una amplia variedad de programas. Por ejemplo, una función que recibe la ruta de un directorio y retorna todos los archivos XML en el directorio, o una función que recibe el nombre de un archivo y retorna su contenido, se puede aplicar a muchas situaciones diferentes.

Con el tiempo formará una librería personal de utilidades. A menudo ensamblará nuevos programas organizando funciones existentes en una nueva configuración y escribiendo unas pocas funciones especializadas para la tarea actual.

Iteradores

Comenzaré viendo una característica del lenguaje Python que es una base importante para escribir programas en estilo funcional: iteradores.

Un iterador es un objeto que representa un flujo de datos; este objeto retorna los datos de a un elemento a la vez. Un iterador de Python debe soportar un método llamado __next__() que no recibe argumentos y siempre retorna el siguiente elemento en el flujo. Si no hay más elementos en el flujo, __next__() debe lanzar la excepción StopIteration. Los iteradores no tienen que ser finitos; es perfectamente razonable escribir un iterador que produce un flujo de datos infinito.

La función incorporada iter() recibe un objeto arbitrario e intenta retornar un iterador que retornará los contenidos o elementos del objeto, lanzando TypeError si el objeto no soporta iteración. Muchos tipos de datos integrados de Python soportan iteración, siendo los más comunes las listas y los diccionarios. Un objeto se llama iterable si puede obtener un iterador para él.

Puede experimentar con la interfaz de iteración manualmente:

>>> L = [1, 2, 3]
>>> it = iter(L)
>>> it  
<...iterator object at ...>
>>> it.__next__()  # same as next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>

Python espera objetos iterables en muchos contextos diferentes, siendo el más importante la sentencia for. En la sentencia for X in Y, Y debe ser un iterador o algún objeto para el que iter() puede crear un iterador. Estas dos sentencias son equivalentes:

for i in iter(obj):
    print(i)

for i in obj:
    print(i)

Los iteradores se pueden materializar como listas o tuplas utilizando las funciones constructoras list() o tuple():

>>> L = [1, 2, 3]
>>> iterator = iter(L)
>>> t = tuple(iterator)
>>> t
(1, 2, 3)

El desempaquetado de secuencias también soporta iteradores: si sabe que un iterador retornará N elementos, puede desempaquetarlos en una N-tupla:

>>> L = [1, 2, 3]
>>> iterator = iter(L)
>>> a, b, c = iterator
>>> a, b, c
(1, 2, 3)

Las funciones incorporadas como max() y min() pueden recibir un solo iterador como argumento y retornarán el elemento más grande o más pequeño. Los operadores "in" y "not in" también soportan iteradores: X in iterator es verdadero si X se encuentra en el flujo que retornó el iterador. Se encontrará con problemas obvios si el iterador es infinito; max(), min() nunca retornarán, y si el elemento X nunca aparece en el flujo, los operadores "in" y "not in" tampoco retornarán.

Note que solo puede ir hacia adelante en un iterador; no hay forma de obtener el elemento anterior, reiniciar el iterador o hacer una copia de él. Los objetos iteradores opcionalmente pueden proveer estas capacidades adicionales, pero el protocolo del iterador solo especifica el método __next__(). Por lo tanto las funciones pueden consumir toda la salida del iterador, y si necesita hacer algo diferente con el mismo flujo, tendrá que crear un nuevo iterador.

Tipos de datos que soportan iteradores

Ya hemos visto cómo las listas y tuplas soportan iteradores. De hecho, cualquier tipo de secuencia de Python, como cadenas de caracteres, automáticamente soportará la creación de un iterador.

Llamar a iter() en un diccionario retornará un iterador que recorrerá sobre las claves del diccionario:

>>> m = {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
...      'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12}
>>> for key in m:
...     print(key, m[key])
Jan 1
Feb 2
Mar 3
Apr 4
May 5
Jun 6
Jul 7
Aug 8
Sep 9
Oct 10
Nov 11
Dec 12

Note que a partir de Python 3.7, se garantiza que el orden de iteración del diccionario es el mismo que el orden de inserción. En versiones anteriores, el comportamiento no estaba especificado y podía variar entre implementaciones.

Aplicar iter() a un diccionario siempre recorre sobre las claves, pero los diccionarios tienen métodos que retornan otros iteradores. Si quiere iterar sobre valores o pares clave/valor, puede explícitamente llamar a los métodos values() o items() para obtener un iterador apropiado.

El constructor dict() puede aceptar un iterador que retorna un flujo finito de tuplas (key, value):

>>> L = [('Italy', 'Rome'), ('France', 'Paris'), ('US', 'Washington DC')]
>>> dict(iter(L))
{'Italy': 'Rome', 'France': 'Paris', 'US': 'Washington DC'}

Los archivos también soportan iteración llamando al método readline() hasta que no haya más líneas en el archivo. Esto significa que puede leer cada línea de un archivo de esta forma:

for line in file:
    # do something for each line
    ...

Los conjuntos pueden recibir sus contenidos de un iterable y le permiten iterar sobre los elementos del conjunto:

>>> S = {2, 3, 5, 7, 11, 13}
>>> for i in S:
...     print(i)
2
3
5
7
11
13

Expresiones generadoras y listas por comprensión

Dos operaciones comunes en la salida de un iterador son 1) realizar alguna operación para cada elemento, 2) elegir un subconjunto de elementos que reúnen alguna condición. Por ejemplo, dada una lista de cadena de caracteres, podría querer remover los espacios finales de cada línea o extraer todas las cadenas de caracteres que contienen una subcadena de caracteres dada.

Las listas por comprensión y las expresiones generadoras (forma abreviada: «listcomps» y «genexps») son una notación concisa para tales operaciones, prestadas del lenguaje de programación funcional Haskell (https://www.haskell.org/). Puede remover todos los espacios de un flujo de cadena de caracteres con el siguiente código:

>>> line_list = ['  line 1\n', 'line 2  \n', ' \n', '']

>>> # Generator expression -- returns iterator
>>> stripped_iter = (line.strip() for line in line_list)

>>> # List comprehension -- returns list
>>> stripped_list = [line.strip() for line in line_list]

Puede seleccionar solo ciertos elementos agregando una condición "if":

>>> stripped_list = [line.strip() for line in line_list
...                  if line != ""]

Con una lista por comprensión, obtiene una lista de Python; stripped_list es una lista que contiene las líneas resultantes, no un iterador. Las expresiones generadoras retornan un iterador que calcula los valores cuando es necesario, sin necesidad de materializar todos los valores a la vez. Esto significa que las listas por comprensión son inútiles si está trabajando con iteradores que retornan un flujo infinito o una gran cantidad de datos. En estas situaciones son preferibles las expresiones generadoras.

Las expresiones generadoras están rodeadas por paréntesis («()») y las listas por comprensión están rodeadas por corchetes («[]»). Las expresiones generadoras tienen la forma:

( expression for expr in sequence1
             if condition1
             for expr2 in sequence2
             if condition2
             for expr3 in sequence3
             ...
             if condition3
             for exprN in sequenceN
             if conditionN )

Nuevamente, para una lista por comprensión solo los corchetes exteriores son diferentes (corchetes en lugar de paréntesis).

Los elementos de la salida generada serán valores sucesivos de expression. Las cláusulas if son todas opcionales; si está presente, expression es solo evaluado y añadido al resultado cuando condition es verdadero.

Las expresiones generadoras siempre se tienen que escribir dentro de paréntesis, pero los paréntesis que indican la llamada a una función también cuentan. Si quiere crear un iterador que se pase inmediatamente a una función puede escribir:

obj_total = sum(obj.count for obj in list_all_objects())

Las cláusulas for...in contienen las secuencias sobre las que se itera. Las secuencias no tienen que tener la misma longitud, porque son iteradas de izquierda a derecha, no en paralelo. Por cada elemento en sequence1, se recorre sequence2 desde el inicio. Luego se recorre sequence3 por cada par de elementos resultante de sequence1 y sequence2.

Para ponerlo en otra forma, una lista por comprensión o expresión generadora es equivalente al siguiente código Python:

for expr1 in sequence1:
    if not (condition1):
        continue   # Skip this element
    for expr2 in sequence2:
        if not (condition2):
            continue   # Skip this element
        ...
        for exprN in sequenceN:
            if not (conditionN):
                continue   # Skip this element

            # Output the value of
            # the expression.

Esto significa que cuando hay múltiples cláusulas for...in pero no cláusulas if, la longitud de la salida resultante será igual al producto de las longitudes de todas las secuencias. Si tiene dos listas de longitud 3, la lista de salida tendrá 9 elementos:

>>> seq1 = 'abc'
>>> seq2 = (1, 2, 3)
>>> [(x, y) for x in seq1 for y in seq2]  
[('a', 1), ('a', 2), ('a', 3),
 ('b', 1), ('b', 2), ('b', 3),
 ('c', 1), ('c', 2), ('c', 3)]

Para evitar introducir una ambigüedad en la gramática de Python, si expression está creando una tupla, debe estar rodeada de paréntesis. La primera lista por comprensión de abajo tiene un error de sintaxis, mientras que la segunda es correcta:

# Syntax error
[x, y for x in seq1 for y in seq2]
# Correct
[(x, y) for x in seq1 for y in seq2]

Generadores

Los generadores son una clase especial de funciones que simplifican la tarea de escribir iteradores. Las funciones regulares calculan un valor y lo retornan, pero los generadores retornan un iterador que retorna un flujo de valores.

Sin duda está relacionado con cómo funcionan las llamadas a funciones regulares en Python o C. Cuando llama a una función, esta obtiene su espacio de nombres privado donde se crean sus variables locales. Cuando la función alcanza una sentencia return, las variables locales son destruidas y el valor se retorna al llamador. Una llamada posterior a la misma función crea un nuevo espacio de nombres privado y un conjunto limpio de variables locales. Pero, ¿qué pasa si las variables locales no fueron desechadas en la salida de una función? ¿Qué pasa si más tarde podría reanudar la función desde donde quedó? Esto es lo que proveen los generadores; se pueden pensar como funciones que se reanudan.

Este es el ejemplo más simple de una función generadora:

>>> def generate_ints(N):
...    for i in range(N):
...        yield i

Cualquier función que contiene una palabra clave yield es una función generadora; esto es detectado por el compilador bytecode de Python que compila la función de forma especial como resultado.

Cuando llama a una función generadora, no retorna un solo valor; en lugar de eso retorna un objeto generador que soporta el protocolo iterador. Al ejecutar la expresión yield, el generador produce el valor de i, de forma similar a una sentencia return. La gran diferencia entre una sentencia yield y un return es que al alcanzar un yield se suspende el estado de ejecución del generador y se preservan las variables locales. En la próxima llamada al método __next__() del generador, la función reanudará la ejecución.

Este es un ejemplo de uso del generador generate_ints():

>>> gen = generate_ints(3)
>>> gen  
<generator object generate_ints at ...>
>>> next(gen)
0
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
Traceback (most recent call last):
  File "stdin", line 1, in <module>
  File "stdin", line 2, in generate_ints
StopIteration

De igual forma podría escribir for i in generate_ints(5), o a, b, c = generate_ints(3).

Dentro de una función generadora, return value causa que se lance StopIteration(value) del método __next__(). Una vez que esto pase, o que se alcance el final de la función, termina la procesión de valores y el generador no puede producir más valores.

Podría lograr el efecto de los generadores manualmente escribiendo su propia clase y guardando todas las variables locales del generador como variables de instancia. Por ejemplo, retornar una lista de enteros se podría hacer estableciendo self.count a 0, y teniendo el método __next__() que incrementa self.count y lo retorna. Sin embargo, para un generador moderadamente complicado, escribir una clase correspondiente puede ser mucho más confuso.

El banco de pruebas incluido con la librería de Python, Lib/test/test_generators.py, contiene un número de ejemplos más interesantes. Este es un generador que implementa un recorrido inorden de un árbol usando generadores recursivamente.

# A recursive generator that generates Tree leaves in in-order.
def inorder(t):
    if t:
        for x in inorder(t.left):
            yield x

        yield t.label

        for x in inorder(t.right):
            yield x

Otros dos ejemplos en test_generators.py producen soluciones al problema de N-reinas (ubicar N reinas en un tablero de ajedrez de NxN de forma que ninguna reina amenace a otra) y el problema del caballo (encontrar una ruta que lleve a un caballo a cada cuadro de un tablero de ajedrez de NxN sin visitar ningún cuadro dos veces).

Pasar valores a un generador

En Python 2.4 y anteriores, los generadores solo producían salida. Una vez que el código de un generador era invocado para crear un iterador, no había forma de pasar ninguna información nueva a la función cuando se reanuda su ejecución. Podría lograr esta habilidad haciendo que el generador mire una variable global o pasando en algún objeto mutable que el llamador luego modifica, pero estos enfoques son confusos.

En Python 2.5 hay una forma más simple de pasar valores a un generador. yield se convirtió en una expresión, retornando un valor que se puede asignar a una variable o sobre el que se puede operar:

val = (yield i)

Recomiendo que siempre ponga paréntesis alrededor de una expresión yield cuando esté haciendo algo con el valor retornado, como en el ejemplo de arriba. Los paréntesis no siempre son necesarios, pero siempre es más fácil agregarlos en lugar de tener que recordar cuándo son necesarios.

(PEP 342 explica las reglas exactas, que son que una expresión yield siempre debe estar entre paréntesis excepto cuando se encuentra en la expresión de primer nivel en el lado derecho de una asignación. Esto significa que puede escribir val = yield i pero tiene que usar paréntesis cuando hay una operación, como en val = (yield i) + 12.)

Los valores son enviados a un generador llamando a su método send(value). Este método reanuda el código del generador y la expresión yield retorna el valor especificado. Si se llama al método regular __next__(), yield retorna None.

Este es un simple contador que incrementa por 1 y permite cambiar los valores del contador interno.

def counter(maximum):
    i = 0
    while i < maximum:
        val = (yield i)
        # If value provided, change counter
        if val is not None:
            i = val
        else:
            i += 1

Y este es un ejemplo del cambio del contador:

>>> it = counter(10)  
>>> next(it)  
0
>>> next(it)  
1
>>> it.send(8)  
8
>>> next(it)  
9
>>> next(it)  
Traceback (most recent call last):
  File "t.py", line 15, in <module>
    it.next()
StopIteration

Como yield a menudo retornará None, siempre debería comprobar este caso. No use simplemente su valor en expresiones a menos que esté seguro que el método send() será el único método utilizado para reanudar su función generadora.

Además del método send(), hay otros dos métodos para generadores:

  • throw(value) is used to raise an exception inside the generator; the exception is raised by the yield expression where the generator’s execution is paused.

  • close() lanza una excepción GeneratorExit dentro del generador para terminar la iteración. Al recibir esta excepción, el código del generador debe lanzar GeneratorExit o StopIteration; capturar la excepción y hacer cualquier otra cosa es ilegal y disparará un RuntimeError. close() también se llamará por el recolector de basura de Python cuando se recolecte el generador.

    Si necesita ejecutar un código de limpieza cuando ocurre un GeneratorExit, sugiero usar un try: ... finally: en lugar de capturar GeneratorExit.

El efecto acumulativo de estos cambios es convertir a los generadores de productores de información unidireccionales en productores y consumidores.

Los generadores también se convierten en corrutinas, una forma más generalizada de subrutinas. Las subrutinas inician en un punto y salen en otro punto (el inicio de la función, y la sentencia return), pero las corrutinas pueden iniciar, salir, y reanudarse en muchos puntos diferentes (las sentencias yield).

Funciones incorporadas

Veamos con más detalle las funciones incorporadas usadas a menudo con iteradores.

Dos de las funciones incorporadas de Python, map() y filter() duplican las características de las expresiones generadoras:

map(f, iterA, iterB, ...) retorna un iterador sobre la secuencia

f(iterA[0], iterB[0]), f(iterA[1], iterB[1]), f(iterA[2], iterB[2]), ....

>>> def upper(s):
...     return s.upper()
>>> list(map(upper, ['sentence', 'fragment']))
['SENTENCE', 'FRAGMENT']
>>> [upper(s) for s in ['sentence', 'fragment']]
['SENTENCE', 'FRAGMENT']

Por supuesto puede lograr el mismo efecto con una lista por comprensión.

filter(predicate, iter) retorna un iterador sobre todos los elementos de la secuencia que reúnen cierta condición, y de forma similar está duplicado por las listas por comprensión. Un predicado es una función que retorna un valor de verdad de alguna condición; para usarlo con filter(), el predicado debe recibir un solo valor.

>>> def is_even(x):
...     return (x % 2) == 0
>>> list(filter(is_even, range(10)))
[0, 2, 4, 6, 8]

Esto también se puede escribir como una lista por comprensión:

>>> list(x for x in range(10) if is_even(x))
[0, 2, 4, 6, 8]

enumerate(iter, start=0) enumera los elementos en el iterable retornando 2-tuplas que contienen la enumeración (desde start) y cada elemento.

>>> for item in enumerate(['subject', 'verb', 'object']):
...     print(item)
(0, 'subject')
(1, 'verb')
(2, 'object')

enumerate() a menudo se usa cuando se recorre una lista y se registran los índices en los que se reúnen una cierta condición:

f = open('data.txt', 'r')
for i, line in enumerate(f):
    if line.strip() == '':
        print('Blank line at line #%i' % i)

sorted(iterable, key=None, reverse=False) reúne todos los elementos de un iterable en una lista, ordena la lista, y retorna el resultado ordenado. Los argumentos key y reverse se pasan a través del método sort() de la lista construida.

>>> import random
>>> # Generate 8 random numbers between [0, 10000)
>>> rand_list = random.sample(range(10000), 8)
>>> rand_list  
[769, 7953, 9828, 6431, 8442, 9878, 6213, 2207]
>>> sorted(rand_list)  
[769, 2207, 6213, 6431, 7953, 8442, 9828, 9878]
>>> sorted(rand_list, reverse=True)  
[9878, 9828, 8442, 7953, 6431, 6213, 2207, 769]

(Para un tratamiento más detallado del ordenamiento, ver HOW TO - Ordenar.)

Las funciones incorporadas any(iter) y all(iter) ven los valores de verdad de los contenidos de un iterable. any() retorna True si algún elemento en el iterable es un valor verdadero, y all() retorna True si todos los elementos son valores verdaderos:

>>> any([0, 1, 0])
True
>>> any([0, 0, 0])
False
>>> any([1, 1, 1])
True
>>> all([0, 1, 0])
False
>>> all([0, 0, 0])
False
>>> all([1, 1, 1])
True

zip(iterA, iterB, ...) toma un elemento de cada iterable y los retorna en una tupla:

zip(['a', 'b', 'c'], (1, 2, 3)) =>
  ('a', 1), ('b', 2), ('c', 3)

Esto no construye una lista en memoria y consume todos los iteradores de entrada antes de ejecutar; en lugar de eso, las tuplas se construyen y retornan solo si son requeridas. (El término técnico para este comportamiento es evaluación perezosa.)

Se pretende que el iterador se use con iterables que tengan todos la misma longitud. Si los iterables son de diferentes longitudes, el flujo resultante tendrá la misma longitud que el iterable más corto.

zip(['a', 'b'], (1, 2, 3)) =>
  ('a', 1), ('b', 2)

Sin embargo, debería evitar hacer esto, porque se puede tomar un elemento de los iteradores más largos y descartarlo. Esto significa que ya no puede seguir usando los iteradores porque corre el riesgo de saltarse un elemento descartado.

El módulo itertools

The itertools module contains a number of commonly used iterators as well as functions for combining several iterators. This section will introduce the module’s contents by showing small examples.

Las funciones del módulo caen en unas pocas clases generales:

  • Funciones que crean un nuevo iterador basado en un iterador existente.

  • Funciones para tratar los elementos de un iterador como argumentos de función.

  • Funciones para seleccionar porciones de la salida de un iterador.

  • Una función para agrupar la salida de un iterador.

Crear nuevos iteradores

itertools.count(start, step) retorna un flujo infinito de valores equiespaciados. Opcionalmente puede suministrar el número de inicio, que por defecto es 0, y el intervalo entre números, que por defecto es 1:

itertools.count() =>
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...
itertools.count(10) =>
  10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ...
itertools.count(10, 5) =>
  10, 15, 20, 25, 30, 35, 40, 45, 50, 55, ...

itertools.cycle(iter) guarda una copia de los contenidos de un iterable provisto y retorna un nuevo iterador que retorna sus elementos del primero al último. El nuevo iterador repetirá estos elementos infinitamente.

itertools.cycle([1, 2, 3, 4, 5]) =>
  1, 2, 3, 4, 5, 1, 2, 3, 4, 5, ...

itertools.repeat(elem, [n]) retorna el elemento provisto n veces, o retorna el elemento indefinidamente si no se provee n.

itertools.repeat('abc') =>
  abc, abc, abc, abc, abc, abc, abc, abc, abc, abc, ...
itertools.repeat('abc', 5) =>
  abc, abc, abc, abc, abc

itertools.chain(iterA, iterB, ...) recibe un número arbitrario de iterables como entrada, y retorna todos los elementos del primer iterador, luego todos los elementos del segundo, y así sucesivamente, hasta que hayan sido agotados todos los iterables.

itertools.chain(['a', 'b', 'c'], (1, 2, 3)) =>
  a, b, c, 1, 2, 3

itertools.islice(iter, [start], stop, [step]) retorna un flujo que es un segmento del iterador. Con solo un argumento stop, retornará los primeros stop elementos. Si suministra un índice de inicio, obtendrá los elementos stop-start, y si suministra un valor para step, los elementos se saltarán en consecuencia. A diferencia de la segmentación de listas y cadenas de caracteres de Python, no puede usar valores negativos para start, stop o step.

itertools.islice(range(10), 8) =>
  0, 1, 2, 3, 4, 5, 6, 7
itertools.islice(range(10), 2, 8) =>
  2, 3, 4, 5, 6, 7
itertools.islice(range(10), 2, 8, 2) =>
  2, 4, 6

itertools.tee(iter, [n]) replica un iterador; retorna n iteradores independientes que retornarán los contenidos del iterador fuente. Si no suministra un valor para n, por defecto es 2. Replicar iteradores requiere guardar algunos de los contenidos del iterador fuente, esto puede consumir memoria significativa si el iterador es grande y uno de los nuevos iteradores se consume más que los otros.

itertools.tee( itertools.count() ) =>
   iterA, iterB

where iterA ->
   0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...

and   iterB ->
   0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...

Aplicar funciones a los elementos

El módulo operator contiene un conjunto de funciones correspondientes a los operadores de Python. Algunos ejemplos son operator.add(a, b) (suma dos valores), operator.ne(a, b) (lo mismo que a != b) y operator.attrgetter('id') (retorna un invocable que obtiene el atributo .id).

itertools.starmap(func, iter) asume que el iterable retornará un flujo de tuplas, y llama a func usando estas tuplas como los argumentos:

itertools.starmap(os.path.join,
                  [('/bin', 'python'), ('/usr', 'bin', 'java'),
                   ('/usr', 'bin', 'perl'), ('/usr', 'bin', 'ruby')])
=>
  /bin/python, /usr/bin/java, /usr/bin/perl, /usr/bin/ruby

Seleccionar elementos

Otro grupo de funciones elige un subconjunto de elementos de un iterador basado en un predicado dado.

itertools.filterfalse(predicate, iter) es el opuesto de filter(), retornando todos los elementos para los que el predicado retorna falso:

itertools.filterfalse(is_even, itertools.count()) =>
  1, 3, 5, 7, 9, 11, 13, 15, ...

itertools.takewhile(predicate, iter) retorna los elementos mientras el predicado retorne verdadero. Una vez que el predicado retorna falso, el iterador indicará el final de sus resultados.

def less_than_10(x):
    return x < 10

itertools.takewhile(less_than_10, itertools.count()) =>
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9

itertools.takewhile(is_even, itertools.count()) =>
  0

itertools.dropwhile(predicate, iter) descarta los elementos mientras el predicado retorne verdadero, y luego retorna el resto de los resultados del iterable.

itertools.dropwhile(less_than_10, itertools.count()) =>
  10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ...

itertools.dropwhile(is_even, itertools.count()) =>
  1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...

itertools.compress(data, selectors) recibe dos iteradores y retorna solo aquellos elementos de data para los cuales el elemento correspondiente de selectors es verdadero, deteniéndose cuando alguno se termine:

itertools.compress([1, 2, 3, 4, 5], [True, True, False, False, True]) =>
   1, 2, 5

Funciones combinatorias

La función itertools.combinations(iterable, r) retorna un iterador proporcionando todas las combinaciones de r-tuplas de los elementos contenidos en iterable.

itertools.combinations([1, 2, 3, 4, 5], 2) =>
  (1, 2), (1, 3), (1, 4), (1, 5),
  (2, 3), (2, 4), (2, 5),
  (3, 4), (3, 5),
  (4, 5)

itertools.combinations([1, 2, 3, 4, 5], 3) =>
  (1, 2, 3), (1, 2, 4), (1, 2, 5), (1, 3, 4), (1, 3, 5), (1, 4, 5),
  (2, 3, 4), (2, 3, 5), (2, 4, 5),
  (3, 4, 5)

Los elementos dentro de cada tupla permanecen en el mismo orden en el que iterable los retornó. Por ejemplo, el número 1 siempre está antes que 2, 3, 4 o 5 en los ejemplos de arriba. Una función similar, itertools.permutations(iterable, r=None), remueve esta restricción en el orden, retornando todas las disposiciones posibles de longitud r:

itertools.permutations([1, 2, 3, 4, 5], 2) =>
  (1, 2), (1, 3), (1, 4), (1, 5),
  (2, 1), (2, 3), (2, 4), (2, 5),
  (3, 1), (3, 2), (3, 4), (3, 5),
  (4, 1), (4, 2), (4, 3), (4, 5),
  (5, 1), (5, 2), (5, 3), (5, 4)

itertools.permutations([1, 2, 3, 4, 5]) =>
  (1, 2, 3, 4, 5), (1, 2, 3, 5, 4), (1, 2, 4, 3, 5),
  ...
  (5, 4, 3, 2, 1)

Si no suministra un valor para r se usa la longitud del iterable, lo que significa que se permutan todos los elementos.

Note que estas funciones producen todas las combinaciones posibles por posición y no requieren que los contenidos de iterable sean únicos:

itertools.permutations('aba', 3) =>
  ('a', 'b', 'a'), ('a', 'a', 'b'), ('b', 'a', 'a'),
  ('b', 'a', 'a'), ('a', 'a', 'b'), ('a', 'b', 'a')

La tupla idéntica ('a', 'a', 'b') aparece dos veces, pero las dos cadenas de caracteres “a” provienen de diferentes posiciones.

La función itertools.combinations_with_replacement(iterable, r) relaja una restricción diferente: los elementos se pueden repetir dentro de una misma tupla. Conceptualmente un elemento se elige para la primera posición de cada tupla y luego se reemplaza antes de que se elija el segundo elemento.

itertools.combinations_with_replacement([1, 2, 3, 4, 5], 2) =>
  (1, 1), (1, 2), (1, 3), (1, 4), (1, 5),
  (2, 2), (2, 3), (2, 4), (2, 5),
  (3, 3), (3, 4), (3, 5),
  (4, 4), (4, 5),
  (5, 5)

Agrupar elementos

La última función que trataré, itertools.groupby(iter, key_func=None), es la más complicada. key_func(elem) es una función que calcula un valor clave para cada elemento retornado por el iterable. Si no suministra una función de clave, la clave será simplemente el elemento mismo.

groupby() reúne todos los elementos consecutivos del iterable subyacente que tienen el mismo valor clave, y retorna un flujo de 2-tuplas que contienen un valor clave y un iterador para los elementos con esa clave.

city_list = [('Decatur', 'AL'), ('Huntsville', 'AL'), ('Selma', 'AL'),
             ('Anchorage', 'AK'), ('Nome', 'AK'),
             ('Flagstaff', 'AZ'), ('Phoenix', 'AZ'), ('Tucson', 'AZ'),
             ...
            ]

def get_state(city_state):
    return city_state[1]

itertools.groupby(city_list, get_state) =>
  ('AL', iterator-1),
  ('AK', iterator-2),
  ('AZ', iterator-3), ...

where
iterator-1 =>
  ('Decatur', 'AL'), ('Huntsville', 'AL'), ('Selma', 'AL')
iterator-2 =>
  ('Anchorage', 'AK'), ('Nome', 'AK')
iterator-3 =>
  ('Flagstaff', 'AZ'), ('Phoenix', 'AZ'), ('Tucson', 'AZ')

groupby() asume que los contenidos del iterable subyacente ya se ordenó basado en la clave. Note que los iteradores retornados también usan el iterable subyacente, así que tiene que consumir los resultados de iterador-1 antes de solicitar iterador-2 y su clave correspondiente.

El módulo functools

El módulo functools en Python 2.5 contiene algunas funciones de orden superior. Una función de orden superior recibe una o más funciones como entrada y retorna una nueva función. La herramienta más útil en este módulo es la función functools.partial().

Para programas escritos en un estilo funcional, a veces querrá construir variantes de funciones existentes que tienen algunos de los parámetros predefinidos. Considere una función de Python f(a, b, c); puede querer crear una nueva función g(b, c) que sea equivalente a f(1, b, c); está completando un valor para uno de los parámetros de f(). Esto se llama «aplicación parcial de funciones».

El constructor para partial() recibe los argumentos (function, arg1, arg2, ..., kwarg1=value1, kwarg2=value2). El objeto resultante es invocable, por lo que puede invocar a function con los argumentos rellenados.

Aquí hay un ejemplo pequeño pero realista:

import functools

def log(message, subsystem):
    """Write the contents of 'message' to the specified subsystem."""
    print('%s: %s' % (subsystem, message))
    ...

server_log = functools.partial(log, subsystem='server')
server_log('Unable to open socket')

functools.reduce(func, iter, [initial_value]) realiza acumulativamente una operación en todos los elementos del iterable y, por lo tanto, no se puede aplicar a infinitos iterables. func debe ser una función que recibe dos elementos y retorna un solo valor. functools.reduce() recibe los primeros dos elementos A y B retornados por el iterador y calcula func(A, B). Luego pide el tercer elemento, C, calcula func(func(A, B), C), combina este resultado con el cuarto elemento retornado, y continua hasta que se agote el iterable. Si el iterable no retorna ningún valor, se lanza una excepción TypeError. Si se suministra el valor inicial, se usa como punto inicial y func(initial_value, A) es el primer cálculo.

>>> import operator, functools
>>> functools.reduce(operator.concat, ['A', 'BB', 'C'])
'ABBC'
>>> functools.reduce(operator.concat, [])
Traceback (most recent call last):
  ...
TypeError: reduce() of empty sequence with no initial value
>>> functools.reduce(operator.mul, [1, 2, 3], 1)
6
>>> functools.reduce(operator.mul, [], 1)
1

Si usa operator.add() con functools.reduce(), sumará todos los elementos del iterable. Este caso es tan común que hay una función incorporada especial llamada sum() para calcularla:

>>> import functools, operator
>>> functools.reduce(operator.add, [1, 2, 3, 4], 0)
10
>>> sum([1, 2, 3, 4])
10
>>> sum([])
0

Sin embargo, para muchos usos de functools.reduce() puede ser mas claro simplemente escribir el ciclo for:

import functools
# Instead of:
product = functools.reduce(operator.mul, [1, 2, 3], 1)

# You can write:
product = 1
for i in [1, 2, 3]:
    product *= i

Una función relacionada es itertools.accumulate(iterable, func=operator.add). Realiza el mismo cálculo, pero en lugar de retornar solo el resultado final, accumulate() retorna un iterador que también produce cada resultado parcial:

itertools.accumulate([1, 2, 3, 4, 5]) =>
  1, 3, 6, 10, 15

itertools.accumulate([1, 2, 3, 4, 5], operator.mul) =>
  1, 2, 6, 24, 120

El módulo operator

El módulo operator se mencionó anteriormente. Contiene un conjunto de funciones que corresponden a los operadores de Python. Estas funciones a menudo son útiles en código en estilo funcional porque le salvan de escribir funciones triviales que realizan una sola operación.

Algunas de las funciones en este módulo son:

  • Operaciones matemáticas: add(), sub(), mul(), floordiv(), abs(), …

  • Operaciones lógicas: not_(), truth().

  • Operaciones bit a bit: and_(), or_(), invert().

  • Comparaciones: eq(), ne(), lt(), le(), gt() y ge().

  • Identidad de objeto: is_(), is_not().

Consulte la documentación del módulo operator para una lista completa.

Funciones pequeñas y la expresión lambda

Cuando se escriben programas en estilo funcional, a menudo necesitará pequeñas funciones que actúen como predicados o que combinen elementos de alguna manera.

Si hay una función incorporada o un módulo de Python, no necesita definir una nueva función en absoluto:

stripped_lines = [line.strip() for line in lines]
existing_files = filter(os.path.exists, file_list)

Si la función que necesita no existe, necesita escribirla. Una forma de escribir funciones pequeñas es usar la expresión lambda. lambda recibe un número de parámetros y una expresión que combina estos parámetros, y crea una función anónima que retorna el valor de la expresión:

adder = lambda x, y: x+y

print_assign = lambda name, value: name + '=' + str(value)

Una alternativa es simplemente usar la sentencia def y definir una función en la forma usual:

def adder(x, y):
    return x + y

def print_assign(name, value):
    return name + '=' + str(value)

¿Qué alternativa es preferible? Esa es una pregunta de estilo; mi rumbo usual es evitar usar lambda.

Una razón para mi preferencia es que lambda es muy limitado en las funciones que puede definir. El resultado tiene que ser calculable como una sola expresión, que significa que no tiene comparaciones multivía if... elif... else o sentencias try... except. Si intenta hacer mucho en una sentencia lambda, terminará con una expresión demasiado complicada que es difícil de leer. Rápido, ¿qué hace el siguiente código?

import functools
total = functools.reduce(lambda a, b: (0, a[1] + b[1]), items)[1]

Puede averiguarlo, pero toma tiempo desenredar la expresión para averiguar que está pasando. Usar una corta sentencia def anidada hace las cosas un poco mejor:

import functools
def combine(a, b):
    return 0, a[1] + b[1]

total = functools.reduce(combine, items)[1]

Pero lo mejor de todo sería si simplemente hubiese usado un ciclo for:

total = 0
for a, b in items:
    total += b

O la función incorporada sum() y una expresión generadora:

total = sum(b for a, b in items)

Muchos usos de functools.reduce() son más claros cuando se escriben como ciclos for.

Fredrik Lundh una vez sugirió el siguiente conjunto de reglas para refactorizar los usos de lambda:

  1. Escribir una función lambda.

  2. Escribir un comentario explicando qué demonios hace esa lambda.

  3. Estudiar el comentario por un momento, y pensar en un nombre que capture la esencia del comentario.

  4. Convertir la lambda a una sentencia def, usando ese nombre.

  5. Remover el comentario.

Me gustan mucho estas reglas, pero es libre de disentir acerca de si este estilo libre de lambda es mejor.

Historia de revisiones y reconocimientos

Al autor le gustaría agradecer a las siguientes personar por ofrecer sugerencias, correcciones y asistencia con varios borradores de este articulo: Ian Bicking, Nick Coghlan, Nick Efford, Raymond Hettinger, Jim Jewett, Mike Krell, Leandro Lameiro, Jussi Salmela, Collin Winter, Blake Winton.

Versión 0.1: publicada el 30 de junio de 2006.

Versión 0.11: publicada el 1 de julio de 2006. Errores tipográficos arreglados.

Versión 0.2: publicada el 10 de julio de 2006. Secciones genexp y listcomp unidas en una sola. Errores tipográficos arreglados.

Versión 0.21: Agregadas mas referencias sugeridas en la lista de correos tutor.

Versión 0.30: Agrega una sección sobre el módulo functional escrito por Collin Winter; agrega una sección corta sobre el módulo operator; y unas pocas otras ediciones.

Referencias

Generales

Estructura e interpretación de programas de computadora, por Harold Abelson y Gerald Jay Sussman con Julie Sussman. Texto completo en https://mitpress.mit.edu/sicp/. En este libro clásico de ciencia de computación, los capítulos 2 y 3 tratan el uso de secuencias y flujos para organizar el flujo de datos dentro de un programa. El libro usa Scheme para sus ejemplos, pero muchos de los enfoques de diseño descritos en estos capítulos son aplicables al código de Python en estilo funcional.

https://www.defmacro.org/ramblings/fp.html: A general introduction to functional programming that uses Java examples and has a lengthy historical introduction.

https://es.wikipedia.org/wiki/Programaci%C3%B3n_funcional: Entrada general de Wikipedia que describe la programación funcional.

https://es.wikipedia.org/wiki/Corrutina: Entrada para corrutinas.

https://es.wikipedia.org/wiki/Currificaci%C3%B3n: Entrada para el concepto de currificación.

Específicas de Python

https://gnosis.cx/TPiP/: The first chapter of David Mertz’s book Text Processing in Python discusses functional programming for text processing, in the section titled «Utilizing Higher-Order Functions in Text Processing».

Mertz también escribió una serie de artículos de 3 partes sobre programación funcional para el sitio DeveloperWorks de IBM; ver parte 1, parte 2, y parte 3,

Documentación de Python

Documentación del módulo itertools.

Documentación del módulo functools.

Documentación del módulo operator.

PEP 289: «Expresiones generadoras»

PEP 342: «Corrutinas a través de generadores mejorados» describe las características del nuevo generador en Python 2.5.