Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there an advantage of using the property decorator compared to the property class?

I can see two very similar ways of having properties in Python

(a) Property class

class Location(object):

    def __init__(self, longitude, latitude):
        self.set_latitude(latitude)
        self.set_longitude(longitude)

    def set_latitude(self, latitude):
        if not (-90 <= latitude <= 90):
            raise ValueError('latitude was {}, but has to be in [-90, 90]'
                             .format(latitude))
        self._latitude = latitude

    def set_longitude(self, longitude):
        if not (-180 <= longitude <= 180):
            raise ValueError('longitude was {}, but has to be in [-180, 180]'
                             .format(longitude))
        self._longitude = longitude

    def get_longitude(self):
        return self._latitude

    def get_latitude(self):
        return self._longitude

    latitude = property(get_latitude, set_latitude)
    longitude = property(get_longitude, set_longitude)

(b) Property decorator

class Location(object):

    def __init__(self, longitude, latitude):
        self.latitude = latitude
        self.longitude = latitude

    @property
    def latitude(self):
        """I'm the 'x' property."""
        return self._latitude

    @property
    def longitude(self):
        """I'm the 'x' property."""
        return self._longitude

    @latitude.setter
    def latitude(self, latitude):
        if not (-90 <= latitude <= 90):
            raise ValueError('latitude was {}, but has to be in [-90, 90]'
                             .format(latitude))
        self._latitude = latitude

    @longitude.setter
    def longitude(self, longitude):
        if not (-180 <= longitude <= 180):
            raise ValueError('longitude was {}, but has to be in [-180, 180]'
                             .format(longitude))
        self._longitude = longitude

Question

Are those two pieces of code identical (e.g. bytecode wise)? Do they show the same behavior?

Are there any official guides which "style" to use?

Are there any real advantages of one over the other?

What I've tried

py_compile + uncompyle6

I've compiled both:

>>> import py_compile
>>> py_compile.compile('test.py')

and then decompiled both with uncompyle6. But that just returned exactly what I started with (with a bit different formatting)

import + dis

I tried

import test  # (a)
import test2  # (b)
dis.dis(test)
dis.dis(test2)

I'm super confused by the output of test2:

Disassembly of Location:
Disassembly of __init__:
 13           0 LOAD_FAST                2 (latitude)
              2 LOAD_FAST                0 (self)
              4 STORE_ATTR               0 (latitude)

 14           6 LOAD_FAST                2 (latitude)
              8 LOAD_FAST                0 (self)
             10 STORE_ATTR               1 (longitude)
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE

whereas the first one was way bigger:

Disassembly of Location:
Disassembly of __init__:
 13           0 LOAD_FAST                0 (self)
              2 LOAD_ATTR                0 (set_latitude)
              4 LOAD_FAST                2 (latitude)
              6 CALL_FUNCTION            1
              8 POP_TOP

 14          10 LOAD_FAST                0 (self)
             12 LOAD_ATTR                1 (set_longitude)
             14 LOAD_FAST                1 (longitude)
             16 CALL_FUNCTION            1
             18 POP_TOP
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

Disassembly of set_latitude:
 17           0 LOAD_CONST               3 (-90)
              2 LOAD_FAST                1 (latitude)
              4 DUP_TOP
              6 ROT_THREE
              8 COMPARE_OP               1 (<=)
             10 JUMP_IF_FALSE_OR_POP    18
             12 LOAD_CONST               1 (90)
             14 COMPARE_OP               1 (<=)
             16 JUMP_FORWARD             4 (to 22)
        >>   18 ROT_TWO
             20 POP_TOP
        >>   22 POP_JUMP_IF_TRUE        38

 18          24 LOAD_GLOBAL              0 (ValueError)
             26 LOAD_CONST               2 ('latitude was {}, but has to be in [-90, 90]')
             28 LOAD_ATTR                1 (format)
             30 LOAD_FAST                1 (latitude)
             32 CALL_FUNCTION            1
             34 CALL_FUNCTION            1
             36 RAISE_VARARGS            1

 19     >>   38 LOAD_FAST                1 (latitude)
             40 LOAD_FAST                0 (self)
             42 STORE_ATTR               2 (latitude)
             44 LOAD_CONST               0 (None)
             46 RETURN_VALUE

Disassembly of set_longitude:
 22           0 LOAD_CONST               3 (-180)
              2 LOAD_FAST                1 (longitude)
              4 DUP_TOP
              6 ROT_THREE
              8 COMPARE_OP               1 (<=)
             10 JUMP_IF_FALSE_OR_POP    18
             12 LOAD_CONST               1 (180)
             14 COMPARE_OP               1 (<=)
             16 JUMP_FORWARD             4 (to 22)
        >>   18 ROT_TWO
             20 POP_TOP
        >>   22 POP_JUMP_IF_TRUE        38

 23          24 LOAD_GLOBAL              0 (ValueError)
             26 LOAD_CONST               2 ('longitude was {}, but has to be in [-180, 180]')
             28 LOAD_ATTR                1 (format)
             30 LOAD_FAST                1 (longitude)
             32 CALL_FUNCTION            1
             34 CALL_FUNCTION            1
             36 RAISE_VARARGS            1

 24     >>   38 LOAD_FAST                1 (longitude)
             40 LOAD_FAST                0 (self)
             42 STORE_ATTR               2 (longitude)
             44 LOAD_CONST               0 (None)
             46 RETURN_VALUE

Where does that difference come from? Where is the value range check for the first example?

like image 849
Martin Thoma Avatar asked Oct 19 '18 20:10

Martin Thoma


2 Answers

You want to use the decorator, always. There is no advantage to the other syntax, and only disadvantages.

The point of decorators

That's because the decorator syntax was invented specifically to avoid the other syntax. Any examples you find of the name = property(...) variety is usually in code that predates decorators.

Decorator syntax is syntactic sugar; the form

@decorator
def functionname(...):
    # ...

is executed a lot like

def functionname(...):
    # ...

functionname = decorator(functionname)

without functionname being assigned to twice (the def functionname(...) part creates a function object and assigns to functionname normally, but with a decorator the function object is created and passed directly to the decorator object).

Python added this feature because when your function body is long, you can't easily see that the function has been wrapped with a decorator. You'd have to scroll down past the function definition to see that, and that's not very helpful when almost everything else you'd want to know about a function is right at the top; the arguments, the name, the docstring are right there.

From the original PEP 318 – Decorators for Functions and Methods specification:

The current method of applying a transformation to a function or method places the actual transformation after the function body. For large functions this separates a key component of the function's behavior from the definition of the rest of the function's external interface.

[...]

This becomes less readable with longer methods. It also seems less than pythonic to name the function three times for what is conceptually a single declaration.

and under Design Goals:

The new syntax should

  • [...]
  • move from the end of the function, where it's currently hidden, to the front where it is more in your face

So using

@property
def latitude(self):
    # ...

@latitude.setter
def latitude(self, latitude):
    # ...

is far more readable and self documenting than

def get_latitude(self):
    # ...

def set_latitude(self, latitude):
    # ...

latitude = property(get_latitude, set_latitude)

No namespace pollution

Next, because the @property decorator replaces the function object you decorate with the decoration result (a property instance), you also avoid namespace pollution. Without @property and @<name>.setter and @<name>.deleter, you have to add 3 extra, separate names to your class definition that then no-one will ever use:

>>> [n for n in sorted(vars(Location)) if n[:2] != '__']
['get_latitude', 'get_longitude', 'latitude', 'longitude', 'set_latitude', 'set_longitude']

Imagine a class with 5, or 10 or even more property definitions. Developers less familiar with the project and an auto-completing IDE will surely get confused by the difference between get_latitude, latitude and set_latitude, and you end up with code that mixes styles and makes it harder to now move away from exposing these methods at the class level.

Sure, you can use del get_latitude, set_latitude right after the latitude = property(...) assignment, but that's yet more extra code to execute for no real purpose.

Confusing method names

Although you can avoid having to prefix the accessor names with get_ and set_ or otherwise differentiate between the names to create a property() object from them, that's still how almost all code that doesn't use the @property decorator syntax ends up naming the accessor methods.

And that can lead to some confusion in tracebacks; an exception raised in one of the accessor methods leads to a traceback with get_latitude or set_latitude in the name, while the preceding line used object.latitude. It may not always be clear to the Python property novice how the two are connected, especially if they missed the latitude = property(...) line further down; see above.

Accessing to the accessors, how to inherit

You may point out that you may need access to those functions anyway; for example when overriding just the getter or a setter of for the property in a subclass, while inheriting the other accessor.

But the property object, when accessed on the class, already gives you references to the accessors, via the .fget, .fset and .fdel attributes:

>>> Location.latitude
<property object at 0x10d1c3d18>
>>> Location.latitude.fget
<function Location.get_latitude at 0x10d1c4488>
>>> Location.latitude.fset
<function Location.set_latitude at 0x10d195ea0>

and you can reuse the @<name>.getter / @<name>.setter / @<name>.deleter syntax in a subclass without having to remember to create a new property object!

With the old syntax, it was commonplace to try to override just one of the accessors:

class SpecialLocation(Location):
    def set_latitude(self, latitude):
        # ...

and then wonder why it would not be picked up by the inherited property object.

With the decorator syntax, you'd use:

class SpecialLocation(Location):
    @Location.latitude.setter
    def latitude(self, latitude):
        # ...

and the SpecialLocation subclass then is given a new property() instance with the getter inherited from Location, and with a new setter.

TLDR

Use the decorator syntax.

  • It is self-documenting
  • It avoids namespace pollution
  • It makes inheriting accessors from properties cleaner and more straightforward
like image 153
Martijn Pieters Avatar answered Nov 14 '22 21:11

Martijn Pieters


The results of the two versions of your code will be almost exactly the same. The property descriptor you have at the end will be functionally identical in both cases. The only difference in the descriptors will be in the function names you can access if you really try (via Location.longitude.fset.__name__), and that you might see in an exception traceback, if something goes wrong.

The only other difference is the presence of the get_foo and set_foo methods after you're done. When you use @property, you won't have those methods cluttering up the namespace. If you build the property object yourself manually, they will remain in the class namespace, and so you can call them directly if you really want to instead of using normal attribute access via the property object.

Unusually the @property syntax is better since it hides the methods, which you usually don't need. The only reason I can think of that you might want to expose them is if you expect to pass the methods as callbacks to some other function (e.g. some_function(*args, callback=foo.set_longitude)). You could just use a lambda though for the callback though (lambda x: setattr(foo, "longitude", x)), so I don't think it's worth polluting a nice API with extraneous getter and setter methods just for this corner case.

like image 25
Blckknght Avatar answered Nov 14 '22 22:11

Blckknght