Table of Contents
- Table of Contents
- Introduction
- Creating a Class
- Creating Instance Objects
- Class and Instance Variables
- Using Dunder init for Class Invariance
- Class and Instance Methods
- Static Methods
- Data and Implementation Hiding
- Inheritance
- Printing and Debugging An Object
- Data Classes
- Summary
Introduction
Classes are a classic feature of objected-oriented programming, a programming paradigm that structures code as objects with attributes (variables) and behavior (functions or methods). The attributes and functions usually form a single logical unit. For example, you may define a Person
class to represent the attributes and behavior of a person.
You can also see a class as a blueprint for creating objects, similar to how architectures are blueprints for making concrete buildings. The objects are called instances of that class.
Unlike object-oriented languages like Java, Python lets you use classes or not. You can choose between using the variables and functions individually or group them under a class.
Before proceeding, I assume you understand basic Python programming, including variables and functions.
Creating a Class
Use the class
keyword followed by the class name and a colon. For example:
class Restaurant:
# ...
While you can use any valid naming pattern to define your class names, by convention, classes are defined using Pascal cases. For example, Animal, MyBook, ItalianRestaurant, etc.
Next, include the attributes and methods that are either attached to the class itself or the instances of the class. For example:
class Restaurant:
"""A restaurant class"""
count: int = 0
def __init__(self) -> None:
self.name: str = ''
self.address: str = ''
self.type: str = ''
self.capacity: int = 1
self.spacing: float = 1.0
def get_name(self) -> str:
return self.name
def set_name(self, name: str) -> None:
self.name = name
@classmethod
def increase_count(cls) -> None:
cls.count += 1
@staticmethod
def describe() -> None:
print('My awesome Restaurant')
The Restaurant
class has the following:
- A docstring
"""A restaurant class"""
accessible through__doc__
attribute - A class attribute
count
, an integer directly attached to theRestaurant
class and shared by all instance objects. - An
__init__()
method where instance variables are created and or initialized - Five instance attributes or variables -
name
,address
,type
,capacity
, andspacing
are attached to each unique instance of theRestaurant
class. - Two instance methods -
get_name()
andset_name()
. An instance method must take itself (self
) as its first argument. - A class method
increase_count()
which is attached to theRestaurant
class. A class method must take the class itself as its first argument. - An independent static method
describe()
which is in the class basically for the sake of grouping. This means that static methods can exist as regular functions outside the class. Note that you can access a static method via the class object and an instance object.
In Python, a class, like its instances, is also an object.
Defining a class automatically a class object. You can access its attributes using the ClassName.name
syntax. For example:
# Print some of the class attributes
print(Restaurant.count)
print(Restaurant.__doc__)
print(Restaurant.increase_count)
print(Restaurant.describe)
# You can also add new attributes to a class
# However, do not do this. It is better to include
# your variables in the class definition
Restaurant.age = 10
print(Restaurant.age)
# Delete the added attribute
del Restaurant.age
# print(Restaurant.age) # Throws AttributeError
# call the class method
Restaurant.increase_count()
print(Restaurant.count)
# call the static method
Restaurant.describe()
0
A restaurant class
<bound method Restaurant.increase_count of <class '__main__.Restaurant'>>
<function Restaurant.describe at 0x000002882CECE3E0>
1
My awesome Restaurant
0x000002882CECE3E0 is the current memory address of the function
Restaurant.describe()
. The memory address usually changes each time you run the Python program.
Creating Instance Objects
You can also create an instance object using the ClassName()
syntax. For example:
italian_rest = Restaurant()
# Print the instance object
# Prints the memory address at the time of creation
print(italian_rest)
# Check the type
print(type(italian_rest))
# Create another independent instance of Restaurant
chinese_res = Restaurant()
print(type(chinese_rest))
# Instances are independent or unique
print(italian_rest == chinese_res)
<__main__.Restaurant object at 0x00000229E1AB78F0>
<class '__main__.Restaurant'>
<class '__main__.Restaurant'>
False
Checking the types shows that both italian_rest
and chinese_rest
objects are Restaurant
objects. Also, you can use the built-in isinstance()
function to determine if an object was created from a class. For example:
print(isinstance(italian_rest, Restaurant))
print(isinstance(chinese_res, Restaurant))
print(isinstance(chinese_res, list))
True
True
False
The instance variables and methods (including static methods) can be accessed using the instance.name
syntax. For example:
print(italian_rest.capacity)
print(italian_rest.spacing)
italian_rest.describe()
print(italian_rest.name) # empty string
italian_rest.set_name('Italian restaurant')
print(italian_rest.name)
1
1.0
My awesome Restaurant
Italian restaurant
Class and Instance Variables
Looking at the Restaurant
class, count
is a class variable while name
, address
, and so on are instance variables.
All instances of a class share class variables while instance variables are unique to each instance object created from the class. Each time you create a new instance of Restaurant
, a call to Restaurant.increase_count()
or self.increase_count()
within the __init__()
method increases count
by one.
class Restaurant:
"""A restaurant class"""
count: int = 0
def __init__(self) -> None:
self.name: str = ''
self.address: str = ''
self.type: str = ''
self.capacity: int = 1
self.spacing: float = 1.0
Restaurant.increase_count()
# self.increase_count()
# ... Elided for brevity
italian = Restaurant()
chinese = Restaurant()
print(Restaurant.count) # 2
print(italian.count) # 2
print(chinese.count) # 2
Note that while count
also appears to be accessible via the instances - chinese
and italian
, it's not an instance variable. The __dict__
attribute (a dictionary) of an instance contains the variables attached to an instance.
print(italian.__dict__)
print(chinese.__dict__)
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
count
is not in __dict__
. You could "hide" or override a class variable by adding the same variable directly to an instance object. Don't do this:
italian.count = 3
print(italian.count) # 3
print(chinese.count) # 2
print(italian.__dict__)
print(chinese.__dict__)
A new instance variable count
on the italian
instance now overshadows the class variable count
.
3
2
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0, 'count': 3}
{'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
Deleting the instance count
makes the class count
re-appear.
del italian.count
print(italian.count) # 2
print(italian.__dict__)
# {'name': '', 'address': '', 'type': '', 'capacity': 1, 'spacing': 1.0}
In Python, instance variables, like class variables, must be initialized upon creation inside the __init__()
method. The string variables such as name
are currently empty strings ''
while capacity
and spacing
are int
and float
types, respectively. You could modify the instance variables after creation. For example:
from pprint import pp # pretty print
print(italian.name)
print(italian.address)
italian.name = 'Francesco T. Burgers'
italian.address = "21st Navy Avenue, Italy."
italian.capacity = 80
italian.type = 'Italian restaurant'
italian.spacing = 18.2
print(italian.__dict__)
print(italian.name)
print(italian.address)
print(italian.capacity)
{'name': 'Francesco T. Burgers',
'address': '21st Navy Avenue, Italy.',
'type': 'Italian restaurant',
'capacity': 80,
'spacing': 18.2}
Francesco T. Burgers
21st Navy Avenue, Italy.
80
Modifying each instance variable feels like a lot of work. Luckily, the __init__()
can take any number of arguments you can use to set instance variables. For example:
class Restaurant:
# ...
def __init__(self, name: str, address: str, type: str,
capacity: int = 1, spacing: float = 1.0):
self.name = name
self.address = address
self.type = type
self.capacity = capacity
self.spacing = spacing
Restaurant.increase_count()
# ...
You can now create an instance and set appropriate values upon creation. Recreating the italian
restaurant you get:
italian = Restaurant('Francesco T. Burgers', '21st Navy Avenue, Italy.',
'Italian restaurant', 80, 18.2)
print(italian.name)
print(italian.address)
print(italian.capacity)
print(italian.type)
print(italian.spacing)
Francesco T. Burgers
21st Navy Avenue, Italy.
80
Italian restaurant
18.2
How about the chinese
instance:
chinese = Restaurant('Jet Li Spot', '31st Broadway Palace',
'Chinese restaurant', 80, 16.2)
print(chinese.name)
print(chinese.address)
print(chinese.capacity)
print(chinese.type)
print(chinese.spacing)
Jet Li Spot
31st Broadway Palace
80
Chinese restaurant
16.2
Using Dunder init for Class Invariance
You can also set constraints on instantiating a class in the __init__()
method. For example, it might not make sense for a restaurant to have no name or address or have a capacity or spacing of zero. You can include checks to ensure that nobody can create such a class.
The concept is known as establishing class invariance. A state where an object of a class meets certain expectations that never change throughout its lifetime. For example, we use the __init__()
to ensure Restaurant
s are never created with empty strings or with zero(0) and negative spacing and capacity.
class Restaurant:
"""A restaurant class"""
count = 0
def __init__(self, name: str, address: str, type: str,
capacity: int = 1, spacing: float = 1.0):
invalid_name = self._invalid_str(name)
invalid_address = self._invalid_str(address)
invalid_type = self._invalid_str(type)
if invalid_name or invalid_address or invalid_type:
raise ValueError('Please provide a non-empty string for name, address and type')
if capacity <= 0 or spacing <= 0:
raise ValueError('capacity and spacing cannot be zero or less')
self.name = name
self.address = address
self.type = type
self.capacity = capacity
self.spacing = spacing
self.increase_count()
# ...
def set_name(self, name):
if self._invalid_str(name):
raise ValueError('Name must be string and cannot be empty')
self.name = name
def _invalid_str(self, value):
return not isinstance(value, str) or value == ''
# ... Elided for brevity
The set_name()
method also has a check to ensure non-violation of the class invariance.
# Works
Restaurant('Jet Li Spot', '31st Broadway Palace',
'Chinese restaurant', 80, 16.2)
# Invariance violated
# Fails: empty name, address
Restaurant('', '', 'Italian', 18, 17.3)
# ValueError: Please provide a non-empty string for name, address, and type
# Invariance violated
# Fails: capacity is 0
Restaurant('Jet Li Spot', '31st Broadway Palace',
'Chinese restaurant', 0, 16.2)
# ValueError: capacity and spacing cannot be zero or less
Class and Instance Methods
A class method, like a class variable, is associated with a class. The method has an annotation @classmethod
on top of it. Its first argument is the class itself. See the increase_count(cls, by)
method defined earlier. You can use the word cls as the first argument since the class
keyword is reserved.
You can use ClassName.class_method()
within or outside the class or self.class_method()
within the class. For example:
class Restaurant:
# ... Elided for brevity
def __init__(
# ... Elided for brevity
):
# ... Elided for brevity
# Restaurant.increase_count()
self.increase_count()
@classmethod
def increase_count(cls):
cls.count += 1
Restaurant.increase_count()
chinese.increase_count()
italian.increase_count()
print(Restaurant.count) # 5
As you've seen above, each instance can also call a class method. However, all class instances share the result of the call.
Static Methods
A method marked with @staticmethod
is a static method. As stated earlier, a static method can exist as a function outside a class. While a class or instance can call a static method, for example, MyClass.static_method()
or my_instance.static_method()
, the static method does not take the class cls
or instance self
as its first argument.
Essentially, it is not necessary to have a static method in a class. For example, the describe()
method could be a regular function in the module:
def describe() -> None:
print('My awesome Restaurant')
class Restaurant:
# ...
Data and Implementation Hiding
Unlike Python, some programming languages like C#, Java, and Cpp have public, private, and protected variables or methods. This helps programmers hide data (private variables) and implementation details (through private methods). To mimic this behavior, many Python programmers, by convention, prefix "private" variables and methods with underscores (_
). For example:
class MyClass:
def __init__():
self._my_private_variable = 0
def _my_private_method(self):
pass
While all instance methods and variables (whether prefixed or not) can still be accessed or modified. However, many people follow the convention. That is usually enough.
Inheritance
A class can inherit attributes and behavior from another class. This is known as inheritance in object-oriented programming. You usually want to do this to extend or modify the behavior of a class. The class inheriting is referred to as a "child" or "sub" or "derived" class while its parent, the "parent" or "base" class.
In Python, a base class can have one or multiple children or subclasses (single inheritance). A child class can have multiple parents (multiple inheritance). The syntax for inheritance in Python is class Child(ParentA, ..., ParentZ):
Single Inheritance
A class class can inherit from a single parent. All classes you create implicitly inherit from the built-in object
class. This means that class Restaurant:
is equivalent to class Restaurant(object):
. For example:
class Restaurant(object):
# ...
Some people like to be explicit, but I don't think this is necessary.
Add a cook_meal()
method to the Restaurant
class. You will expect the implementation of this method in an italian
instance to be different from a chinese
instance or any other instance since they serve different meals.
class Restaurant:
# ...
def cook_meal():
# raise NotImplementedError so that each
# subclass must override this method
raise NotImplentedError
While you could use an if statement
that checks the type
of a restaurant, the easiest way to specialize the cook_meal()
is to create subclasses representing each kind of restaurant. For example:
class ItalianRestaurant(Restaurant):
def cook_meal(self):
self._make_italian_dishes()
def _make_italian_dishes(self):
"""Meant to be private"""
print("I am making Carbonara, Lasagna, and Pizza!")
class ChineseRestaurant(Restaurant):
def cook_meal(self):
self._make_chinese_dishes()
def _make_chinese_dishes(self):
print("I am making Kung Pao Chicken, Fried Rice, and Hot Pot!")
class NigerianRestaurant(Restaurant):
def cook_meal(self):
self._make_nigerian_dishes()
def _make_nigerian_dishes(self):
print("I am making Egusi soup, Pounded yam, and Pepper soup")
You can create new instances like you would a Restaurant
class. The variable initialization stays the same. For example:
italian = ItalianRestaurant('Francesco T. Burgers', '21st Navy Avenue, Italy.',
'Italian restaurant', 80, 18.2)
chinese = ChineseRestaurant('Jet Li Spot', '31st Broadway Palace, China.',
'Chinese restaurant', 80, 16.2)
print(type(italian))
print(isinstance(italian, Restaurant))
print(isinstance(italian, ItalianRestaurant))
print(issubclass(ItalianRestaurant, Restaurant))
print(type(chinese))
print(isinstance(chinese, Restaurant))
print(isinstance(chinese, ChineseRestaurant))
print(issubclass(ChineseRestaurant, Restaurant))
<class '__main__.ItalianRestaurant'>
True
True
True
<class '__main__.ChineseRestaurant'>
True
True
True
Like the
isinstance()
function, you can use theissubclass()
function to check if a class is a subclass.
You can now call the cook_meal()
of each instance to get specialized meals. For example:
italian.cook_meal()
chinese.cook_meal()
I am making Carbonara, Lasagna, and Pizza!
I am making Kung Pao Chicken, Fried Rice, and Hot Pot!
Notice that each subclass has its own _make_<country>_dishes()
unique to that class. The other variables and methods are inherited as is:
print(chinese.address)
print(chinese.type)
print(chinese.spacing)
print(chinese.get_name())
chinese.describe()
31st Broadway Palace, China.
Chinese restaurant
16.2
Jet Li Spot
My awesome Restaurant
Inheritance can be as deep as you want. That is to say that a child class can also be the base class of another class. For example:
class RomanRestaurant(ItalianRestaurant):
pass
roman = RomanRestaurant('Roman T. Burgers', '21st Navy Avenue, Rome.',
'Italian restaurant', 80, 18.2)
# Type and instance checks
print(type(roman))
print(isinstance(roman, Restaurant))
print(isinstance(roman, ItalianRestaurant))
print(isinstance(roman, RomanRestaurant))
# Subclass checks
print(issubclass(RomanRestaurant, ItalianRestaurant))
print(issubclass(RomanRestaurant, Restaurant))
<class '__main__.RomanRestaurant'>
True
True
True
True
True
Too many levels deep can be problematic. Remember to keep it simple.
The super() Function
What if you need to add new instance variables to a derived class or call a parent class method within a derived class? Enter the super()
function. For example:
class ParentClass:
def __init__(self, name, age) -> None:
self.name = name
self.age = age
def talk(self):
print(f'My name is {self.name} and I am {self.age} years old!')
def increase_age(self, by = 1):
self.age += by
class ChildClass(ParentClass):
def __init__(self, name, age, some_other_variable) -> None:
# call the ParentClass __init__()
super().__init__(name, age)
self.some_other_variable = some_other_variable
def talk(self):
if self.age >= 2:
super().talk()
else:
print('I cannot talk yet!')
father = ParentClass('John Doe', 945)
son = ChildClass('Peter Doe', 1, 'Whatever you like')
father.talk()
son.talk()
son.increase_age()
son.talk()
My name is John Doe and I am 945 years old!
I cannot talk yet!
My name is Peter Doe and I am 2 years old!
-
ChildClass
derives fromParentClass
. - Using
super()
, theChildClass
callsParentClass
's__init__()
method to initialize thename
andage
instance variables. It (ChildClass
) then creates and initializessome_other_variable
specific to itself. - Within the overridden
talk()
method, theChildClass
calls the parentsParentClass
'stalk()
method through thesuper()
function ifage
is 2 or greater.
The super()
function gives you access to the immediate base class. Let's add a GrandChildClass
to the mix.
class GrandChildClass(ChildClass):
def __init__(self, name, age, some_other_variable) -> None:
super().__init__(name, age, some_other_variable)
def talk(self):
super().talk()
print('I am a grandchild')
grandson = GrandChildClass("JC", 2, "Whatever")
grandson.talk()
My name is JC and I am 2 years old!
I am a grandchild
The super()
in the grandchild points to ChildClass
.
Multiple Inheritance
As stated earlier, a derived class can inherit from multiple base classes. For example, the StudentWorker
class inherits from both Student
and Worker
classes.
class Student:
def __init__(self, school) -> None:
self.school = school
def read(self, hours=1):
print(f'I have read for {hours} hours!')
def describe(self):
print(f"I am a student at {self.school}")
class Worker:
def __init__(self, occupation) -> None:
self.occupation = occupation
def work(self, hours=1):
print(f'I have worked for {hours} hours!')
def describe(self):
print(f"I work as a {self.occupation}")
class StudentWorker(Student, Worker):
def __init__(self, school, occupation) -> None:
Student.__init__(self, school) # same as super().__init__(school)
Worker.__init__(self, occupation)
def work_and_read(self):
super().work()
super().read()
A StudentWorker
has the variables and methods defined in both base classes.
sw = StudentWorker("John Hopkins University", "Bartender")
sw.read()
sw.work()
sw.work_and_read()
I have read for 1 hours!
I have worked for 1 hours!
I have worked for 1 hours!
I have read for 1 hours!
work()
, read()
, and work_and_read()
are unique to each class. So, the outcome is straightforward.
But what happens if the parents have the same methods? For example, both Student
and Worker
classes have a describe()
method. What happens if you call sw.describe()
or super().describe()
?
If you guessed "I am a student" you are right! "I am a worker" is not.
sw.describe()
I am a student at John Hopkins University
However, you would get "I am a worker" if you swap the order of inheritance. That is StudentWorker(Worker, Student)
. The reason is that Python maintains a list of classes it checks (depth-first, then left-right) when searching for a method. This concept is known as Method Resolution Order (MRO).
The first item on this list is the class itself, next are the base classes ordered according to the class definition, and lastly, the object
class, the mother of all classes. The mro()
method gives you access to this list. For example:
print(StudentWorker.mro())
[<class '__main__.StudentWorker'>, <class '__main__.Student'>, <class '__main__.Worker'>, <class 'object'>]
Therefore, when you call sw.describe()
or super().describe()
or any method in fact, Python will check in the following order and pick the first match:
StudentWorker
-
Student
and its parents (since Student has its own MRO) -
Worker
and its parents (since Worker has its own MRO) object
If Python does not find a method after these checks, it raises an
AttributeError
. See the article on Exceptions for more details.
Instead of swapping the order of inheritance, you could use the base class directly. For example, Worker.__init__(self, occupation)
and Worker.describe(self)
will initialize the occupation variable and call the Worker
's describe()
method, respectively.
Printing and Debugging An Object
When you print an instance using either print()
, str()
, format()
, or repr()
, you will get a somewhat cryptic representation of that class or instance. For example:
sw = StudentWorker()
print(sw)
print(str(sw))
print(repr(sw))
print(format(sw))
<__main__.StudentWorker object at 0x0000027314327D40>
<__main__.StudentWorker object at 0x0000027314327D40>
<__main__.StudentWorker object at 0x0000027314327D40>
<__main__.StudentWorker object at 0x0000027314327D40>
Luckily, you can get better and friendlier output by overriding the following methods inherited from the base object
class:
-
__repr__(self)
- which is called by therepr()
function as well as the REPL at the command line -
__str__(self)
- which is called by both thestr()
andprint()
functions -
__format__(self, format_spec)
which is called by theformat()
function and the string.format()
method
Let's override only __repr()__
and __str()__
since the format()
function uses __str__()
if it is present.
class StudentWorker(Student, Worker):
# ... Elided for brevity
def __repr__(self) -> str:
return f"StudentWorker(school={self.school}, occupation={self.occupation})"
def __str__(self) -> str:
return f"Studies at {self.school} and works as a {self.occupation}"
We now see a different outcome when we print the object using the same functions mentioned earlier.
sw = StudentWorker("John Hopkins University", "Bartender")
print(repr(sw)) # repr() calls __repr__
print("StudentWorker:", sw)
print(str(sw))
print(format(sw)) # format() calls __str__
print(f"He is serious! He {sw}") # format() calls __str__
StudentWorker(school=John Hopkins University, occupation=Bartender)
StudentWorker: Studies at John Hopkins University and works as a Bartender
Studies at John Hopkins University and works as a Bartender
Studies at John Hopkins University and works as a Bartender
He is serious! He Studies at John Hopkins University and works as a Bartender
- Overriding
__repr__()
is useful for debugging.StudentWorker(school=John Hopkins University, occupation=Bartender)
is better than<__main__.StudentWorker object at 0x0000027314327D40>
- Overriding
__str__()
gives a friendlier message.
Data Classes
Data classes are useful for data grouping, representation, or transfer. A data class is a regular class decorated with dataclasses.dataclass
. Like regular classes, it can take any number of variables (with types specified) and methods and supports single or multiple inheritance.
However, unlike the regular classes, a data class contains default implementations of __init__()
and __repr()
methods. For example:
from dataclasses import dataclass, field
@dataclass
class Person:
name: str
age: int
# All keyword variables must come after the positional variables
# Keyword variables: variables with default arguments
occupation: str = 'none'
# hobbies: list = [] # FAILs no mutable argument allowed
hobbies: list[str] = field(default_factory=list)
john = Person('John Doe', 45, 'Bartender')
print(john)
Person(name='John Doe', age=45, occupation='Bartender', hobbies=[])
-
name
andage
are positional variables -
occupation
is a keyword variable with a default value ofnone
. -
hobbies
is a list initialized to[]
by a callfield()
. Direct initialization of mutable data types likedict
andlist
is not allowed.
The __init__()
and repr()
implemented for you might look like these:
# ... Within the Person class
def __init__(self, name, age, occupation='none', hobbies=[]):
self.name = name
self.age = age
self.occupation = occupation
self.hobbies = hobbies
def __repr__(self) -> str:
result = f'{type(self).__name__}(' # Person(
# ', 'join() takes a generator expression
# Generator expression yields variable=value
result += ', '.join(f"{k}={v!r}" for k, v in self.__dict__.items())
result += ')' # close it
return result
# ... Within the Person Class
You can get a glimpse of the implemented __init__()
by looking at its signature.
import inspect
print(inspect.signature(john.__init__))
(name, age, occupation='none', hobbies=[]) -> None
Feel free to drop the
__repr__(self)
code above in any regular class you create. It just works! Thetype(self)__name__
ensures that a subclass returns the appropriate type, which is itself.
Every other thing we've discussed for regular classes applies to data classes. Check out the official Python documentation to learn more about data classes.
Note that Python also supports Enumerations (classes that provide unique values). Check the official documentation also on how to use enumerations.
Summary
In this article, you learn how to create a class and instances of classes. You also learned about class and instance variables and methods. You learned about single and multiple inheritance and how Python searches for methods (and variables). Finally, you saw how to create a data class.
Thank you for reading!
Top comments (0)