15.1. OOP Mutability

  • Function and method arguments should not be mutable

Immutable Types:

  • int

  • float

  • complex

  • bool

  • None

  • str

  • bytes

  • tuple

  • frozenset

  • mappingproxy

Mutable Types:

  • list

  • set

  • dict

15.1.1. Problem

Let's define a class:

>>> class User:
...     def __init__(self, firstname, lastname, groups=[]):
...         self.firstname = firstname
...         self.lastname = lastname
...         self.groups = groups

Now, we create an instance of a class:

>>> mark = User('Mark', 'Watney')
>>> melissa = User('Melissa', 'Lewis')

Check groups for both Users:

>>> mark.groups
[]
>>>
>>> melissa.groups
[]

We will assign Mark Watney to three groups: admins, staff, editors:

>>> mark.groups.append('admins')
>>> mark.groups.append('staff')
>>> mark.groups.append('editors')

Now, check the groups once again:

>>> mark.groups
['admins', 'staff', 'editors']
>>>
>>> melissa.groups
['admins', 'staff', 'editors']

This is not a mistake! Both users Mark and Melissa has the same groups despite the fact, that we set values only for Mark. This is because both both Mark and Melissa has attribute groups pointing to the same memory address:

>>> hex(id(mark.groups))  
'0x10e732500'
>>>
>>> hex(id(melissa.groups))  
'0x10e732500'

This is the same object!

>>> from inspect import signature
>>>
>>>
>>> signature(User.__init__)
<Signature (self, firstname, lastname, groups=['admins', 'staff', 'editors'])>
>>>
>>> signature(User.__init__).parameters.get('groups').default
['admins', 'staff', 'editors']
>>>
>>> hex(id(signature(User.__init__).parameters.get('groups').default))  
'0x10e732500'

15.1.2. Rationale

Note, You should not set mutable objects as a default function argument. More information in Argument Mutability. This is how all dynamically typed languages work (including JavaScript, PHP, Ruby, Perl etc).

The problem lays in __init__() method signature. It consist a reference to the mutable object: list. Python will create a new list instance on class creation, not an instance creation! Therefore each user will reference to the same list which was created when Python interpreted class.

>>> class User:
...     def __init__(self, firstname, lastname, groups=[]):
...         self.firstname = firstname
...         self.lastname = lastname
...         self.groups = groups

However method body is not interpreted on class creation. This is done in a runtime. Creating a new list in method's body will instantiate a new sequence each time the new instance is created. Consider the following code:

>>> class User:
...     def __init__(self, firstname, lastname, groups=None):
...         self.firstname = firstname
...         self.lastname = lastname
...         self.groups = groups if groups else []

None object is a singleton, which can be reused. Also is not a problematic, because we will not append or modify anything to the None itself. As soon as the new instance is created, the __init__() body is evaluated and self.groups is assigned to newly created list instance.

15.1.3. Solution

>>> class User:
...     def __init__(self, firstname, lastname, groups=None):
...         self.firstname = firstname
...         self.lastname = lastname
...         self.groups = groups if groups else []

Now, we create an instance of a class:

>>> mark = User('Mark', 'Watney')
>>> melissa = User('Melissa', 'Lewis')

Check groups for both Users:

>>> mark.groups
[]
>>>
>>> melissa.groups
[]

We will assign Mark Watney to three groups: admins, staff, editors:

>>> mark.groups.append('admins')
>>> mark.groups.append('staff')
>>> mark.groups.append('editors')

Now, check the groups once again:

>>> mark.groups
['admins', 'staff', 'editors']
>>>
>>> melissa.groups
[]

This time their addresses are differs:

>>> hex(id(mark.groups))  
'0x108ca7ac0'
>>>
>>> hex(id(melissa.groups))  
'0x109a88540'

And they are not the same object:

>>> from inspect import signature
>>>
>>>
>>> signature(User.__init__)
<Signature (self, firstname, lastname, groups=None)>
>>>
>>> signature(User.__init__).parameters.get('groups').default
>>>
>>> hex(id(signature(User.__init__).parameters.get('groups').default))  
'0x106ef4948'

This mechanism works the same, but this time points to the immutable object which as the name says, cannot be changed, so we are safe now:

>>> hex(id(None))  
'0x106ef4948'

15.1.4. Assignments

Code 15.9. Solution
"""
* Assignment: OOP Mutability list
* Complexity: easy
* Lines of code: 4 lines
* Time: 3 min

English:
    1. Create class `User`, with attributes:
        a. `firstname: str` (required)
        b. `lastname: str` (required)
        c. `groups: list[str]` (optional)
    2. Attributes must be set at the initialization from constructor arguments
    3. Avoid mutable parameter problem
    4. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `User`, z atrybutami:
        a. `firstname: str` (wymagane)
        b. `lastname: str` (wymagane)
        c. `groups: list[str]` (opcjonalne)
    2. Atrybuty mają być ustawiane przy inicjalizacji z parametrów konstruktora
    3. Uniknij problemu mutowalnych parametrów
    4. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass

    >>> assert isclass(User)
    >>> assert hasattr(User, '__annotations__')

    >>> mark = User('mwatney', 'Ares3')
    >>> melissa = User('mlewis', 'Nasa1')
    >>> assert 'firstname' in vars(mark)
    >>> assert 'lastname' in vars(mark)
    >>> assert 'groups' in vars(mark)
    >>> assert 'firstname' in vars(melissa)
    >>> assert 'lastname' in vars(melissa)
    >>> assert 'groups' in vars(melissa)
    >>> assert mark.groups is not melissa.groups
"""

# Create class `User`, with attributes:
# - `firstname: str` (required)
# - `lastname: str` (required)
# - `groups: list[str]` (optional)
# type: type[User]
class User:
    ...


Code 15.10. Solution
"""
* Assignment: OOP Mutability Randint
* Complexity: easy
* Lines of code: 6 lines
* Time: 3 min

English:
    1. Create class `Hero`, with attributes:
        a. `name: str` (required)
        b. `health: int` (optional), default: randint(50, 100)
    2. Attributes must be set at the initialization from constructor arguments
    3. Avoid mutable parameter problem
    4. Użyj funkcji `randint()` z biblioteki `random`
    5. Do not use `dataclass`
    6. Run doctests - all must succeed

Polish:
    1. Stwórz klasę `Hero`, z atrybutami:
        a. `name: str` (wymagane)
        b. `health: int` (opcjonalne), domyślnie: randint(50, 100)
    2. Atrybuty mają być ustawiane przy inicjalizacji z parametrów konstruktora
    3. Uniknij problemu mutowalnych parametrów
    4. Użyj funkcji `randint()` z biblioteki `random`
    5. Nie używaj `dataclass`
    6. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass
    >>> from random import seed; seed(0)

    >>> assert isclass(Hero)
    >>> assert hasattr(Hero, '__annotations__')

    >>> warrior = Hero('Warrior')
    >>> assert warrior.name == 'Warrior'
    >>> assert warrior.health == 74

    >>> mage = Hero('Mage')
    >>> assert mage.name == 'Mage'
    >>> assert mage.health == 98

    >>> rouge = Hero('Rouge')
    >>> assert rouge.name == 'Rouge'
    >>> assert rouge.health == 76

    >>> cleric = Hero('Cleric')
    >>> assert cleric.name == 'Cleric'
    >>> assert cleric.health == 52
"""
from random import randint


# Create class `User`, with attributes:
# - `name: str` (required)
# - `health: int` (optional), default: randint(50, 100)
# Do not use `dataclass`
# type: type[Hero]
class Hero:
    ...