Is this meant to be an expected behavior?

Suppose one wishes to instantiate a class:

Summary
  def __init__(self,
    num: int = 5,
    text: str = 'txt',
    listed: list = [],
    dicted: dict = {}
  ):
    self.num = num
    self.text = text
    self.listed = listed
    self.dicted = dicted
  # for debugging purposes
  def print(self, last=None):
    print(self.num, self.text, 
      self.listed, self.dicted, 
      f'# {last}' if last else '')

It may be important to have an option to provide both the list and the dict arguments manually - e.g. in the case one needs to create an instance dynamically. Such as:

Summary
cls = Test 
args = { 
  'num': 5, 
  'text': 'txt', 
  'list': [], 
  'dict': {} 
}
instance = cls(**args)

Creating new instances of the class manually, however, introduces a rather interesting surprise to the behaviour of all those instances, at once. Specifically, the following code:

Summary
t1 = Test()
t2 = Test()

t1.print('t1: start')
t2.print('t2: start\n')

t1.num = 3
t2.text = 'next'

t1.print('t1: num_text')
t2.print('t2: num_text\n')

t1.list.append('app')
t2.dict['key'] = 'val'

t1.print('t1: list_dict')
t2.print('t2: list_dict')

Will print the following:

Summary
5 txt [] {} # t1: start
5 txt [] {} # t2: start

3 txt [] {} # t1: num_text
5 next [] {} # t2: num_text

3 txt ['app'] {'key': 'val'} # t1: list_dict
5 next ['app'] {'key': 'val'} # t2: list_dict

If this is not the prime example of the most unintuitive feature a class / function declaration may have, I do not dare to imagine what that may even be. Certainly, it is possible to avoid such a problem via:

Summary
def __init__(self,
  num: int = 5,
  text: str = 'txt',
  listed = None, 
  dicted = None
):
  self.num = num
  self.text = text
  self.list = listed or []
  self.dict = dicted or []

Yet is that not precisely what the author of the class would expect to happen in the first declaration, as well? Under which circumstances could one intend to link all the instances of a particular class to one single list / dict reference, declared in the instantiating __init__ in a manner identical to the passed/copied by value, not by reference, int / str? Was there any discussion about it? Is this simply “the way it’s come to be”?

Yes, that’s expected.

Welcome to the forum!

This is intended behaviour, the default arguments to a function or class are evaluated at function creation time, which means they will be shared by all calls.

As a side note, putting your code inside of detail elements makes it harder to read your post. I suggest you make them visible by default.

Without discussing why things are, I suggest you run a linter like Ruff over your code.

Yes, this is one of the best known quirks of the language:

It has nothing to do with classes, but with functions (including methods). It has always worked this way; there has been an incredible amount of discussion of it; and the Ideas category contains many proposals for changing how it works.

It also isn’t clear to me what you mean by “creating new instances of the class manually” (as opposed to what?); and the observed behaviour is a result of not passing an explicit list or dict to the Test constructor. It’s also important to understand (this is thoroughly addressed in the linked explanation, but it’s a key conceptual issue) that doing something like t1.listed.append('app') is fundamentally different from doing something like t1.num = 3. In the first case, the object that t1.listed refers to is modified; in the second, the object that t1.num refers to is replaced (there is no way from within Python to modify the integer object itself; it would be very bad if the concept of 3 itself could be forced to change).

2 Likes

4 posts were split to a new topic: Mutating immutable objects with ctypes

They’re “passed/copied by reference” as well, exactly the same way.