Search code examples
pythonfor-loopanonymous-function

Python lambda function is not being called correctly from within a for loop


I am trying to make a calculator using Tkinter in Python. I use a for loop to draw the buttons in, and I'm trying to use lambda functions so the buttons' actions are only called when pressed instead of calling right when the program starts. When I attempt this, however, a "list index out of range" error is thrown.

This is the relevant section which draws the buttons and sets their processes:

# button layout
    #  (  )  AC **
    #  7  8  9  /
    #  4  5  6  *
    #  1  2  3  -
    #  0  .  =  +

    # list that holds the buttons' file names in order
    imgOrder = ["lpr", "rpr", "clr", "pow",
                "7", "8", "9", "div",
                "4", "5", "6", "mul",
                "1", "2", "3", "sub",
                "0", "dot", "eql", "add"];

    # list containing the actual values assigned to buttons in order
    charOrder = ["(", ")", "AC", "**",
                 "7", "8", "9", "/",
                "4", "5", "6", "*",
                "1", "2", "3", "-",
                "0", ".", "=", "+"];

    imgIndex = 0;

    for rowi in range(1, 6):
        for coli in range(4):
            
            # fetch and store the img
            img = PhotoImage(file="images/{}.gif".format(imgOrder[imgIndex]));
            # create button
            button = Button(self, bg="white", image=img,
                            borderwidth=0, highlightthickness=0, activebackground="white",
                            command=lambda: self.process(charOrder[imgIndex]));
            # set button image
            button.image = img;
            # put the button in its proper row and column
            button.grid(row=rowi, column=coli, sticky=N+S+E+W);

            imgIndex += 1;

    
    # pack the GUI
    self.pack(fill=BOTH, expand=1);

    
# processes button presses
def process(self, button):
    print("Button {button} pressed!");
    
    # AC clears the display
    if (button == "AC"):
        # clear
        self.display["text"] = "";

    # otherwise, the number/operator can be tacked on the end
    else:
        self.display["text"] += button;

Specifically the line that is throwing the error is the line with the lambda function, but I don't know of any way to only make the buttons register on click, short of writing out this block for every button individually.


Solution

  • This is a classic case of unwanted closure. Here's a simplified example:

    funcs = []
    for i in range(3):
        funcs.append(lambda: i + 1)
    for func in funcs:
        print(func())
    

    One might expect that this will print 1 2 3, but it prints 3 3 3 instead. The reason is, lambda is a closure, closing over the variable i (capturing it in its context). When we execute the functions, the variable i is left at its last value in the loop (2). To reiterate, the lambda does not capture the value, but the variable. To avoid this, we want to pass the current value of i into the function as a parameter. To do that, we can construct a function that accepts i as the parameter, captures the parameter into the closure and returns the "customised" function we want:

    from functools import partial
    funcs = []
    for i in range(3):
        funcs.append((lambda val: lambda: val + 1)(i))
    for func in funcs:
        print(func())
    

    Equivalently, we can use functools.partial, which does just this:

    from functools import partial
    funcs = []
    for i in range(3):
        funcs.append(partial(lambda val: val + 1, i))
    for func in funcs:
        print(func())
    

    Here, lambda val: val + 1 will expect a parameter; partial(lambda val: val + 1, 0) will produce a new function where the first parameter is fixed at 0 - basically, lambda: 0 + 1. This captures no variables, and thus avoids the problem you encountered.

    tl;dr:

    command=partial(lambda i: self.process(charOrder[i]), imgIndex)