Python: Anonymous function

# Sort a list or words by the number of unique letters
result = sorted(words, key=lambda word: len(set(word)))

Multiple statements or nonexpression statements like try or while cannot appear in lambda expression.

If free variables (not specified as parameters) are used in a lambda, the value of the variable is not the one when the lambda was defined, instead the value at the time of lambda evaluation is used (late binding). To capture the value of a free variable at lambda’s definition time, default argument helps. Because default arguments are evaluated once at function definition.

x = 2
f = lambda y, x=x: x*y
x = 3
g = lambda y, x=x: x*y
print(f(10)) # --> prints 20
print(g(10)) # --> prints 30

Passing arguments to callback function

from time import sleep
def after(seconds, func):
  sleep(seconds)
  func()
def add(x, y):
  print(f"{x} + {y} = {x+y}")
  return x+y
after(10, add(2, 3))

The result is:

2 + 3 = 5
Traceback (most recent call last):
  File "/home/runner/FuncScope/main.py", line 11, in <module>
    after(10, add(2, 3))
  File "/home/runner/FuncScope/main.py", line 5, in after
    func()
TypeError: 'int' object is not callable

Here add(2, 3) runs immediately resulting in 5. So, after 10 seconds of sleep, within after() the func() becomes 5() and since int does not implement __call__(), we have a TypeError.

We can package add(2, 3) into a zero argument function (thunk).

after(10, lambda: add(2, 3))

Or, we could use partial() from functools module.

from functools import partial
after(10, partial(add, 2, 3))

While lambda is lazy-binding, partial is eager-binding. Unlike lambda, a callable with partial is fully-evaluated and thus it can be serialized into bytes, saved in files, and transmitted across network connections (pickle).

Another option for passing add(2, 3) would be to pass all arguments to the outer after().

def after(seconds, func, debug, /, *args, **kwargs):
  sleep(seconds)
  if debug:
    print("About to call", func.__name__, args, kwargs)
  func(*args, **kwargs)
def add(x, y):
  print(f"{x} + {y} = {x+y}")
  return x+y
after(10, add, True, 2, y=3)

More exotic: make after() a composition of two separate function calls.

def after(seconds, func, debug=False):
  def call(*args, **kwargs):
    sleep(seconds)
    if debug:
      print("About to call", func.__name__, args, kwargs)
    func(*args, **kwargs)
  return call
after(10, add, debug=True)(2, y=3)

Returning results from callback function

def after(seconds, func, *args):
  sleep(seconds)
  return func(*args)
def add(x, y):
  return x+y
after("10", add, 2, 3) # TypeError: 'str' object cannot be interpreted as an integer
after(10, add, "2", 3) # TypeError: can only concatenate str (not "int") to str

To distinguish between the two types of errors, we can chain with a custom exception.

class CallbackError(Exception):
  pass
def after(seconds, func, *args):
  sleep(seconds)
  try:
    return func(*args)
  except Exception as err:
    raise CallbackError(err) from err

We could return an object that can contain value or exception. When the caller tries to access the result, for failure case, the exception is raised.

class Result:
  def __init__(self, value=None, exc=None):
    self._value = value
    self._exc = exc
  def result(self):
    if self._exc:
      raise self._exc
    else:
      return self._value
def after(seconds, func, *args):
  sleep(seconds)
  try:
    return Result(value=func(*args))
  except Exception as err:
    return Result(exc=CallbackError(err))
r = after(10, add, "2", 3)
r.result()

We can now use Result for type hinting:

def after(seconds, func, *args) -> Result:
    ...

Python’s Future uses this boxing approach.

from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(4)
r = pool.submit(add, 2, 3) # returns a Future
print(r.result()) # unwraps Future result and may throw

Leave a comment