Class and Objects

Contents

Class and Objects#

Classes are one of the most important concepts in Python. They allow us to model real-world entities by combining data and behavior into a single structure. When writing programs, we often want to model real-world things: robots, users, bank accounts, layers in a neural network, and so on. Each of these “things” has two important aspects:

  1. Data: information that describes the thing

  2. Behavior: actions that the thing can perform

Python provides classes and objects as a structured way to represent both.

Objects: Bundling Data and Behavior#

An object is a single entity that contains:

  • attributes (variables that store data)

  • methods (functions that define behavior)

Consider a simple example: robots on a website.

Each robot has:

  • a name

  • a color

  • a weight

Each robot can also:

  • introduce itself

A single robot, such as Tom, can be thought of as one object that holds all this information and functionality together.

Defining a Class in Python#

In Python, a class is defined using the class keyword followed by the class name and a colon.

class Robot:
    pass

This defines a class named Robot, but it does nothing yet. To make it useful, we need to add attributes and methods.

Naming Convention#

By convention, class names start with an uppercase letter (PascalCase). This helps distinguish classes from variables and functions. Python does not enforce this rule, but it is strongly recommended.

Classes: Blueprints for Objects#

A class is a blueprint that describes:

  • which attributes an object will have

  • which methods the object can use

The class itself does not represent a specific robot. Instead, it represents the idea of a robot. From that blueprint, we can create many different robot objects.

Note:
A class itself is an object and occupies memory.
When Python executes a class definition, it creates a class object in memory that contains the class’s attributes and methods.

Creating an Instance (Object)#

Once a class is defined, we can create objects from it. These objects are called instances.

r1 = Robot()

When this line runs:

  1. Python allocates memory for a new instance

  2. A reference named r1 points to that instance

  3. The instance stores a reference to its class (Robot)

Conceptually:

  • r1 is not the object itself

  • r1 points to an object in memory

Consider this code:

class Robot:
    species = "Machine"

    def introduce_self(self):
        print("Hello")

When Python executes this code:

  1. Python creates a class object named Robot

  2. Memory is allocated for:

    • the class itself

    • its class attributes (species)

    • its methods (introduce_self)

Important points:

  • A class does occupy memory

  • A class is itself an object

  • Methods belong to the class, not to individual instances

Conceptual View#

Memory Layout:

┌──────────────────────┐
│ Class Object: Robot  │
│  - species           │
│  - introduce_self()  │
└──────────────────────┘

At this point:

  • No robots exist yet

  • No instance attributes exist

  • Only the class definition exists in memory

What Happens When We Create an Instance?#

Now consider:

r1 = Robot()

What happens step by step:

  1. Python allocates memory for a new instance

  2. A reference (r1) points to that instance

  3. The instance stores its own attributes

  4. The instance stores a reference to its class

Conceptually:

Memory Layout:

┌──────────────────────┐
│ Class Object: Robot  │
│  - species           │
│  - introduce_self()  │
└──────────────────────┘
          ▲
          │
┌──────────────────────┐
│ Instance Object: r1  │
│  - (instance data)   │
│  - __class__ → Robot │
└──────────────────────┘
Key idea:

An instance does not copy the class. It only points to it.

Where Do Instance Attributes Live?#

When we write:

r1.name = "Tom"
r1.weight = 30

Memory is allocated inside the instance

These attributes belong only to r1

If we create another instance:

r2 = Robot()
r2.name = "Jerry"

Then memory looks like this:

Class Robot
 ├─ species
 └─ introduce_self()

Instance r1
 ├─ name = "Tom"
 └─ weight = 30
 └─ __class__ → Robot
    

Instance r2
 └─ name = "Jerry"
 └─ __class__ → Robot   

Each instance:

  • has its own attribute storage

  • does not affect other instances

Dynamic Attributes and Memory#

Python allows attributes to be added dynamically:

r1.color = "red"

This:

allocates new memory only for r1

  • does not modify the class

  • does not affect other instances

This flexibility is powerful but must be used carefully to avoid inconsistent objects.

Where Do Methods Live?#

Methods are stored once, inside the class.

class Robot:
    def introduce_self(self):
        print("Hello")

Important:

  • Methods are not copied into each instance

  • Instances only store a reference to the class

When you call:

r1.introduce_self()
Hello

Python:

  1. Looks for introduce_self in r1

  2. Does not find it

  3. Looks in Robot

  4. Finds the method

  5. Automatically passes r1 as self

So this call is equivalent to:

Robot.introduce_self(r1)
Hello

This design:

  • saves memory

  • ensures consistency

  • allows polymorphism

Class Attributes and Memory Sharing#

Class attributes are stored once, in the class.

class Robot:
    species = "Machine"

All instances refer to the same memory location:

r1.species
r2.species
'Machine'

Both read the same value.

Why this is useful:

  • constants

  • shared counters

  • configuration data

Example:

class Robot:
    count = 0

    def __init__(self):
        Robot.count += 1
r3 = Robot()
r3
<__main__.Robot at 0x108226920>

Here:

  • count exists only once

  • all instances update the same value

Instance Attributes vs Class Attributes (Memory View)#

Attribute Type

Stored Where

Shared?

Instance attribute

Inside each instance

No

Class attribute

Inside class object

Yes

Method

Inside class object

Yes

Magic Methods and the Constructors and Attributes (__init__)#

Methods with double underscores (for example __init__, __call__) are called magic methods or dunder methods. They define special behavior in Python. When we create an object from a class, Python calls a special method named __init__. This method is called the constructor.

The constructor’s job is to initialize the object’s attributes.

class Robot:
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight

Key ideas:

  • __init__ runs automatically when a new object is created

  • self refers to the current object (to the newly created instance)

  • self.name, self.color, and self.weight are attributes

  • Each object gets its own copy of these attributes

Creating Objects from a Class#

Once the class is defined, we can create objects from it.

r1 = Robot("Tom", 30)

Internally, Python does something similar to:

Robot.__init__(r1, "Tom", 30)

You never pass self manually—Python does this automatically.

Understanding self#

Every instance method must have at least one parameter, and by convention it is called self.

  • self represents the current object

  • It allows methods to access and modify the object’s attributes

Example:

def introduce_self(self):
    print(self.name)

Instance Attributes (Object Attributes)#

Attributes defined using self belong to a specific instance.

r1 = Robot("Tom", 30)
r2 = Robot("Jerry",  40)

Here:

  • r1 and r2 are two different objects

  • Both are created from the same class

  • They share the same structure but have different data

Dynamic Attributes in Python#

Python allows attributes to be added outside the constructor.

r1.color = "red"

This attribute:

  • exists only on r1

  • does not affect r2

  • does not modify the class

This flexibility is powerful but should be used carefully.

Methods: Behavior Inside a Class#

Methods are functions defined inside a class. They describe what an object can do.

Let’s add a method that allows a robot to introduce itself.

class Robot:
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight

    def introduce_self(self):
        print(f"My name is {self.name}.")

Now we can call this method on each object:

r1 = Robot("Tom", 30)
r2 = Robot("Jerry",  40)
r1.introduce_self()  # My name is Tom.
r2.introduce_self()  # My name is Jerry.
My name is Tom.
My name is Jerry.

The same method behaves differently depending on which object it is called on, because self refers to a different object each time.

Attributes vs. Methods (Terminology)#

Inside an object:

  • Attributes store data

    • example: r1.name, r1.weight

  • Methods define behavior

    • example: r1.introduce_self()

Objects as Function-Like Entities#

In many programs, especially in scientific computing and machine learning, objects are not just passive containers of data. Instead, they represent operations.

For example:

  • a robot performs an action

  • a layer transforms input data into output data

Python allows objects to behave like functions using a special method called __call__.

The __call__ Method#

If a class defines a method named __call__, its objects can be called like functions.

class Robot:
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight

    def introduce_self(self):
        print(f"My name is {self.name}.")

    def __call__(self):
        self.introduce_self()

Creating Robot Objects#

r1 = Robot("Tom", 30)
r2 = Robot("Jerry", 40)

Calling a Normal Method#

r1.introduce_self()   # My name is Tom.
r2.introduce_self()   # My name is Jerry.
My name is Tom.
My name is Jerry.

Calling the Object Itself (__call__)#

r1()   # My name is Tom.
r2()   # My name is Jerry.
My name is Tom.
My name is Jerry.

What Is Actually Happening?#

When you write:

r1()
My name is Tom.

Python internally translates this to:

r1.__call__()
My name is Tom.

Since __call__ calls introduce_self(), the robot prints its name.

Why This Is Useful#

Using __call__ allows:

  • Cleaner and more natural syntax

  • Objects to behave like actions

  • Code that is easier to read

This line:

r1()
My name is Tom.

reads naturally as:

“Make the robot act.”

Comparison#

Without __call__:

r1.introduce_self()
My name is Tom.

With call:#

r1()
My name is Tom.

The __call__ method lets a Python object be used like a function, so calling robot() automatically runs robot.__call__().

Inheritance: Extending Classes#

Inheritance allows one class to reuse and extend another class. In the previous section, we defined a Robot class and created multiple robot objects from it. In practice, however, not all robots are the same. Some robots may be service robots, others may be military robots, and others may be medical robots.

Rather than rewriting similar code for each type of robot, Python allows us to extend existing classes using inheritance.

Base Class: Robot#

We start with the base class that represents a general robot.

class Robot:
    species = "Machine"

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

    def introduce_self(self):
        print(f"My name is {self.name}.")

This class defines:

  • common attributes (name, weight)

  • common behavior (introduce_self)

This is our base class.

Creating a Child Class#

Now let’s define a more specific type of robot: a service robot.

class ServiceRobot(Robot):
    pass

Syntax:

class ChildClass(ParentClass): …

Here:

  • ServiceRobot inherits from Robot

  • It automatically has access to all attributes and methods of Robot

Using the Inherited Behavior#

sr = ServiceRobot("HelperBot", 50)
sr.introduce_self()
My name is HelperBot.

Even though ServiceRobot has no code of its own yet, it can still call introduce_self() because it inherits it from Robot.

This demonstrates the “is-a” relationship:

  • A ServiceRobot is a Robot.

Adding New Behavior in the Child Class#

A child class can define new methods that do not exist in the base class.

class ServiceRobot(Robot):
    def clean(self):
        print(f"{self.name} is cleaning.")

Usage:

sr = ServiceRobot("HelperBot", 50)

sr.clean()
HelperBot is cleaning.

The base Robot class remains unchanged, but the child class gains extra functionality.

Overriding a Method#

A child class can also override a method from the base class.

class ServiceRobot(Robot):
    def introduce_self(self):
        print(f"I am service robot {self.name}.")

Now:

sr = ServiceRobot("HelperBot", 50)

sr.introduce_self()
I am service robot HelperBot.

The original Robot.introduce_self method is not called at all.

If you want to extend the parent behavior instead of fully replacing it, use super():

class ServiceRobot(Robot):
    def introduce_self(self):
        super().introduce_self()
        print("I am designed to assist with services.")
sr = ServiceRobot("HelperBot", 50)

sr.introduce_self()
My name is HelperBot.
I am designed to assist with services.

This is an example of polymorphism: the same method name behaves differently depending on the object type.

Inheritance and Constructors (super())#

If the child class defines its own constructor, the base class constructor is not called automatically.

Incorrect (duplicated logic):

class ServiceRobot(Robot):
    def __init__(self, name, weight, area):
        self.name = name
        self.weight = weight
        self.area = area

Correct (using super()):

class ServiceRobot(Robot):
    def __init__(self, name, weight, area):
        super().__init__(name, weight)
        self.area = area

Why this matters:

  • avoids duplicated code

  • keeps base-class logic centralized

  • supports future changes safely

isinstance with Inheritance#

sr = ServiceRobot("HelperBot", 50, "Hospital")

isinstance(sr, ServiceRobot)  # True
isinstance(sr, Robot)         # True
True

This confirms that a child class object is also considered an instance of the base class.

Combining Inheritance with __call__#

We can even allow robots to behave like functions.

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

    def __call__(self):
        self.introduce_self()

    def introduce_self(self):
        print(f"My name is {self.name}.")


class ServiceRobot(Robot):
    def introduce_self(self):
        print(f"I am service robot {self.name}.")
Now all subclasses inherit this behavior automatically.
  Cell In[45], line 1
    Now all subclasses inherit this behavior automatically.
        ^
SyntaxError: invalid syntax
sr = ServiceRobot("HelperBot")
sr()
I am service robot HelperBot.

Calls __call__, which calls introduce_self.

Why Inheritance Matters (Robot Example)#

Inheritance allows us to:

  • define shared behavior once

  • create specialized robots easily

  • write polymorphic code

Example:

robots = [
    Robot("BasicBot", 30),
    ServiceRobot("CleanerBot", 50, "Office")
]

for r in robots:
    r.introduce_self()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[95], line 2
      1 robots = [
----> 2     Robot("BasicBot", 30),
      3     ServiceRobot("CleanerBot", 50, "Office")
      4 ]
      6 for r in robots:
      7     r.introduce_self()

TypeError: Robot.__init__() takes 2 positional arguments but 3 were given

Each robot behaves correctly without conditional logic.

Summary#

  • Inheritance allows one class to extend another

  • Robot is the base class

  • ServiceRobot is a derived class

  • Child classes inherit attributes and methods

  • Methods can be overridden

  • super() ensures proper initialization

  • isinstance respects inheritance

  • __call__ behavior is inherited automatically

This completes a smooth transition from classes → inheritance → polymorphism,

Here:

  • Robot is the base (parent) class

  • ServiceRobot is the derived (child) class

  • A ServiceRobot is a Robot

Adding and Overriding Behavior#

A child class can add new methods:

class ServiceRobot(Robot):
    def clean(self):
        print(f"{self.name} is cleaning.")

It can also override existing methods:

class ServiceRobot(Robot):
    def introduce_self(self):
        print(f"I am service robot {self.name}.")

This allows different objects to respond differently to the same method call.

Constructors and super()#

If a child class defines its own constructor, the base class constructor is not called automatically.

Correct usage:

class ServiceRobot(Robot):
    def __init__(self, name, weight, area):
        super().__init__(name, weight)
        self.area = area

super() ensures that base-class initialization logic is reused correctly.

isinstance and Type Relationships#

The isinstance function checks whether an object is an instance of a class or its subclasses.

isinstance(r1, Robot)         # True
isinstance(sr, ServiceRobot)  # True
isinstance(sr, Robot)         # True

This reflects the inheritance hierarchy.

Class Methods in Python#

Here, we will examine additional examples to gain more practice in creating classes and to become more familiar with objects in Python.

Here, we want to create a class that represents points.

class Point: 
    def __init__(self,x,y):
        self.x = x
        self.y = y
    def draw(self):
        print(f"Point({self.x},{self.y})")
point = Point(1,3)
point.draw()
Point(1,3)

So far, we have seen instance methods, which operate on a specific object and use self. However, not all methods logically belong to a single object. Some methods are related to the class itself, not to any particular instance. For this purpose, Python provides class methods.

What Is a Class Method?#

A class method is a method that:

  • belongs to the class, not to a specific instance

  • receives the class itself as its first argument

  • is defined using the @classmethod decorator

Instead of self, a class method uses the parameter cls.

class Point: 
    def __init__(self,x,y):
        self.x = x
        self.y = y

    @classmethod
    def zero(cls):
        return cls(0,0)
    def draw(self):
        print(f"Point({self.x},{self.y})")

The method zero() is a class method.

point  = Point.zero()
point.draw()
Point(0,0)

Lets see another example:

class Robot:
    def __init__(self, name, weight, role):
        self.name = name
        self.weight = weight
        self.role = role

    def introduce_self(self):
        print(f"My name is {self.name}. I am a {self.role} robot.")

This constructor requires three pieces of information:

  • name

  • weight

  • role

Now we add a Class Method:

class Robot:
    def __init__(self, name, weight, role):
        self.name = name
        self.weight = weight
        self.role = role
    def introduce_self(self):
        print(f"My name is {self.name}. I am a {self.role} robot.")
    @classmethod
    def service_robot(cls, name):
        return cls(name, 50, "service")
r3 = Robot.service_robot("HelperBot")
r3.introduce_self()
My name is HelperBot. I am a service robot.

Why This Is a Class Method#

  • We do not need an existing robot to create a new one

  • The method belongs to the Robot class, not to a specific robot

  • The method creates and returns a new object

This makes it a factory method.

Why Not an Instance Method?#

This would be confusing:

r = Robot("Tom", 30, "basic")
r.service_robot("HelperBot")   # ❌

Magic method#

Magic methods are written between double underscores (__method__) and are called automatically by the Python interpreter.

class Point: 
    def __init__(self,x,y):
        self.x = x
        self.y = y
    def draw(self):
        print(f"Point({self.x},{self.y})")

point = Point(1,2)

print(point)
<__main__.Point object at 0x11191be20>

__main__is the module name, Point is the class name, and the number following at represents the object’s memory address. This is the output of magic method __str__. now we can change this magic method as we want it.

class Point: 
    def __init__(self,x,y):
        self.x = x
        self.y = y
    def __str__(self):
        return f"({self.x}, {self.y})"
    def draw(self):
        print(f"Point({self.x},{self.y})")

point = Point(1,2)

print(point)
(1, 2)

Compare two points created by our class#

class Point: 
    def __init__(self,x,y):
        self.x = x
        self.y = y
    def draw(self):
        print(f"Point({self.x},{self.y})")

point = Point(1,2)
another = Point(1,2)

print(point == another)
False

The above code compares the memory addresses of the two instances, pointand another, rather than their values. To compare the actual values of these two points, we need to override the __eq__ magic method.

class Point: 
    def __init__(self,x,y):
        self.x = x
        self.y = y
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
              
point = Point(1,2)
another = Point(1,2)

print(point == another)
True

Magic method for summation#

class Point: 
    def __init__(self,x,y):
        self.x = x
        self.y = y
    def __add__(self, other):
        return Point( self.x + other.x, self.y + other.y)
    def __str__(self):
        return f"({self.x}, {self.y})"
              
point = Point(1,2)
another = Point(1,2)

print(point + another)
(2, 4)

create a new data structure using class#

we want to make a new data strucutre that count the number of words in a document.

class BagofWord:
    def __init__(self):
        self.words= {}
    def add(self, word):
        self.words[word] = self.words.get(word,0) + 1

document = BagofWord()
document.add("python")

print(document.words)
{'python': 1}
class BagofWord:
    def __init__(self):
        self.words= {}
    def add(self, word):
        self.words[word.lower()] = self.words.get(word.lower(),0) + 1

document = BagofWord()
document.add("python")
document.add("Python")

print(document.words)
{'python': 2}

At this stage:

  • BagofWord is a custom object

  • self.words is a dictionary attribute

  • The object itself is not a dictionary and does not yet support indexing, length queries, or iteration.

The __getitem__ method defines how an object behaves when accessed using square brackets:

obj[key]

Internally, Python translates this into:

obj.__getitem__(key)

class BagofWord:
    def __init__(self):
        self.words= {}
    def add(self, word):
        self.words[word.lower()] = self.words.get(word.lower(),0) + 1
    def __getitem__(self, key):
        return self.words.get(key.lower(), 0)

document = BagofWord()
document.add("python")
document.add("Python")

document["python"]
2

The __setitem__ method controls assignment using square brackets:

obj[key] = value

Which is translated to:

obj.__setitem__(key, value)

class BagofWord:
    def __init__(self):
        self.words= {}
    def add(self, word):
        self.words[word.lower()] = self.words.get(word.lower(),0) + 1
    def __getitem__(self, key):
        return self.words.get(key.lower(), 0)
    def __setitem__(self, key, value):
        self.words[key.lower()] = value

document = BagofWord()
document["python"] = 3
print(document["python"])
3

The __len__ method defines the behavior of the built-in len() function:

len(obj)

Internally:

obj.__len__()

class BagofWord:
    def __init__(self):
        self.words= {}
    def add(self, word):
        self.words[word.lower()] = self.words.get(word.lower(),0) + 1
    def __getitem__(self, key):
        return self.words.get(key.lower(), 0)
    def __setitem__(self, key, value):
        self.words[key.lower()] = value
    def __len__(self):
        return len(self.words)


document = BagofWord()
document.add("python")
document.add("Python")
len(document)
1

Iteration Support: __iter__

Python does not define a method called __iterable__. An object is considered iterable if it implements the __iter__ method.

The __iter__ method is invoked when using constructs such as:

for x in obj:     ...

Internally:

iterator = obj.__iter__()

Delegating Iteration to an Internal Container

Since self.words is already a dictionary (and therefore iterable), the most Pythonic solution is delegation.

Iterating Over Words (Keys)

class BagofWord:
    def __init__(self):
        self.words= {}
    def add(self, word):
        self.words[word.lower()] = self.words.get(word.lower(),0) + 1
    def __getitem__(self, key):
        return self.words.get(key.lower(), 0)
    def __setitem__(self, key, value):
        self.words[key.lower()] = value
    def __len__(self):
        return len(self.words)
    def __iter__(self):
        return iter(self.words)


document = BagofWord()
document.add("python")
document.add("Python")
for word in document:
    print(word)
python

Iterating Over Word–Count Pairs

class BagofWord:
    def __init__(self):
        self.words= {}
    def add(self, word):
        self.words[word.lower()] = self.words.get(word.lower(),0) + 1
    def __getitem__(self, key):
        return self.words.get(key.lower(), 0)
    def __setitem__(self, key, value):
        self.words[key.lower()] = value
    def __len__(self):
        return len(self.words)
    def __iter__(self):
        return iter(self.words)
    def __iter__(self):
        return iter(self.words.items())


document = BagofWord()
document.add("python")
document.add("Python")
for word, count in document:
    print(word, count)
python 2

In Python, identifiers that begin with two leading underscores and do not end with two underscores (for example, __word) have a special meaning. This naming pattern activates a mechanism known as name mangling. It is distinct from Python’s special (dunder) methods such as __init__ or __len__.

What Is Name Mangling?#

When an attribute or method is defined with a leading double underscore inside a class, Python automatically rewrites its name internally by prefixing it with the class name.

class BagofWord:
    def __init__(self):
        self.__words= {}
    def add(self, word):
        self.__words[word.lower()] = self.__words.get(word.lower(),0) + 1
    def __getitem__(self, key):
        return self.__words.get(key.lower(), 0)
    def __setitem__(self, key, value):
        self.words[key.lower()] = valueß
    def __len__(self):
        return len(self.__words)
    def __iter__(self):
        return iter(self.__words)
    def __iter__(self):
        return iter(self.__words.items())


document = BagofWord()
document.add("python")
document.add("Python")