Mutabilité¶
- Il existe en Python des types mutables et immutables
- Les valeurs de types immutables ne peuvent pas être altérées
- Par exemple :
int,float,str,tuple,frozenset
In [2]:
my_list = [0]
my_list[0] = 12
my_list
Out[2]:
[12]
In [3]:
my_tuple = (0,)
my_tuple[0] = 12
my_tuple
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[3], line 2 1 my_tuple = (0,) ----> 2 my_tuple[0] = 12 3 my_tuple TypeError: 'tuple' object does not support item assignment
Mutabilité¶
- Redéfinition ≠ altération
In [4]:
my_tuple = my_other_tuple = ()
my_tuple += (42,)
my_tuple
Out[4]:
(42,)
In [5]:
my_other_tuple
Out[5]:
()
Frozendict ?¶
- Python propose des équivalents immutables à ses collection muables :
list→tupleset→frozenset- mais pas aux dictionnaires
- Il est impossible en Python de créer de nouveaux types immutables
- … vraiment ?
Construire un frozendict en Python¶
- Avec quelques contraintes :
- En pur Python, sans extension C ou autre
- S'appuyant uniquement sur la lib standard
- Respectant l'interface du dictionnaire (mapping, opérateur d'union)
- Parfaitement immutable & hashable
- Accès aux éléments en temps constant (O(1))
Frozen dataclass¶
Frozen dataclass¶
@dataclass(frozen=True)construit une dataclass immutable- Stockant deux tuples : clés et valeurs
Frozen dataclass¶
- Première ébauche avec une interface provisoire
In [6]:
from dataclasses import dataclass
@dataclass(frozen=True)
class frozendict:
_keys: tuple
_values: tuple
@classmethod
def from_dict(cls, base={}, **kwargs):
base = base | kwargs
return cls(_keys=tuple(base.keys()), _values=tuple(base.values()))
In [7]:
fd = frozendict.from_dict(key='value')
fd
Out[7]:
frozendict(_keys=('key',), _values=('value',))
Frozen dataclass¶
- L'objet semble bien immutable
In [8]:
fd._values[0] = None
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[8], line 1 ----> 1 fd._values[0] = None TypeError: 'tuple' object does not support item assignment
In [9]:
fd._values = (None,)
--------------------------------------------------------------------------- FrozenInstanceError Traceback (most recent call last) Cell In[9], line 1 ----> 1 fd._values = (None,) File <string>:16, in __setattr__(self, name, value) FrozenInstanceError: cannot assign to field '_values'
Frozen dataclass¶
- Mais pas trop !
In [10]:
fd.__dict__['_values'] = (None,)
fd
Out[10]:
frozendict(_keys=('key',), _values=(None,))
Les slots à la rescousse¶
- Normal, c'est à cause de
__dict__ - Utilisons donc des
__slots__(@dataclass(slots=True))
In [11]:
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class frozendict:
_keys: tuple
_values: tuple
@classmethod
def from_dict(cls, base={}, **kwargs):
base = base | kwargs
return cls(_keys=tuple(base.keys()), _values=tuple(base.values()))
In [12]:
frozendict.__slots__
Out[12]:
('_keys', '_values')
In [13]:
fd = frozendict.from_dict(key='value')
fd
Out[13]:
frozendict(_keys=('key',), _values=('value',))
Les slots à la rescousse¶
- Jusqu'ici tout va bien
In [14]:
fd._values[0] = None
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[14], line 1 ----> 1 fd._values[0] = None TypeError: 'tuple' object does not support item assignment
In [15]:
fd._values = (None,)
--------------------------------------------------------------------------- FrozenInstanceError Traceback (most recent call last) Cell In[15], line 1 ----> 1 fd._values = (None,) File <string>:16, in __setattr__(self, name, value) FrozenInstanceError: cannot assign to field '_values'
In [16]:
fd.__dict__['_values'] = (None,)
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[16], line 1 ----> 1 fd.__dict__['_values'] = (None,) AttributeError: 'frozendict' object has no attribute '__dict__'
Les slots à la rescousse¶
- Et en fait non
In [17]:
object.__setattr__(fd, '_values', (None,))
fd
Out[17]:
frozendict(_keys=('key',), _values=(None,))
- Inutile d'aller plus loin dans cette approche
Mapping proxy¶
Mapping proxy¶
- Type immutable méconnu de la lib standard :
mappingproxy - Wrapper immutable sur un mapping (dictionnaire)
- Utilisé pour stocker les attributs d'une classe
- Vraie immutabilité (implémenté dans l'interpréteur)
- Pas d'attribut exposé
Mapping proxy¶
- Il respecte l'interface des mappings
In [18]:
from types import MappingProxyType
m = MappingProxyType({'key': 'value'})
m
Out[18]:
mappingproxy({'key': 'value'})
In [19]:
m['key']
Out[19]:
'value'
- Est immutable
In [20]:
m['key2'] = None
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[20], line 1 ----> 1 m['key2'] = None TypeError: 'mappingproxy' object does not support item assignment
Mapping proxy¶
- Mais ne respecte pas à 100% l'interface voulue
In [21]:
type(m | MappingProxyType({}))
Out[21]:
dict
Mapping proxy¶
- Mêmes performances que le dictionnaire
- Ce n'est qu'un proxy sur ce dictionnaire
- Ce qui peut poser problème :
In [22]:
empty_dict = {}
empty_mapping = MappingProxyType(empty_dict)
empty_mapping
Out[22]:
mappingproxy({})
In [23]:
empty_dict['key'] = 12
empty_mapping
Out[23]:
mappingproxy({'key': 12})
Mapping proxy¶
- Et son hashabilité dépend de la valeur proxifiée
In [24]:
{empty_mapping}
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[24], line 1 ----> 1 {empty_mapping} TypeError: unhashable type: 'dict'
- Il faudrait le créer avec un mapping immutable…
Mapping proxy¶
- Et avec un wrapper copiant le dictionnaire et surchargeant l'opérateur
|?- On ne peut pas hériter de
MappingProxyType - Comment stocker le mapping dans un attribut qui ne serait pas redéfinissable ?
- Il reste un dictionnaire mutable quelque part en mémoire
- On ne peut pas hériter de
Construire un type immutable¶
Se baser sur un type immutable existant¶
- Les types immutables de Python sont implémentés dans l'interpréteur, sans attributs
- On peut les étendre par héritage
- Une classe héritant de
tupleproduit des tuples immutables
- Une classe héritant de
In [25]:
class T(tuple):
pass
values = T((1, 2, 3))
values
Out[25]:
(1, 2, 3)
In [26]:
values[0] = 3
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[26], line 1 ----> 1 values[0] = 3 TypeError: 'T' object does not support item assignment
Collection immutable¶
- Quel type choisir pour l'étendre ?
int/float/complex? Pas pratique voire impossible
str? Mieux mais contraignant
tuple?((k1, v1), (k2, v2), ...)ou((k1, k2, ...), (v1, v2, ...))- Pas mal mais demande du boulot pour les opérations en temps constant
frozenset!- Implémente déjà une table de hashage
frozenset → frozendict¶
frozendict=frozensetde paires (clé, valeur)
In [27]:
class frozendict(frozenset):
def __new__(cls, base={}, /, **kwargs):
return super().__new__(cls, dict(base, **kwargs).items())
frozenset → frozendict¶
- Premiers pas vers l'interface d'un mapping : protocole d'itération
In [28]:
class frozendict(...):
[...]
def __iter__(self):
return self.keys()
def items(self):
return super().__iter__()
def keys(self):
return (key for key, _ in self.items())
def values(self):
return (value for _, value in self.items())
frozenset → frozendict¶
- Représentation similaire à celle d'un dictionnaire
In [29]:
class frozendict(...):
[...]
def __repr__(self):
name = type(self).__name__
if self:
return f'{name}({dict(self.items())})'
else:
return f'{name}()'
frozenset → frozendict¶
In [30]:
fd = frozendict(key='value', foo='bar')
fd
Out[30]:
frozendict({'foo': 'bar', 'key': 'value'})
In [31]:
list(fd)
Out[31]:
['foo', 'key']
In [32]:
list(fd.keys())
Out[32]:
['foo', 'key']
In [33]:
list(fd.values())
Out[33]:
['bar', 'value']
In [34]:
list(fd.items())
Out[34]:
[('foo', 'bar'), ('key', 'value')]
Opérateur in¶
- Comment gérer l'opération
in?- Comment savoir qu'une clé existe dans un des couples stockés ?
- Classe
_matcherpermettant_matcher(key) == (key, value)
In [35]:
class _matcher:
def __init__(self, key):
self.key = key
def __eq__(self, rhs):
key, value = rhs
return self.key == key
Opérateur in¶
- Utilisée ensuite par
__contains__
In [36]:
class frozendict(...):
[...]
def __contains__(self, key):
return super().__contains__(_matcher(key))
In [37]:
fd = frozendict(key='value', foo='bar')
In [38]:
'key' in fd
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[38], line 1 ----> 1 'key' in fd Cell In[36], line 5, in frozendict.__contains__(self, key) 4 def __contains__(self, key): ----> 5 return super().__contains__(_matcher(key)) TypeError: unhashable type: '_matcher'
Opérateur in¶
- Les valeurs égales doivent avoir le même hash
hash(_matcher(key)) == hash(key)
In [39]:
class _matcher:
[...]
def __hash__(self):
return hash(self.key)
In [40]:
'key' in fd
Out[40]:
False
- L'ensemble ne contient pas
keymais(key, value)hash(_matcher(key)) != hash((key, value))
Opérateur in¶
- Nouvelle classe
_pair(couple immutable) pour garantir l'égalité de hash avec la clé
In [41]:
class _pair(tuple):
def __new__(cls, key, value):
return super().__new__(cls, (key, value))
def __hash__(self):
return hash(self[0])
- On stocke alors des
_pairdans notre ensemble plutôt que destuple
In [42]:
class frozendict(...):
[...]
def __new__(cls, base={}, /, **kwargs):
return super().__new__(
cls,
(
_pair(key, value)
for key, value in dict(base, **kwargs).items()
),
)
def items(self):
return (tuple(pair) for pair in super().__iter__())
Opérateur in¶
In [43]:
fd = frozendict(key='value', foo='bar')
In [44]:
'key' in fd
Out[44]:
True
In [45]:
'foo' in fd
Out[45]:
True
In [46]:
'key2' in fd
Out[46]:
False
Opérateur []¶
- Comment gérer l'accès à un élément ?
- Réutiliser l'opérateur
in - Le
_matcherpeut stocker la valeur associée lors du test d'égalité
- Réutiliser l'opérateur
In [47]:
class _matcher:
[...]
def __eq__(self, rhs):
key, value = rhs
if self.key == key:
self.value = value
return True
return False
Opérateur []¶
In [48]:
class frozendict(...):
[...]
def __getitem__(self, key):
m = _matcher(key)
if super().__contains__(m):
return m.value
raise KeyError(key)
In [49]:
fd = frozendict(key='value', foo='bar')
In [50]:
fd['key']
Out[50]:
'value'
In [51]:
fd['foo']
Out[51]:
'bar'
In [52]:
fd['key2']
--------------------------------------------------------------------------- KeyError Traceback (most recent call last) Cell In[52], line 1 ----> 1 fd['key2'] Cell In[48], line 8, in frozendict.__getitem__(self, key) 6 if super().__contains__(m): 7 return m.value ----> 8 raise KeyError(key) KeyError: 'key2'
Opérateur get¶
- Idem avec une valeur par défaut sur le
_matcher
In [53]:
class _matcher:
[...]
def __init__(self, key, value=None):
self.key = key
self.value = value
In [54]:
class frozendict(...):
[...]
def get(self, key, default=None):
m = _matcher(key, default)
super().__contains__(m)
return m.value
Opérateur get¶
In [55]:
fd = frozendict(key='value', foo='bar')
In [56]:
fd.get('key')
Out[56]:
'value'
In [57]:
fd.get('key', 'default')
Out[57]:
'value'
In [58]:
fd.get('key2')
In [59]:
fd.get('key2', 'default')
Out[59]:
'default'
frozenset → frozendict¶
- Finalisons l'interface :
__len__est déjà implémentée surfrozenset- Ajout des opérateurs d'égalité et d'union se basant sur des dictionnaires
- Suppression des méthodes inutilisées de
frozenset
In [60]:
class frozendict(...):
[...]
def __eq__(self, rhs):
return dict(self) == rhs
def __hash__(self):
return hash(frozenset(self.items()))
def __or__(self, rhs):
return type(self)(dict(self) | rhs)
def __ror__(self, lhs):
return type(self)(lhs | dict(self))
__and__ = None
copy = None
different = None
intersection = None
isdisjoint = None
issubset = None
issuperset = None
symmetric_difference = None
union = None
Quelques tests¶
In [61]:
empty_dict = frozendict()
assert str(empty_dict) == repr(empty_dict) == 'frozendict()'
assert len(empty_dict) == 0
assert list(empty_dict) == []
assert 'key' not in empty_dict
assert empty_dict.get('key') is None
assert empty_dict.get('key', 'default') == 'default'
assert list(empty_dict.keys()) == []
assert list(empty_dict.values()) == []
assert list(empty_dict.items()) == []
assert empty_dict == empty_dict
assert empty_dict == frozendict()
assert empty_dict == {}
assert hash(empty_dict) == hash(frozendict())
Quelques tests¶
In [62]:
fdict = frozendict({'key': 'value'})
assert str(fdict) == repr(fdict) == "frozendict({'key': 'value'})"
assert len(fdict) == 1
assert list(fdict) == ['key']
assert 'key' in fdict
assert fdict['key'] == 'value'
assert fdict.get('key') == 'value'
assert list(fdict.keys()) == ['key']
assert list(fdict.values()) == ['value']
assert list(fdict.items()) == [('key', 'value')]
assert fdict == fdict
assert fdict != empty_dict
assert fdict != {}
assert fdict == frozendict(key='value')
assert fdict == {'key': 'value'}
assert hash(fdict) == hash(frozendict(key='value'))
assert hash(fdict) != hash(empty_dict)
assert hash(fdict) != hash(frozendict(key=None))
frozenset → frozendict¶
- Encore un petit problème…
In [63]:
tuple(fdict)
Out[63]:
('key',)
In [64]:
list(fdict)
Out[64]:
['key']
In [65]:
set(fdict)
Out[65]:
{('key', 'value')}
Wrapper immutable¶
Wrapper immutable¶
- Il faudrait un wrapper autour du
frozenset- Mais un wrapper immutable
- Avec
super!
In [66]:
t = super(tuple, (1, 2, 3))
t.__self__
Out[66]:
(1, 2, 3)
In [67]:
t.__self__[0] = 4
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[67], line 1 ----> 1 t.__self__[0] = 4 TypeError: 'tuple' object does not support item assignment
In [68]:
t.__self__ = (1, 2)
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[68], line 1 ----> 1 t.__self__ = (1, 2) AttributeError: readonly attribute
Wrapper immutable¶
frozendictbasé sursuperutilisant unfrozenseten suppprt
In [69]:
class frozendict(super):
[...]
def __init__(self, base={}, /, **kwargs):
super().__init__(
frozenset,
frozenset(_pair(key, value) for key, value in dict(base, **kwargs).items()),
)
Wrapper immutable¶
- Redéfinition de toutes les méthodes nécessaires
super().__self__pour accéder aufrozenset
In [70]:
class frozendict(...):
[...]
def __len__(self):
return len(super().__self__)
def __contains__(self, key):
return _matcher(key) in super().__self__
def __getitem__(self, key):
m = _matcher(key)
if m in super().__self__:
return m.value
raise KeyError
def get(self, key, default=None):
m = _matcher(key, default)
m in super().__self__
return m.value
Wrapper immutable¶
In [71]:
class frozendict(...):
[...]
def __iter__(self):
it = iter(super().__self__)
return (key for key, _ in it)
def keys(self):
return iter(self)
def values(self):
it = iter(super().__self__)
return (value for _, value in it)
def items(self):
it = iter(super().__self__)
return (tuple(pair) for pair in it)
Wrapper immutable¶
- Suppression des attributs inutilisés
In [72]:
class frozendict(...):
[...]
__self__ = None
__self_class__ = None
__thisclass__ = None
Wrapper immutable¶
- On reproduit les tests précédents
In [73]:
fdict = frozendict({'key': 'value'})
assert str(fdict) == repr(fdict) == "frozendict({'key': 'value'})"
assert len(fdict) == 1
assert list(fdict) == ['key']
assert 'key' in fdict
assert fdict['key'] == 'value'
assert fdict.get('key') == 'value'
assert list(fdict.keys()) == ['key']
assert list(fdict.values()) == ['value']
assert list(fdict.items()) == [('key', 'value')]
assert fdict == fdict
assert fdict != empty_dict
assert fdict != {}
assert fdict == frozendict(key='value')
assert fdict == {'key': 'value'}
assert hash(fdict) == hash(frozendict(key='value'))
assert hash(fdict) != hash(empty_dict)
assert hash(fdict) != hash(frozendict(key=None))
Wrapper immutable¶
- Et c'est tout bon !
In [74]:
tuple(fdict)
Out[74]:
('key',)
In [75]:
list(fdict)
Out[75]:
['key']
In [76]:
set(fdict)
Out[76]:
{'key'}
Conclusion¶
- Des questions ?
