1

Here is the random problem I was solving the other day. Given an age in seconds, one must calculate how old someone would be on particular planet. I was trying to add new methods to my class dynamically and came up with this solution:

class MyClass(object):
    year_in_seconds_on_earth = 31557600
    planets = {
        'earth': 1,
        'mercury': 0.2408467,
        'venus': 0.61519726,
        'mars': 1.8808158,
        'jupiter': 11.862615,
        'saturn': 29.447498,
        'uranus': 84.016846,
        'neptune': 164.79132
    }

    def __init__(self, seconds):
        self.seconds = seconds
        for planet in self.planets:
            func = lambda: self._on_planet(planet)
            self.__setattr__('on_' + planet, func)
            # self._add_method(planet)

    # def _add_method(self, planet):
    #     func = lambda: self._on_planet(planet)
    #     self.__setattr__('on_' + planet, func)

    def _on_planet(self, planet):
        return round(self.seconds / self.year_in_seconds_on_earth / self.planets[planet], 2)

print(MyClass(2134835688).on_mercury())

So when I call lambda and setattr from the separate method (commented part), it works perfectly fine. But when they are called from __init__, only the last value, neptune, is used when calling on_mercury, on_mars or other similar methods.

I understand that in __init__ it takes the value from the closure of outer function and planets value is changed in the loop. But I don't quite understand what exactly is happening in both cases. Here are some questions:

  • Is it a copy of planet variable passed to _add_method?
  • Why doesn't the value passed to _add_method change, but changes when passed directly in the loop?
MT13
  • 13
  • 2
  • @Sayse they are, but they all should have a different argument. There must be something fishy going on here with the python argument binding in lambdas. – Finomnis Jun 12 '19 at 12:24
  • @MT13 did you run through it with a debugger? – Finomnis Jun 12 '19 at 12:25
  • 1
    I think this thread is closely related: https://stackoverflow.com/questions/10452770/python-lambdas-binding-to-local-values – Finomnis Jun 12 '19 at 12:32

1 Answers1

1
class MyClass(object):
    year_in_seconds_on_earth = 31557600
    planets = {
        'earth': 1,
        'mercury': 0.2408467,
        'venus': 0.61519726,
        'mars': 1.8808158,
        'jupiter': 11.862615,
        'saturn': 29.447498,
        'uranus': 84.016846,
        'neptune': 164.79132
    }

    def __init__(self, seconds):
        self.seconds = seconds
        for planet in self.planets:
            func = lambda planet=planet: self._on_planet(planet)
            self.__setattr__('on_' + planet, func)

    def _on_planet(self, planet):
        return round(self.seconds / self.year_in_seconds_on_earth / self.planets[planet], 2)

print(MyClass(2134835688).on_mercury())

The details of why this works can be read here: Python lambda's binding to local values

In summary, a python lambda only holds references to external variables. If the value changes, the variable changes.

By defining a local variable with the value of the external variable, in this case by planet=planet, you can bind the value to the lambda at definition time.

Finomnis
  • 18,094
  • 1
  • 20
  • 27
  • Thank you for the answer. I read the links you provided and went even further and read about early and late bindings. It is clear now why it prints only last value. And what about calling the method? As I understand when I call _add_method it also binds the current value to that method, like it does with default value in your solution and in the scope of that function it preserves the value, right? – MT13 Jun 12 '19 at 14:34
  • The problem is that the variable `planet` is only defined once in the `__init__` function, and putting it in the lambda function does not create a second variable. Once the `planet` reference points to the new string of the next iteration, that directly affects the lambda. Redefining it in the lambda creates a new variable that points to the same string, and is therefore immune to the actual value of the for-variable. Using a variable as parameter does the same thing. – Finomnis Jun 12 '19 at 22:12