Pygirls OOP Course
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 ๐
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:
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:
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.
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:pawandname. - You may as well wonder, what
selfhere is. In this caseselfrefers to the instance of the class (meaning specific Cat), which should possess the attributes paw and name.
def sneak(self) -> Noneand
def react(self) -> None- these are the methods our class
Cat๐พ implements. To put it simply, what ourCat๐ฑ can do.
Excercise 1
Now it is time to put what we learned into practice.
# 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 DogHere 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:
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
# 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 ageHere 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
# 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
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
# 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:
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
# create the class Venom which inherits the attribute: web and method: swing from class SpiderManHere you can view the solution . ๐
Multiple Inheritance ๐จ๐ฉโก๏ธ๐ถ
The child class gets things from two parents!
Example:
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.
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 callReady for one more challenge? ๐ค
Excercise 5
# 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:
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 granddadPractice Time!
Excercise 6
# 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:
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 ParentUsing the super() Function ๐
The super() function lets the child class use the parent classโs methods! ๐ฏ
Example:
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 likeintroduce
โ 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
# 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:
class Toy:
def play(self) -> str:
return 'Let's play! ๐ฎ'
toy = Toy()
print(toy.play()) # Everyone can use it!playmethod is public and may be used by everyone
Protected ๐ก๏ธ
Protected things are for you and your family (subclasses) to use!
Example:
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
is supposed to be protected (meaning not allowed for usage outside of its own class or child classes)_kind - to get
we need to call_kindshow_kindmethod
๐ค 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:
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
is private attribute, which cannot be accessed outside of its own class (!also not in child classes)__secret
๐ค GOOD TO KNOW
enforces functionality, where it converts this method to so-called__which cannot be accessed from the instance of class.__dunder__
Getters and Setters in Python ๐ฌ๐ฆ
Getters and Setters help you get and set private data safely! ๐๏ธ
Example:
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())getterallows you to enrich access to attributes with additional functionality- here for example we convert the private attribute
to string representation__balance setterallows 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:
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
getterandsetter.
๐ NOTE: you may also delete the properties using
del. You may also enrich deleting functionalities by adding@.deleter
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:
__get__: Called when reading a value.__set__: Called when setting a value.__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.
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__ ๐
__set_name__With , the descriptor automatically knows the attributeโs name (e.g., weight or price).__set_name__
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: 25Ready for one more challenge time? ๐ค
Excercise 8
# 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:
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)) # 10Method Overriding ๐จ
Method overriding means a child class can change the way a method from the parent class works. Example:
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 ParentDuck 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:
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 ๐ฅ:
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:
from typing import Listtypingis 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
from abc import ABC, abstractmethod- Here we import two modules:
ABCandabstractmethod. 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 likeCheesePizzais supposed to implement)
- Meaning the class (in our case
โ NOTE
class Pizzadoes not provide the implementation ofbakemethod. It just says that this method should be implemented by child classes.
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.
@abstractmethodis decorator which enforcesbakemethod to be mandatory for child classes
โ NOTE using
@abstractmethodis not mandatory here. Since weraise NotImplementedError, the MRO will search for the first possible implementation of bake method, which will be in parentclass Pizzaand raises this error. So we will be notified either way, that child class misses bake method also without@abstractmethod
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)orVeganPizza(Pizza). - Each of the child classes has its own implementation of
bake๐ณ method. - In
CheezePizzawe add more cheese through_add_cheesemethod - In
VeganPizzawe add more vegis through_add_vegismethod - In
NapoliPizzawe add more tomatoes through_add_tomatoesmethod
โ NOTE The abstraction enables us to bake each of pizzas just by calling
bakemethod on instance. We are sure they implement this method, since they inherit from abstract classclass Pizzawith@abstractmethodbake.
๐ค GOOD TO KNOW according to PEP8, the hidden (=private) methods or attributes like
_add_cheese,_add_vegis,_add_tomatoesusually haveprefix._
Ready for one more challenge? ๐
Excercise 9
# 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__
__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().
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 (+=) updates an object instead of creating a new one. So it modifies __iadd__self.
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__, and __len__, we can make objects act like lists!__iter__
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 and __setattr__, we can customize how attributes work!__getattr__
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__, and get().__contains__
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
mixinsare 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 ๐๏ธ
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
UserDictobject) - 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 doessuper()refer to then? It refers to the next standing parent class usingMROlogic. That’s why you haveclass CaseInsensitiveDict(UpperCaseSearcher, UserDict)UpperCaseSearcherstanding on the left fromUserDict. SoUpperCaseSearchersuper()will refer toUserDict.
๐ญ Example: Adapting Methods in Mixins
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
OriginalClasswithout modifying it. - we introduced the new class
AdaptedClasswhich contains defaultOriginalClassfunctionality with some extensions coming fromMixin
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:
# 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'typehere allows to create class which will be calledMetaClass, haveobjectas base, and have the attributexcontaining value12
โ NOTE 1: base classes for
typefunction 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.
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 callablemethods(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:
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
metaclassalso ensures that certain attributes or methods likeI_AM_HEREare 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:
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().
- The Person class is abstract because it has an abstract method
- Registering
AnotherPerson:- When we use the
@Person.registerdecorator, theclass AnotherPersonregisters itself withPerson. - Even though
AnotherPersondoes not implement the abstract methodsay_hello(), it is still considered a subclass ofPerson. - Itโs like signing up to follow the blueprint, without actually inheriting everything.
- When we use the
- Method Resolution Order (
__mro__):- The MRO shows the order in which Python looks for methods.
- In the case of
AnotherPerson,Personis not in the MRO becauseAnotherPersonwas not inherited from Person. It was just registered.
issubclass()andisinstance():- Even though
AnotherPersondoesn’t inheritPerson, itโs still treated as a subclass ofPersonbecause it was registered. - You can create an instance of
AnotherPersonand it will behave as if itโs aPerson, even though the abstract method isnโt implemented!
- Even though
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:
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 method to do this!__hash__
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
@dataclassworks differently with hashing and has some different implementations of hashing algorithms likefrozen=Trueconfiguration and etc. Please refer to the section of@dataclassfor more details
@dataclass
Python has a cool tool called @dataclass that makes creating classes easier! It helps automate many things, like:
- Creating the
method for you โ__init__ - Adding the
method for nice printing โ__repr__ - Comparing objects easily โ
- Making objects unchangeable (
) โfrozen=True
Basic Example
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
method! Python did it for us. ๐__init__ - The
method was automatically created so we can print the object nicely.__repr__
โ๏ธ 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
dictandset
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:
@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
@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)
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.
๐ : Running Code After Initialization__post_init__
The method runs after the object is created. We can use it for extra validation.__post_init__
@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 Features@dataclass
| 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… ๐ค
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)!Person1has their own separate list because they provided a list (['Migel', 'Benjamin']).Person2andPerson3did NOT provide a list (which was declared above in__init__). So, Python gives them the same shared list.- When
Person2adds‘Lupin’,Person3also sees “Lupin”โbecause they share the same list in memory!
Excercise 10
# 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 usingdefault_factory=list:
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 () that stores its attributes. This is flexible but takes up extra memory! ๐__dict__
By using , 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)__slots__
โก Creating a Class with __slots__
__slots__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:
is not automatically inherited and needs to be recreated.__slots__
1๏ธโฃ If a Child Class Has No __slots__
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)
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 emptytuple (()), 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.
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
# 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