Everything is an Object in Python (almost!)

Paul Manot
8 min readJan 17, 2021
An object!

For this tutorial I am going to assume that you are already familiar with Object Oriented Programming (OOP) and know what an object is, what a class is and how to instantiate an object. But in a nutshell an object is a unique instance of a given class, it has a unique ID and is of a given type. Let’s dig a little deeper into these notions…

ID and Type

So lets assume we have the following code:

  1 #!/usr/bin/python3                                                              
2
3 class MobilePhone:
4 def __init__(self, make, model, color):
5 self.make = make
6 self.model = model
7 self.color = color
8
9 if __name__ == '__main__':
10 s10 = MobilePhone('Samsung', 'S10', 'black')
11 iphone11 = MobilePhone('Macintosh', 'Iphone11', 'white')
12 print(type(s10))
13 print(type(iphone11))

When we execute it we get the following result:

<class '__main__.MobilePhone'>
<class '__main__.MobilePhone'>

type() is a built-in function that gives the type of an object or in other words from what class the object stems from. And not surprisingly since s10 and iphone11 are 2 instances of the same class they are of the same type.

What if we do this (for simplicity sake let’s just show the executable part of the code):

1 s10 = MobilePhone('Samsung', 'S10', 'black')                                
2 iphone11 = MobilePhone('Macintosh', 'Iphone11', 'white')
3 print(id(s10))
4 print(id(iphone11))

We get 2 different IDs:

139827681077288
139827681077344

Again this was to be expected as s10 and iphone11 are 2 different objects. They have 2 different unique IDs which correspond to 2 different memory locations (NB: the ID is not the actual memory location per say but a given unique number associated to that location).

If we do this on the other hand:

1 s10 = MobilePhone('Samsung', 'S10', 'black')                               
2 iphone11 = MobilePhone('Macintosh', 'Iphone11', 'white')
3 print(id(s10))
4 print(id(iphone11))
5 s10 = iphone11
6 print(id(s10))
7 print(id(iphone11))

We get the following result:

140191421814824
140191421814880
140191421814880
140191421814880

We can see that before the new assignment at line 5 s10 and iphone11 have 2 different IDs but then we overwrite the value in s10 with the value in iphone11. In other words, after line 5, the identifier s10 is pointing to the same location in memory as the identifier iphone11. The connection to the object pointed created at line 1 at by s10 is now lost and both s10 and iphone11 now points to the same object, the object originally associated to iphone11 at line 2.

Another easier way to tell if 2 identifiers are referencing the same objects is to use the is operator. So if we keep the same example as before and tweak it a little bit:

1 s10 = MobilePhone('Samsung', 'S10', 'black')                               
2 iphone11 = MobilePhone('Macintosh', 'Iphone11', 'white')
3 s10 = iphone11
4 print(s10 is iphone11)

We get:

True

Which is not super impressive but proves what we were saying earlier… Also note that, what is actually happening behind the scene when using the is operator is: id(s10) == id(iphone11) . From this we can also deduce that the == equality operator checks for values when the is operator checks for identity. Let’s take another example to demonstrate this even further but this time let’s use an interactive Python 3 console rather than executing a script. This will be of significance in a moment but let’s not worry about it for now…

$ python3
>>> a = 1024
>>> b = 1024
>>> a == b
True
>>> a is b
False

So here we can clearly see what the Python 3 interpreter is doing. The a and b identifiers are associated to the same value (1024) but are not referring to the same object. Therefore we can deduce that in memory 2 objects with the same value were created at 2 different addresses. We can easily verify it (as long as we stay in the same session) by doing the fallowing:

>>> id(a)
140151730060240
>>> id(b)
140151730060304

There is an edge can to this though… Had we selected values of a and b between -5 and 256 we would have gotten a different result.

$ python3
>>> a = 2
>>> b = 2
>>> a == b
True
>>> a is b
True
>>> id(a)
94790124144416
>>> id(b)
94790124144416

Wait! What!? Well yes the Python interpreter stores some default objects for all integers values between -5 and 256. Therefore when you do a = 2 and b = 2 the 2 object already exists and is ready to be associated to identifiers such as a and b . For efficiency reasons there is no need to create new objects with the same values stored inside of them since there is already one ready to use… If you are wondering why such a weird range of -5 to 256 I suggest you dig deeper and research NSMALLPOSINTS, NSMALLNEGINTS in google! There is a similar mechanism for strings but again I’ll let you discover it by yourself.

And why was it so important that we discovered this in a live interpreter session? Because when you run a Python 3 script there is a compilation step. Yes I know, crazy, an interpreted language with a compilation mechanism (by the way JS works the same way)… During compilation the code is read multiple time allowing to do numerous optimization including saving memory by declaring one object for all the same values of that object. There the size of the integer doesn’t matter anymore. let’s look at an example:

1 #!/usr/bin/python3
2
3 a = 351568684641
4 b = 351568684641
5 print(a == b)
6 print(a is b)
7 print(id(a))
8 print(id(b))

And sure enough when we run the above we get this:

True
True
140401736571248
140401736571248

The same object was used for all operations as the compiler new ahead of run time that it could use the same…

Mind blowing! Isn’t it?

Now let’s talk about mutability and immutability.

Mutable objects

A mutable object is an object that can change. Lists, dicts, sets, byte array are all mutable objects.

If you take a list for example, it’s quite easy to change it…

$ python3
>>> l1 = [1, 2, 3]
>>> print(l1)
[1, 2, 3]
>>> id(l1)
140412391220672
>>> l1.append(4)
>>> print(l1)
[1, 2, 3, 4]
>>> id(l1)
140412391220672

So what did we do here? We declared a list with 3 integers in it, printed it, checked the id of the list and then we appended a new element to the list. When printing the list again we can see that 4 was appended at the end of the list but that the list is unchanged otherwise, i.e it is the same object as can be shown by the unchanged id when checking the id of the now 4 items long list…

This is what happens in memory:

Note that you have to be careful with mutable objects to not change there values by accident. Also some operations can create surprising results.

For example the fallowing script:

1 l1 = [1, 2, 3]
2 l2 = l1
3 l1 = l1 + [4]
4 print(l2)

produces:

[1, 2, 3]

At line 1 we associate the list object [1, 2, 3] with the identifier l1. At line 2 we associate the same object to a new identifier l2. But at line we override the association to l1 with the reference to a new object created by the concatenation of the object referred to by l1 and a new list object [4]. If we were to compare the IDs of l1 and l2 at the start of the program and at the end we would see that they are the same at the start and different at the end.

Immutable objects

By opposition to a mutable object, an immutable object is an object that cannot change. Strings, integers, floats, complex, tuples, frozen sets, bytes are all immutable objects.

If you take a string in Python you can’t do this:

my_string = 'Hello World'
my_string[1] = 'a'

Or you’ll get a type error TypeError:'my_string' object does not support item assignment because since my_string is immutable you can’t change it!

Similarly to before if you concatenate 2 strings you’ll get a new object that stems from the 2 original strings but the originals strings are unchanged. And that can be easily proved like so:

1 s1 = 'hello'
2 s2 = s1
3 s3 = ' world'
3 print(id(s1))
4 print(id(s2))
5 print(id(s3))
6 s1 = s1 + s2
7 print(id(s1))
8 print(id(s2))
9 print(id(s3))

And we get:

139831319685808  # s1
139831319685808 # s2 pointing to the same object as s1
139831319686000 # s3
139831319686192 # s1 is a different object now
139831319685808 # but the original s1 string still exist through s2
139831319686000 # s3 is unchanged

This figure shows what happens in memory:

a = 1 followed by a = 2

How does Python treat mutable Vs immutable objects?

In a nutshell and given what we have already seen the main difference between mutable and immutable object is there habitability or not to be altered after creation. An immutable object cannot be changed after creation whereas a mutable object can.

How arguments are passed to functions and what does that imply for mutable and immutable objects?

Classically variables are passed to functions either by value or by reference but in python that’s not the case, arguments are passed by object reference.

If the object passed as an argument to the function is mutable, then modifications to the object will persist. On the other hand If the argument is immutable, then changes to the object are not persisted outside of the function scope.

Let’s see a couple of examples:

1 def increment(n):                                                              
2 n.append(4)
3
4 l = [1, 2, 3]
5 increment(l)
6 print(l)

Will print:

[1, 2, 3, 4]

The mutable list object [1, 2, 3] referred to by l outside the function call was indeed changed when the function was called on line 5.

In the case of an immutable object:

1 def increment(n):                                                              
2 n = n + 1
3
4 i = 5 5 increment(i)
6 print(i)

We get:

5

i wasn’t incremented even-though we reassigned n like so n = n + 1 inside the function call.

It is also interesting to note that reassignments always create a reference to a new object therefore even a mutable object reassigned with a function will not be changed outside of the function execution context.

1 def assign_value(n, v):                                                 
2 n = v
3
4 l1 = [1, 2, 3]
5 l2 = [4, 5, 6]
6 assign_value(l1, l2)
7 print(l1)

Prints:

[1, 2, 3]

Meaning that l1 wasn’t changed inside assign_value() because the assignation created a new spot in memory to store the reference to that assignation. If we were to print the id of n inside of assign_value() before and after the assignment we would see the ID change.

1 def assign_value(n, v):
2 print(id(n))
3 n = v
4 print(id(n))
5
6 l1 = [1, 2, 3]
7 print(id(l1))
7 l2 = [4, 5, 6]
8 assign_value(l1, l2)
9 print(l1)

like so:

140171895031552
140171895031552
140171895032192
[1, 2, 3]

TypeError:'my_string' object does not support item assignement

--

--