bigonotes
CS ConceptsOOP

Classes and Objects

What classes and objects are in Python, where attributes live, and how __new__ and __init__ build an object.

4 min readLast updated OOPListen · Watch on NotebookLM

A class is a blueprint that says which attributes and behaviors its objects will have. An object is one concrete instance built from that blueprint. Almost everything else in OOP builds on this, so it pays to know exactly where an object's data lives and how Python creates it.

Classes and objects#

Think of a cookie cutter: the cutter is the class, and each cookie is an object.

class Dog:
    pass

d1 = Dog()
d2 = Dog()
# d1 and d2 are two independent objects built from the same blueprint

__init__ and self#

__init__ runs automatically when you create an object and sets up its initial state. self is the object being set up, so self.name = name means "on this object, set name".

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

d1 = Dog("Bruno", 3)
d2 = Dog("Max", 5)
print(d1.name)  # Bruno
print(d2.name)  # Max

When you write Dog("Bruno"), Python effectively calls Dog.__init__(obj, "Bruno"), passing the new object in as self. You can prove that self and the variable are the same object:

class Dog:
    def __init__(self, name):
        self.name = name
        print(id(self))  # memory address of the object being built

d1 = Dog("Bruno")
print(id(d1))  # same address as printed above

Instance variables vs class variables#

Instance variables are set on self (usually in __init__). Each object has its own copy.

Class variables are defined in the class body, outside any method. All objects share one copy.

class Dog:
    species = "Canis familiaris"  # class variable: shared

    def __init__(self, name):
        self.name = name          # instance variable: per object

d1 = Dog("Bruno")
d2 = Dog("Max")
print(d1.species, d2.species)  # Canis familiaris Canis familiaris

Dog.species = "Unknown"        # changing it on the class affects everyone
print(d1.species, d2.species)  # Unknown Unknown

__dict__: where attributes live#

Every class and object has a __dict__ that stores its attributes.

d1 = Dog("Bruno")
print(d1.__dict__)   # {'name': 'Bruno'}
print(Dog.__dict__)  # {'species': ..., '__init__': <function ...>, ...}
What Stored in
Instance variables obj.__dict__
Class variables Class.__dict__
Methods Class.__dict__

Attribute lookup order#

When you read d1.species, Python searches in this order and stops at the first hit:

  1. d1.__dict__ (the instance)
  2. Dog.__dict__ (the class)
  3. Each parent class's __dict__, and so on up the MRO

The shadowing trap#

Assigning a class variable through an object does not change the class. It creates a new instance variable that hides (shadows) the class one, for that object only.

class Dog:
    species = "Canis familiaris"

    def __init__(self, name):
        self.name = name

d1 = Dog("Bruno")
d2 = Dog("Max")
d1.species = "Unknown"

print(d1.__dict__)  # {'name': 'Bruno', 'species': 'Unknown'}  <- new entry
print(d2.__dict__)  # {'name': 'Max'}                          <- untouched
print(Dog.species)  # Canis familiaris                         <- untouched

Reading the attribute back follows the lookup order:

Read Where Python finds it Value
d1.species d1.__dict__ "Unknown"
d2.species Not in d2.__dict__, so Dog.__dict__ "Canis familiaris"
Class variable Instance variable
Defined In class body, outside methods On self, usually in __init__
Shared? Yes, by all objects No, one per object
Stored in Class.__dict__ obj.__dict__
Assign via class Affects all objects n/a
Assign via object Creates a shadowing instance variable Affects only that object

Class variables are shared, instance variables are owned. Assigning on an object never modifies the class variable; it creates a shadow. Lookup always goes instance, then class, then parents.

How an object is created: __new__ then __init__#

A common misconception is that __init__ is the constructor. It is the initializer. Creation is a two-step process:

When you write d1 = Dog("Bruno"):

  1. Python calls __new__(Dog, "Bruno"), which creates an empty Dog object and returns it.
  2. Python calls __init__(obj, "Bruno") on that object, which fills obj.__dict__ and returns None.
  3. The new object is bound to d1.
class Dog:
    def __new__(cls, name):
        # __new__ receives the same arguments as __init__, so accept them here
        print("__new__: creating object")
        return super().__new__(cls)  # allocate an empty Dog

    def __init__(self, name):
        print("__init__: filling object")
        self.name = name

d1 = Dog("Bruno")
# __new__: creating object
# __init__: filling object
__new__ __init__
Role Constructor Initializer
Called First Second
Receives cls (the class) self (the new object)
Job Create and return the object Set attributes on it
Returns The new object Always None
Override? Rarely Almost always

When you need __new__: Singleton#

The main time you override __new__ is to control whether a new object is created at all, as in the Singleton pattern:

class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance  # every call returns the same object

s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # True

Common __init__ patterns#

Default arguments:

class Dog:
    def __init__(self, name, age=0):
        self.name = name
        self.age = age

Dog("Bruno")    # age = 0
Dog("Max", 5)   # age = 5

Calling the parent initializer with super():

class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # let Animal set up its part
        self.breed = breed

d1 = Dog("Bruno", "Labrador")
print(d1.name, d1.breed)  # Bruno Labrador

Key takeaways#

  • A class is a blueprint; an object is an instance with its own state.
  • Instance variables live in obj.__dict__; class variables and methods live in Class.__dict__.
  • Attribute lookup goes instance, then class, then parent classes.
  • Assigning a class variable through an object creates a shadowing instance variable.
  • __new__ creates the object, __init__ initializes it and returns None. Override __new__ mainly for patterns like Singleton.

Found a mistake? Tell me on LinkedIn ↗

Further reading#