Skip to content

Latest commit

 

History

History
843 lines (622 loc) · 22.8 KB

File metadata and controls

843 lines (622 loc) · 22.8 KB

Funciones, closures y decoradores

En Python todo es un objeto, incluídas las funciones.

Las funciones son un tipo de callable (invocable), en particular, son del tipo de callable que se define con la sintáxis def.

Una función está formada por una signatura y un bloque de código. La signatura es el nombre de la función y la lista de sus parámetros formales, junto con los valores por defecto y anotaciones.

El bloque de código es el conjunto de instrucciones que forman el cuerpo de la función. El cuerpo de una función define un alcance (scope) para las variables definidas en ellas, que quedan aisladas de los bloques exteriores.

El bloque puede comenzar por una cadena de texto que representa la documentación de la función.

  1. Considera la siguiente función:

    def simple():
       """Returns the answer to the life, universe and everything else."""
       return 42

    Una función debe tener al menos una sentencia. Esto también son funciones:

    def stub(): ...
    def another_stub():
       ...

    Ambas devuelven None:

    stub() is None
    another_stub() is None
    stub is not another_stub

    Podemos visualizar la documentación de una función con la función help():

    help(simple)
  2. El nombre de la función es parte del objeto:

    print(simple.__name__)
    simple is not simple.__name__
  3. El nombre de la función es, también, una variable que se refiere al objeto:

    id(simple)
    type(simple)
    simple()

    Su alcance (scope) es el del bloque donde fue definida y el cuerpo de la función (lo que permite hacer llamadas recursivas):

    def factorial(n):
       """Recursively calculate the factorial of a number."""
       if n == 0:
           return 1
    
       return n * factorial(n-1)
    
    factorial(10)
  4. Como cualquier otro objeto, podemos asignarlo a otras variables:

    same_function = simple
    same_function()
    same_function is simple
    same_function.__name__ != 'same_function'
  5. Como cualquier otro objeto, podemos pasarlo como parámetro:

    def print_name(f):
       """Print the given name of a function."""
       print(f.__name__)
    
    print_name(simple)
    print_name(print_name)
  6. El resultado de una función no debe ser confundido con la función en sí:

    simple() is not simple
    simple() == 42
    type(simple)
    type(simple())

Anotaciones

Las anotaciones son valores arbitrarios que acompañan a los parámetros y a la propia función sin ningún significado por defecto para Python.

  1. Esto son anotaciones válidas:

    def func(a: 'some text', b: int) -> (lambda x: x):
       ...
  2. Las anotaciones de una función pueden consultarse en el atributo __annotations__:

    func.__annotations__
  • El PEP 3107 explica la motivación y la sintaxis de las anotaciones.

La sintaxis de llamada

Un invocable es un objeto sobre el que podemos usar la sintaxis de llamada, formalmente definida en la documentación de Python.

  1. La sintaxis de llamada consiste en acompañar un objeto invocable con unos paréntesis que encierran una lista de valores llamados parámetros.

    print_name(simple)
  2. Python fuerza que exista una correspondencia entre los parametros formales y los parámetros reales. No puede haber parámetros de menos:

    print_name()

    Los parámetros formales son aquellos en la definición de la función y los parámetros reales son aquellos encerrados entre los paréntesis de la llamada de la función.

  3. Tampoco puede haber parámetros de más:

    print_name(simple, 2)

    Pero podemos "recoger" los parámetros extra con un parámetro especial en la signatura de la función, el parámetro "estrella" (stararg):

    def print_name(f, *others):
       """Print the given name of a function."""
       print(f'others is of type {type(others)} and contains {others}')
       print(f.__name__)
    
    print_name(simple, range, enumerate)
  4. También podemos utilizar los valores recolectados:

    def print_name(f, *others):
       """Print the given name several functions."""
       print(f'others is of type {type(others)} and contains {others}')
       print(f.__name__)
       for g in others:
           print(g.__name__)
    
    print_name(simple, range, enumerate)

    El parámetro estrella sirve para implementar funciones variádicas, es decir, con un número variable de argumentos.

    def average(*values):
       """Calculate the average of the arguments."""
       return sum(values) / len(values)
    
    average(10)
    average(10, 20)
    average(10, 20, 30)

    La función print_names realiza la misma acción sobre todos sus argumentos. ¿Podrías simplificar su signatura y su implementación?

  5. Como en el caso de las colecciones, podemos desempaquetar secuencias en las llamadas a una función:

    values = [1, 2, 3, 4, 5]
    average(*values)

    Nota: fíjate que la sintaxis de desempaquetado y la sintáxis del parámetro estrella es "la misma". Se desambiguan por el contexto. Un parámetro formal precedido de asterisco es un parámetro estrella siempre. Es en la llamada donde la sintáxis se utiliza para desempaquetar.

    Observa también que no es necesario que exista un parámetro estrella para poder desempaquetar secuencias en una llamada:

    def solve(a, b, c):
       """Solves a quadratic equation given the coefficients."""
       root = (b**2 - 4*a*c) ** 1/2
       return (-b + root)/2*a, (-b - root)/2*a
    
    coefficients = (4, 2, 1)
    solve(*coefficients)
    solve(8, 4, 2)
  6. Python permite el paso de parámetros por nombre explícito:

    def nroot(value, root):
       """Calculate the nth root of value."""
       return value ** (1/root)
    
    root(49, 2)
    root(value=49, root=2)
    root(root=2, value=49)

    Distinguiremos entra parámetros con nombre (keyword parameters) y parámetros posicionales (positional parameters).

  7. Los parámetros con nombre añaden claridad:

    nroot(81, root=3)

    Pero sólo pueden aparecer después de los parámetros posicionales:

    nroot(root=3, 81)
    nroot(value=81, 3)
  8. De nuevo, debe haber una correspondencia entre los parámetros de la llamada y los parámetros en la signatura. No podemos pasarmás parámetros con nombre de los esperados:

    nroot(81, root=3, another_parameter=4)
  9. Pero podemos utilizar otro parámetro estrella para recolectar los valores sobrantes:

    def print_profile(name, surname, **extra_info):
       """Format a profile of a person."""
       print(f'Profile of: {surname}, {name}')
       if extra_info:
           fields = sorted(extra_info.keys())
           for field in fields:
               print(f'\t{field}: {extra_info[field]}')
    
    print_profile('Salvador', 'de la Puente')
    print_profile(surname='de la Puente', name='Salvador')
    print_profile('Salvador', 'de la Puente', company='IBM Research')

    El parámetro estrella para recolectar los parámetros con nombre sobrantes se suele llamar kwargs (del inglés keyword arguments).

    De nuevo, no se debe confundir con la sintáxis de desempaquetado. En una signatura, un parámetro precedido de dos asteriscos es siempre un parámetro kwargs.

  10. De manera similar a las secuencias, podemos desempaquetar diccionarios en las llamadas a función. Por ejemplo:

    extra_data = {'company': 'IBM Research', 'age': 33}
    print_profile('Salvador', 'de la Puente', **extra_data)
    complete_data = {'name': 'Salvador', 'surname':'de la Puente', **extra_data}
    print_profile(**complete_data)

    Tampoco es necesario que exista un parámetro kwargs para utilizar el desempaquetado de mapas:

    def do_get_request(host, username, password):
        """Perform an authenticated GET request to an endpoint."""
        print(f'Faking GET request to {username}:{password}@{host}')
    
    credentials = {'username': 'salva', 'password':'1234'}
    do_get_request('example.com', **credentials)

Parámetros por defecto

Python permite el uso de parámetros por defecto.

  1. Considera esta nueva versión de la función que calcula raíces:

    def nroot(value, root=2):
       """Calculate the nth root of value. If omitting root, calculate
       the square root."""
       return value ** (1/root)
    
    nroot(49)
    nroot(81)
    nroot(81, root=3)
  2. Los parámetros por defecto se evalúan una sola vez en el momento de la creación del objeto función y no en el momento de la llamada. Es decir:

    def shared_default(list_=[]):
        list_.append('another item')
        print(len(list_))
    
    shared_default()
    shared_default()
    shared_default()

    La recomendación es la de sólo usar tipos inmutables como valores por defecto. Si, de todas formas, quisieras lograr el comportamiento "esperado", ¿cómo lo harías?

Lambdas

Una lambda es una función anónima con una sintáxis simplificada que no require nombre, ni paréntesis en la signatura y que sólo admite una expresión como cuerpo de la función, que además debe escribirse a continuación de la signatura y cuyo resultado es el valor de retorno:

  1. Una función lambda también es un objeto:

    lambda x: 2*x
  2. Una función lambda puede pasarse como parámetro:

    print_name(lambda x: 2*x)
  3. Una función lambda puede asignarse a una variable:

    double = lambda x: 2*x

    Pero si esto ocurre, se contradice el propósito de la expresión lambda de ser una función anónima.

  4. Una función lambda es un invocable porque es una función:

    type(lambda x: 2*x)
    (lambda x: 2*x)(4)
  5. Las funciones lambdas suelen ser útiles cuando se usan como parámetros en funciones "de segundo order":

    input = [1, 2, 3, 4]
    doubles = list(map(lambda x: 2*x, input))

    Pero puede ser más expresivo utilizar una función al uso:

    def double(x): return 2*x
    input = [1, 2, 3, 4]
    doubles = list(map(double, input))

Closures

Una clausura o closure es un conjunto de valores para las variables libres de una función. Es decir, aquellas variables no definidas en el cuerpo de la función sino en bloques de funciones exteriores.

def greetings_factory(greeting):
   """Return a function for greeting people with a customize expression."""
   def greetings(name):
       """Print a greetings to name."""
       return f'{greeting} {name}'

   return greetings

spanish_greetings = greetings_factory('Hola')
english_greetings = greetings_factory('Hello')
spanish_greetings.__closure__[0].cell_contents
english_greetings.__closure__[0].cell_contents
spanish_greetings('Salva')
english_greetings('Salva')

Atributos de una función

Todas la funciones tienen una serie de atributos.

  1. Considera esta versión más completa de la función anterior:

    from typing import Callable
    
    def greetings_factory(greeting: str ='Hi') -> Callable[[str], str]:
       """Return a function for greeting people with a customize expression."""
       def greetings(name: str) -> str:
           """Print a greetings to name."""
           return f'{greeting} {name}'
    
       return greetings
  2. Ahora crea una nueva función de saludo:

    spanish_greetings = greetings_factory('Hola')
  3. Y comprueba algunos sus atributos:

    spanish_greetings.__doc__
    spanish_greetings.__name__
    spanish_greetings.__qualname__
    spanish_greetings.__module__
    spanish_greetings.__defaults__
    spanish_greetings.__code__
    spanish_greetings.__globals__
    spanish_greetings.__dict__
    spanish_greetings.__closure__
    spanish_greetings.__annotations__
    spanish_greetings.__kwdefaults__
  4. Además, a las funciones definidas por el usuario con def, podemos añadir nuevos atributos como si se tratara de metadatos:

    def factorial(n):
        accumulator = 1
        for v in range(2, n+1):
            accumulator *= v
        return accumulator
    
    factorial.version = '2.0'
    factorial(10)
    print(factorial.version)

Decoradores

Un decorador es un invocable que devuelve otro invocable. Un decorador recibe como parámetro una función definida por el usuario y, de alguna forma, aumenta su funcionalidad. Por ejemplo:

@cached
def fibonacci(n):
    """Calculate the nth fibonacci number."""
    if n == 0 or n == 1:
        return 1

    return fibonacci(n-1) + fibonacci(n-2)

El decorador cached podría guardar los resultados de las invocaciones de forma que segundas invocaciones para valores ya calculados fueran más rápidos.

La sintaxis anterior es tan sólo azúcar sintáctico para el siguiente código:

def fibonacci(n):
    """Calculate the nth fibonacci number."""
    if n == 0 or n == 1:
        return 1

    return fibonacci(n-1) + fibonacci(n-2)

fibonacci = cached(fibonacci)

Vamos a implementar el decorador cached:

  1. Empecemos por un decorador que no hace nada:

    def leave_the_same(target):
       return target
    
    @leave_the_same
    def fibonacci(n):
        """Calculate the nth fibonacci number."""
        if n == 0 or n == 1:
            return 1
    
        return fibonacci(n-1) + fibonacci(n-2)
  2. Una implementación alternativa del mismo comportamiento sería:

    def leave_the_same(target):
       def _decorated(*args, **kwargs):
           return target(*args, **kwargs)
    
       return _decorated
    
    @leave_the_same
    def fibonacci(n):
        """Calculate the nth fibonacci number."""
        if n == 0 or n == 1:
            return 1
    
        return fibonacci(n-1) + fibonacci(n-2)
  3. Sin embargo, esta implementación nos da más juego puesto que ahora podemos hacer cosas antes y después de la invocación. Por ejemplo, imprimir los parámetros y el resultado:

    def log(target):
       def _decorated(*args, **kwargs):
           print(f'Called with {args} and {kwargs}')
           result = target(*args, **kwargs)
           print(f'Returned {result}')
           return result
    
       return _decorated
    
    @log
    def solve(a, b, c):
       """Solves a quadratic equation given the coefficients."""
       root = (b**2 - 4*a*c) ** 1/2
       return (-b + root)/2*a, (-b - root)/2*a
    
    
    solve(4, 2, 1)
    solve(16, 25, c=5)
  4. Los decoradores son altamente combinables y podemos crear unos a partir de otros más sencillos:

    def log_parameters(target):
       def _decorated(*args, **kwargs):
           print(f'Called with {args} and {kwargs}')
           return target(*args, **kwargs)
       return _decorated
    
    def log_return(target):
       def _decorated(*args, **kwargs):
           result = target(*args, **kwargs)
           print(f'Returned {result}')
           return result
    
       return _decorated
    
    def log(target):
       @log_parameters
       @log_return
       def _decorated(*args, **kwargs):
           return target(*args, **kwargs)
       return _decorated
    
    @log
    def solve(a, b, c):
       """Solves a quadratic equation given the coefficients."""
       root = (b**2 - 4*a*c) ** 1/2
       return (-b + root)/2*a, (-b - root)/2*a
    
    solve(4, 2, 1)
    solve(16, 25, c=5)
  5. Como ocurría con el primer ejemplo, un decorador no tiene por qué siempre devolver otra función. Un decorador puede añadir metadatos a la función objetivo:

    VERSION = '2.0'
    
    def version(target):
        global VERSION
        target.version = VERSION
        return target
    
    
    @version
    def some_function():
       return ...
  6. Con lo visto, podemos implementar el comportamiento de la caché:

    def cached(target):
       cache = target.__cache__ = {}
       def _decorated(n):
           if n not in cache:
               cache[n] = target(n)
    
           return cache[n]
    
       return _decorated

    Y comparar:

    def test_fibonacci():
    
        def fibonacci(n):
            """Calculate the nth fibonacci number."""
            if n == 0 or n == 1:
                return 1
    
            return fibonacci(n-1) + fibonacci(n-2)
    
        fibonacci(100)
        fibonacci(100)
        fibonacci(200)
    
    def test_cached_fibonacci():
    
        @cached
        def fibonacci(n):
            """Calculate the nth fibonacci number."""
            if n == 0 or n == 1:
                return 1
    
            return fibonacci(n-1) + fibonacci(n-2)
    
        fibonacci(100)
        fibonacci(100)
        fibonacci(200)
    
    import timeit
    timeit.timeit('test_fibonacci()', globals=globals(), number=1)
    timeit.timeit('test_cached_fibonacci()', globals=globals(), number=1)

    También podemos manipular la caché de la función a través del atributo __cache__:

    cached_fibonacci.__cache__
    cached_fibonacci.__cache__.clear()

El decorador @wraps

El problema, al decorar una función, es que en realidad estamos perdiendo algo de información de la función original.

  1. Ejecuta el siguiente código para restaurar la función solve a su versión sin decorar:

    def solve(a, b, c):
       """Solves a quadratic equation given the coefficients."""
       root = (b**2 - 4*a*c) ** 1/2
       return (-b + root)/2*a, (-b - root)/2*a

    Y comprueba la siguiente información:

    solve.__doc__
    solve.__name__
  2. Ahora ejecuta el siguiente código para decorar la función y vuelve a comprobar los mismos valores:

    @log
    def solve(a, b, c):
       """Solves a quadratic equation given the coefficients."""
       root = (b**2 - 4*a*c) ** 1/2
       return (-b + root)/2*a, (-b - root)/2*a

    ¿Qué le ha pasado a solve.__doc__ y a solve.__name__?

  3. Para restaurar el comportamiento, Python provee del decorador @functools.wraps que copia los valores de la función objetivo a la función decoradora:

    from functools import wraps
    
    def log(target):
    
       @wraps(target)
       def _decorated(*args, **kwargs):
           print(f'Called with {args} and {kwargs}')
           result = target(*args, **kwargs)
           print(f'Returned {result}')
           return result
    
       return _decorated
    
    @log
    def solve(a, b, c):
       """Solves a quadratic equation given the coefficients."""
       root = (b**2 - 4*a*c) ** 1/2
       return (-b + root)/2*a, (-b - root)/2*a

    ¿Qué pasa ahora con solve.__doc__ y solve.__name__?

  4. La función original aun está disponible en el atributo __wrapped__:

    solve(1,2,3)
    solve.__wrapped__(1,2,3)

    Corrige los decoradores de ejemplo de este tema para que envuelvan correctamente las funciones objetivo utilizando @wraps.

Decoradores parametrizados

Un decorador parametrizado toma argumentos. Tiene esta pinta:

@version('2.0')
def solve(a, b, c):
   """Solves a quadratic equation given the coefficients."""
   root = (b**2 - 4*a*c) ** 1/2
   return (-b + root)/2*a, (-b - root)/2*a

Y, de nuevo, la sintáxis es equivalente a:

def solve(a, b, c):
   """Solves a quadratic equation given the coefficients."""
   root = (b**2 - 4*a*c) ** 1/2
   return (-b + root)/2*a, (-b - root)/2*a

solve = version('2.0')(solve)

Eso significa que el resultado de version('2.0') debe ser un invocable.

  1. Considera el decorador @version(version_string) que añade el atributo version a la función objetivo:

    def version(version_string):
       def _decorator(target):
           target.version = version_string
           return target
    
       return _decorator
    
    @version('2.0')
    def solve(a, b, c):
        """Solves a quadratic equation given the coefficients."""
        root = (b**2 - 4*a*c) ** 1/2
        return (-b + root)/2*a, (-b - root)/2*a
    
    solve.version == '2.0'
  2. Considera ahora el decorador @log(label) que añade una etiqueta de la forma [label] antes de imprimir parámetros y resultado:

    def log(label):
        def _decorator(target):
    
           @wraps(target)
           def _decorated(*args, **kwargs):
               print(f'[{label}] Called with {args} and {kwargs}')
               result = target(*args, **kwargs)
               print(f'[{label}] Returned {result}')
               return result
    
           return _decorated
    
        return _decorator
    
    @log('XXX')
    def solve(a, b, c):
        """Solves a quadratic equation given the coefficients."""
        root = (b**2 - 4*a*c) ** 1/2
        return (-b + root)/2*a, (-b - root)/2*a

Este patrón es en realidad una versión de la función factoría que vimos con greetings_factory donde el decorador actúa en realidad como una factoría de decoradores.

Los decoradores son aun más potentes cuando los aplicamos a clases aunque esto ya lo veremos más adelante. También son útiles para implementar el paradigma de la programación orientada a aspectos.

Con decoradores podemos implementar logging automático, funciones espía, validación de tipos en tiempo de ejecución, generación segura de valores por defecto, generación de documentación, sustitución de parámetros...