Saturday, August 30, 2014

And the missing puzzle piece was...

Yesterday I struggled with the problem of how to direct the clicked() signal of a QPushButton. Although the symptoms were confusing, it seemed fairly clear that I was unintentionally connecting the default signal, whose signature is "clicked(bool)", when what I wanted was the no-parameter version, "clicked()".

In the old code, using the PyQt4 syntax, the signature of the desired signal was specified using the SIGNAL macro, SIGNAL("clicked()"). In the new signal/slot API, the doc shows how to specify a signal with a different parameter list, by "indexing" the signal name with a type, e.g. signalname[str].connect(...). What the doc didn't show was how to select the no-parameter signal.

This question was quickly answered on the PyQt mailing list by Baz Walter: you "index" the signal name with an empty tuple, signalname[()].connect(...). And that worked just fine, so my user-button connecting code now reads,

        for j in range(FindPanel.USER_BUTTON_MAX):
            self.user_buttons[j].clicked[()].connect(
                lambda b=j: self.user_button_click(b)
                )
            self.user_buttons[j].user_button_ctl_click.connect(
                lambda b=j : self.user_button_load(b)
                )

I worked out for myself what I was doing with an expression like

    lambda b=j: self.user_button_click(b)

A lambda expression lambda args : expr is exactly equivalent to

    def anonymous (args):
        expr

so

    lambda b=j: self.user_button_click(b)

is the equivalent of

    def anonymous (b=j):
        self.user_button_click(b)

In other words, I am specifying an argument with a default value! At execution time in the for-loop, the current value of j is substituted, for example

    def anonymous (b=17):
        self.user_button_click(b)

When the no-argument version of the signal is specified, the anonymous function is called with no arguments and the default index value is provided. When I unintentionally invoked the bool-passing signal version, the boolean it passed was taken as the argument b, and passed along instead of the default.

This does not explain to me why the current value of j is not also substituted when I write it this way:

    lambda : self.user_button_click(j)

This ought to mean,

    def anonymous ():
        self.user_button_click(17)

What happens instead is that every user-button clicked passes 23, the last-defined value of j in that loop. Which suggests to me that it is actually referencing the variable j. Oh wait, I could test that... Uh-huh! When I code it this way:

        for j in range(FindPanel.USER_BUTTON_MAX):
            self.user_buttons[j].clicked[()].connect(
                lambda : self.user_button_click(j)
                )
            self.user_buttons[j].user_button_ctl_click.connect(
                lambda b=j : self.user_button_load(b)
                )
            j = 'gotcha!'

and then click a user button, guess what the parameter to user_button_click is. Yup. "gotcha!"

So when a variable appears in the argument part of a lambda expression, its value is substituted for its name (b=j becomes b=17), but when a variable appears in the expression part, it is parsed as a reference to that variable, just as if the expression was in open code.

I'm glad it works this way or I'd have to think of a different scheme for directing the signals from multiple buttons to a single method (I'm sure there are other ways). But it seems inconsistent.

Edit: Actually, this is the result of Python's documented handling of default arguments. From the doc,

Default parameter values are evaluated when the function definition is executed. This means that the expression is evaluated once, when the function is defined, and that the same “pre-computed” value is used for each call.

So the conversion of b=j into b=17 (or whatever number) is exactly what should happen. So this is not some kind of hack, but rather an involved but acceptable way of getting a literal value into a lambda.

No comments: