Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

report very strange behavior of user-defined class lambda function #126666

Closed
gseismic opened this issue Nov 11, 2024 · 8 comments
Closed

report very strange behavior of user-defined class lambda function #126666

gseismic opened this issue Nov 11, 2024 · 8 comments
Assignees
Labels

Comments

@gseismic
Copy link

gseismic commented Nov 11, 2024

Bug report

Bug description:

class X:
    # HARD to define a pure lambda function in class
    fn_a = lambda x: x

    def test(self):
        print(f'{type(self.fn_a)=}')  # type(self.fn_a)=<class 'method'>
        assert self.fn_a() == self    # OK
        # assert self.fn_a(1) == 1    # ERROR: TypeError: X.<lambda>() takes 1 positional argument but 2 were given

    @classmethod
    def cls_test(cls):
        print(f'{type(cls.fn_a)=}') # type(cls.fn_a)=<class 'function'>
        assert cls.fn_a(1) == 1     # OK


x = X()
x.cls_test()
x.test()

print(f'{X.fn_a=}') # X.fn_a=<function X.<lambda> at 0x103086b60>

It is very HARD to define a pure lambda function in class, the result is unexpected.
I’m not sure if it was intentionally designed this way or if I missed something.

CPython versions tested on:

3.12

Operating systems tested on:

macOS

Tasks

No tasks being tracked yet.
@gseismic gseismic added the type-bug An unexpected behavior, bug, or error label Nov 11, 2024
@gseismic gseismic changed the title report very strange behavior of class user-defined lambda function report very strange behavior of user-defined class lambda function Nov 11, 2024
@TeamSpen210
Copy link

Lambdas are regular functions, just with no name set. And if you have a function defined in a class which you access on the instance, you get a bound method. To stop that you'll need to wrap it in a staticmethod object.

@tim-one
Copy link
Member

tim-one commented Nov 11, 2024

To make @TeamSpen210's comment more explicit, your lambda is just a different way to spell this, which acts the same way in all respects:

    def fn_a(x):
        return x

Which in turn works the same as:

    def fn_a(self):
        return self

That is, there's nothing magical about the name "self" either - the first argument to a method is just usually named "self" by convention. Spelling it "x" instead is fine (just unusual).

@skirpichev skirpichev added the pending The issue will be closed if no feedback is provided label Nov 11, 2024
@skirpichev
Copy link
Member

skirpichev commented Nov 11, 2024

To stop that you'll need to wrap it in a staticmethod object.

Just to keep an explicit example:

>>> class X:
...     fn_a = staticmethod(lambda x: x)
...     def test(self):
...         print(f'{type(self.fn_a)=}')
...         assert self.fn_a(1) == 1
...         
>>> X().test()
type(self.fn_a)=<class 'function'>

I think this can be closed.

@tim-one tim-one self-assigned this Nov 11, 2024
@tim-one tim-one added invalid and removed type-bug An unexpected behavior, bug, or error pending The issue will be closed if no feedback is provided labels Nov 11, 2024
@tim-one tim-one closed this as completed Nov 11, 2024
@skirpichev skirpichev closed this as not planned Won't fix, can't repro, duplicate, stale Nov 11, 2024
@gseismic
Copy link
Author

thank you all!
but:

in

    def test(self):
        print(f'{type(self.fn_a)=}')  # type(self.fn_a)=<class 'method'>
        assert self.fn_a() == self    # OK

the self bind as the first argument of function fn_a
but in

    def cls_test(cls):
        print(f'{type(cls.fn_a)=}') # type(cls.fn_a)=<class 'function'>
        assert cls.fn_a(1) == 1     # OK

the cls not bind as the first argument of function fn_a

when we define another class function

     @classmethod
     def fn_b(cls, x):
            return x

we can use like this:

      def test(self):
              self.fn_b(1)
              
       @classmethod
       def cls_test(cls):
               cls.fn_b(1)

both self and cls bind as the first argument of fn_b

I don't known what is making the difference, it not so straightforward

@gseismic
Copy link
Author

Maybe the key point is: the doing-things of . is not so clear for outsiders. used for bind or a member of, not so clear.

actually, if . used as a member of, in:

 def test(self):
        print(f'{type(self.fn_a)=}')  # type(self.fn_a)=<class 'method'>
        assert self.fn_a() == self    # OK 

fn_a IS a member of class X, and the result of self.fn_a SHOULD be lambda x: x, so:

self.fn_a(1) is LEGAL, self.fn_a() NOT legal

@skirpichev
Copy link
Member

the cls not bind as the first argument of function fn_a

Because cls_test in this example is a class method.

cls.fn_a - reference to the class function fn_a, i.e. cls.__dict__['fn_a'].
self.fn_a - (where self - an instance) reference to the bound method on instance. This method has a different signature.

>>> class A:
...     f = lambda x: x
...     
>>> A.f
<function A.<lambda> at 0x7f7cf5e67890>
>>> A().f
<bound method A.<lambda> of <__main__.A object at 0x7f7cf5e03600>>
>>> A.__dict__['f']
<function A.<lambda> at 0x7f7cf5e67890>
>>> import inspect
>>> inspect.signature(A.f)
<Signature (x)>
>>> inspect.signature(A().f)
<Signature ()>

@gseismic
Copy link
Author

Thank very much, I started a new discussion in:
https://discuss.python.org/t/behavior-of-lambda-function-of-class-is-strange-should-we-change-the-syntax/70812

Maybe we should define class method more explicitly just like @skirpichev mentioned

class A:
     cls_method1 = classmethod(lambda cls, x: x)

Other than:

class A:
    cls_method1 = lambda cls, x: x

the current default behavior make too much confusion.

Looking forward to your opinion:
@skirpichev @tim-one @TeamSpen210 @alex

@zahlman
Copy link

zahlman commented Nov 12, 2024

class A:
     cls_method1 = classmethod(lambda cls, x: x)

This is valid syntax. It makes cls_method1 into a class method. You call it by looking it up in the class: A.cls_method1('x_value'). It takes one argument, because A (the class itself) will be passed as cls to the lambda. That's what classmethod is for (whether you call it this way, or use it as a decorator, which is just syntactic sugar).

class A:
    cls_method1 = lambda cls, x: x

This makes cls_method1 not a class method. It works the same way as any other method: if you look it up in the class and call it, A.cls_method1('cls_value', 'x_value'), it takes two arguments; if you look it up on an instance, A().cls_method1('x_value'), it takes one argument. That's because the A instance (created by A()) will be passed as cls to the lambda. And that's because the lambda is the same thing as a function.


Functions in Python have a special method __get__ that gets called automatically when you try to do a method lookup. It makes the method call work in that special way. We can analyze how it works step by step in the REPL:

>>> class A:
...     def method(self, x):
...         return x
... 
>>> a = A()
>>> A.method # gives the actual function, which is actually a part of the class
<function A.method at 0x...>
>>> a.method # gives a "bound method" object that remembers a value to pass for `self`
<bound method A.method of <__main__.A object at 0x...>>
>>> A.method.__get__(a, A) # gives another object with the same value
<bound method A.method of <__main__.A object at 0x...>>

I hid the object addresses because they don't matter. We might get the same address or different addresses depending on Python's internal memory management. Every time that we do a.method, Python translates it into the __get__ call shown. More accurately: first Python looks inside a itself to find the method; it fails (because it's in the class), so Python looks in the class; then because the method was found in the class (at this point Python doesn't care yet whether it actually found something callable) it checks to see if it has a __get__ method. Since it does, Python calls that, and returns the result, instead of the thing it found in the class.

Yes, I said "__get__ method". So, yes, there's a sort of recursion involved. It gets harder at this point, but you don't normally need to worry about it.

Anyway, a "method" is just a function object that belongs to the class itself. A "method call" works because looking for the function finds it in the class, and then automatically creates that "bound method" object. Calling that object does the rest of the work.

This is called the descriptor protocol. There is much more detail in the documentation. It is complex. But it allows you to write simple code for simple tasks while not needing special cases - and it also makes things like @classmethod itself, as well as @staticmethod and @property, work.

Notice, it does not matter what we name the method. It does not matter what we name the parameters (including the first one: names like self and cls are not special). It does not matter whether we define the method function normally (with def) or by assigning a lambda (because there are only very tiny distinctions that don't make a difference to the example).

What matters is:

  1. Where is the attribute actually found - in the instance itself, or in its class?
  2. Does it have a __get__?
  3. If it does, what happens when it's called?

From the Discourse thread: the reason you get a different result with the callable object

class B:
    def __call__(self, x):
        return x

fn_b = B()

is because fn_b does not have a __get__ method. So when you set up the rest of your example:

fn_c = lambda x: x

class X:
    fn_b = fn_b
    fn_c = fn_c

now when we look up X().fn_c, it's a function, so it has a __get__, so that is called in order to "bind" the method. And then the lambda had 1 parameter, therefore the bound method has 1 - 1 = 0 parameters. But X().fn_b doesn't have __get__, so fn_b itself is the result. And when we call fn_b - which uses the __call__ method - that is itself a normal method that was looked up in the fn_b object. The __call__ function has 2 parameters; we bound 1 by looking it up in fn_b and finding it in B; so the method has 1 parameter.


If you want to use just the function and have the same parameters whether you look it up from an instance or directly in the class, that is what staticmethod is for:

>>> class A: # Using the decorator syntax.
...     @staticmethod
...     def static(x):
...         return x
>>>
>>> class A: # Or we can do it this way, which is basically the same
...     static = staticmethod(lambda x: x)
>>>
>>> A.static # No matter what, we just get the function.
<function A.static at 0x...>
>>> A().static
<function A.static at 0x...>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

5 participants