<jupyter_start><jupyter_text>Introduction (Objects, Values, and Types)All the data in a Python code is represented by **objects** or by **relations** between objects. Every object has an *identity*, a *type*, and a *value*.1. **Identity**An object’s identity never changes once it has been created; you may think of it as the object’s address in memory.The *is* operator compares the identity of two objects; the *id()* function returns an integer representing its identity.<jupyter_code>a = [1, 2, 3]
b = 3
print(f"The id of [[a]]: {id(a)}")
print(f"The id of [[b]]: {id(b)}")

a is b<jupyter_output>The id of [[a]]: 1731029395400
The id of [[b]]: 140704644413520<jupyter_text>2. **Type**An object’s type defines the **possible values** and **operations** (e.g. “does it have a length?”) that type supports.The *type()* function returns the type of an object. An object type is **unchangeable** like the identity<jupyter_code>type(a)<jupyter_output><empty_output><jupyter_text>3. **Value**The value of some objects can be changed or not. Objects whose value can change are said to be **mutable**; objects whose value is unchangeable once they are created are called **immutable**. The mutability of an object is determined by its type.Some objects contain references to other objects, these objects are called **containers**. Some examples of containers are a *tuple, list, and dictionary*. The value of an immutable container that contains a reference to a mutable object can be changed if that mutable object is changed. However, the container is still considered immutable because when we talk about the mutability of a container only the identities of the contained objects are implied.  Mutable and Immutable Data Types in Python1. **Mutable** data types: *list, dictionary, set, bytearray and user-defined classes*.2. **Immutable** data types: *int, float, decimal, complex, bool, string, tuple, range, frozenset, bytes*<jupyter_code># From both data types, we can access elements by index and we can iterate over them. 
# The main difference is that a tuple cannot be changed once it’s defined.
numbers_list = [1, 2, 3] # a list
numbers_tuple = (1, 2, 3) # a tuple
# We can see that when we try to change the tuple we get an error, 
# but we don’t have that problem with the list.
numbers_list[0] = 100
print(numbers_list)
numbers_tuple[0] = 100
old_id_list = id(numbers_list)
old_id_tuple = id(numbers_tuple)

numbers_list += [4, 5, 6] # expand a list
numbers_tuple += (4, 5, 6) # expand a tuple

# We can see that the list identity is not changed, while the tuple identity is changed. 
# This means that we have expanded our list, but created a completely new tuple. 
# Lists are more memory efficient than tuples.
print(f"List Value: {numbers_list}, its ID: {old_id_list} --> {id(numbers_list)}")
print(f"Tuple Value: {numbers_tuple}, its ID: {old_id_tuple} --> {id(old_id_tuple)}")
# Second example about a int type, like other types: string, bool, ....
a = 3 # Once it is initialized, its value cannot be changed
old_id = id(a)
a = a + 3 # create a new object to represent [a + 3]
print(f"Value: {a}, its ID: {old_id} --> {id(a)}")<jupyter_output>Value: 6, its ID: 140704644413520 --> 140704644413616<jupyter_text>Copying Mutable Objects by ReferenceWe can see that the variable names have the same identity meaning that they are referencing to the same object in computer memory.<jupyter_code>values = [4, 5, 6]
values2 = values

print(f"[1] The value: [{values} -> {values2}], their corresponding IDs: {id(values)} -> {id(values2)}")

values2.append(7)

print(f"[2] The value: [{values} -> {values2}], their corresponding IDs: {id(values)} -> {id(values2)}")<jupyter_output>[1] The value: [[4, 5, 6] -> [4, 5, 6]], their corresponding IDs: 1731038586120 -> 1731038586120
[2] The value: [[4, 5, 6, 7] -> [4, 5, 6, 7]], their corresponding IDs: 1731038586120 -> 1731038586120<jupyter_text>So, when we have changed the values of the second variable, the values of the first one are also changed. This happens only with the mutable objects  Copying Immutable ObjectsEvery time when we try to update the value of an immutable object, a new object is created instead. That’s when we have updated the first string it doesn’t change the value of the second.<jupyter_code>import copy
text = "Data Science"
_text = text # both of them refer to same memoby block, their IDs are identical

print(f"[1] The value: [{text} -> {_text}], their corresponding IDs: {id(text)} -> {id(_text)}")

_text_copy = copy.copy(text) # both of them refer to same memoby block, their IDs are identical

print(f"[2] The value: [{text} -> {_text_copy}], their corresponding IDs: {id(text)} -> {id(_text_copy)}")

# Create a new memory block so its id would be changed, 
# The original memory block does not change, the [_text] and [_text_copy] still points to this block
text += " with Python"

print(f"[3] The value: [{text} -> {_text_copy} --> {_text}],\n"
      f"\t\ttheir corresponding IDs: {id(text)} -> {id(_text_copy)} -->{id(_text)}")<jupyter_output>[1] The value: [Data Science -> Data Science], their corresponding IDs: 1731029037296 -> 1731029037296
[2] The value: [Data Science -> Data Science], their corresponding IDs: 1731029037296 -> 1731029037296
[3] The value: [Data Science with Python -> Data Science --> Data Science],
		their corresponding IDs: 1731038459264 -> 1731029037296 -->1731029037296<jupyter_text>Immutable Object Changing Its ValueAs we said before the value of an immutable container (Tuple: *person*) that contains a reference to a mutable object  (List: *skills*) can be changed if that mutable object is changed. Let’s see an example of this.<jupyter_code>skills = ["Programming", "Machine Learning", "Statistics"]
person = (129392130, skills) # a immutable object containing a mutable object skills

print(f"[1] The person: {person}, \n its type is: {type(person)}, its ID: {id(person)}")

skills[2] = "Maths"
print(f"[2] The person: {person}, \n its type is: {type(person)}, its ID: {id(person)}")


skills += ["Maths"]
print(f"[3] The person: {person}, \n its type is: {type(person)}, its ID: {id(person)}")

person[1] += ["Maths"]  # Cannot compile because 'tuple' object does not support item assignment.<jupyter_output>[1] The person: (129392130, ['Programming', 'Machine Learning', 'Statistics']), 
 its type is: <class 'tuple'>, its ID: 1731029475592
[2] The person: (129392130, ['Programming', 'Machine Learning', 'Maths']), 
 its type is: <class 'tuple'>, its ID: 1731029475592
[3] The person: (129392130, ['Programming', 'Machine Learning', 'Maths', 'Maths']), 
 its type is: <class 'tuple'>, its ID: 1731029475592<jupyter_text>The object *person* is still considered immutable because when we talk about the mutability of a container only the identities of the contained objects are implied. However, if your immutable object contains only immutable objects, we cannot change their value. Instead a new object is created instead when you try to update the value of an immutable object. Let’s see an example.<jupyter_code>unique_identifier = 42
age = 24
skills = ("Python", "pandas", "scikit-learn")
info = (unique_identifier, age, skills)
print(id(unique_identifier))
print(id(age))
print(info)

unique_identifier = 50 # create a new object
age += 1 # create a new object
skills += ("machine learning", "deep learning") # create a new object
print(id(unique_identifier))
print(id(age))
print(info) # info will not be changed even their old elements have been changed<jupyter_output>140704644414768
140704644414192
(42, 24, ('Python', 'pandas', 'scikit-learn'))
140704644415024
140704644414224
(42, 24, ('Python', 'pandas', 'scikit-learn'))<jupyter_text>Mutable objects and agruments to functions or classWhen we pass a mutable object to a function or initialize a class declaraton, if its value would be updated in the objects, its value will be updated accordningly. In order to aviod this situation, we can call *copy()* function on mutable objects to create a new object with identical values. Then passing this copying object to functions or class.<jupyter_code>from typing import List
def divide_and_average(var: List):
    for i in range(len(var)):
        var[i] /= 2
    avg = sum(var)/len(var)
    return avg

my_list = [1, 2, 3]
print(divide_and_average(my_list))
print(my_list) # We can see that the value of [my_list] has been updated
from typing import List
class myObject:
    def __init__(self, var: List):
        self.data = var
    def divide_and_average(self):
        for i in range(len(self.data)):
            self.data[i] /= 2
        avg = sum(self.data)/len(self.data)
        return avg
my_list = [1, 2, 3]
_my_list = my_list.copy()
data = myObject(_my_list) # pass a copy of my_list, they are different objects
print(f"Their ids: {id(my_list)} --> {id(_my_list)}")
print(data.divide_and_average())
print(my_list) # We can see that the value of [my_list] has been updated<jupyter_output>Their ids: 1731029383624 --> 1731038610568
1.0
[1, 2, 3]<jupyter_text>Default Arguments in Functions/ClassA common practice when we are defining a function is to assign default values to its arguments. On the one hand, this allows us to include new parameters without changing the downstream code. Still, it also allows us to call the function with fewer arguments, making it easier to use. Let's see, for example, a function that increases the value of the elements of a list. The code would look like:<jupyter_code>def increase_values(var1=[1, 1], value=0):
    value += 1
    var1[0] += value
    var1[1] += value
    return var1, value
print(increase_values())
print(increase_values())<jupyter_output>([2, 2], 1)
([3, 3], 1)<jupyter_text>The first time, it prints ([2, 2], 1) as expected, but the second time it prints ([3, 3], 1). It means that the default argument of the function is changing every time we run it. When we run the script, Python evaluates the function **definition only once** and then creates **the default list and the default value**. Because lists are mutable, every time we call the function, we change its default argument. However, value is immutable, and it remains the same for all subsequent function calls. The next logical question is, how can we prevent this from happening. And the short answer is to use **immutable types** as default arguments for functions. We could have used *None*, for instance:<jupyter_code>def increase_values(var1=None, value=0):
    if var1 is None:
        var1 = [1, 1]
    value += 1
    var1[0] += value
    var1[1] += value
    return var1, value
print(increase_values())
print(increase_values())<jupyter_output>([2, 2], 1)
([2, 2], 1)<jupyter_text>**Important Usage: Cahce**, Of course, the decision always depends on the use case. We may want to update the default value from one call to another. Imagine the case where we would like to perform a computationally expensive calculation. Still, we don't want to run twice the function with the same input and use a cache of values instead. We could do the following:<jupyter_code>def calculate(var1, var2, cache={}):
    try:
        value = cache[var1, var2]
    except KeyError:
        value = expensive_computation(var1, var2)
        cache[var1, var2] = value
    return value<jupyter_output><empty_output><jupyter_text>When we run calculate for the first time, there will be nothing stored in the **cache** dictionary. When we execute the function more than once, **cache** will start changing, appending the new values. If we **repeat the arguments** at some point, they will be part of the **cache** dictionary, and the stored value will be returned. Notice that we are leveraging the handling of exceptions to avoid checking explicitly whether the combination of values already exists in memory.  Our immutable objectswe can achieve by reimplementing the "\__setattr\__" method to define a immutable objects. As soon as we try to instantiate this class, the TypeError will be raised. Even within the class itself, assigning values to attributes is achieved through the "\__setattr\__ "method. To bypass it, we need to use the super() object:<jupyter_code>class MyImmutable:
    def __init__(self, var1, var2):
        super().__setattr__('var1', var1)
        super().__setattr__('var2', var2)

    def __setattr__(self, key, value):
        raise TypeError('MyImmutable cannot be modified after instantiation')

    def __str__(self):
        return 'MyImmutable var1: {}, var2: {}'.format(self.var1, self.var2)
my_immutable = MyImmutable(1, 2)
print(my_immutable)
# If we instantiate the class and try to assign a value to an attribute of it, an error will appear:
my_immutable.var1 = 2<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.10653218816484122, 'fraction_numerical': 0.04152549050508234, 'mean_word_length': 4.846153846153846, 'pattern_counts': {'":': 0, '<': 43, '<?xml version=': 0, '>': 73, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '12', 'dir': 'jupyter-structured-clean-dedup', 'id': '622803', 'license': 'MIT', 'n_tokens_mistral': 4158, 'n_tokens_neox': 3758, 'n_words': 1682, 'path': 'Python/Mutable vs Immutable Objects.ipynb', 'repo_name': 'bingrao/notebook', 'size': '12691'}