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:
Data: information that describes the thing
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.
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:
Python allocates memory for a new instance
A reference named r1 points to that instance
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:
Python creates a class object named Robot
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:
Python allocates memory for a new instance
A reference (r1) points to that instance
The instance stores its own attributes
The instance stores a reference to its class
Conceptually:
Memory Layout:
┌──────────────────────┐
│ Class Object: Robot │
│ - species │
│ - introduce_self() │
└──────────────────────┘
▲
│
┌──────────────────────┐
│ Instance Object: r1 │
│ - (instance data) │
│ - __class__ → Robot │
└──────────────────────┘
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:
Looks for
introduce_selfinr1Does not find it
Looks in
RobotFinds the method
Automatically passes
r1asself
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:
countexists only onceall 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 createdselfrefers to the current object (to the newly created instance)self.name,self.color, andself.weightare attributesEach 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.
selfrepresents the current objectIt 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:
r1andr2are two different objectsBoth 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
r1does not affect
r2does 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:
ServiceRobotinherits from RobotIt 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
ServiceRobotis aRobot.
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
Robotis the base classServiceRobotis a derived classChild classes inherit attributes and methods
Methods can be overridden
super()ensures proper initializationisinstancerespects inheritance__call__behavior is inherited automatically
This completes a smooth transition from classes → inheritance → polymorphism,
Here:
Robotis the base (parent) classServiceRobotis the derived (child) classA
ServiceRobotis aRobot
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
@classmethoddecorator
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")