Search code examples
pythonlambdatkinter

Lambda function behavior with and without keyword arguments


I am using lambda functions for GUI programming with tkinter. Recently I got stuck when implementing buttons that open files:

self.file=""
button = Button(conf_f, text="Tools opt.",
        command=lambda: tktb.helpers.openfile(self.file))

As you see, I want to define a file path that can be updated, and that is not known when creating the GUI. The issue I had is that earlier my code was :

button = Button(conf_f, text="Tools opt.",
        command=lambda f=self.file: tktb.helpers.openfile(f))

The lambda function had a keyword argument to pass the file path. In this case, the parameter f was not updated when self.file was.

I got the keyword argument from a code snippet and I use it everywhere. Obviously I shouldn't...

This is still not clear to me... Could someone explain me the difference between the two lambda forms and when to use one an another?

PS: The following comment led me to the solution but I'd like a little more explanations: lambda working oddly with tkinter


Solution

  • I'll try to explain it more in depth.

    If you do

    i = 0
    f = lambda: i
    

    you create a function (lambda is essentially a function) which accesses its enclosing scope's i variable.

    Internally, it does so by having a so-called closure which contains the i. It is, loosely spoken, a kind of pointer to the real variable which can hold different values at different points of time.

    def a():
        # first, yield a function to access i
        yield lambda: i
        # now, set i to different values successively
        for i in range(100): yield
    
    g = a() # create generator
    f = next(g) # get the function
    f() # -> error as i is not set yet
    next(g)
    f() # -> 0
    next(g)
    f() # -> 1
    # and so on
    f.func_closure # -> an object stemming from the local scope of a()
    f.func_closure[0].cell_contents # -> the current value of this variable
    

    Here, all values of i are - at their time - stored in that said closure. If the function f() needs them. it gets them from there.

    You can see that difference on the disassembly listings:

    These said functions a() and f() disassemble like this:

    >>> dis.dis(a)
      2           0 LOAD_CLOSURE             0 (i)
                  3 BUILD_TUPLE              1
                  6 LOAD_CONST               1 (<code object <lambda> at 0xb72ea650, file "<stdin>", line 2>)
                  9 MAKE_CLOSURE             0
                 12 YIELD_VALUE
                 13 POP_TOP
    
      3          14 SETUP_LOOP              25 (to 42)
                 17 LOAD_GLOBAL              0 (range)
                 20 LOAD_CONST               2 (100)
                 23 CALL_FUNCTION            1
                 26 GET_ITER
            >>   27 FOR_ITER                11 (to 41)
                 30 STORE_DEREF              0 (i)
                 33 LOAD_CONST               0 (None)
                 36 YIELD_VALUE
                 37 POP_TOP
                 38 JUMP_ABSOLUTE           27
            >>   41 POP_BLOCK
            >>   42 LOAD_CONST               0 (None)
                 45 RETURN_VALUE
    >>> dis.dis(f)
      2           0 LOAD_DEREF               0 (i)
                  3 RETURN_VALUE
    

    Compare that to a function b() which looks like

    >>> def b():
    ...   for i in range(100): yield
    >>> dis.dis(b)
      2           0 SETUP_LOOP              25 (to 28)
                  3 LOAD_GLOBAL              0 (range)
                  6 LOAD_CONST               1 (100)
                  9 CALL_FUNCTION            1
                 12 GET_ITER
            >>   13 FOR_ITER                11 (to 27)
                 16 STORE_FAST               0 (i)
                 19 LOAD_CONST               0 (None)
                 22 YIELD_VALUE
                 23 POP_TOP
                 24 JUMP_ABSOLUTE           13
            >>   27 POP_BLOCK
            >>   28 LOAD_CONST               0 (None)
                 31 RETURN_VALUE
    

    The main difference in the loop is

            >>   13 FOR_ITER                11 (to 27)
                 16 STORE_FAST               0 (i)
    

    in b() vs.

            >>   27 FOR_ITER                11 (to 41)
                 30 STORE_DEREF              0 (i)
    

    in a(): the STORE_DEREF stores in a cell object (closure), while STORE_FAST uses a "normal" variable, which (probably) works a little bit faster.

    The lambda as well makes a difference:

    >>> dis.dis(lambda: i)
      1           0 LOAD_GLOBAL              0 (i)
                  3 RETURN_VALUE
    

    Here you have a LOAD_GLOBAL, while the one above uses LOAD_DEREF. The latter, as well, is for the closure.

    I completely forgot about lambda i=i: i.

    If you have the value as a default parameter, it finds its way into the function via a completely different path: the current value of i gets passed to the just created function via a default parameter:

    >>> i = 42
    >>> f = lambda i=i: i
    >>> dis.dis(f)
      1           0 LOAD_FAST                0 (i)
                  3 RETURN_VALUE
    

    This way the function gets called as f(). It detects that there is a missing argument and fills the respective parameter with the default value. All this happens before the function is called; from within the function you just see that the value is taken and returned.

    And there is yet another way to accomplish your task: Just use the lambda as if it would take a value: lambda i: i. If you call this, it complains about a missing argument.

    But you can cope with that with the use of functools.partial:

    ff = [functools.partial(lambda i: i, x) for x in range(100)]
    ff[12]()
    ff[54]()
    

    This wrapper gets a callable and a number of arguments to be passed. The resulting object is a callable which calls the original callable with these arguments plus any arguments you give to it. It can be used here to keep locked to the value intended.