Comparing class methods using `is` is wrong?

Hi there,

I ran into the problem when comparing class methods using is.
The following example seems very strange to me.
Why can’t I use is?

>>> class A:
...     def call(self, a):
...         pass
...
>>> a = A()
>>> a.call is a.call
False

Print it and you’ll see?

What is the motivation for doing this? What are you trying to check?

is is typically used to check if two variables point to the same object–they aren’t just equal, they point to the same address in memory. Using it on a method is a bit odd, and I wonder what you are hoping to check here.

Nah, I think this is weirder. a.call points to a specific memory bytecode address, and one might except is to work here.

The tricky bit is that is uses the id() function to decide if two things are the same, and id(a.call) doesn’t give the same answer every time. Honestly I don’t know why that is? Perhaps because it’s storing traceback info?

I’m not sure how illuminating it is to print

In [4]: id(a.call)
Out[4]: 140684486544512

In [5]: id(a.call)
Out[5]: 140684489158400

That really just restates what the OP sees, but does not explain it.

To answer the actual question: when you define a method on a class, what Python actually does it to create a descriptor for that attribute name, and that descriptor returns a “bound” version of original plain function (i.e. that has self) with the self parameter bound to the instance (think of what functools.partial does). Offhand, I’m not entirely sure why these bound method objects are not cached, but certainly that would add non-trivial complexity to do so.

3 Likes

You don’t need to look at the “memory addresses”. It’s a bound method object returned by the descriptor protocol method __get__ of functions. That method is being called twice, and returning two different bound methods.

I didn’t say to print the id. I meant just print the a.call, which will show that they’re both bound methods. You can learn how bound methods are produced from the documentation.

But that’s not really instructive either:

In [6]: print(a.call)
<bound method A.call of <__main__.A object at 0x7ff3d865af70>>

In [7]: print(a.call)
<bound method A.call of <__main__.A object at 0x7ff3d865af70>>

The only id present is for the instance, which is the same in both cases, so the output looks identical in both cases. I’m not sure a reasonable person would conclude that these are different objects based solely on this output. But regardless, it does not explain “why”, which was the actual question.

1 Like

So your advice “print it and you’ll see” was actually “print it, see that this doesn’t answer anything, and then read the documentation to figure out the real answer” :wink:

But that’s not really instructive either:

In my opinion, good pedagogy is teaching people to answer their own questions. When you print it, you see that you’re not getting functions (what you would get if you printed A.call), but something else. So that should be enough to give you search terms (“bound method”) to do your own investigation using the documentation.

I guess it’s a philosophical question about how teaching ought to be done, but this is what helped me grow, so this is how I teach others.

1 Like

It would be significantly slower for no benefit. The bound method is just an aggregate of its parameters, so there can’t be any computational efficiency to caching it.

Notably, though, these bound method objects don’t actually exist when you simply call a method directly. So there isn’t a massive spam of these objects for every method call that ever happens, only the ones where you refer to the method without calling it (eg stashing it in a collection or something).

For a class of your own, you could actually cache these, having it so that whenever a lookup is done, the resulting bound method gets stashed into the object’s __dict__; but this would come at the price of defeating the aforementioned optimization, for little real benefit.

1 Like

I thought CPython does cache methods now. Does it not? Or does it only for method calls or so?

It elides the method object in a call. The exact details are subject to change, but broadly speaking, x.y() doesn’t fully evaluate x.y before calling. Which covers the vast majority of situations.

If you’re curious, try this on your version of Python:

def f(obj):
    meth = obj.method
    meth()
    obj.method()

import dis
dis.dis(f)

This will tell you how the interpeter handles method calls. Older versions of Python would do this as “get obj, get attrinbute method, call function”, the same whether it’s being stored in the name meth or not (the only difference literally being “store into meth, load from meth”). Newer Pythons will do something a bit different, but the details do vary.

1 Like

With dis.dis(f, show_caches=True) it also shows some CACHE entries. Do you know what those do?

my dis output
  1           0 RESUME                   0

  2           2 LOAD_FAST                0 (obj)
              4 LOAD_ATTR                0 (method)
              6 CACHE                    0
              8 CACHE                    0
             10 CACHE                    0
             12 CACHE                    0
             14 STORE_FAST               1 (meth)

  3          16 PUSH_NULL
             18 LOAD_FAST                1 (meth)
             20 PRECALL                  0
             22 CACHE                    0
             24 CALL                     0
             26 CACHE                    0
             28 CACHE                    0
             30 CACHE                    0
             32 CACHE                    0
             34 POP_TOP

  4          36 LOAD_FAST                0 (obj)
             38 LOAD_METHOD              0 (method)
             40 CACHE                    0
             42 CACHE                    0
             44 CACHE                    0
             46 CACHE                    0
             48 CACHE                    0
             50 CACHE                    0
             52 CACHE                    0
             54 CACHE                    0
             56 CACHE                    0
             58 CACHE                    0
             60 PRECALL                  0
             62 CACHE                    0
             64 CALL                     0
             66 CACHE                    0
             68 CACHE                    0
             70 CACHE                    0
             72 CACHE                    0
             74 POP_TOP
             76 LOAD_CONST               0 (None)
             78 RETURN_VALUE
1 Like

Not sure, but they’re largely NOPs that can be used for other information, I think. You might be able to dig up a thread or tracker issue with details.

it’s tricky

In broad strokes I agree, but…

Speaking from practical experience, there are very few students who can make this kind of logical inference and take this kind of initiative to translate “huh what is this result” into search terms and then understand the discussion that would be turned up that way. And those who can do this… wouldn’t have asked the question in the first place, because simply coming up with the idea to print the a.call result is a lot easier than all the rest that you’re expecting here.

If you reference call via the class, you can use is:

>>> class A:
...     def call(self, a):
...         pass
... 
>>> A.call is A.call
True

This is important when monkeypatching an existing class with custom behavior.

1 Like

which is the function of the binding: C blasts off into OO

>>> class A:
...     def call(self, a):
...             pass
...
>>> a = A()
>>> a.call.__func__ is a.call.__func__
True

(Off-topic) Thanks, I had not noticed that option to dis. They provide a space in which the interpreter does bookkeeping in support of “quickening” in PEP-659. You can regard them as NOPs or part of the space occupied by an opcode.

Thank you very much everyone for the hints and instructions especially, @Rosuav and @pochmann.

I wanted to make an adapter function that takes a method and returns a function that can accept at least one GUI event argument; Otherwise, it returns the method as-is if not needed to change. I noticed the problem when I created the test code in which assert a.call is f(a.call) always fails even if f does nothing. Whatever, I don’t think it is a general use case…

Yep :sunglasses: I just have to keep in mind to use ==.