6.5. Property

6.5.1. Rationale

  • Disable attribute modification

  • Logging value access

  • Check boundary

  • Raise exceptions (TypeError)

  • Check argument type

6.5.2. Problem

>>> class Point:
...     x: int
...
...     def get_x(self): pass
...     def set_x(self, newvalue): pass
...     def del_x(self): pass
>>>
>>>
>>> pt = Point()
>>> pt.set_x(1)
>>> class Point:
...     x: int
...     y: int
...
...     def get_x(self): pass
...     def set_x(self, newvalue): pass
...     def del_x(self): pass
...     def get_y(self): pass
...     def set_y(self, newvalue): pass
...     def del_y(self): pass
>>>
>>>
>>> pt = Point()
>>> pt.set_x(1)
>>> pt.set_y(1)
>>> class Point:
...     x: int
...     y: int
...     z: int
...
...     def get_x(self) -> int: pass
...     def get_x(self): pass
...     def set_x(self, newvalue): pass
...     def del_x(self): pass
...     def get_y(self): pass
...     def set_y(self, newvalue): pass
...     def del_y(self): pass
...     def get_z(self): pass
...     def set_z(self, newvalue): pass
...     def del_z(self): pass
>>>
>>>
>>> pt = Point()
>>> pt.set_x(1)
>>> pt.set_y(1)
>>> pt.set_z(1)

6.5.3. What if...

>>> class Point:
...     x: int
...     y: int
...     z: int
...
...     def set_position(self, x, y, z):
...         self.x = x
...         self.y = y
...         self.z = z
>>>
>>> pt = Point()
>>> pt.set_position(1, 2, 3)

Works for point. How about astronauts

>>> class Astronaut:
...     firstname: str
...     middlename: str
...     lastname: str
...
...     def set_name(self, name):
...         first, mid, last = name.split()
...         self.firstname = first
...         self.middlename = mid
...         self.lastname = last

Do everyone have a middle name? Do everyone have first or lastname?

6.5.4. Solution

>>> class Point:
...     x: int
...     y: int
...     z: int
>>>
>>>
>>> pt = Point()
>>> pt.x = 1
>>> pt.y = 2
>>> pt.z = 3

But what if we want to make validation:

>>> class Point:
...     x: int
...     y: int
...     z: int
...
...     def set_x(self, newvalue):
...         if newvalue > 0:
...             self.x = newvalue
...         else:
...             raise ValueError
...
...     def set_y(self, newvalue):
...         if newvalue > 0:
...             self.y = newvalue
...         else:
...             raise ValueError
...
...     def set_z(self, newvalue):
...         if newvalue > 0:
...             self.z = newvalue
...         else:
...             raise ValueError

We can refactor this code:

>>> class Point:
...     x: int
...     y: int
...     z: int
...
...     def _is_valid(self, value):
...         if newvalue > 0:
...             return value
...         else:
...             raise ValueError
...
...     def set_x(self, newvalue):
...         self.x = self._valid(newvalue)
...
...     def set_y(self, newvalue):
...         self.y = self._valid(newvalue)
...
...     def set_z(self, newvalue):
...         self.z = self._valid(newvalue)

But problem persist.

What if all parameters can have different ranges:

  • age between 0 and 130

  • height between 150 and 210

  • name first capital letter, then lowercased letters

6.5.5. Protocol

  • myattribute = property() - creates property

  • @myattribute.getter - getter for attribute

  • @myattribute.setter - setter for attribute

  • @myattribute.deleter - deleter for attribute

  • Method name must be the same as attribute name

  • myattribute has to be property

  • @property - creates property and a getter

>>> class MyClass:
...     myattribute = property()
...
...     @myattribute.getter
...     def myattribute(self):
...         return ...
...
...     @myattribute.setter
...     def myattribute(self):
...         ...
...
...     @myattribute.deleter
...     def myattribute(self):
...         ...

6.5.6. Example

>>> class KelvinTemperature:
...     value: float
>>>
>>> t = KelvinTemperature()
>>> t.value = -2               # Should raise ValueError('Kelvin cannot be negative')
>>> class KelvinTemperature:
...     value: float
...
...     def __init__(self, initialvalue):
...         self.value = initialvalue
>>>
>>> t = KelvinTemperature(-1)   # Should raise ValueError('Kelvin cannot be negative')
>>> t.value = -2                # Should raise ValueError('Kelvin cannot be negative')
>>> class KelvinTemperature:
...     value: float
...
...     def __init__(self, initialvalue):
...         if initialvalue < 0:
...             raise ValueError('Negative Kelvin Temperature')
...         self.value = initialvalue
>>>
>>>
>>> t = KelvinTemperature(1)
>>> t.value = -1  # Should raise ValueError('Kelvin cannot be negative')
>>> class KelvinTemperature:
...     _value: float
...
...     def __init__(self, initialvalue):
...         self.set_value(initialvalue)
...
...     def set_value(self, newvalue):
...         if newvalue < 0:
...             raise ValueError('Negative Kelvin Temperature')
...         self._value = newvalue
>>> class KelvinTemperature:
...     _value: float
...     value = property()
...
...     def __init__(self, initialvalue):
...         self.value = initialvalue
...
...     @value.setter
...     def value(self, newvalue):
...         if newvalue < 0:
...             raise ValueError('Negative Kelvin Temperature')
...         self._value = newvalue

6.5.7. Use Cases

>>> class Astronaut:
...     def __init__(self, firstname, lastname):
...         self._firstname = firstname
...         self._lastname = lastname
...
...     @property
...     def name(self):
...         return f'{self._firstname} {self._lastname[0]}.'
>>>
>>>
>>> astro = Astronaut('Mark', 'Watney')
>>> print(astro.name)
Mark W.
>>> class Astronaut:
...     name = property()
...
...     def __init__(self, firstname, lastname):
...         self._firstname = firstname
...         self._lastname = lastname
...
...     @name.getter
...     def name(self):
...         return f'{self._firstname} {self._lastname[0]}.'
>>>
>>>
>>> astro = Astronaut('Mark', 'Watney')
>>> print(astro.name)
Mark W.
>>> class Temperature:
...     kelvin = property()
...     __value: float
...
...     def __init__(self, kelvin=None):
...         self.__value = kelvin
...
...     @kelvin.setter
...     def kelvin(self, newvalue):
...         if newvalue < 0:
...             raise ValueError('Negative Kelvin Temperature')
...         else:
...             self.__value = newvalue
>>>
>>>
>>> t = Temperature()
>>> t.kelvin = 10
>>> t.kelvin = -1
Traceback (most recent call last):
ValueError: Negative Kelvin Temperature

6.5.8. Attribute Access

  • Java way: Setter and Getter

  • Pythonic way: Properties, Reflection, Descriptors

Accessing class fields using setter and getter:

>>> class Astronaut:
...     def __init__(self, name=None):
...         self._name = name
...
...     def set_name(self, name):
...         self._name = name
...
...     def get_name(self):
...         return self._name
>>>
>>>
>>> astro = Astronaut()
>>> astro.set_name('Mark Watney')
>>> print(astro.get_name())
Mark Watney

Accessing class fields. Either put name as an argument for __init__() or create dynamic field in runtime:

>>> class Astronaut:
...     def __init__(self, name=None):
...         self.name = name
>>>
>>>
>>> astro = Astronaut()
>>> astro.name = 'Jan Twardowski'
>>> print(astro.name)
Jan Twardowski

6.5.9. Property class

  • Property's arguments are method pointers get_name, set_name, del_name and a docstring

  • Not recommended

>>> class Astronaut:
...     def __init__(self, name=None):
...         self._name = name
...
...     def get_name(self):
...         return self._name
...
...     def set_name(self, value):
...         self._name = value
...
...     def del_name(self):
...         del self._name
...
...     name = property(get_name, set_name, del_name, "I am the 'name' property.")

6.5.10. Property Descriptor

  • Prefer name = property()

>>> class Astronaut:
...     name = property()
...
...     def __init__(self, name=None):
...         self._name = name
...
...     @name.getter
...     def name(self):
...         return self._name
...
...     @name.setter
...     def name(self, value):
...         self._name = value
...
...     @name.deleter
...     def name(self):
...         del self._name

6.5.11. Property Decorator

  • Typically used when, there is only getter and no setter and deleter methods

>>> class Astronaut:
...     def __init__(self, name=None):
...         self._name = name
...
...     @property
...     def name(self):
...         return self._name
...
...     @name.setter
...     def name(self, value):
...         self._name = value
...
...     @name.deleter
...     def name(self):
...         del self._name

6.5.12. Use Case 1

>>> class Astronaut:
...     def __init__(self):
...         self._name = None
...
...     def set_name(self, name):
...         self._name = name.title()
...
...     def get_name(self):
...         if self._name:
...             firstname, lastname = self._name.split()
...             return f'{firstname} {lastname[0]}.'
...
...     def del_name(self):
...         self._name = None
>>>
>>>
>>> astro = Astronaut()
>>>
>>> astro.set_name('JaN TwARdoWskI')
>>> print(astro.get_name())
Jan T.
>>>
>>> astro.del_name()
>>> print(astro.get_name())
None
>>> class Astronaut:
...     name = property()
...
...     def __init__(self):
...         self._name = None
...
...     @name.getter
...     def name(self):
...         if self._name:
...             firstname, lastname = self._name.split()
...             return f'{firstname} {lastname[0]}.'
...
...     @name.setter
...     def name(self, name):
...         self._name = name.title()
...
...     @name.deleter
...     def name(self):
...         self._name = None
>>>
>>>
>>> astro = Astronaut()
>>>
>>> astro.name = 'JAN TwARdoWski'
>>> print(astro.name)
Jan T.
>>>
>>> del astro.name
>>> print(astro.name)
None

6.5.13. Use Case 2

  • Calculate age

from dataclasses import dataclass
from datetime import date

DAY = 1
YEAR = 365.2425 * DAYS


@dataclass
class Astronaut:
    firstname: str
    lastname: str
    date_of_birth: date

    @property
    def age(self):
        age = date.today() - self.date_of_birth
        return round(age.days/YEAR, 1)


astro = Astronaut('Mark', 'Watney', date(1969, 7, 21))
print(astro.age)

6.5.14. Use Case 3

  • Cached Property

>>> from dataclasses import dataclass, field
>>> from datetime import date
>>>
>>> YEAR = 365.2425
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     date_of_birth: date
...     __cache: dict = field(default_factory=dict)
...
...     @property
...     def age(self):
...         if 'age' not in self.__cache:
...             age = (date.today() - self.date_of_birth).days / YEAR
...             self.__cache['age'] = round(age, 1)
...         return self.__cache['age']
>>>
>>>
>>> astro = Astronaut('Mark', 'Watney', date(1969, 7, 21))
>>> print(astro.age)
51.6

6.5.15. Use Case 4

>>> class Temperature:
...     def __init__(self, initial_temperature):
...         self._protected = initial_temperature
...
...     @property
...     def value(self):
...         print('You are trying to access a value')
...         return self._protected
>>>
>>>
>>> t = Temperature(100)
>>>
>>> print(t.value)
You are trying to access a value
100
>>> class Temperature:
...     def __init__(self, initial_temperature):
...         self._protected = initial_temperature
...
...     @property
...     def value(self):
...         return self._protected
...
...     @value.setter
...     def value(self, new_value):
...         if new_value < 0.0:
...             raise ValueError('Kelvin Temperature cannot be negative')
...         else:
...             self._protected = new_value
>>>
>>>
>>> t = Temperature(100)
>>> t.value = -10
Traceback (most recent call last):
ValueError: Kelvin Temperature cannot be negative
>>> class Temperature:
...     def __init__(self, initial_temperature):
...         self._protected = initial_temperature
...
...     @property
...     def value(self):
...         return self._protected
...
...     @value.deleter
...     def value(self):
...         print('Resetting temperature')
...         self._protected = 0.0
>>>
>>>
>>> t = Temperature(100)
>>>
>>> del t.value
Resetting temperature
>>>
>>> print(t.value)
0.0

6.5.16. Assignments

Code 6.7. Solution
"""
* Assignment: Protocol Property Getter
* Complexity: easy
* Lines of code: 4 lines
* Time: 5 min

English:
    1. Use data from "Given" section (see below)
    2. Define class `Point` with `x`, `y`, `z` attributes
    3. Define property `position` in class `Point`
    4. Accessing `position` returns `(x, y, z)`
    5. Compare result with "Tests" section (see below)

Polish:
    1. Użyj danych z sekcji "Given" (patrz poniżej)
    2. Zdefiniuj klasę `Point` z atrybutami `x`, `y`, `z`
    3. Zdefiniuj property `position` w klasie `Point`
    4. Dostęp do `position` zwraca `(x, y, z)`
    5. Porównaj wyniki z sekcją "Tests" (patrz poniżej)

Tests:
    >>> pt = Point(x=1, y=2, z=3)
    >>> pt.x, pt.y, pt.z
    (1, 2, 3)
    >>> pt.position
    (1, 2, 3)
"""


# Given
class Point:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z


Code 6.8. Solution
"""
* Assignment: Protocol Property Setter
* Complexity: easy
* Lines of code: 9 lines
* Time: 5 min

English:
    1. Use data from "Given" section (see below)
    2. Define class `Point` with `x`, `y`, `z` attributes
    3. Define property `position` in class `Point`
    4. Setting `position`:
        a. If argument is not list, tuple, set raise Type Error
        b. If argument has length other than 3, raise Value
        b. Else sets `x`, `y`, `z` attributes from sequence
    5. Compare result with "Tests" section (see below)

Polish:
    1. Użyj danych z sekcji "Given" (patrz poniżej)
    2. Zdefiniuj klasę `Point` z atrybutami `x`, `y`, `z`
    3. Zdefiniuj property `position` w klasie `Point`
    4. Ustawianie `position`:
        a. Jeżeli argument nie jest list, tuple, set podnieś TypeError
        b. Jeżeli argument nie ma długości 3, podnieś ValueError
        b. W przeciwnym wypadku ustaw kolejne atrybuty `x`, `y`, `z` z sekwencji
    5. Porównaj wyniki z sekcją "Tests" (patrz poniżej)

Tests:
    >>> pt = Point(x=1, y=2, z=3)
    >>> pt.x, pt.y, pt.z
    (1, 2, 3)
    >>> pt.position = 4, 5, 6
    >>> pt.x, pt.y, pt.z
    (4, 5, 6)
    >>> pt.position = [7, 8, 9]
    >>> pt.x, pt.y, pt.z
    (7, 8, 9)
    >>> pt.position = 1, 2
    Traceback (most recent call last):
    ValueError
    >>> pt.position = {'a':1, 'b':2}
    Traceback (most recent call last):
    TypeError
"""


# Given
class Point:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z


Code 6.9. Solution
"""
* Assignment: Protocol Property Deleter
* Complexity: easy
* Lines of code: 6 lines
* Time: 5 min

English:
    1. Use data from "Given" section (see below)
    2. Define class `Point` with `x`, `y`, `z` attributes
    3. Define property `position` in class `Point`
    4. Deleting `position` sets all attributes to 0 (`x=0`, `y=0`, `z=0`)
    5. Compare result with "Tests" section (see below)

Polish:
    1. Użyj danych z sekcji "Given" (patrz poniżej)
    2. Zdefiniuj klasę `Point` z atrybutami `x`, `y`, `z`
    3. Zdefiniuj property `position` w klasie `Point`
    4. Usunięcie `position` ustawia wszystkie atrybuty na 0 (`x=0`, `y=0`, `z=0`)
    5. Porównaj wyniki z sekcją "Tests" (patrz poniżej)

Tests:
    >>> pt = Point(x=1, y=2, z=3)
    >>> pt.x, pt.y, pt.z
    (1, 2, 3)
    >>> del pt.position
    >>> pt.x, pt.y, pt.z
    (0, 0, 0)
"""


# Given
class Point:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z