Search code examples
pythonpython-3.x

The right way to compare function argument with callback function in Python


I am confused with how Python compares two objects. The code that makes things confusing is roughly like that:

class Window(QtWidgets.QMainWindow):

   def __init__(self):
      super(Window,self).__init__()
      uic.loadUi('window.ui', self)
      self.show()

   def thread_it(self, func_to_execute):
      worker = Worker(func_to_execute)

      if func_to_execute == self.mpositioner.movetostart:
          worker.signals.progress.connect(self.create_raw_log_line)

      self.threadpool.start(worker)
      return worker

What is confusing is that the code works only if the line reading if func_to_execute == self.mpositioner.movetostart uses the == operator. When using with is operator it doesn't work. I was trying the is approach after it was suggested by VSCode Pylint, but Ruff linter doesn't complain about the ==.

So the question would be: is there any particular reason why the is doesn't work?

EDIT:

I made the test using suggested callback_test and the result is the following:

callback: <bound method MotorPositioner.movetostart of <__main__.MotorPositioner object at 0x000001B81B7C60E0>>
callback id: 1890339656832
callback_fun: <bound method MotorPositioner.movetostart of <__main__.MotorPositioner object at 0x000001B81B7C60E0>>
callback_fun id: 1890339659712
Yup same function

Solution

  • The issue here is how methods in Python work. Briefly:

    A method is just a function that exists in the namespace of a class, that when it is accessed through an instance of the class, returns a bound method which is a callable that effectively partially applies the instance to the first positional argument to the original function i.e. it provides the self parameter (which is just the name we conventionally give to the first positional parameter of a method) with the instance as an argument.

    So, consider:

    >>> class Foo:
    ...     def bar(self):
    ...         print("barring", self)
    ...
    >>> Foo.bar
    <function Foo.bar at 0x111e16fc0>
    

    When accessed on the class itself, it just returns the function object. Note, here, it is always going to be the case that you get the same object back, just the one that was created in the body of the class definition statement.

    >>> Foo.bar is Foo.bar # this will always be True
    True
    

    But if we create an instance, we get a different kind of objet:

    >>> foo = Foo()
    >>> foo.bar
    <bound method Foo.bar of <__main__.Foo object at 0x11026cad0>>
    >>> type(Foo.bar), type(foo.bar)
    (<class 'function'>, <class 'method'>)
    >>> foo.bar()
    barring <__main__.Foo object at 0x11026cad0>
    

    Appreciate again, that when accessed through the class, it is just the regular function you defined before, it works just like any function defined anywhere else, it simply exists in the class namespace:

    >>> Foo.bar(42)
    baring 42
    >>> def baz(self):
    ...     print("bazzing", self)
    ...
    >>> baz(42)
    bazzing 42
    

    Note, a bound-method object is created every time you access the object through the instance. So the following will always be evaluate to False:

    >>> foo = Foo()
    >>> foo.bar is foo.bar # will always be false
    False
    

    Note, however, that bound-method objects will be equal if they are binding the same instance to the same function. So:

    >>> foo1 = Foo()
    >>> foo2 = Foo()
    >>> foo1.bar == foo1.bar # always True
    True
    >>> foo1.bar == foo2.bar # always False
    False
    

    Additionally, these are introspect-able:

    >>> foo.bar.__self__
    <__main__.Foo object at 0x11026cad0>
    >>> foo.bar.__func__
    <function Foo.bar at 0x110250180>
    

    A quick gotcha:

    If you are following along in a REPL, you may do something like the following, trying to verify that the claims I'm making are true. And you do this reasonable test:

    >>> foo = Foo()
    >>> for _ in range(5):
    ...     print(id(foo.bar))
    ...
    4581348032
    4581348032
    4581348032
    4581348032
    4581348032
    

    "Huh?" You might ask. "What is going on here, why am I getting the same id's for purportedly different bound method objects? But when I check directly"

    >>> foo.bar is foo.bar
    False
    

    What is going on above is that the bound method gets reclaimed immediately after it is called because it isn't referenced anywhere. Since it is reclaimed, it's lifetime doesn't overlap, so the id doesn't guaratnee that they are distinct objects. This is because in CPython, the id is merely the value of the PyObject pointer, that is, the memory address of the object on the privately managed heap. This heap will re-use that memory happily. But if you keep a reference around, it is not reclaimed, so it is forced to use another one:

    >>> foo = Foo()
    >>> for _ in range(5):
    ...     bound_method = foo.bar
    ...     print(id(bound_method))
    ...
    4581907008
    4581530752
    4581907008
    4581530752
    4581907008
    

    You can see a similar phenomenon with this example:

    >>> id(object()) == id(object())
    True
    >>> object() is object()
    False
    >>> obj1 = object()
    >>> obj2 = object()
    >>> id(obj1) == id(obj2)
    False
    

    Moral of the story here is that you should be careful when using id.


    if you really want to know how this happens, then you need to understand descriptors. The Descriptor HOWTO goes into the nitty-gritty details, and is worth a read in its entirety. Descriptors are used all over by Python to implement core features.