Pygirls OOP Course

06 Jan 2026 โ€ข 41 min read

This is one of the curriculum blocks I designed for PyGirls initiative.

Python Advanced OOP (Object Oriented Programming) Course ๐Ÿ

Whom is this course for?

This course is aimed at advanced or semi-advanced python users, who are already familiar with Python basics and have some experience developing simple functions and procedures on Python.

Course Objective ๐ŸŽฏ

The material of this course will introduce you into object-oriented programming approaches which can be useful for any programming language of your future choice (not only for Python). It will teach you to build robust, decoupled and easily extensible systems.

Course Content ๐Ÿ“–

  1. Introduction to Object-Oriented Programming
  2. Key Concepts of OOP
  3. Advanced OOP Concepts
  4. Solutions

Introduction to Object-Oriented Programming

What is OOP?

OOP stands for object oriented programming. As the name suggests, key role in there belongs to so-called objects. But what are these ephimeric objects?

In any programming languages objects are encapsulated data structures, which usually have methods and features (usually called attributes). Whereas methods refer to what object does, attributes refer to features object has.

Let’s see some simple practical example here: Attribute can be any feature of your object that you want it to have.

Let’s imagine, we have an object Cat ๐Ÿฑ . What do Cats have? Alright, they usually have nicknames. What else? Maybe paws of different colors? ๐Ÿพ Paw Color and Nickname can be therefore very easily the attributes of an object Cat.

We know that methods are the actions objects may do. What can Cats do? Most of the time I see mine sneaking around. So our cat may sneak around on his paws. What does Cat๐Ÿฑ do with his nickname? Mine usually reacts when his nickname is called. So most obvious methods would be sneak and react.

Enough theory though! Let’s how it looks like when implemented in Python:

PYTHON
class Cat:
    # initialisator
    def __init__(self, name: str, paw_color: str) -> None:
        self.name = name
        self.paw_color = paw_color
    #method 1
    def sneak(self) -> None:
        print(f'I am sneaking around on my: {self.paw_color} paw')
    #method 2
    def react(self) -> None:
        print(f'I am reacting (only when called respectfully) to name: {self.name}')

CONGRATS! ๐Ÿ˜œ We have created our first Cat ๐Ÿพ object. Let’s break down, what we have written here:

PYTHON
class Cat
  • this is the name of our class. Since we want to create class for object Cat, we just name our class the same way here.

โ— NOTE: according to PEP8 naming convention, you name your classes starting with UPPER-CASE letters. This is the way for other programmers to quickly grasp, that this is a class.

PYTHON
def __init__(self, name: str, paw: str) -> None
  • This is so-called initialise method (part of __dunder__ methods we will discuss later).
  • When you initialise your class, you define, what kind of attributes your class is supposed to have.
  • Here we defined, that we want to have 2 attributes of type string: paw and name.
  • You may as well wonder, what self here is. In this case self refers to the instance of the class (meaning specific Cat), which should possess the attributes paw and name.
PYTHON
def sneak(self) -> None

and

PYTHON
def react(self) -> None
  • these are the methods our class Cat๐Ÿพ implements. To put it simply, what our Cat๐Ÿฑ can do.

Excercise 1

Now it is time to put what we learned into practice.

PYTHON
# Create a class Dog which will have attributes: name and age. 
# The Dog class should have a method: bark_name and bark_age, which will print the name and the age of the Dog

Here you can view the solution . ๐Ÿ™„

Why Use OOP?

Congratulations on coming so far. Now we know how to create simple objects in Python. ๐ŸŽ‰ But how can we use our objects now? What is the magical purpose of these objects?

Let’s dive into that… ๐ŸŠโ€โ™‚๏ธ

After creating our object we may create the so-called instances of our object.

Let’s imagine, we have our object Cat๐Ÿฑ. I have my own Cat๐Ÿฑ, whose name can be different from your Cat๐Ÿฑ. They are both Cats, but have different features.

So these 2 are perfect examples of instances of object Cat๐Ÿฑ.

My cat’s name is Robert, let’s imagine yours is Whisker. ๐Ÿˆ ๐Ÿˆ

Let’s now instantiate the object Cat and create Robert and Whisker:

PYTHON
robert = Cat(name='Robert', paw_color='red')
whisker = Cat(name='Whisker', paw_color='black')

robert.sneak()
whisker.sneak()
robert.react()
whisker.react()
I am sneaking around on my: red paw
I am sneaking around on my: black paw
I am reacting (only when called respectfully) to name: Robert
I am reacting (only when called respectfully) to name: Whisker

As you may see, we have created 2 instances of Cat๐Ÿฑ object. When we call the method sneak or react on our instance, we receive different results for Robert and Whisker, since they have different paw colors and names.

Now it is Challenge Time ๐Ÿ†

Excercise 2

PYTHON
# create 2 instances of Dog class from Excercise 1: Jack and Ruphy. Jack is 2 years and Ruphy is 7 years old. 
# Let them bark their name and age

Here you can view the solution . ๐Ÿ™„

Comparing OOP with Procedural Programming

๐Ÿ› ๏ธ Procedural Programming (Step-by-Step)

Procedural programming is like following a recipe ๐Ÿฅž. You write step-by-step instructions, and the program executes them in order.

๐Ÿ”น Everything happens one after another.
๐Ÿ”น Uses functions to break down code.
๐Ÿ”น Data and functions are separate.

๐Ÿณ Example: Making Pancakes with Procedural Code

PYTHON
# Define ingredients (data)
flour = '2 cups'
milk = '1 cup'
egg = '1'

# Step-by-step functions
def mix_ingredients() -> None:
    print(f'Mix {flour}, {milk}, and {egg} together.')

def cook_pancake() -> None:
    print('Pour batter onto pan and cook.')

def serve() -> None:
    print('Pancake is ready to eat!')

# Call functions in order
mix_ingredients()
cook_pancake()
serve()

๐ŸŽญ Object-Oriented Programming (OOP - Organizing with Objects)

OOP is like playing with LEGO ๐Ÿ—๏ธ! Instead of just writing steps, you create objects that can do things by themselves.

๐Ÿ”น Code is organized into objects.
๐Ÿ”น Objects have attributes (data) and methods (actions).
๐Ÿ”น Makes it easy to reuse and expand.

๐Ÿ—๏ธ Example: Making Pancakes with OOP

PYTHON
class Pancake:
    def __init__(self, flour: str, milk: str, egg: str) -> None:
        self.flour = flour
        self.milk = milk
        self.egg = egg

    def mix(self) -> None:
        print(f'Mix {self.flour}, {self.milk}, and {self.egg} together.')

    def cook(self) -> None:
        print('Pour batter onto pan and cook.')

    def serve(self) -> None:
        print('Pancake is ready to eat!')

# Create a Pancake object
my_pancake = Pancake('2 cups', '1 cup', '1')
my_pancake.mix()
my_pancake.cook()
my_pancake.serve()

So what’s the Difference? ๐Ÿคทโ€โ™‚๏ธ

Feature Procedural Programming ๐Ÿ› ๏ธ Object-Oriented Programming (OOP) ๐ŸŽญ
Style Step-by-step, functions Organized into objects
Data Separate from functions Inside objects (attributes)
Reusability Hard to reuse Easy to reuse with classes
Scaling Harder for big projects Easier for big projects

Typing Time! ๐Ÿ–จ

Excercise 3

PYTHON
# create two implementations (procedural and oop) of building lego man.
# lego man should have the methods collect_blocks, build_blocks and put_on_shelf
# lego man should have the attributes: legs, arms, head and body. 

Here you can view the solution . ๐Ÿ™„

Key Concepts of OOP

Now that we know, what objects and their instances are, it is time to talk about key concepts of OOP.

Inheritance

Inheritance is like sharing something from your parents! ๐Ÿ‘ซ
For example:

  • A puppy ๐Ÿถ gets its looks from its parents ๐Ÿ•.
  • A kitten ๐Ÿฑ learns to meow from its parents ๐Ÿˆ.

In Python, inheritance means one class (child ๐Ÿ‘ถ) can use stuff from another class (parent ๐Ÿ‘ฉโ€๐Ÿฆฐ).

There are different types of inheritance. Let’s go through them one by one…

Single Inheritance ๐Ÿ‘ฉโ€๐Ÿฆฐโžก๏ธ๐Ÿ‘ถ

The child class gets things from only one parent.

Example:

PYTHON
class Mom:
    def say_hello(self) -> str:
        return 'Hello! ๐Ÿ‘‹'

class Child(Mom):
    pass # here the instance of Child class will be having say_hello method 

Challenge Time! ๐Ÿ†

Excercise 4

PYTHON
# create the class Venom which inherits the attribute: web and method: swing from class SpiderMan

Here you can view the solution . ๐Ÿ™„

Multiple Inheritance ๐Ÿ‘จ๐Ÿ‘ฉโžก๏ธ๐Ÿ‘ถ

The child class gets things from two parents!

Example:

PYTHON
class Mom:
    def say_hello(self) -> str:
        return 'Hello!'

class Dad:
    def say_bye(self) -> str:
        return 'bye!'

class Child(Mom, Dad):
    pass # Child can say_bye and say_hello

๐Ÿค” NOTE: here you may see how method resolution order acts in the multiple inheritance case. MRO - defines how python goes through inheritance chain upwards to refer to the parent objects with super() method.

PYTHON
class Root:
	def ping(self):
		print(f'{self!r} is called in Root')

	def __repr__(self):
		return type(self).__name__


class A(Root):
	def ping(self):
		print(f'{self} is called in A')
		super().ping()


class B(Root):
	def ping(self):
		print(f'{self} is called in B')
		super().ping()

class U(A, B):
	def ping(self):
		print(f'{self} is called in U')
		super().ping()

u_class = U()
print(U.__mro__) # __mro__ shows how the object gets resolved up to the root using super()

u_class.ping()
#U is called in U -> first call
#U is called in A -> A implements super to the U -> therefore next activated class in called (activated in as the reference to U class)
#U is called in B -> B implements super to the B -> no additional class - therefore call root
#U is called in Root -> last call

Ready for one more challenge? ๐Ÿค”

Excercise 5

PYTHON
# during my time there was a popular cartoon running on Nickelodean with the name CatDog. 
# I challenge you to create this character using python! :) 
# the class CatDog should inherit from classes Cat and Dog class simultaneously. 
# Cat should have attributes: paw and ears. Cat should also be able to meow!
# Dog should have attributes: tail and teeth. Dog should also be able to bark!
# CatDog should inherit from both and be able to bark and meow in the same time. 

Here you can view the solution . ๐Ÿ™„

Multilevel Inheritance ๐Ÿ‘จโžก๏ธ๐Ÿ‘ดโžก๏ธ๐Ÿ‘ถ

A chain of inheritance!

Example:

PYTHON
class Granddad:
    def say_hello(self) -> str:
        return 'Hello!'

class Dad(Granddad):
    def say_bye(self) -> str:
        return 'bye!'

class Child(Dad):
    pass # Child can say_bye and say_hello inheriting everything from Dad only. Dad also inherited say_hello from granddad

Practice Time!

Excercise 6

PYTHON
# create the class Grandchildren which will inherit from Parents which in turn will inherit from Grandparents
# Grandchildren have not learnt anything yet, so they only will have attributes and methods inherited from classes above
# Parents will be having attributes: salary and education. They will also have have method: work. 
# Grandparents will be having attributes: pension and leisure. They will also have the method: do_hobby. 

Here you can view the solution . ๐Ÿ™„

Overriding Methods in Subclasses ๐ŸŽจ

Sometimes, the child class can change the parentโ€™s method to do something different! ๐Ÿ˜ฎ So we override the inherited method in child classes with something else.

Example:

PYTHON
class Parent:
    def talk(self) -> str:
        return 'Iโ€™m the parent!'

class Child(Parent):
    def talk(self) -> str:
        return 'Iโ€™m the child!' # here we override the method talk inherited from Parent

Using the super() Function ๐Ÿš€

The super() function lets the child class use the parent classโ€™s methods! ๐ŸŽฏ

Example:

PYTHON
class Parent:
    def introduce(self) -> str:
        return 'Iโ€™m the parent!'

class Child(Parent):
    def introduce(self) -> str:
        return f'{super().introduce()} And Iโ€™m the child!'
  • here super() refers to the parent class above (Parent).
  • since super() returns pointer to Parent class, we may call its methods like introduce

โ— NOTE: be careful implementing super() method with multilevel and multiple inheritance. Method resolution order ( mro ) implementation follows a relatively complex algorithm.

Let’s try to override the methods in our children subclass from excercise 6 and extend how Parents work. are you ready?

Excercise 7

PYTHON
# imagine grandchildren started working earlier making their own money - try to override the method work inherited from parents. 

Here you can view the solution . ๐Ÿ™„

Encapsulation

Encapsulation is like putting all your important things in a bag ๐ŸŽ’ so you can protect them and only share whatโ€™s needed!

In Python:

  • We hide some things (attrbutes or methods) inside a class to protect them. ๐Ÿ”’
  • We control who can use or change those things. ๐ŸŽ›๏ธ

Access Modifiers: Public, Protected, and Private ๐Ÿ”‘

Public ๐ŸŒŸ

Public things are for everyone to use!

Example:

PYTHON
class Toy:
   def play(self) -> str:
       return 'Let's play! ๐ŸŽฎ'

toy = Toy()
print(toy.play())  # Everyone can use it!
  • play method is public and may be used by everyone

Protected ๐Ÿ›ก๏ธ

Protected things are for you and your family (subclasses) to use!

Example:

PYTHON
class Animal:
    def __init__(self) -> None:
        self._kind = 'Unknown'  # Protected attribute

    def show_kind(self):
        return f'This is an {self._kind}.'

class Dog(Animal):
    def __init__(self) -> None:
        super().__init__()
        self._kind = 'Dog'

pet = Dog()
print(pet.show_kind())  # Dog ๐Ÿถ
  • here _kind is supposed to be protected (meaning not allowed for usage outside of its own class or child classes)
  • to get _kind we need to call show_kind method

๐Ÿค” GOOD TO KNOW _ prefix is only naming convention, it does not enforce any additional functionality. By using _ prefix you just tell your programmer friends, that this method/attribute should be protected.

Private ๐Ÿ”’

Private things are for yourself only! Example:

PYTHON
class Safe:
    def __init__(self) -> None:
        self.__secret = 'Shhh... It is a secret! ๐Ÿคซ'  # Private attribute

    def reveal_secret(self) -> str:
        return self.__secret

my_safe = Safe()
print(my_safe.reveal_secret())  # You can access it safely!
  • here __secret is private attribute, which cannot be accessed outside of its own class (!also not in child classes)

๐Ÿค” GOOD TO KNOW __ enforces functionality, where it converts this method to so-called __dunder__ which cannot be accessed from the instance of class.

Getters and Setters in Python ๐Ÿ“ฌ๐Ÿ“ฆ

Getters and Setters help you get and set private data safely! ๐ŸŽ›๏ธ

Example:

PYTHON
class BankAccount:
    def __init__(self, balance: int) -> None:
        self.__balance = balance  # Private attribute

    def get_balance(self) -> str:  # Getter
        return f'Your balance is ${self.__balance}'

    def set_balance(self, amount: int) -> str:  # Setter
        if amount >= 0:
            self.__balance = amount
            return 'Balance updated! โœ…'
        else:
            return 'Invalid amount! โŒ'

# Using the class
account = BankAccount(100)
print(account.get_balance())  # Get the balance
print(account.set_balance(200))  # Update the balance
print(account.get_balance())
  • getter allows you to enrich access to attributes with additional functionality
  • here for example we convert the private attribute __balance to string representation
  • setter allows you to enrich setting attributes with additional functionality
  • here for example we built in some additional validation (>=0) before setting private attribute __balance

Property Decorators for Attribute Management ๐Ÿกโœจ

With property decorators, you can make getters and setters easier to use! ๐Ÿง™โ€โ™‚๏ธ

Example:

PYTHON
class Circle:
    def __init__(self, radius: int) -> None:
        self.__radius = radius

    @property  # Getter
    def radius(self) -> int:
        return self.__radius

    @radius.setter  # Setter
    def radius(self, new_radius: int) -> None:
        if new_radius > 0:
            self.__radius = new_radius
        else:
            print('Radius must be positive!')

# Using the class
circle = Circle(5)
print(circle.radius)  # Access the radius
circle.radius = 10  # Update the radius
print(circle.radius)
circle.radius = -1  # Try invalid value
  • this is basically more effective implementation of getter and setter.

๐Ÿ™‹ NOTE: you may also delete the properties using del. You may also enrich deleting functionalities by adding @.deleter

PYTHON
class LegoMan:
    def __init__(self) -> None:
        self.lego_parts = ['first lego part', 'second lego part', 'third lego part', 'fourth lego part']

    @property
    def lego_part(self) -> str:
        return f'next lego part is {self.lego_parts[0]}'

    @lego_part.deleter
    def lego_part(self) -> None:
        print(f'Lost: {self.lego_parts.pop(0)}')

lego_guy = LegoMan()

del lego_guy.lego_part #Lost: first lego part
del lego_guy.lego_part #Lost: second lego part
del lego_guy.lego_part #Lost: third lego part
print(lego_guy.lego_part)
del lego_guy.lego_part #Lost: fourth lego part

๐Ÿค” GOOD TO KNOW: you may also customise your getters even more using descriptors. This is more advanced and effective way to use getters and setters. This concepts takes benefit of metaprogramming in python.

Sometimes we want to control how we set or get values in a class. For example:

  • โœ… Check if a number is positive before saving it.
  • ๐Ÿ› ๏ธ Automatically perform actions when a value is updated.

Python gives us tools like property decorators and descriptors to make this easier. Letโ€™s learn how!

A descriptor is like a magic tool in Python. It controls what happens when:

  • You read an attribute (instance.attribute)
  • You set an attribute (instance.attribute = value)

Descriptors use special methods:

  1. __get__: Called when reading a value.
  2. __set__: Called when setting a value.
  3. __set_name__: Automatically links the descriptor to the attribute name.

๐Ÿ› ๏ธ Examples of Descriptors

Example 1: Using a Descriptor to Ensure Positive Numbers ๐Ÿ”ข

This descriptor makes sure values like weight and price are always positive.

PYTHON
class PositiveNumber:
    def __init__(self, name):
        self.name = name

    def __set__(self, instance, value):
        if value < 0:  # Prevent negative numbers
            raise ValueError(f"{self.name} must be positive!")
        instance.__dict__[self.name] = value

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

class Item:
    weight = PositiveNumber('weight')
    price = PositiveNumber('price')

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

# Testing
item = Item(10, 20)
print(item.weight)  # Output: 10
print(item.price)   # Output: 20

# Try setting a negative value (it will throw an error)
# item.weight = -5

Example 2: Making Descriptors Smarter with __set_name__ ๐ŸŽ“

With __set_name__, the descriptor automatically knows the attributeโ€™s name (e.g., weight or price).

PYTHON
class PositiveNumber:
    def __set_name__(self, owner, name):
        self.name = name

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError(f"{self.name} must be positive!")
        instance.__dict__[self.name] = value

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

class Item:
    weight = PositiveNumber()
    price = PositiveNumber()

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

# Testing
item = Item(15, 25)
print(item.weight)  # Output: 15
print(item.price)   # Output: 25

Ready for one more challenge time? ๐Ÿค”

Excercise 8

PYTHON
# create a class called Vault. Vault should have protected attribute __secret. 
#__secret should have setter and getter. 
# with setter validate that __secret is minimum 8 characters long. 
# with getter show only the first 3 letters of secret and the rest of characters should be shown as "*"

Polymorphism

Polymorphism is a fancy word that means โ€œmany formsโ€! ๐ŸŽญ
Itโ€™s like one thing can act in different ways, depending on the situation.

  • A dog ๐Ÿถ can bark: “Woof!”
  • A cat ๐Ÿฑ can meow: “Meow!”
    Even though theyโ€™re both animals, they make different sounds!

Method Overloading and Overriding ๐ŸŽญ

Method Overloading ๐Ÿ“š

In Python, method overloading means creating a method that works in many ways.
Example:

PYTHON
class Calculator:
    def add(self, *args) -> int:
        return sum(args)  # Add any number of values!

calc = Calculator()
print(calc.add(1, 2))  # 3
print(calc.add(1, 2, 3, 4))  # 10

Method Overriding ๐ŸŽจ

Method overriding means a child class can change the way a method from the parent class works. Example:

PYTHON
class Parent:
    def talk(self) -> str:
        return 'Iโ€™m the parent!'

class Child(Parent) -> str:
    def talk(self):
        return 'Iโ€™m the child!' # here we override the method talk inherited from Parent

Duck Typing and Dynamic Typing in Python ๐Ÿฆ†โœจ

Duck Typing: “If it quacks like a duck, then it is likely a duck!” ๐Ÿฆ†

In Python, we donโ€™t care what something is, as long as it behaves like we need!

Example:

PYTHON
from typing import Protocol, runtime_checkable

@runtime_checkable
class SwimmingAnimal(Protocol):
    def swim(self) -> str:
        pass
        
class Duck:
    def swim(self) -> str:
        return 'Splash! Duck swimming!'

class Fish:
    def swim(self) -> str:
        return 'Swish! Fish swimming!'

def make_it_swim(creature: SwimmingAnimal) -> None:
    print(creature.swim())

duck = Duck()
fish = Fish()
make_it_swim(duck)  # Duck swimming
make_it_swim(fish)  # Fish swimming
print(isinstance(duck, SwimmingAnimal)) # returns True, since we have @runtime_checkable and Duck satisfies the Protocol of SwimmingAnimal by implementing the swim method

๐Ÿค” GOOD TO KNOW: make_it_swim() pattern is called dependency injenction . We insert reference to the object as function parameter. We know this object is supposed to implement certain method (either through protocol or abstraction) and we just call this method without worrying about the details of implementation.

Abstraction

Abstraction is like hiding the boring details and showing only the important stuff. ๐ŸŽฉโœจ Think of it like this:

๐Ÿ• Pizza Example

When you eat a pizza ๐Ÿ•, you donโ€™t need to know:

  • How the dough is made ๐Ÿฅ–
  • How the toppings are prepared ๐Ÿ…๐Ÿง€
  • How the oven works ๐Ÿ”ฅ

You just eat the pizza and enjoy it! ๐Ÿ˜‹

In Python, abstraction works the same way:

  • You donโ€™t need to know everything inside a program.
  • You just need the parts that are important to you! ๐ŸŽฏ

For example, when we want to make different sorts of pizza ๐Ÿ•, it is the easiest for us to let restaurant make this sort of Pizza so we do not need to worry about complex recipes and ingedients chef uses to bake our delicious pizza. ๐Ÿ•

Now let’s see the abstraction in action and create our mini pizza bakery ๐Ÿฅ–:

PYTHON
from typing import List
from abc import ABC, abstractmethod

class Pizza(ABC):
    def __init__(self, ingredients: List[str]) -> None:
        self.ingredients = ingredients

    @abstractmethod
    def bake(self) -> None:
        raise NotImplementedError

class CheesePizza(Pizza):
    def bake(self) -> None:
        for i in range(4):
            self._add_cheese()
        print('your pizza is served!')

    def _add_cheese(self):
        print('adding more cheese')


class VeganPizza(Pizza):
    def bake(self) -> None:
        for i in range(4):
            self._add_vegis()
        print('your pizza is served!')

    def _add_vegis(self):
        print('adding more vegis')


class NapoliPizza(Pizza):
    def bake(self) -> None:
        for i in range(4):
            self._add_tomatoes()
        print('your pizza is served!')

    def _add_tomatoes(self):
        print('adding more tomatoes')
        

As you may see here, we have created class Pizza๐Ÿ• which has @abstractmethod bake. Bake is not implemented in the class but implemented in each of the classes (CheesePizza, VeganPizza, NapoliPizza) which inherit from it.

You might see that we may just call bake ๐Ÿณ method for each of the classes without worrying about details how this method is implemented. We know for sure that each Pizza will be baked, but how it happens we do not need to know.

In the code snippet above, we introduced several new syntax words, which might be unknown to you.

So let’s break it down and discuss what each part of the code does here:

PYTHON
from typing import List
  • typing is the library which can be used to annotate the types. Python is interpreted language, meaning you do not need to declare which type each particular variable is supposed to hold. However, for static checking annotating types helps the programmers and IDEs to see the potential errors due to type clashes in advance.

โ— You are highly encouraged to use type annotations when programming in Python. It will allow your text editors to see potential type clashes in advance

PYTHON
from abc import ABC, abstractmethod
  • Here we import two modules: ABC and abstractmethod.
  • ABC - makes the class abstract.
    • Meaning the class (in our case Pizza ๐Ÿ• is the class which is supposed to define which methods and features each of child classes like CheesePizza is supposed to implement)

โ— NOTE class Pizza does not provide the implementation of bake method. It just says that this method should be implemented by child classes.

PYTHON
class Pizza(ABC):
    def __init__(self, ingredients: List[str]) -> None:
        self.ingredients = ingredients

    @abstractmethod
    def bake(self) -> None:
        raise NotImplementedError
  • Here we create abstractclass. Abstractclass is the base class, which serves like a blueprint prescribing which methods and attributes other child classes are supposed to implement.
  • @abstractmethod is decorator which enforces bake method to be mandatory for child classes

โ— NOTE using @abstractmethod is not mandatory here. Since we raise NotImplementedError, the MRO will search for the first possible implementation of bake method, which will be in parent class Pizza and raises this error. So we will be notified either way, that child class misses bake method also without @abstractmethod

PYTHON
class CheesePizza(Pizza):
    def bake(self) -> None:
        for i in range(4):
            self._add_cheese()
        print('your pizza is served!')

    def _add_cheese(self):
        print('adding more cheese')


class VeganPizza(Pizza):
    def bake(self) -> None:
        for i in range(4):
            self._add_vegis()
        print('your pizza is served!')

    def _add_vegis(self):
        print('adding more vegis')


class NapoliPizza(Pizza):
    def bake(self) -> None:
        for i in range(4):
            self._add_tomatoes()
        print('your pizza is served!')

    def _add_tomatoes(self):
        print('adding more tomatoes')
  • Here we have child classes: CheesePizza, VeganPizza, NapoliPizza. They all inherit from Pizza through i.e. CheesePizza(Pizza) or VeganPizza(Pizza).
  • Each of the child classes has its own implementation of bake ๐Ÿณ method.
  • In CheezePizza we add more cheese through _add_cheese method
  • In VeganPizza we add more vegis through _add_vegis method
  • In NapoliPizza we add more tomatoes through _add_tomatoes method

โ— NOTE The abstraction enables us to bake each of pizzas just by calling bake method on instance. We are sure they implement this method, since they inherit from abstract class class Pizza with @abstractmethod bake.

๐Ÿค” GOOD TO KNOW according to PEP8, the hidden (=private) methods or attributes like _add_cheese, _add_vegis, _add_tomatoes usually have _ prefix.

Ready for one more challenge? ๐Ÿ†

Excercise 9

PYTHON
# Create abstract class Animal and child classes Wolf, Dog and Duck. 
# class Animal should have attributes color and weight. 
# cass Animal should enforce implementation of @abstractmethod make_sound. 
# make_sound will print how the animal might sound. 
# make_sound should be implemented differently by each of the subclasses: Wolf = 'aooo', Dog = 'augh', and Duck = 'quack'

Here you can view the solution . ๐Ÿ™„

Advanced OOP Concepts

__DUNDERS__

Dunder methods (short for Double UNDERscore methods) are special functions that start and end with __.

They help Python understand how objects should behave!

  • They make objects act like numbers, lists, and dictionaries!
  • They allow custom behavior when using +, -, [], len(), and more!
  • Python uses them behind the scenes to perform cool tricks! ๐ŸŽฉ

๐Ÿ”ข Example 1: Unary Operators (- and abs) Let’s create a class Multiplier that holds numbers and reacts to - and abs().

PYTHON
from itertools import zip_longest

class Multiplier:
    def __init__(self, values):
        self.values = values

    # โ— When we use - on an object
    def __neg__(self):
        return Multiplier([-value for value in self.values])

    # โ— When we use abs() on an object
    def __abs__(self):
        return Multiplier([abs(value) for value in self.values])

    def __iter__(self):
        return (value for value in self.values)

    # โ— When we add two objects (+)
    def __add__(self, other):
        longest_items = zip_longest(self, other, fillvalue=0)
        return Multiplier([a + b for a, b in longest_items])  # Creates a new object

multiplier = Multiplier([1, 5])
print((-multiplier).values)  # [-1, -5]

multiplier2 = Multiplier([-1, 5, -1, 15, -10])
print(abs(multiplier2).values)  # [1, 5, 1, 15, 10]

print((multiplier + multiplier2).values)  # [0, 10, -1, 15, -10]

๐Ÿ”— Example 2: Using += (iadd) Dunder method __iadd__ (+=) updates an object instead of creating a new one. So it modifies self.

PYTHON
class Person:
    def __init__(self, names):
        self.names = names

    def __iadd__(self, other):
        if not isinstance(other, Person):
            return NotImplemented  
        self.names.extend(other.names)  # Extending the list of names
        return self  

person1 = Person(['Jack', 'John'])
person2 = Person(['Jason', 'Viktor'])

print(id(person1))  # Same ID before +=
person1 += person2
print(id(person1))  # Same ID after += (object updated, not replaced!)

print(person1.names)  # ['Jack', 'John', 'Jason', 'Viktor']

๐Ÿ“œ Example 3: Treating Objects Like Lists! Using __getitem__, __len__, and __iter__, we can make objects act like lists!

PYTHON
class Person:
    def __init__(self, names):
        self.names = names

    def __getitem__(self, position):
        return self.names[position]  # Allows indexing (e.g., person[0])

    def __len__(self):
        return len(self.names)  # Allows len(person)

    def __iter__(self):
        return (name for name in self.names)  # Allows iteration

person1 = Person(['Nik', 'Michael'])

print(', '.join(person1))  # "Nik, Michael"
print(len(person1))  # 2
print(person1[0])  # "Nik"

๐Ÿ—๏ธ Example 4: Custom Attribute Handling! Using __setattr__ and __getattr__, we can customize how attributes work!

PYTHON
class Person:
    __match_args__ = ('Name1', 'Name2')

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

    def __setattr__(self, attr, value):
        super().__setattr__(attr, value)  # Default behavior

    def __getattr__(self, attr):
        _cls = type(self)
        if attr in _cls.__match_args__:
            index = _cls.__match_args__.index(attr) # looking for index of attribute in __match_args__
            return self.names[index] # calling this index on self.names
        raise AttributeError(f'Attribute {attr} not found!')

person1 = Person(['Michael', 'Nik'])
person1.Name2 = 'Gregor'  # Overwriting the second name
print(person1.Name2)  # "Gregor"

๐Ÿ“– Example 5: Custom Dictionary (dict) We can modify dictionary behavior using __missing__, get(), and __contains__.

PYTHON
class CustomDict(dict):
    def __missing__(self, key):
        if isinstance(key, str):
            raise KeyError(key)
        return self[str(key)]  # Convert non-string keys to strings

    def get(self, key, default=None):
        try:
            return self[key]
        except KeyError:
            return default  # Default value if key is missing

    def __contains__(self, key):
        return str(key) in self.keys() or key in self.keys()  # Allow searching both str & int keys

my_dict = CustomDict({'1': 2, 2: 3})
print(my_dict[1])  # 2 (since '1' is converted)
print(1 in my_dict)  # True (searching for 1 also finds '1')

๐ŸŽ‰ Summary: What Can Dunder Methods Do?

Dunder Method What It Does? ๐Ÿค” Example Use
__init__ Creates an object obj = MyClass()
__str__ Converts to string print(obj)
__len__ Returns length len(obj)
__getitem__ Enables indexing obj[0]
__setitem__ Allows setting values obj[0] = 'Hello'
__iter__ Enables iteration for item in obj:
__add__ Allows + operator obj1 + obj2
__iadd__ Allows += operator obj1 += obj2
__neg__ Defines -obj behavior -obj
__abs__ Defines abs(obj) behavior abs(obj)
__contains__ Defines in keyword 'apple' in obj

Mixins

Mixins are special helper classes that extend functionality but cannot be used alone! ๐Ÿš€

Imagine you have a superhero ๐Ÿฆธ who can fly, but another superhero who can turn invisible.
What if you combine these powers? BOOM! ๐ŸŽ† You now have a superhero that can fly and turn invisible!

Mixins work the same way in Python! They add extra abilities to classes without making them a complete class themselves.

๐Ÿค” GOOD TO KNOW: the mixins are usually very helpful if you want to modify the behavior of the class without actually modifying original class (for example if you work with python libraries you cannot modify)

โœจ Example: UpperCase Mixin ๐Ÿ—๏ธ

PYTHON
from collections import UserDict

class UpperCaseSearcher:
    def __setitem__(self, key, value):
        super().__setitem__(key.upper(), value)  # Converts keys to uppercase

    def __getitem__(self, key):
        return super().__getitem__(key.upper())

    def __contains__(self, key):
        return super().__contains__(key.upper())

    def get(self, key, default=None):
        return super().get(key.upper(), default)

class CaseInsensitiveDict(UpperCaseSearcher, UserDict):
    ...

test_dict = CaseInsensitiveDict({'a': 1, 'b': 2, 'c': 3})

print(test_dict.keys())  # Outputs: dict_keys(['A', 'B', 'C'])
print(test_dict['A'])    # Outputs: 1
  • here we modified the default behavior of dictionary (without changing the original underlying UserDict object)
  • we enabled it to work case-insensitively by modifying its dunder methods (__setitem__, __getitem__, __contains__).

โ— NOTE: here you may that we call super() in the class which do not have any parent class. Where does super() refer to then? It refers to the next standing parent class using MRO logic. That’s why you have class CaseInsensitiveDict(UpperCaseSearcher, UserDict) UpperCaseSearcher standing on the left from UserDict. So UpperCaseSearcher super() will refer to UserDict.

๐ŸŽญ Example: Adapting Methods in Mixins

PYTHON
class OriginalClass:
    def original_method(self, value):
        print(f"Original value: {value}")

class Mixin:
    def original_method(self, value, additional_value):
        new_value = f"{value}, {additional_value}"
        super().original_method(new_value)  # Calls the original method

class AdaptedClass(Mixin, OriginalClass):
    pass

adapted_instance = AdaptedClass()
adapted_instance.original_method("test", "one more method")  
# Outputs: Original value: test, one more method
  • here we again modified and extended the behavior of OriginalClass without modifying it.
  • we introduced the new class AdaptedClass which contains default OriginalClass functionality with some extensions coming from Mixin

Metaclasses & Metaprogramming

Metaclasses are like factories ๐Ÿญ that build classes. Imagine you want to make many superhero costumes (classes), but instead of designing each costume by hand, you use a costume factory (metaclass) to build them!

In Python, classes are objects too! They are created by a metaclass. By default, type is the metaclass that creates classes.

Metaprogramming

What is a Metaclass?

  • A metaclass is a class that defines how other classes should be created.
  • It is like a blueprint for creating classes.

๐Ÿ—๏ธ Metaclass Example

Hereโ€™s how Python creates a class using a metaclass:

PYTHON
# Metaclass Example: Using the 'type' function to create a class dynamically
MetaClass = type('MetaClass', (object,), {'x': '12'})

# The same as writing:
class MetaClass:
    x = '12'
  • type here allows to create class which will be called MetaClass, have object as base, and have the attribute x containing value 12

โ— NOTE 1: base classes for type function are the classes which serve as parents for the created class - since we may have multiple inheritence, its type is tuple (basically unmutable list) - that is why you have (object,) โ— NOTE 2: you may not only define attributes through dictionary as {‘x’: ‘12’} you may also add methods the same way by putting as dictionary key name of your method and as value reference to the your function. You may see similar implementation in example below.

๐Ÿ† Example: Dynamic Class Creation ๐ŸŽจ

Now letโ€™s see how we can dynamically create a class using a function.

PYTHON
from typing import Callable, List

def create_dynamic_class(name: str, attr: List, callables: List[Callable]) -> Callable:
    def __init__(self, *args, **kwargs) -> None:
        attrs = dict(zip(attr, args)) 
        attrs.update(kwargs) # sets kwargs dict as attributes

        for key, value in attrs.items():
            setattr(self, key, value) # sets args list as attributes

        for index, cal in enumerate(callables): 
            setattr(self, cal, __fetch_callables(index))

    def __repr__(self) -> str:
        return f'attributes: {self.__dict__}'

    def __fetch_callables(index: int) -> Callable:
        return lambda: print(f'I am callable {index}') # returns callable (function / method) for each of the index of callables 

    cls_attr = dict(
        __init__=__init__,
        __repr__ = __repr__
    )# attributes of the class itself - it has __init__ -> initialisor we defined above which assigns attributes and methods to the instance of the class

    return type(name, (object,), cls_attr)

TestClass = create_dynamic_class('NewClass', ['x', 'y', 'z'], ['call_me1', 'call_me2', 'call_me3'])

test_instance = TestClass(z=12, s=10)
print(test_instance.__dict__)
print(test_instance.call_me3())
  • We dynamically create a class TestClass that has attributes (x, y, z) and callable methods (call_me1, call_me2, etc.).
  • This shows how we can generate a class on the fly using metaprogramming!

Metaprogramming allows us to automatically create or modify code during the programโ€™s execution! ๐Ÿ› ๏ธ Itโ€™s like writing code that can change how the program works while itโ€™s running!

Metaclass

Metaclasses are a big part of metaprogramming. They help us modify classes when they are created.

๐ŸŒŸ Metaclass Example: Changing Class Attributes Dynamically Letโ€™s create a metaclass that changes the class attributes:

PYTHON
def i_am_function():
    print('Stop changing me!')

# Custom Metaclass to change class attributes
class Meta(type):
    def __new__(cls, name, bases, attributes):
        print(cls)  # <class '__main__.Meta'>
        print(name)  # Test1
        print(bases)  # (<class '__main__.Base'>, <class '__main__.Another'>)
        print(attributes)  # {'__module__': '__main__', '__qualname__': 'Test1', 'a': 10}

        # Changing the class attributes to uppercase
        new_attributes = {key.upper(): value for key, value in attributes.items()}
        new_attributes['TEST_METHOD'] = i_am_function  # Add a new method to the class

        # Checking if the class has a specific method
        if 'I_AM_HERE' not in new_attributes:
            raise ValueError(f'I AM HERE NOT HERE {cls.__name__}')

        return super().__new__(cls, name, bases, new_attributes)  # Creating class with modified attributes

class Base(metaclass=Meta):
    def i_am_here(self):
        ...

class Another:
    ...

class Test1(Base, Another):
    a = 10

    def test_method(self):
        print('test')

    def i_am_here(self):
        ...# will raise a value error if not available

# Displaying class attributes
print(Test1.__dict__)
#{'__MODULE__': '__main__', '__QUALNAME__': 'Test1', '__FIRSTLINENO__': 29, 'A': 10, 'TEST_METHOD': <function i_am_function at 0x118815bc0>, 'I_AM_HERE': <function Test1.i_am_here at 0x118816020>, '__STATIC_ATTRIBUTES__': (), '__module__': '__main__', '__doc__': None}

# Calling the dynamically added method
Test1.TEST_METHOD()  # Will print "Stop changing me!"
  • We modify class attributes using the Meta metaclass.
  • We add a new method (TEST_METHOD) to the class dynamically.
  • The metaclass also ensures that certain attributes or methods like I_AM_HERE are present when the class is created.

Bypassing Class Abstraction

In Python, when we use abstract classes, it helps us define a blueprint ๐Ÿ› ๏ธ that other classes must follow. But sometimes, we might not want to inherit all the methods of the abstract class, just register with it, and use the same blueprint.

Let’s imagine abstract classes like building blueprints ๐Ÿ—๏ธ:

  • Abstract classes have some things you must do.
  • Concrete classes are like buildings ๐Ÿ  that actually build the blueprint and complete the design.

But with @class.register, you can skip the inheritance and directly follow the blueprint without needing to implement everything. It means that python will blindly ๐Ÿ™ˆ accept your child as following the protocol of parent class without actually validating if you implement methods and attributes required.

Example

Here’s the code example with a Person class, which is an abstract class, and how we can bypass abstract method implementation:

PYTHON
from abc import ABC, abstractmethod

# Abstract class with an abstract method
class Person(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def say_hello(self):
        return f'Hi, my name is {self.name}'

# Using @Person.register to register the AnotherPerson class with Person
@Person.register
class AnotherPerson:
    pass  # No error even though we haven't implemented 'say_hello'

# If we try to directly inherit from Person, we must implement the abstract method
class OneMorePerson(Person):
    pass

# Uncommenting the line below would raise an error since OneMorePerson doesn't implement say_hello
# one_more_person = OneMorePerson()

# Checking the method resolution order (MRO) to see the class hierarchy
print(AnotherPerson.__mro__)  # Shows the class hierarchy - Notice 'Person' is not directly there

# Checking if AnotherPerson is a subclass of Person
print(issubclass(AnotherPerson, Person))  # Returns True because it is registered

# Creating an instance of AnotherPerson and checking its type
person1 = AnotherPerson()
print(isinstance(person1, Person))  # Returns True since it's registered as an instance of Person
  • Person Class:
    • The Person class is abstract because it has an abstract method say_hello().
    • This means that any class that inherits from Person must implement say_hello().
  • Registering AnotherPerson:
    • When we use the @Person.register decorator, the class AnotherPerson registers itself with Person.
    • Even though AnotherPerson does not implement the abstract method say_hello(), it is still considered a subclass of Person.
    • Itโ€™s like signing up to follow the blueprint, without actually inheriting everything.
  • Method Resolution Order (__mro__):
    • The MRO shows the order in which Python looks for methods.
    • In the case of AnotherPerson, Person is not in the MRO because AnotherPerson was not inherited from Person. It was just registered.
  • issubclass() and isinstance():
    • Even though AnotherPerson doesn’t inherit Person, itโ€™s still treated as a subclass of Person because it was registered.
    • You can create an instance of AnotherPerson and it will behave as if itโ€™s a Person, even though the abstract method isnโ€™t implemented!

Hashing Classes

Imagine you have a magic machine ๐Ÿญ that takes a word and turns it into a secret code ๐Ÿ”ข. This is what hashing does! It takes an input (like a name, number, or even a whole file) and turns it into a unique number (hash).

Example:

  • If you give the word “apple” ๐ŸŽ, the machine gives back 34987
  • If you give “banana” ๐ŸŒ, it gives 75834
  • But if you give “apple” again, you get the same number (34987)!

This is useful because:

  • It helps quickly find things (like searching in a library ๐Ÿ“š).
  • It keeps data secure ๐Ÿ”’ (used in passwords & security).
  • It ensures fast lookups in dictionaries or sets.

When we create a class in Python, it gets a unique ID. By default, hashing a class object uses this ID to generate a unique hash number.

Let’s see this in action:

PYTHON
class Person:
    def __init__(self, name):
        self.name = name

person = Person(name='Nik')
print(hash(person))  # Returns a unique hash value
  • The hash() function turns person into a unique number.
  • By default, this number is based on the memory location (ID) (also called pointers in other programming languages) of the object.
  • If you create another Person with the same name, it wonโ€™t have the same hash because it’s a different object in memory.

๐Ÿ  Overriding the hash() Method

Sometimes, we want objects with the same data to have the same hash. We can override the __hash__ method to do this!

PYTHON
class Person:
    def __init__(self, name):
        self.name = name

    def __hash__(self):
        return hash(self.name)  # Hashing based on name

person1 = Person('Nik')
person2 = Person('Nik')

print(hash(person1))  # Both will have the same hash
print(hash(person2)) 
  • Now, two “Nik” persons have the same hash because we are hashing based on self.name, not the object’s ID.
  • This makes sense if we want to compare people by name, not their memory locations.

โ— NOTE: Notice that @dataclass works differently with hashing and has some different implementations of hashing algorithms like frozen=True configuration and etc. Please refer to the section of @dataclass for more details

@dataclass

Python has a cool tool called @dataclass that makes creating classes easier! It helps automate many things, like:

  • Creating the __init__ method for you โœ…
  • Adding the __repr__ method for nice printing โœ…
  • Comparing objects easily โœ…
  • Making objects unchangeable (frozen=True) โœ…

Basic Example

PYTHON
from dataclasses import dataclass

@dataclass
class Person:
    age: int = 27
    weight: int = 80
    height: int = 183

person = Person()
print(person)  # Output: Person(age=27, weight=80, height=183)
  • We didnโ€™t write an __init__ method! Python did it for us. ๐ŸŽ‰
  • The __repr__ method was automatically created so we can print the object nicely.

โ„๏ธ frozen=True: Making Objects Unchangeable By default, you can change attributes after creating an object. But sometimes, we want unchangeable objects.

โ— NOTE: You would prefer unchangeable objects in hashed data structures, where value should be uniquely identified like dict and set

PYTHON
from dataclasses import dataclass

@dataclass(frozen=True)
class Person:
    name: str
    age: int = 12

person = Person('Nik', 10)
# person.age = 15  # โŒ This will cause an error because it's frozen!
  • It makes objects immutable (unchangeable). ๐Ÿ”
  • Allows the object to be hashed, so it can be used in sets and dictionaries.

๐Ÿ”ข Hashing a @dataclass

Normally, dataclasses donโ€™t have a hash function, but when frozen=True, it automatically adds one:

PYTHON
@dataclass(frozen=True)
class Person:
    name: str
    age: int = 12

person1 = Person('Nik', 10)
person2 = Person('Nik', 10)

print(hash(person1))  # โœ… Works because of frozen=True
print(hash(person2))
print(person1 == person2)  # โœ… True, because they have the same values
print(id(person1), id(person2))  # โŒ Different IDs (because they are different objects) - here compared based on hash values not on ids

๐ŸŽจ field() and default_factory Sometimes, we want default values for attributes. But mutable objects (like lists or dicts) should use default_factory.

โŒ Problem: Shared Mutable Data

PYTHON
@dataclass
class Person:
    partners: list = []  # โŒ This causes an error because lists are mutable and there can be memory leaks

โœ… Solution: Use field(default_factory=list)

PYTHON
from dataclasses import field

@dataclass
class Person:
    partners: list = field(default_factory=list)  # โœ… Each person gets a new list

person1 = Person()
person2 = Person()

person1.partners.append("Alice")
print(person1.partners)  # ['Alice']
print(person2.partners)  # [] (Not shared!)
  • Fixes shared mutable objects.
  • Ensures each object gets a new list/dictionary.

๐Ÿš€ __post_init__: Running Code After Initialization

The __post_init__ method runs after the object is created. We can use it for extra validation.

PYTHON
@dataclass
class Person:
    name: str
    statuses: list = field(default_factory=list)

    def __post_init__(self):
        if self.name == "Nik":
            print(f"Warning! Name cannot be 'Nik'")

person = Person('Nikola')  # โœ… Works fine
person_nik = Person('Nik')  # โš ๏ธ Prints warning

๐Ÿ Summary of @dataclass Features

Feature Default Behavior With @dataclass With frozen=True
Creates __init__ method? โŒ No โœ… Yes โœ… Yes
Creates __repr__ method? โŒ No โœ… Yes โœ… Yes
Objects are mutable? โœ… Yes โœ… Yes โŒ No
Supports hashing? โŒ No โŒ No โœ… Yes
Shared mutable data? โœ… Yes โœ… Yes โŒ (Use field(default_factory=list))

Mutability and Memory Leaks

In Python, there can be funny things happening to mutable objects if handled incorrectly within your class. You can actually trick python to perform things it does not intend to do. Let’s see how… ๐Ÿค”

PYTHON
class Person:
    def __init__(self, names=[]):  # โš ๏ธ Oops! This is dangerous!
        self.names = names  # This list is SHARED with other people!

    def append_name(self, name):
        self.names.append(name)

# ๐ŸŽญ Different people with different lists
person1 = Person(['Migel', 'Benjamin'])  # This person brings their OWN list
person2 = Person()  # This person does NOT bring a list
person3 = Person()  # This person also does NOT bring a list

# ๐Ÿ‘€ Let's add a new name to person2
person2.append_name('Lupin')

# ๐Ÿ˜ฒ Oh no! Person3 also has "Lupin" in their list!
print(person3.names)  # Output: ['Lupin']

# ๐ŸŽญ The trick is that person2 and person3 SHARE the same list!
print(id(person1.names), id(person2.names), id(person3.names))  # Same ID for person2 and person3 (lists are shared)!
  • Person1 has their own separate list because they provided a list (['Migel', 'Benjamin']).
  • Person2 and Person3 did NOT provide a list (which was declared above in __init__). So, Python gives them the same shared list.
  • When Person2 adds ‘Lupin’, Person3 also sees “Lupin”โ€”because they share the same list in memory!

Excercise 10

PYTHON
# try to use some other mutable type instead of names like dictionary and check your results

โ— NOTE: Instead of using names=[], we should create a new list for every person using default_factory=list:

PYTHON
class Person:
    def __init__(self, names=None):  # โŒ Don't use mutable default values
        self.names = names if names is not None else []  # โœ… Create a new list for each person!

Memory Efficient Classes (SLOTS)

In Python, every object usually has a dictionary (__dict__) that stores its attributes. This is flexible but takes up extra memory! ๐Ÿš€

By using __slots__, Python stores attributes in a fixed structure instead of a dictionary. This makes objects smaller and faster. ๐Ÿš€๐Ÿ’จ (very useful if you are using collections of many instances of object)

โšก Creating a Class with __slots__

PYTHON
class Person:
    __slots__ = ('name', 'last_name')  # Define allowed attributes (no __dict__)

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

person = Person('Harley', 'Quinn')
#print(person.__dict__)  # โŒ ERROR! No __dict__ because of __slots__
  • Less memory usage (No extra dictionary ๐Ÿ“ฆ).
  • Faster attribute access (Python knows exactly where to find them ๐Ÿ“Œ).

โ— Important Sidenote: __slots__ is not automatically inherited and needs to be recreated.

1๏ธโƒฃ If a Child Class Has No __slots__

PYTHON
class AnotherPerson(Person):
    ...
    
another_person = AnotherPerson('Jocker', 'Unknown')
print(another_person.__dict__)  # โœ… Works! Creates a dictionary automatically by default
  • If a subclass doesnโ€™t have __slots__, Python reverts to using a dictionary (__dict__), even though the parent class had __slots__!

2๏ธโƒฃ If a Child Class Has __slots__ = () (Empty Slots)

PYTHON
class OneMorePerson(Person):
    __slots__ = ()  # โŒ No extra attributes allowed!

one_more_person = OneMorePerson('Bruce', 'Wayne')
#print(one_more_person.__dict__)  # โŒ ERROR! No __dict__, and no extra slots allowed!
  • Since we redefine __slots__ as an empty tuple (()), no extra attributes can be added at all!

๐Ÿš€ Extending slots in Inheritance ๐Ÿ—๏ธ

  • When inheriting from a class that has __slots__, the child class does not automatically inherit the parent’s slots.
  • You must explicitly declare new __slots__ if you want to add more attributes.
PYTHON
class Person:
    __slots__ = ('name', 'last_name')  # Parent class slots

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

class Employee(Person):
    __slots__ = ('job_title',)  # Extending slots in subclass

    def __init__(self, name, last_name, job_title):
        super().__init__(name, last_name)
        self.job_title = job_title

# Creating an instance
employee = Employee('Jocker', 'Unknown', 'Gotham Troublemaker')

print(employee.name)  # โœ… Works
print(employee.last_name)  # โœ… Works
print(employee.job_title)  # โœ… Works

#print(employee.__dict__)  # โŒ ERROR! No __dict__ because of __slots__

Solutions

PYTHON
# Excercise 1
class Dog:
    def __init__(self, name: str, age: int) -> None:
        self.name = name
        self.age = age 

    def bark_name(self) -> str:
        print(f'my name is: {self.name}')
        
    def bark_age(self) -> str:
        print(f'I am {self.age} years old')


# Excercise 2
jack = Dog(name='Jack', age=2)
ruphy = Dog(name='Ruphy', age=7)
jack.bark_name()
ruphy.bark_name()
jack.bark_age()
ruphy.bark_age()


# Excercise 3
# procedural solution
legs = 'lego legs'
arms = 'lego arms'
head = 'lego head'
body = 'lego body'

def collect_blocks() -> None:
    print(f'collecting blocks: {legs}, {arms}, {head}, {body}')

def build_blocks() -> None:
    print(f'building blocks: {legs}, {arms}, {head}, {body}')

def put_on_shelf() -> None:
    print(f'putting on shelf...')

# oop solution
class LegoMan:
    def __init__(self, legs: str, arms: str, head: str, body: str) -> None:
        self.legs = legs
        self.arms = arms
        self.head = head
        self.body = body

    def collect_blocks(self) -> None:
        print(f'collecting blocks: {self.legs}, {self.arms}, {self.head}, {self.body}')
        # or 
        print(f'collecting blocks: {self!r}')

    def build_blocks(self) -> None:
        print(f'building blocks: {self.legs}, {self.arms}, {self.head}, {self.body}')
        # or 
        print(f'building blocks: {self!r}')
        
    def put_on_shelf(self) -> None:
        print(f'putting on shelf...')
        
    def __repr__(self) -> str:
        return '{legs}, {arms}, {head}, {body}'.format(**self.__dict__) # allows to call {self!r} with !r calling __dunder__ repr__

#Excercise 4
class SpiderMan:
    def __init__(self, web: str) -> None:
        self.web = web

    def swing(self) -> None:
        print(f'uuugh. I am swinging with my {self.web}')


class Venom(SpiderMan):
    ...


# Excercise 5
class Dog:
    def __init__(self, tail: str, teeth: str) -> None:
        self.tail = tail
        self.teeth = teeth

    def bark(self) -> None:
        print(f'I agh agh with my {self.teeth}')

class Cat:
    def __init__(self, paw: str, ears: str) -> None:
        self.paw = paw
        self.ears = ears 

    def meow(self) -> None:
        print(f'I meow and hear myself with my {self.ears}')

class CatDog(Cat, Dog):
    ...


# Excercise 6
class Grandparents:
    def __init__(self, pension: int, leisure: str) -> None:
        self.pension = pension
        self.leisure = leisure

    def do_hobby(self) -> str:
        return f'enjoying the well-deserved pension: {self.pension}'


class Parents(Grandparents):
    def __init__(self, pension: int, leisure: str, salary: int, education: str) -> None:
        super().__init__(pension, leisure) # refers to Grandparents to save pension and leisure
        self.salary = salary
        self.education = education

    def work(self) -> str:
        return f'working hard... for {self.salary}'


class Grandchildren(Parents):
    pass


# Excercise 7
class Grandchildren(Parents):
    def work(self) -> None:
        print(f'while my parents are {super().work()}. I work as well making half of their {self.salary}')


# Excercise 8
class Vault:
    def __init__(self, secret: str) -> None:
        if (length_secret := len(secret)) < 8:
            raise ValueError(f'secret should be > 8 characters long - your is {length_secret}')
        self.__secret = secret

    @property
    def secret(self) -> str:
        return f'{self.__secret[:3]}{'*' * len(self.__secret[3:])}'

    @secret.setter
    def secret(self, secret: str) -> None:
        if (length_secret := len(secret)) < 8:
            raise ValueError(f'secret should be > 8 characters long - your is {length_secret}')
        self.__secret = secret


# Excercise 9
from abc import ABC, abstractmethod
class Animal(ABC):
    def __init__(self, color: str, weight: int) -> None:
        self.color = color
        self.weight = weight

    @abstractmethod
    def make_sound(self) -> None:
        raise NotImplementedError

class Wolf(Animal):
    def make_sound(self) -> None:
        print('aooo')

class Dog(Animal):
    def make_sound(self) -> None:
        print('augh')

class Duck(Animal):
    def make_sound(self) -> None:
        print('quack')


# Excercise 10
from typing import Dict
class Person:
    def __init__(self, names: Dict[str, str] = {}):
        self.names = names

    def append_name(self, name: Dict[str, str]):
        self.names.update(**name)

person1 = Person({'Person 1': 'Nik', 'Person 2': 'Stefan'})
person2 = Person()
person3 = Person()

person2.append_name({'Person 3': 'Lupin'})

print(person2.names)
print(person3.names)
print(id(person1.names), id(person2.names), id(person3.names))
my name is: Jack
my name is: Ruphy
I am 2 years old
I am 7 years old
{'Person 3': 'Lupin'}
{'Person 3': 'Lupin'}
4394795904 4394752256 4394752256

Start searching

Enter keywords to search articles.