Intermediate

Classes and Object-oriented Programming in Python 3

You may have heard that everything is an object in Python — in this course, you'll learn why this statement is true.

Table of Contents

  1. Course Overview
  2. Everything is an object
  1. Instantiate custom classes
  1. Manage access to attributes
  1. Implement class inheritance
  1. Access class attributes and methods
  1. Use data classes

1. Course Overview

Welcome to this course on classes and object-oriented programming in Python. You may have heard that everything is an object in Python — in this course, you’ll learn why this statement is true.

This course covers object-oriented programming (OOP) in Python and explains how you can use this paradigm to make your projects modular and maintainable by distributing code into small logical units.

Main topics covered

  • Instantiating objects
  • Implementation of properties
  • Class inheritance
  • Class attributes (class attributes)
  • Data classes (data classes)

Prerequisites

Before starting the course, you should already be familiar with the fundamentals of the Python language.


2. Everything is an object

Total module duration: 27m 11s

2.1 Introduction and prerequisites

This course was created with a specific version of Python, and the information applies to the compatible versions listed in the overview.

Technical prerequisites

Before you begin, you should be familiar with:

  • Python language basics (variables, built-in types like int and list)
  • How to define and call functions
  • How to work with conditionals and simple loops
  • Modules and the import instruction
  • How to create and run simple Python scripts from files
  • How to use Python interactively

If you don’t have these basics, check out other courses on Pluralsight, like the Python Fundamentals course.

Target audience

This course is aimed at Python developers who already have the basics of the language and who want to learn how to use object-oriented programming to structure their code professionally.


2.2 Working with complex data

Once we move beyond simple “Hello World” type programs and begin serious projects, we inevitably face the problem of grouping data into small logical units.

Example: employee management

Let’s say you’re building an application for employees of a software development company. The company has information about its employees (Ji-Soo and Lauren) and needs a program to better manage them.

Naive approach — separate variables:

# employee1_name = "Ji-Soo"
# employee1_age = 38
# employee1_position = "developer"
# employee1_salary = 1200

# employee2_name = "Lauren"
# employee2_age = 44
# employee2_position = "tester"
# employee2_salary = 1000

Problem: Not maintainable — would have to manually code new variables each time a new person is hired.

Best approach — lists:

# employee1 = ["Ji-Soo", 38, "developer", 1200]
# employee2 = ["Lauren", 44, "tester", 1000]

Now we only need one variable per employee, and we can group them in another list.

Even better — dictionaries:

employee1 = {
    "name": "Ji-Soo",
    "age": 38,
    "position": "developer",
    "salary": 1200
}
employee2 = {
    "name": "Lauren",
    "age": 44,
    "position": "tester",
    "salary": 1000
}

Dictionaries allow data to be accessed by key name, which is more readable. We can also create an initialization function:

def init_employee(name, age, position, salary):
    return {
        "name": name,
        "age": age,
        "position": position,
        "salary": salary
    }

employee3 = init_employee("Mateo", 38, "developer", 200)

And functions that operate on these dictionaries:

def increase_salary(employee, percent):
    employee['salary'] += employee['salary'] * (percent/100)

def employee_info(employee):
    print(f"{employee['name']} is {employee['age']} years old. "
          f"Employee is a {employee['position']} with the salary of ${employee['salary']}")

employees = [employee1, employee2]
increase_salary(employee2, 20)
for e in employees:
    employee_info(e)

Problem with this approach:

  • Functions and data are separate — they have no explicit logical connection in the code.
  • If someone accidentally passes the wrong data type to increase_salary, it will cause errors.
  • The solution: group data AND related functions into a single logical unit → that’s the purpose of classes and objects.

2.3 Design classes

We will talk about class implementation in the next module. In this lesson, the goal is to focus on the outcome — what we want to achieve in the application code once the class is implemented.

The two main concepts of OOP: objects and classes

What is a class?

The concept of class comes from older object-oriented programming languages ​​like Java or C++. It is usually introduced as a blueprint (blueprint) for creating objects. In Python this is also true, but the implementation differs slightly.

Rather than thinking of a blueprint, imagine that a class is an object factory — a factory that creates these objects.

Example:

Continuing the previous example, we want to have an Employee class. This Employee class is responsible for creating employee objects. It must define which attributes will be present in these new objects: name, age, position, and salary.

# Utilisation de la classe (avant l'implémentation)
employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)

By calling the class as a function and passing all the attributes, we create a new employee object in memory with these values. The factory (Employee) defines what attributes the new objects will have, but the values are specific to each instance.

Classes also provide functionality

Classes not only store data, but also provide functions related to this data. For example :

employee1.increase_salary(20)
employee_info = employee1.info()

The function will be aware of which object called it, so we do not have to pass the object itself as a parameter.

The __class__ attribute

We can use the class attribute __class__ to check which class was responsible for creating the object in question.


2.4 Python and objects

You don’t have to define your own classes in Python, but did you know that Python is an object-oriented language by design? In fact, every value you use in Python is an object created from a class.

Demonstration in the REPL

>>> s = "hello"
>>> i = 2
>>> l = [1, 2, 3]
>>> d = {"a": 2}

>>> s.__class__
<class 'str'>
>>> i.__class__
<class 'int'>
>>> l.__class__
<class 'list'>
>>> d.__class__
<class 'dict'>

Each declaration returns a specific class name, which means that each value is actually an object. This is why we can say with certainty that everything is an object in Python.

Create explicitly with built-in classes

>>> s = str("hello")
>>> i = int(2)

These declarations create two objects in memory. The difference with our Employee objects is that these objects do not need multiple attributes — their state is defined by a single value. Python offers the convenience of avoiding explicitly typing these classes.

Objects have methods

>>> s.upper()
'HELLO'
>>> i.upper()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'int' object has no attribute 'upper'

>>> l.append(4)
>>> l
[1, 2, 3, 4]

Why did Python decide that these simple values ​​need their own class? Because one of the main characteristics of classes is that they can also provide functionality to objects.

Functions and None are also objects

>>> def fun():
...     print("hi")
...
>>> n = None

>>> fun.__class__
<class 'function'>

>>> n.__class__
<class 'NoneType'>

2.5 Should you use OOP?

Now that you know that everything is an object in Python and what classes actually are, the question is: should you use this in your own projects?

The answer: it depends.

Python supports several programming paradigms, all suitable for different types of programs:

  • For small, simple scripts, you will probably make them more complicated by introducing classes.
  • If you use Python for things like machine learning, you’ll probably use a combination of imperative and functional programming.

However, OOP is a great way to divide your large projects into small logical units. This approach has been tested for decades, notably in languages ​​like C++, Java, C# or Ruby.

Concrete example: dog and class

A specific dog named Lassie can be considered a real object, while the general abstraction of what a dog is is a class. So :

  • The Dog class would define what attributes each dog has: color, age, breed…
  • Each individual dog will have specific values for these attributes.
  • The Dog class would also contain functionalities for each dog: walking, barking, etc.

Classes and libraries

Classes are not just modeled around real-world objects. Anytime you check to see if someone has already created a library to make your work easier, you’re using classes. For example, a GUI library already contains a lot of functionality for creating desktop applications, and this library is implemented with classes.


2.6 Summary

In this module, we introduced the concept of object-oriented programming.

  • As your projects grow, code complexity increases.
  • Each specific employee has unique values, but they all have the same attributes and need the same functionality.
  • The code containing this data and functions can be grouped in one place so as not to interfere with the rest of the application code.
  • Python offers a built-in way to do this with classes and objects.
  • Classes are like factories for creating objects. They define the attributes that each new object will have and initialize them with unique values ​​for each object.
  • These attributes are accessed via dot notation.
  • Classes also provide related functionality. Via the dot notation, we can call these functions from each object.
  • Using the __class__ attribute on fundamental data types, one can see that even these elementary values ​​are objects created from specific built-in classes.
  • Everything in Python is an object → Python is an object-oriented language.
  • By defining your own classes, you can reduce complexity by dividing code into small logical units, making it reusable and maintainable.

3. Instantiate custom classes

Total module duration: 30m 16s

3.1 Objects are dictionaries

In this module, we will implement a class and use it to create objects.

Define empty class

To define a class, we use the keyword class followed by the name of the class, then a colon to start the code block:

class Employee:
    pass

e = Employee()
print(e)
# <__main__.Employee object at 0x...>

Simply calling an empty class allocates space for a new object in memory. In essence, objects in Python are dictionaries (with a few extra features).

The internal dictionary

Each object has an internal dictionary accessible via __dict__:

e = Employee()
print(e.__dict__)
# {}

Set __init__ to initialize attributes

The __init__ method is the method that initializes the attributes of each new object. We can use self.__dict__ directly or — more idiomatically — the dot notation:

# Approche directe via __dict__ (déconseillée)
class Employee:
    def __init__(self):
        self.__dict__["name"] = "Ji-Soo"
        self.__dict__["age"] = 38
        self.__dict__["position"] = "developer"
        self.__dict__["salary"] = 1200
# Approche recommandée via notation pointée
class Employee:
    def __init__(self):
        self.name = "Ji-Soo"
        self.age = 38
        self.position = "developer"
        self.salary = 1200

e = Employee()
print(e.__class__)
# <class '__main__.Employee'>

The dot notation is simpler, more readable, and more Pythonic.


3.2 Classes and instances

The purpose of having multiple objects is to have the same data structure with different values.

Passing values ​​to __init__

To provide initial values ​​to the new object, we can pass them as arguments when calling the class:

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.salary = salary

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)
print(employee1.name)  # Ji-Soo
print(employee2.name)  # Lauren

Make sure you don’t forget self as the first parameter. The number of arguments must match the number of parameters, just like in a regular function. The new object is always provided implicitly first.

Important Terminology

  • These two employees can be called objects, but it would be more accurate to say that they are Employee instances.
  • The process of using a class to create a new object is called instantiation.
  • We use the Employee class to instantiate a new employee instance.

Two important methods called during instantiation

When you try to create a new instance, two important methods are called by the background class:

  1. __new__: creates a new object in memory and passes it to __init__.
  2. __init__: initializes the attributes of this object.

If we don’t define __init__ in our class, a new instance will just be empty.


3.3 Passing self to instance methods

Now that we have implemented the data attributes, let’s also provide functionality to the instances.

Add methods to class

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.salary = salary

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def info(self):
        print(f"{self.name} is {self.age} years old. "
              f"Employee is a {self.position} with the salary of ${self.salary}")

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)
employee2.increase_salary(20)
employee2.info()

Functions defined inside a class are known as instance methods because they are used to work with instances of that class. They are also simply called methods.

How self works

When you call a method from an instance, that same instance is automatically provided as the first argument of the method. So self will always be the instance that actually called the method.

# Ces deux appels sont équivalents :
employee2.increase_salary(20)
Employee.increase_salary(employee2, 20)

3.4 Convert instances to strings

When directly printing an instance:

print(employee1)
# <__main__.Employee object at 0x...>

This tells us that we are working with an employee instance, but not much else. It’s not very descriptive.

The __str__ method

When providing an object to the print function, Python looks for the special __str__ method inside the class that created this object. If not set, the default output is used.

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.salary = salary

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)
print(str(employee1))
# Ji-Soo is 38 years old. Employee is a developer with the salary of $1200

The same __str__ method will be triggered if we pass the object into the global str() function. This method should return a string (not the printer).


3.5 Modify instance representation

The __str__ method vs __repr__

  • __str__: Returns a readable representation of the object, usually for the end user or for debugging.
  • __repr__: returns the official representation of the object — a more formal representation intended for developers.

The ideal for __repr__: the returned string should allow recreation of the same exact object.

Example with datetime

>>> import datetime
>>> dt = datetime.date(2022, 11, 6)

>>> str(dt)
'2022-11-06'

>>> repr(dt)
'datetime.date(2022, 11, 6)'

>>> dt2 = eval(repr(dt))
>>> print(dt2)
2022-11-06

str(dt) is in human readable format. repr(dt) is formal and can be used to recreate the object with eval().

Implementation in the Employee class

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.salary = salary

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

    def __repr__(self):
        return (
            f"Employee("
            f"{repr(self.name)}, {repr(self.age)}, "
            f"{repr(self.position)}, {repr(self.salary)})"
        )

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)
# print(str(employee1))
print(eval(repr(employee1)))

Using eval(repr(employee1)) should recreate the same object.


3.6 Overview of dunder special methods

Why the double underscores?

The main reason is to avoid name collisions. Imagine you define a class that adds a dict attribute to an instance — this is not uncommon. The name dict would represent a certain dictionary. By surrounding special attributes with double underscores, the creators of the language avoided confusion. This is why you should never do this with your own user-defined attributes.

These methods are also known as:

  • special methods (special methods)
  • magic methods (magic methods)
  • dunder methods (dunder methods) — dunder means “double underscore”

Characteristics of dunder methods

These methods aren’t just special because of their name — they also offer special functionality. They are intended to be called indirectly via another specific piece of code. These dunder methods typically implement an interface for some common interaction with an object.

Example: operators

This is how Python handles operators. If you try to add two Employee instances, what do you think will happen?

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)
# employee3 = employee1 + employee2  # TypeError !

The program would not know what to do. But with __add__, we can define this behavior:

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.salary = salary

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

    def __repr__(self):
        return (
            f"Employee("
            f"{repr(self.name)}, {repr(self.age)}, "
            f"{repr(self.position)}, {repr(self.salary)})"
        )

    def __add__(self, other_employee):
        # Exemple : combine leurs âges et retourne un nouvel Employee
        return Employee("New", self.age + other_employee.age, "dev", 2000)

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)
# employee3 = employee1.__add__(employee2)
employee3 = employee1 + employee2

When you use + on two instances, Python implicitly calls __add__. Likewise, when you use print() on an object, Python calls __str__.


3.7 Summary

In this module we talked about the relationship between classes and instances.

  • We define a class with the keyword class followed by the name of the class.
  • Even an empty class can be used to create a new object (an instance). The process is called instantiation.
  • When creating a new instance, two important methods are called: __new__ (creates the object in memory) and __init__ (initializes the attributes).
  • Each instance has its own internal dictionary accessible via __dict__.
  • To automatically fill this dictionary upon instantiation, we define attributes in the __init__ method.
  • The new instance is implicitly passed as the first parameter of __init__. The convention is to call it self.
  • Internal dictionary attribute keys are accessed simply via dot notation.
  • Functions defined inside a class are called methods. Just define them in the class with self as the first parameter.
  • When calling the method from an instance, this instance is automatically provided as the first argument.
  • We can define __str__ to control the output of print() on an instance.
  • We can define __repr__ for a more formal representation, ideally allowing the object to be recreated.
  • The dunder (double underscore) methods allow you to override built-in behaviors such as operators (__add__, __eq__, etc.).

4. Manage access to attributes

Total module duration: 28m 47s

4.1 Validate attribute values

Now that we know how to access attributes via dot notation, let’s see how to implement validation. Let’s assume that the minimum wage in our hypothetical scenario is $1,000. How to implement this?

Naive approach: validation in the application code

user_input = int(input("Input salary: "))
if user_input < 1000:
    raise ValueError('Minimum wage is $1000')
else:
    employee1.salary = user_input

Problem: Validation of an Employee instance is done in the application code. The purpose of OOP is to contain logic related to a specific object in the appropriate class. If we have to check this several times in the code, these additional lines will pollute the application code.

Best approach: getter and setter methods

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.set_salary(salary)

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

    def __repr__(self):
        return (
            f"Employee("
            f"{repr(self.name)}, {repr(self.age)}, "
            f"{repr(self.position)}, {repr(self.salary)})"
        )

    def get_salary(self):
        return self.salary

    def set_salary(self, salary):
        if salary < 1000:
            raise ValueError('Minimum wage is $1000')
        self.salary = salary

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)

employee1.set_salary(2000)
print(employee1.get_salary())

Now the validation is in the class. But this approach introduces its own problems.


4.2 Encapsulation and name mangling

Implementing the setter method seems to solve our validation problem, but there are still many problems:

  • The developer could still accidentally access the salary attribute directly.
  • Not all developers read documentation before using imported classes.
  • The existence of getter/setter methods for salary is not obvious if the other attributes are accessible directly.

The four pillars of OOP

There are four pillars of OOP. In this module, we cover the first two:

1. Abstraction

Abstraction means showing only basic information and hiding implementation details. The user of our Employee class should only know the interface (what attributes and methods are available). He doesn’t need to know the implementation details — that’s the responsibility of the class creator.

2. Encapsulation

The encapsulation states two main purposes:

  1. The class must be able to create instances that encapsulate (group) related data and methods into a single logical unit.
  2. The class must restrict direct access to its internal data.

The underscore prefix _

The convention in Python for marking an attribute as non-public is to prefix it with an underscore:

def get_salary(self):
    return self._salary

def set_salary(self, salary):
    if salary < 1000:
        raise ValueError('Minimum wage is $1000')
    self._salary = salary

This is not an actual restriction — it is an agreement between developers. Python does not have access modifiers like other object-oriented languages. The underscore indicates that this attribute is considered private and should not be accessed directly.

Name mangling with double underscore __

If you prefix an attribute with two underscores (__), Python renames the attribute internally to avoid collisions in subclasses:

self.__salary = salary
# Devient en interne : self._Employee__salary

To access an attribute with double underscore from outside:

print(employee1._Employee__salary)

Summary: The simple underscore _ means “non-public by convention”. The double underscore __ triggers name mangling and makes external access more difficult.

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.set_salary(salary)

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

    def __repr__(self):
        return (
            f"Employee("
            f"{repr(self.name)}, {repr(self.age)}, "
            f"{repr(self.position)}, {repr(self.salary)})"
        )

    def get_salary(self):
        return self._salary

    def set_salary(self, salary):
        if salary < 1000:
            raise ValueError('Minimum wage is $1000')
        self._salary = salary

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)

employee1.set_salary(2000)
print(employee1.get_salary())
# print(employee1._Employee__salary)  # avec double underscore

4.3 Access attributes via properties

Many developers actually view the idea of ​​getters and setters as an anti-pattern in object-oriented design. Here’s why:

  1. If we add getters/setters later, the class will not be backwards compatible with the existing application code.
  2. The code that directly accessed salary will now need to be modified to use getters/setters.
  3. Even inside the class, all references to salary should be updated.

The Python solution: the properties

Properties allow you to manage attributes in a more intuitive and backwards compatible way.

Property syntax

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.set_salary(salary)

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

    def __repr__(self):
        return (
            f"Employee("
            f"{repr(self.name)}, {repr(self.age)}, "
            f"{repr(self.position)}, {repr(self.salary)})"
        )

    @property
    def salary(self):
        return self._salary

    def set_salary(self, salary):
        if salary < 1000:
            raise ValueError('Minimum wage is $1000')
        self._salary = salary

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)

employee1.set_salary(2000)
print(employee1.salary)

The @property decorator allows this method to be called without parentheses, like an ordinary data attribute. The underlying attribute must be named with an underscore.

Note: Properties are implemented with something known as a class decorator which uses the descriptor protocol. You don’t need to know the details — you just need to know the syntax.


4.4 Set property values

The preceding property only defines the behavior of the getter. For the property to also support the setter, it must be defined explicitly:

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.salary = salary

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

    def __repr__(self):
        return (
            f"Employee("
            f"{repr(self.name)}, {repr(self.age)}, "
            f"{repr(self.position)}, {repr(self.salary)})"
        )

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, salary):
        if salary < 1000:
            raise ValueError('Minimum wage is $1000')
        self._salary = salary

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)

employee1.salary = 2000
print(employee1.salary)

The setter decorator is @<property_name>.setter. The function name must match the property name.

Now, self.salary = salary in __init__ automatically calls the setter, so we get validation and abstraction without specific code.

Properties read-only and write-only

Read-only: Raise an error in the setter or do not define a setter:

@property
def salary(self):
    return self._salary

@salary.setter
def salary(self, salary):
    raise AttributeError("Salary is read-only")

Write-only: Raise an error in the getter:

@property
def password(self):
    raise AttributeError("Password is write-only")

@password.setter
def password(self, password):
    # hash password here
    self._password = password

4.5 Using computed properties

Another use case for properties: computed properties.

Example: annual salary

class Employee:
    def __init__(self, name, age, position, salary):
        self.name = name
        self.age = age
        self.position = position
        self.salary = salary
        self._annual_salary = None

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def __str__(self):
        return f"{self.name} is {self.age} years old. " \
               f"Employee is a {self.position} with the salary of ${self.salary}"

    def __repr__(self):
        return (
            f"Employee("
            f"{repr(self.name)}, {repr(self.age)}, "
            f"{repr(self.position)}, {repr(self.salary)})"
        )

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, salary):
        if salary < 1000:
            raise ValueError('Minimum wage is $1000')
        self._annual_salary = None  # Invalide le cache
        self._salary = salary

    @property
    def annual_salary(self):
        if self._annual_salary is None:
            self._annual_salary = self.salary * 12
        return self._annual_salary

employee1 = Employee("Ji-Soo", 38, "developer", 1200)
employee2 = Employee("Lauren", 44, "tester", 1000)

print(employee1.annual_salary)  # 14400
employee1.salary = 1000
print(employee1.annual_salary)  # 12000

This property is known as a computed property because it returns the result of a calculation that usually includes another attribute of the same instance.

Additional advantage (lazy evaluation): The annual salary is only calculated when accessed. We invalidate the cache (_annual_salary = None) when the salary changes.


4.6 Summary

In this module, we talked about managing attributes with properties.

  • To validate a new attribute value before assigning it, OOP inheritance resolves this issue with the getter and setter methods.
  • These methods act as intermediaries between the application code and the actual instance attribute.
  • However, classic getters/setters introduce their own backwards compatibility issues.
  • Python offers a better solution: properties.
  • Instead of defining a getter method, we can use the @property decorator to create a method that corresponds to the name of the attribute to manage.
  • This decorator allows this method to be called without parentheses, like a regular data attribute.
  • The underlying attribute should be named with an underscore _ (non-public convention).
  • This is how Python implements encapsulation and data hiding.
  • Python does not have access modifiers like other object-oriented languages. The concept of private attributes is simply implemented by a common practice between developers.
  • If we want to ensure that someone does not access the internal attribute, we can use the double underscore __ to trigger name mangling.
  • The @<property>.setter decorator allows you to define a setter for the property.
  • A computed property is a property that returns a result calculated from other attributes of the instance.

5. Implement class inheritance

Total module duration: 32m 2s

5.1 Introduction to class inheritance

In this module, we cover the two remaining pillars of OOP: inheritance and polymorphism.

The DRY principle

The key to writing maintainable code is to keep it as DRY as possible. DRY stands for Don’t Repeat Yourself.

But sometimes we need similar classes containing many of the same attributes and methods. For example, we also want to have a Client class in the application. The Client class shares a lot of data and functionality with the Employee class:

  • Employee: name, age, position, salary, project (working), project_update()
  • Client: name, age, project (requested project), project_update()

It would be great to be able to extract these common parts and inject them into each of these two classes. This would keep the code DRY.

The solution: class inheritance

We can extract the shared data into another class representing a more general entity. For example, a Person class:

Person (classe parent / superclasse)
├── Employee (sous-classe)
└── Client (sous-classe)

Note: This could also be solved with composition, which is an alternative to inheritance. But this course focuses on OOP concepts, so we will use inheritance.


5.2 Overriding parent class instance methods

Let’s see how inheritance works in Python with a concrete example.

Define subclass

To inherit data from another class, simply put the superclass name in parentheses next to the class name:

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

class Tester(Employee):
    def run_tests(self):
        print(f"Testing is started by {self.name}...")
        print("Tests are done.")

class Developer(Employee):
    def increase_salary(self, percent, bonus=0):
        self.salary += self.salary * (percent/100)
        self.salary += bonus

employee1 = Tester("Lauren", 44, 1000)
employee2 = Developer("Ji-Soo", 38, 1000)

employee1.increase_salary(20)
employee2.increase_salary(20, 30)
print(employee1.salary)
print(employee2.salary)
# employee1.run_tests()
  • Tester inherits from Employee but has its own run_tests method.
  • Developer override the increase_salary method with a different signature (additional bonus parameter).

Method search order

The instance of Employee first looks for the method in its own class (Tester). If she doesn’t find it, she goes back in the inheritance tree to Employee. This is the Method Resolution Order (MRO).


5.3 Inspect relationships between classes

Implicit inheritance from object

Even if we do not define explicit inheritance, each class in Python implicitly inherits from the built-in object class:

class Employee(object):
    pass

e = Employee()
print(repr(e))
# <__main__.Employee object at 0x...>

In Python 3, “new style classes” are the only ones available — implicit inheritance of object is automatic.

The object class contains default dunder methods like __init__ and __repr__. When we define these methods in our own class, we override the method of the object class.

isinstance and issubclass

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

class Tester(Employee):
    def run_tests(self):
        print(f"Testing is started by {self.name}...")
        print("Tests are done.")

class Developer(Employee):
    def increase_salary(self, percent, bonus=0):
        self.salary += self.salary * (percent/100)
        self.salary += bonus

employee1 = Tester("Lauren", 44, 1000)
employee2 = Developer("Ji-Soo", 38, 1000)

print(isinstance(employee1, Tester))    # True
print(isinstance(employee1, Employee))  # True — polymorphisme !

print(issubclass(Developer, Employee))  # True
print(issubclass(Employee, object))     # True
print(issubclass(Developer, object))    # True

try:
    raise FloatingPointError("Watch out, a floating point error!")
except ArithmeticError as e:
    print(e)

isinstance(employee1, Employee) returns True even if employee1 is an instance of Tester, because Tester is a subclass of Employee. This is an example of polymorphism — we can use this Tester instance in a situation where the code would expect an Employee instance.

Polymorphism in practice

Let’s say we have a list of Employee instances and we want to do something with them all in a for loop. It doesn’t matter if these instances are made of Developer or Tester. As long as we only use the Employee methods, everything will work.


5.4 Extend parent class methods with super

If the original Employee method has a long definition with a lot of complex calculations, one should copy the same code and paste it into the overloaded method. Repetition makes code harder to maintain.

Solution: super()

super() allows access to the parent class method from the subclass:

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

class Tester(Employee):
    def run_tests(self):
        print(f"Testing is started by {self.name}...")
        print("Tests are done.")

class Developer(Employee):
    def increase_salary(self, percent, bonus=0):
        super().increase_salary(percent)
        # Employee.increase_salary(self, percent)  # Equivalent, mais moins dynamique
        self.salary += bonus

employee1 = Tester("Lauren", 44, 1000)
employee2 = Developer("Ji-Soo", 38, 1000)

employee2.increase_salary(20, 30)
print(employee2.salary)

super() automatically passes the self instance to the function call.

Why not use self directly?

# Ceci causerait une récursion infinie !
def increase_salary(self, percent, bonus=0):
    self.increase_salary(percent)  # Appelle la même méthode !
    self.salary += bonus

self will always be an instance of Developer, so self.increase_salary(percent) would call the same Developer.increase_salary method, creating infinite recursion.

Why use super() rather than the direct class name?

super() makes the method call dynamic. It uses the Method Resolution Order (MRO), which we will talk about in the next lesson.


5.5 Add new attributes to subclass instances

To add a new attribute in instances of a subclass, we simply override the __init__ method and use super() to call __init__ of the parent class:

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

class Tester(Employee):
    def run_tests(self):
        print(f"Testing is started by {self.name}...")
        print("Tests are done.")

class Developer(Employee):
    def __init__(self, name, age, salary, framework):
        super().__init__(name, age, salary)
        self.framework = framework

    def increase_salary(self, percent, bonus=0):
        super().increase_salary(percent)
        self.salary += bonus

employee1 = Tester("Lauren", 44, 1000)
employee2 = Developer("Ji-Soo", 38, 1000, "Flask")

print(employee2.name)       # Ji-Soo
print(employee2.framework)  # Flask

We use super().__init__(name, age, salary) to avoid repeating the assignment of inherited attributes. This lesson concludes the implementation of the class hierarchy.


5.6 Optimize memory with slots

Instances store their attribute values ​​in an internal dictionary (__dict__). This implementation introduces some memory overhead, especially when instantiating a large number of instances.

Each instance must store its own dictionary with the same attributes assigned in __init__. The values ​​will be different, but the dictionary structure will be the same.

Slot activation

To reduce the amount of memory allocated for each instance, you can use __slots__:

class Developer:
    __slots__ = ("name", "age", "salary", "framework")

    def __init__(self, name, age, salary, framework):
        self.name = name
        self.age = age
        self.salary = salary
        self.framework = framework

employee1 = Developer("Ji-Soo", 38, 1000, "Flask")

print(employee1.__slots__)
# ('name', 'age', 'salary', 'framework')
# print(employee1.__dict__)  # AttributeError : pas de __dict__ avec __slots__

By declaring __slots__, we ask the class to use slots instead of a dictionary to store attributes in new instances. We must specify all the attributes we plan to use.

Slots with inheritance

With inheritance, each class in the hierarchy can declare its own slots:

class Employee:
    __slots__ = ("name", "age", "salary")

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

class Tester(Employee):
    def run_tests(self):
        print(f"Testing is started by {self.name}...")
        print("Tests are done.")

class Developer(Employee):
    __slots__ = ("framework", )

    def __init__(self, name, age, salary, framework):
        super().__init__(name, age, salary)
        self.framework = framework

    def increase_salary(self, percent, bonus=0):
        super().increase_salary(percent)
        self.salary += bonus

employee1 = Tester("Lauren", 44, 1000)
employee2 = Developer("Ji-Soo", 38, 1000, "Flask")

When to use slots?

  • When instantiating a very large number of instances (significant memory saving).
  • Slots replace the internal dictionary with a more compact mechanism.
  • Disadvantage: we can no longer dynamically add attributes not declared in __slots__.

5.7 Multiple inheritance and Method Resolution Order

Python implements a slightly controversial feature in its class system: multiple inheritance.

Simple inheritance means that a class inherits functionality from a single class. Multiple inheritance means that a child class inherits from more than one parent class. This introduces a lot of unnecessary complexity, and there are very few cases where one really thinks, “I really need multiple inheritance to solve this problem.”

Mixin classes

The most common use case for multiple inheritance is mixin classes. Sometimes we need to add some behavior to many unrelated classes. Instead of redefining the same functionality in all of them, we can create a special mixin class that will contain all of these methods.

class Employee:
    __slots__ = ("name", "age", "salary")

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    def has_slots(self):
        return hasattr(self, "__slots__")

class SlotsInspectorMixin:
    __slots__ = ()

    def has_slots(self):
        return hasattr(self, "__slots__")

class Developer(SlotsInspectorMixin, Employee):
    __slots__ = ("framework", )

    def __init__(self, name, age, salary, framework):
        super().__init__(name, age, salary)
        self.framework = framework

    def increase_salary(self, percent, bonus=0):
        super().increase_salary(percent)
        self.salary += bonus

employee2 = Developer("Ji-Soo", 38, 1000, "Flask")
print(employee2.__dict__)
# print(employee2.has_slots())
# print(Developer.__mro__)

Method Resolution Order (MRO)

When Developer inherits from both SlotsInspectorMixin and Employee, and both define a has_slots method, which one is called?

This is where MRO comes in. Python uses the C3 linearization algorithm to determine the order in which parent classes are consulted when searching for a method.

To see the MRO of a class:

print(Developer.__mro__)
# (<class 'Developer'>, <class 'SlotsInspectorMixin'>, <class 'Employee'>, <class 'object'>)

The order of declaration in the class definition (SlotsInspectorMixin, Employee) determines the priority. SlotsInspectorMixin.has_slots will be called before Employee.has_slots.


5.8 Summary

In this module we talked about class inheritance.

  • A class can inherit methods from another class.
  • Developer is a subclass and Employee is a superclass.
  • We inspect relationships with isinstance() and issubclass().
  • All classes implicitly inherit from the built-in object class, which contains all default behaviors.
  • When calling a method from a subclass instance, the instance first searches its own class. If she doesn’t find it, she goes back up the inheritance tree.
  • We can override the methods of the superclass by defining a method with the same name in the subclass.
  • method overriding is an aspect of polymorphism in OOP.
  • If the overloaded method must include code from the superclass, we can use super().
  • super() allows you to call methods of parent classes dynamically (via MRO).
  • We can also use super().__init__() to avoid repeating the assignment of inherited attributes in __init__ of subclasses.
  • Instances use internal dictionaries to store attribute values. For many instances, these dictionaries can take up a lot of memory. To remedy this, we can declare __slots__.
  • Multiple inheritance is possible in Python, but controversial. mixin classes are the most common use case.
  • The MRO determines the order in which parent classes are consulted when resolving methods.

6. Access class attributes and methods

Total module duration: 13m 41s

6.1 Classes are also objects

Until now, this course has focused on managing class instances. In this short module, we focus on the class itself.

Functions and classes are objects in memory

In most other programming languages ​​(like C++), functions and classes are just code. Functions compile directly into a set of instructions. A class is also just code — unless you use it to instantiate a new object, nothing will be stored in memory.

This is not the case in Python. Every time Python sees a function or class definition, a new object will be allocated in memory.

s = "hello"

def fun():
    pass

class Employee:
    pass
  • s is an identifier pointing to a string object in memory.
  • fun is an identifier pointing to a function object in memory — instance of the function class.
  • Employee is an identifier pointing to a class object in memory — instance of the type class.

The internal dictionary of a class

Since a class is an object, it must have its own internal dictionary. Let’s check:

class Employee:
    minimum_wage = 1000

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, salary):
        if salary < Employee.minimum_wage:
            raise ValueError('Minimum wage is ${Employee.minimum_wage}')
        self._salary = salary

e = Employee("Ji-Soo", 38, 1000)
print(e.minimum_wage)
print(Employee.minimum_wage)

# print(Employee.__dict__)
# Employee.__dict__["increase_salary"](e, 20)
# print(e.salary)

What Employee.__dict__ returns is a mappingproxy instance (not an ordinary dict), but we can see that all instance methods are just attributes of the class object. The method itself is an attribute pointing to an object of the function class.

This is why it makes sense to define them in the class rather than in each instance.

Class attributes

Methods are not the only attributes that can be defined in the class object. You can also define class attributes (class attributes).

A class attribute stores a value that is constant for all instances. In our example, it is minimum_wage. We can access this attribute directly from the class or from any instance:

print(Employee.minimum_wage)  # 1000
print(e.minimum_wage)         # 1000 (via la chaîne de recherche d'attributs)

Important: If an instance has an attribute of the same name, this attribute will be accessed instead of the class attribute.


6.2 Defining class methods

When we define a method inside a class, we usually speak of an instance method. All methods of a class implicitly receive an instance as their first parameter.

But what if we need a method that is within class scope, but doesn’t necessarily need to work with instances of that class? This is why we use the @classmethod decorator.

Example: method for changing the minimum wage

from datetime import date

class Employee:
    minimum_wage = 1000

    @classmethod
    def change_minimum_wage(cls, new_wage):
        if new_wage > 3000:
            raise ValueError("Company is bankrupt.")
        cls.minimum_wage = new_wage

    @classmethod
    def new_employee(cls, name, dob):
        now = date.today()
        age = now.year - dob.year - ((now.month, now.day) < (dob.month, dob.day))
        return cls(name, age, cls.minimum_wage)

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

    def increase_salary(self, percent):
        self.salary += self.salary * (percent/100)

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, salary):
        if salary < Employee.minimum_wage:
            raise ValueError('Minimum wage is ${Employee.minimum_wage}')
        self._salary = salary

# print(Employee.minimum_wage)
# Employee.change_minimum_wage(200)
# print(Employee.minimum_wage)

e = Employee.new_employee("Mary", date(1991, 8, 12))
print(e.name)    # Mary
print(e.age)     # (âge calculé)
print(e.salary)  # 1000

Particularities of class methods

  • The first parameter of a class method is the class itself, which is conventionally called cls (short for class).
  • Instead of self (instance), we receive cls (class).
  • Using cls rather than the direct class name to make the code dynamic — if someone changes the class name, the method will be unaffected.

Factory functions

Another common use case for class methods is factory functions. Factory functions return an instance of the class to which they belong, but they generally prepopulate that instance with pre-processed data.

In the example above, new_employee is a factory function that creates a new employee based on a name and date of birth. It automatically calculates age and uses minimum wage by default.


6.3 Summary

In this module we looked at the class itself in more depth.

  • When you define a class, Python stores it in memory, just like any other object. A class is actually an object.
  • Since the class is an object, it stores its attributes in a sort of internal dictionary (actually a mappingproxy).
  • All instance methods are just attributes of the class object, pointing to objects of type function.
  • This is why it makes sense to define them in the class rather than in each instance.
  • You can also define specific class attributes. Their purpose is to store constant values ​​for all instances.
  • These attributes can be accessed directly from the class. Thanks to the attribute search string, instances of this class can also access it.
  • If an instance has an attribute with the same name, this attribute will be accessed instead of the class attribute.
  • To create a method that changes the value of a class attribute, we use the @classmethod decorator.
  • Class methods implicitly receive the class itself as the first argument (cls).
  • Another purpose of class methods: factory functions, which return an instance of the class by prepopulating the data.

7. Use data classes

Total module duration: 12m 53s

7.1 Introduction to data classes

In this last module, we talk about a relatively new functionality in Python: data classes.

Example: classic Project class vs dataclass

Classic approach:

from dataclasses import dataclass

# Classe classique :
# class Project:
#     def __init__(self, name, payment, client):
#         self.name = name
#         self.payment = payment
#         self.client = client
#
#     def __repr__(self):
#         return f"Project(name={repr(self.name)}, payment={repr(self.payment)}, client={repr(self.client)})"

# Avec @dataclass :
@dataclass
class Project:
    name: str
    payment: int
    client: str

class Employee:
    def __init__(self, name, age, salary, project):
        self.name = name
        self.age = age
        self.salary = salary
        self.project = project

p = Project("Django app", 20000, "Globomantics")
e = Employee("Ji-Soo", 38, 1000, p)
print(e.project)
# Project(name='Django app', payment=20000, client='Globomantics')

The @dataclass decorator automatically transforms a normal class into a dataclass. It implicitly generates:

  • The __init__ method with all declared attributes
  • Dunder methods like __repr__

Composition vs inheritance

In this example, we use an instance of one of our custom classes (Project) as an attribute of another class (Employee). This relationship between classes is known as composition. Many programmers use this approach rather than inheritance. Composition is often summarized as: “favor composition over inheritance”.


7.2 Type hinting of instance attributes

When defining attributes for instances of a dataclass, we must also provide type hints for each attribute. These are indications of the type of value expected for each attribute.

Python is a dynamically typed language — and will remain so — but you can use additional tools to make the code more static.

Use Any to avoid specific type hints

from dataclasses import dataclass
from typing import Any

@dataclass
class Project:
    name: Any
    payment: Any
    client: Any

Although it is always a good idea to specify data types, Any can be used when one does not want to specify the type of each attribute.

Python does not check types at runtime

@dataclass
class Project:
    name: str
    payment: int
    client: str

# p = Project("Django app", "2000", "Globomantics")  # Pas d'erreur !
p = Project("Django app", 20000, "Globomantics")

If we try to assign a string to the payment attribute (which should be an int), Python will not throw an error. You have to use additional tools like mypy to check types.

Type checking with mypy

pip install mypy
mypy my_script.py

mypy will produce an error if you try to assign a string to an attribute that expects an integer. After correcting all errors provided by mypy, you can be sure that your code is consistent with your vision.


7.3 Implement slots and methods

Despite their name, dataclasses do not just contain data. You can also define methods there, just like in any normal class — dataclasses are classes with additional functionality.

from dataclasses import dataclass

@dataclass(slots=True)
class Project:
    name: str
    payment: int
    client: str

    def notify_client(self):
        print(f"Notifying the client about the progress of the {self.name}...")

class Employee:
    def __init__(self, name, age, salary, project):
        self.name = name
        self.age = age
        self.salary = salary
        self.project = project

p = Project("Django app", 20000, "Globomantics")
e = Employee("Ji-Soo", 38, 1000, p)
print(e.project)

Slots in dataclasses

Before Python 3.10, we declared the class variable __slots__. Since Python 3.10, we can simply define slots=True in the @dataclass decorator:

@dataclass(slots=True)
class Project:
    name: str
    payment: int
    client: str

Advantages of dataclasses

  • More compact code: no repetitive code to manage instance attributes.
  • Clarity: we immediately see what attributes the class will have, then directly the methods.
  • Less boilerplate: no need to write __init__, __repr__, etc. by hand.

Inheritance also works with dataclasses in the same way.


7.4 Next steps

You now know the fundamentals of object-oriented programming in Python. Here are the recommended next steps:

1. Design Patterns

Read about different design patterns like:

  • Singleton pattern
  • Facade
  • Adapt
  • Bridge
  • (and many others)

These different design patterns will help you decide which approach to take when designing your classes for a particular project — not all projects are built the same.

2. SOLID Principles

SOLID is an acronym for five principles of object-oriented design:

  • S — Single Responsibility Principle
  • O — Open/Closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

These principles cover established practices that can help you design maintainable and scalable classes.

3. Abstract classes

Abstract classes are classes that do not create their own instances. They define what the subclasses will have to implement. They are designed to be extended — like an interface.

4. Docstrings

Read best practices for creating docstrings for classes. Docstrings are used to document the capabilities of a class so everyone knows what it does without having to go through the entire definition.

5. Advanced concepts

  • Metaclasses (metaclasses)
  • Descriptors (descriptors)
  • Class decorators (class decorators)

Many concepts covered in this course (like properties) rely on descriptors and decorators.

6. Code organization — separate modules

When you have a large project, you usually define classes in different Python files (modules). Having all the class definitions in a single file with the application code can become difficult to manage.

Modular structure example:

project.py:

from dataclasses import dataclass

@dataclass(slots=True)
class Project:
    name: str
    payment: int
    client: str

    def notify_client(self):
        print(f"Notifying the client about the progress of the {self.name}...")

employee.py:

class Employee:
    def __init__(self, name, age, salary, project):
        self.name = name
        self.age = age
        self.salary = salary
        self.project = project

main.py:

from employee import Employee
from project import Project

p = Project("Django app", 20000, "Globomantics")
e = Employee("Ji-Soo", 38, 1000, p)
print(e.project)

7.5 Final summary

In this last module, we discussed what data classes are and how we can use them to avoid repetition in code.

Steps to use data classes

  1. Import the dataclass decorator from the dataclasses module.
  2. Use the decorator to transform an ordinary class into a dataclass.
  3. Declare all instance attributes (instead of defining __init__).
  4. The dataclass itself will implicitly create the __init__ method and other dunder methods (like __repr__).

Complete summary of the key points of the course

ConceptDescription
ClassFactory/blueprint to create objects (instances)
InstanceObject created from a class via instantiation
__init__Method for initializing instance attributes
selfReference to the current instance in methods
__dict__Internal dictionary of an instance or class
Instance methodFunction defined in a class, receives self implicitly
__str__Readable representation for the end user
__repr__Formal representation for developers
Dunder MethodsMagic methods with double underscore (ex: __add__)
PropertyMechanism to manage access to attributes with getter/setter
Computed propertyProperty that returns a calculated result
AbstractionHide implementation details, expose interface
EncapsulationGroup data and methods + restrict direct access
_attributeNon-public convention (“protected” attribute)
__attributeName mangling (“private” attribute)
LegacyMechanism for reusing code between classes
SuperclassParent class to inherit from
SubclassChild class that inherits
isinstance()Checks if an object is an instance of a class
issubclass()Checks if a class is a subclass of another
OverrideRedefine an inherited method
super()Access parent class methods
PolymorphismUse instances of subclasses where instances of the superclass are expected
__slots__Memory optimization — replaces internal dictionary
MROMethod Resolution Order — method resolution order
MixinAuxiliary class to add behaviors via multiple inheritance
Class attributeAttribute shared by all instances
@classmethodMethod that receives the class (cls) instead of the instance
Factory functionClass method that creates and returns an instance
DataclassClass with automatic generation of __init__ and other methods
Type hintIndication of the expected type for an attribute or parameter
mypyStatic Type Checking Tool in Python
Composition”has-a” relationship between classes (using instances of one class as attributes of another)


Search Terms

classes · object-oriented · programming · python · foundations · data · analysis · engineering · analytics · class · methods · attributes · inheritance · objects · method · slots · instance · access · functions · instances · name · oop · order · prerequisites

Interested in this course?

Contact us to book it or get a custom training plan for your team.