20 minute read

After a 1st post covering the core concepts, this 2nd part focus on the Object Oriented part of Python and on functionnal programming. You can find all of this content in a dedicated anki deck WIP to help you memorizing it.

Cards are composed of a simple challenge, then answer shows the code and its’ result in a dedicated jupyter notebook also available on github.

This is a summary of the most important key features and syntax of the language. It was made in order to create an anki deck for memorizing the below examples

Functions

A function is a block of code which only runs when it is called. You can pass data, known as parameters, into a function. A function can return data as a result.

A function prototype

def function_name(parameters ):
   """function_docstring
   on multiple lines"""
   #function_suite
   return [expression]

a simple example of a print_me function

def print_me(str):
    """print the input str"""
    print(str)
    

print_me("Hello")
Hello

How parameters (arguments) in Python are passed ?

by reference: if you change what a parameter refers to within a function, the change also reflects back in the calling function.

def append_lst(lst, param):
    # reference passed
    lst.append(param)

my_lst = [1, 'a', 2.3]
append_lst(my_lst, 'b')
my_lst
[1, 'a', 2.3, 'b']
def change_lst(lst):
    # assign a new reference
    lst = ['a', 'b', 'c']
    

my_lst = [1, 2, 3]
change_lst(my_lst)
my_lst
[1, 2, 3]

Explanation of what is namespace separation

A namespace is a region of a program in which identifiers have meaning. When a Python function is called, a new namespace is created for that function, one that is distinct from all other namespaces that already exist.

In practice, this is that variables can be defined and used within a Python function even if they have the same name as variables defined in other functions or in the main program. In these cases, there will be no confusion or interference because they’re kept in separate namespaces.

This means that when you write code within a function, you can use variable names and identifiers without worrying about whether they’re already used elsewhere outside the function. This helps minimize errors in code considerably.

example of positional arguments
The most straightforward way to pass arguments is with positional arguments (also called required arguments). Order is important:

def you(name, age):
    print(f'{name} is {age} yrs old')
    
    
you("Loic", 20)
Loic is 20 yrs old
you("Loic")
# TypeError: you() missing 1 required
# positional argument: 'age'
you("Loic", 20, ['t', '1'])
# TypeError: you() takes 2 positional 
# arguments but 3 were given

example with global vs. local variables

y = 10
def sum(x):
    return x+y

sum(1)
11
y
10
x
# NameError: name 'x' 
# is not defined

Example of keyword arguments

you specify arguments in the form <keyword>=<value>. Each <keyword> must match a parameter in the function definition. The number of arguments and parameters must still match.

def you(name="Loic", age=20):
    print(f'{name} is {age} yrs old')
    
    
you("Abdel", 30)
Abdel is 30 yrs old

lifts the restriction on argument order

you(age=10, name="Louis")
Louis is 10 yrs old
you(name="Louis", cost=10)
# TypeError: you() got an unexpected 
# keyword argument 'cost'

call a function using both positional and keyword arguments:

def you(name="Loic", age=20):
    print(f'{name} is {age} yrs old')
    

you(30, "Abdel")
30 is Abdel yrs old
you("Ali", age=5)
Ali is 5 yrs old
you(name="Marc", 0)
# SyntaxError: positional argument 
# follows keyword argument

Example of default parameters

If a parameter specified in a function definition has the form <name>=<value>, then <value> becomes a default value for that parameter. They are referred to as default or optional parameters. Any argument that’s left out assumes its default value:

def you(name, age=20):
    print(f'{name} is {age} yrs old')
    

you("Max")
Max is 20 yrs old
def you(name="Mo", age):
    print(f'{name} is {age} yrs old')
    
you(20)
# SyntaxError: non-default argument 
# follows default argument

summary of arguments / parameters:

  • Positional arguments must agree in order and number with the parameters declared in the function definition.
  • __Keyword argument__s must agree with declared parameters in number, but they may be specified in arbitrary order.
  • Default parameters allow some arguments to be omitted when the function is called.

Mutable default parameter values

default parameter values are defined only once when the function is defined

def f(my_list=[]):
    print(id(my_list))
    my_list.append('##')
    return my_list

f([1, 2])
1789862290432





[1, 2, '##']
f([1, 2, 3])
1789862258560





[1, 2, 3, '##']
f()
1789862291584





['##']
f()
1789862291584





['##', '##']
# workaround
def f(my_list=None):
    if my_list is None:
        my_list = []
    my_list.append('##')
    return my_list

Argument passing summary

  • Passing an immutable object (an int, str, tuple, or frozenset) acts like pass-by-value: the function can’t modify the object in the calling environment.

  • Passing a mutable object (a list, dict, or set) acts somewhat—but not exactly—like pass-by-reference: the function can’t reassign the object wholesale, but it can change items in place within the object & these changes will be reflected in the calling environment.

the return statement purposes

1.It immediately terminates the function and passes execution control back to the caller.
2.It provides a mechanism by which the function can pass data back to the caller.

def f():
    print('foo')
    return
    print('bar')

f()
foo

This sort of paradigm can be useful for error checking in a function:

def f():
    if error_cond1:
        return
    if error_cond2:
        return

    <normal processing>

Example of returning data to the caller

a function can return any type of object. In the calling environment, the function call can be used syntactically in any way that makes sense for the type of object the function returns.

def f():
    return dict(foo=1, bar=2)

f()
{'foo': 1, 'bar': 2}
f()['bar']
2
def f():
    return 'foobar'

f()[2:4]
'ob'
f()[::-1]
'raboof'

An example of a function that returns multiple elements

def f():
    return 'a', 1, 'b', 2

f()[2]
'b'
type(f())
tuple
x, *_, z = f()
x, z
('a', 2)

An example of a function that returns nothing

def f():
    return

print(f())
None
def g():
    pass

print(g())
None

An example of a function that doubles an int

def f(x):
    x *= 2

x = 5
f(x)
x
5

this won’t work because integers are immutable,
so a function can’t change an integer argument

def g(x):
    return x * 2

x = 5
x = g(x)
x
10

An example of a function that doubles values of a list (by side effect or not)

def double_list(x):
    i = 0
    while i < len(x):
        x[i] *= 2
        i += 1
 
a = [1, 2, 3, 4, 5]
double_list(a)
a
[2, 4, 6, 8, 10]
def double_list(x):
    return [e*2 for e in x ]

a = [1, 2, 3, 4, 5]
a = double_list(a)
a
[2, 4, 6, 8, 10]

Argument Tuple Packing

when the nb of arguments is not defined and a tuple is probably not something the user of the function would expect. To pass a function a variable nb of arguments use the asterisk (*) operator.

def my_avg(*args):
    return sum(args) / len(args)

my_avg(1, 2, 3)
2.0
my_avg(1, 2, 3, 4)
2.5

Argument Dictionary Packing

the double asterisk (**) can be used to specify dictionary packing and unpacking : the corresponding arguments is expected to be key=value pairs and should be packed into a dictionary (and not considered as a dict):

def dict_arg(**kwargs):
    print(kwargs)

dict_arg(foo=1, bar=2, baz=3)
{'foo': 1, 'bar': 2, 'baz': 3}
dict_arg({"foo":1, "bar":2, "baz":3})
# TypeError: dict_arg() takes 0 
# positional arguments but 1 was given

Credits: Real Python dot com

An example of a function with standard positional parameters, args, and kwargs

def f(a, b, *args, **kwargs):
    print(a, b)
    print([x*2 for x in args])
    print({k:v+1 for k, v in kwargs.items()})
    
f(1,2, 'foo', 'bar', x=100, y=200, z=300)
1 2
['foofoo', 'barbar']
{'x': 101, 'y': 201, 'z': 301}

Multiple Unpackings args in a Python Function Call

def f(*args):
    for i in args:
        print(i)
        
a, b = [1, 2, 3], ['a', 'b', 'c']
f(*a, *b)
1
2
3
a
b
c

Multiple Unpackings kwargs in a Python Function Call

def f(**kwargs):
    for (k, v) in kwargs.items():
        print(k, "-->", v)
        
a, b = {'a': 1, 'b': 2}, {'x': 100, 'y': 200}
f(**a, **b)
a --> 1
b --> 2
x --> 100
y --> 200

Keyword-Only Arguments

Keyword-only arguments allow a Python function to take a variable number of arguments, followed by one or more additional options as keyword arguments.

def concat(*args, prefix='-> ', sep='.'):
    print(f'{prefix}{sep.join(args)}')

concat('a', 'b', 'c')
-> a.b.c
concat('a', 'b', 'c', prefix='//')
//a.b.c
concat('a', 'b', 'c', prefix='//', sep='-')
//a-b-c

Docstrings

def foo(bar=0, baz=1):
    """Perform a foo transfo.
    
    Keyword arguments:
    bar -- blabla (default=1)
    baz -- blabla (default=1)
    """
    #<function_body>
    
print(foo.__doc__)
Perform a foo transfo.
    
    Keyword arguments:
    bar -- blabla (default=1)
    baz -- blabla (default=1)

In the interactive Python interpreter,

help(foo)
Help on function foo in module __main__:

foo(bar=0, baz=1)
    Perform a foo transfo.
    
    Keyword arguments:
    bar -- blabla (default=1)
    baz -- blabla (default=1)

Python Function Annotations

  • Annotations provide a way to attach metadata to a function’s parameters & return value.
  • They don’t impose any semantic restrictions on the code
  • They make good documentation (add clarity to docstring)
def f(a: int = 12, b: str = 'baz') -> float:
    print(a, b)
    return(3.5)

f.__annotations__
{'a': int, 'b': str, 'return': float}
f()
12 baz





3.5
f('foo', 2.5)
foo 2.5





3.5

Enforcing Type-Checking

cf. PEP 484 and use mypy, a free static type checker for Python

when passing a mutable value as a default argument in a function, the default argument is mutated anytime that value is mutated

def append_lst(lst=[]):
    lst.append("aaa")
    return ["zzz"] + lst

append_lst()
['zzz', 'aaa']
append_lst()
['zzz', 'aaa', 'aaa']
def f(value, key, hash={}):
    hash[value] = key
    return hash

print(f('a', 1))
print(f('b', 2))
{'a': 1}
{'a': 1, 'b': 2}

and not as expected
{‘a’: 1}
{‘b’: 2}

solution to mutable value as a default argument in a function

Use None as a default and assign the mutable value inside the function.

def append(element, seq=None):
    if seq is None:
        seq = []
    seq.append(element)
    return seq

append(1)  # `seq` is assigned to []
[1]
append(2)  # `seq` is assigned to [] again!
[2]

Lambda

Sorting with lambda

scientists = [ 'M. Curie', 'A. Einstein', 
              'N. Bohr', 'C. Darwin']

sorted(scientists, key=lambda name: name.split()[-1])
#sorted(iterable, key(callable))
['N. Bohr', 'M. Curie', 'C. Darwin', 'A. Einstein']

An example of a lambda with a tuple as input

(lambda x, y: x + y)(2, 3)
5

Detecting Callable Objects

def is_even(x):
    return x % 2 == 0

callable(is_even)
True
is_odd = lambda x: x % 2 == 1
callable(is_odd)
True
callable(list)
True
callable(list.append)
True
class CallMe:
    def __call__(self):
        print("Called!")
        
callable(CallMe())
True

Object Oriented Programming

This part is based on the examples provided in the excellent course by Joe Marini @LinkedinLearning

Why OOP ?

  • not required to code in Python
  • but complex programs are hard to keep organized
  • OOP can structure code
    • group together data & behavior into one place
    • promotes modularization of programs
    • isolates parts of the code

Concepts / key terms

  • Class = a blueprint for creating objects of a particular type
  • Methods = regular functions that are part of a class
  • Attributes = variables that hold data that are part of a class
  • Object = a specific instance of a class
  • Inheritance = means by which class can inherit capabilitites from another
  • Composition = means of buiding complex objects out of other objects

Basic class definition

create a basic class, an instance & print the class proerty

class Book():
    # the "init" function is called when
    # the instance is created & ready to
    # be initialized
    def __init__(self, title):
        self.title = title
 
b1 = Book("Brave New World")
b1.title
'Brave New World'

Instance methods and attributes

class Book():
    def __init__(self, title, author, pages, price):
        self.title = title
        self.author = author
        self.pages = pages
        self.price = price
        
    def get_price(self):
        if hasattr(self, "_discount"):
            return self.price * (1 - self._discount) 
        else:
            return self.price 
    
    def set_discount(self, amount):
        self._discount = amount

b1 = Book("Brave New World", "L. Tolstoy", 1225, 39.95)
b2 = Book("War and Piece", "JD Salinger", 234, 29.95)

print(b1.get_price())
print(b2.get_price())
b2.set_discount(0.25)
print(b2.get_price())
39.95
29.95
22.4625

What _ & __ are used for ?

  • The _ indicates that an attribute or a method is intended to only be used by the class.
  • With __ the interpreter will change the name of that attribute or method so that other classes will get an error.
  • This prevent subclasses from inadvertently overriding the attribute but other classes can subvert this simply by using the class name.
  • You can use this to make sure that subclasses don’t use the same name for an attribute or don’t have the right to overwrite it.
class Book():
    def __init__(self, title):
        self.title = title
        self.__secret = "SecreT"
 
b1 = Book("Brave New World")
b1._Book__secret
'SecreT'
b1.__secret
# AttributeError: 'Book' object
# has no attribute '__secret'

Checking instance types

class Book():
    def __init__(self, title):
        self.title = title
        
class Newspaper():
    def __init__(self, name):
        self.name = name
        
b1 = Book("The Grapes of Wrath")
b2 = Book("The Catcher In the Rye")
n1 = Newspaper("The NY Times")

type(b1)
__main__.Book
type(n1)
__main__.Newspaper
type(b1) == type(b2)
True
type(b1) == type(n1)
False
isinstance(b1, Book)
True
isinstance(n1, Book)
False
isinstance(n1, object)
True

Class methods and members

attribute at the Class level

class Book():
    # class attribute shared by all instances
    BOOK_TYPES = ("Hardcover", "Paperback", "Ebook")
    
    # a class method
    @classmethod
    def get_book_types(cls):
        return cls.BOOK_TYPES
    
    
    def set_title(self, new_title):
        self.title = new_title
    
    def __init__(self, title, booktype):
        self.title = title
        if (not booktype in Book.BOOK_TYPES):
            raise ValueError(f"{booktype} is not valid")
        else:
            self.booktype = booktype

            
print("Book types: ", Book.get_book_types())
Book types:  ('Hardcover', 'Paperback', 'Ebook')
b1 = Book("Title 1", "Hardcover")
# "Comics" instead of "Hardcover" will raise an error

Static method example

Static methods don’t modify the state of either the class or a specific object instance. Not so many use case : when you don’t need to access any properties of a particular object or the class itself, but it makes sense for the method to belong to the class.
The are global functions in the class namespace.
Example : singleton

Inheritance & Composition

Understanding inheritance

An example to show how to create multiples classes inherited to an other

class Publication():
    def __init__(self, title, price):
        self.title = title
        self.price = price

class Periodical(Publication):
    def __init__(self, title, price, period, publisher):
        super().__init__(title, price)
        self.period = period
        self.publisher = publisher

class Book(Publication):
    def __init__(self, title, author, pages, price):
        super().__init__(title, price)
        self.author = author
        self.pages = pages

class Magazine(Periodical):
    def __init__(self, title, publisher, price, period):
        super().__init__(title, price, period, publisher)

class Newspaper(Periodical):
    def __init__(self, title, publisher, price, period):
        super().__init__(title, price, period, publisher)
        
b1 = Book('bbb', "me", 211, 19.5)
n1 = Newspaper('NY Times', 'my company', 6.0, 'Daily')
m1 = Magazine('Time', 'all', 5.5, 'Monthly')
print(b1.author)
print(n1.publisher)
print(m1.price, n1.price)
me
my company
5.5 6.0

Abstract base classes

You don’t want consumers of your base class to be able to create instances of the base class itself. Because it’s just intended to be a blueprint. Subclasses provide concrete implementations.

from abc import ABC, abstractmethod

class GraphicShape:
    def __init__(self):
        super().__init__()
     
    @abstractmethod
    def calcArea(self):
        pass
    
class Circle(GraphicShape):
    def __init__(self, radius):
        self.radius = radius
        
    def calcArea(self):
        return 3.14 * (self.radius ** 2)
    
        
class Square(GraphicShape):
    def __init__(self, side):
        self.side = side
        
    def calcArea(self):
        return self.side ** 2
# g = GraphicShape()
# cannot be instantiate
c = Circle(10)
print(c.calcArea())
s = Square(12)
print(s.calcArea())
314.0
144

Using multiple inheritance

working example of a class C inherited from both A & B

class A:
    def __init__(self):
        super().__init__()
        self.foo = "foo"
        
class B:
    def __init__(self):
        super().__init__()
        self.bar = "bar"
        
class C(A, B):
    def __init__(self):
        super().__init__()
        
    def showprops(self):
        print(self.foo, self.bar)

c = C()
c.showprops()
foo bar

an other working example of a class C inherited from both A & B, but when both superclasses have the same property with different values.

Python looks in the superclasses in the order in which they are defined from left to right

class A:
    def __init__(self):
        super().__init__()
        self.foo = "foo"
        self.name = "Class A"
        
class B:
    def __init__(self):
        super().__init__()
        self.bar = "bar"
        self.name = "Class B"
        
class C(A, B):
    def __init__(self):
        super().__init__()
        
    def showprops(self):
        print(self.foo, self.name)

c = C()
c.showprops() 
print(C.__mro__)
foo Class A
(<class '__main__.C'>, <class '__main__.A'>, <class '__main__.B'>, <class 'object'>)

Interfaces

What is an interface ?

an interface is a kind of promise for a program behavior / capacity. It’s a programming feature. Unlike Java or C#, Python does not have explicit language support for this. But it is easy to implement

When to use an interface ?

When we want to create a very smal, focused class, that we can use whenever we want another class to be able to indicate. It gives the flexibility to apply this new class which is serving the function of an interface anywhere it is needed. Interfaces are really usefull for declaring that a class has a capability that it knows how to provide

An example of an interface

from abc import ABC, abstractmethod


class GraphicShape(ABC):
    def __init__(self):
        super().__init__()

    @abstractmethod
    def calcArea(self):
        pass


class JSONify(ABC):
    @abstractmethod
    def toJSON(self):
        pass


class Circle(GraphicShape, JSONify):
    def __init__(self, radius):
        self.radius = radius

    def calcArea(self):
        return 3.14 * (self.radius ** 2)

    def toJSON(self):
        return f"square: {str(self.calcArea())"


c = Circle(10)
print(c.calcArea())
print(c.toJSON())
314.0
{ "square": 314.0 }

Understanding composition

a concept that allows to create complex objects out of simpler ones. It is different than inheritance but both two are not exclusive. For instance, a monolithic class definition can be made more extensible & flexible by composiing it with simpler class objects, each of which is responsible for its own features & data.

png

class Book:
    def __init__(self, title, price, author=None):
        self.title, self.price = title, price
        self.author = author
        self.chapters = []
        
    def add_chapter(self, chapter):
        self.chapters.append(chapter)
    
    def get_page_count(self):
        result = 0
        for ch in self.chapters:
            result += ch.page_count
        return result
        
class Author:
    def __init__(self, f_name, l_name):
        self.f_name, self.l_name = f_name, l_name
    
    def __str__(self):
        return f'{self.f_name} {self.l_name}'
    
class Chapter:
    def __init__(self, name, page_count):
        self.name, self.page_count = name, page_count


auth = Author("Leo", "Toltstoy")
b1 = Book("War & Piece", 39., auth)
 
b1.add_chapter(Chapter("Ch. 1", 123))
b1.add_chapter(Chapter("Ch. 2", 96))
print(b1.author, b1.title, b1.get_page_count())
Leo Toltstoy War & Piece 219

Magic Object Methods

A set of methods that Python automatically associates with every class definition. Your class can override these methods to customize a variety of behavior and make them act just like Python’s built-in classes.

  • customize object behavior & integrate with the language
  • define how objects are represented as strings (for user or debugging purpose)
  • control access to attribute values, both for get and set
  • built in comparison and equality testing capabilities
  • allow objects to be called like functions (make code more concise & readable)

String representation

The str function is used to provide a user-friendly string description of the object, and is usually intended to be displayed to the user.
The repr function is used to generate a more developer-facing string that ideally can be used to recreate the object in tis current state (used for debugging purpose)

class Book:
    def __init__(self, title, author, price):
        super().__init__()
        self.title, self.author = title, author
    
    def __str__(self):
        return f'{self.title} by {self.author}' 
    
    def __repr__(self):
        return f'title={self.title}, auth={self.author}'
        
b1 = Book("The Catcher in the Rye", "JD Salinger", 29.95)
print(str(b1))
print(repr(b1))
The Catcher in the Rye by JD Salinger
title=The Catcher in the Rye, auth=JD Salinger

Equality and comparison

Why : plain objects in Python, by default, don’t know how to compare themselves to each other. It can be achieved with the equality & comparison magic methods. Python doesn’t compare objects attributes by attributes : it just compares 2 different instances to each other (same object in memory ?)

class Book:
    def __init__(self, title, author, price):
        super().__init__()
        self.title = title
        self.author = author
        self.price = price
        
    def __eq__(self, value):
        if not isinstance(value, Book):
            raise ValueError("Not a book")
        return (self.title == value.title and 
                self.author == value.author)
        
b1 = Book("My Book", "Me", 20)
b2 = Book("My Book", "Me", 20)
b3 = Book("Your Book", "You", 30)
print(b1 == b2)
print(b1 == b3)
True
False

examples of greater and lesser magic methods

class Book:
    def __init__(self, title, price):
        super().__init__()
        self.title = title
        self.price = price
        
    def __ge__(self, value):
        if not isinstance(value, Book):
            raise ValueError("Not a book")
        return self.price >= value.price
    
    def __lt__(self, value):
        if not isinstance(value, Book):
            raise ValueError("Not a book")
        return self.price < value.price
    
        
b1 = Book("My Book", 40)
b2 = Book("Your Book", 30)
b3 = Book("Other Book", 20)
print(b1 >= b2)

books = [b1, b2, b3]
books.sort()
print([b.title for b in books])
True
['Other Book', 'Your Book', 'My Book']

Attribute access

Your class can define methods that intercept the process any time an attribute is set or retrieved. It gives you a great amount of flexibility and control over hwo attributes are retrieved and set in your classes.

__getattribute__ is similar to __getattr__, with the important difference that __getattribute__ will intercept EVERY attribute lookup, doesn’t matter if the attribute exists or not.

class Book:
    def __init__(self, author, price):
        super().__init__()
        self.author = author
        self.price = price
        self._discount = 0.1

    # Don't directly access the attr name 
    # otherwise a recursive loop is created
    def __getattribute__(self, name):
        if name == "price":
            p = super().__getattribute__("price")
            d = super().__getattribute__("_discount")
            return p - (p * d)
        return super().__getattribute__(name)

    # Don't set the attr directly here otherwise
    # a recursive loop causes a crash
    def __setattr__(self, name, value):
        if name == "price" and type(value) is not float:
            raise ValueError("Not a float")
        return super().__setattr__(name, value)

    # pretty much generate attr on the fly
    def __getattr__(self, name):
        return name.upper()


b1 = Book("My book", 30.0)
print(b1.price)

b1.price = float(40)
print(b1.price)

print(b1.new_attr)
27.0
36.0
NEW_ATTR

Callable objects

Callable like any other function. This features is interesting when you have objects whose attributes change frequently or are often modified together (more compact code & easier to read)

class Book:
    def __init__(self, title, price):
        super().__init__()
        self.title = title
        self.price = price
        
    def __str__(self):
        return f'{self.title} ${self.price}'
    
    def __call__(self, title, price):
        self.title = title
        self.price = price

b1 = Book('War & Piece', 39.95)
print(b1)
b1('My Book', 100.)
print(b1)
War & Piece $39.95
My Book $100.0

Data Classes

In 3.7 Python introduced a new feature called Data Class which helps to automate the creation and managing of classes that mostly exist just to hold data.

Defining a data class

Dataclasses have more benefits than concise code : they also automatically implement init, repr and eq magic methods

from dataclasses import dataclass

@dataclass
class Book:
    title : str
    author : str
    pages : int
    price : float
    
    def book_infos(self):
        return f'{self.title} ${self.price}'

b1 = Book("My Book", "Meh", 120, 2.5)
b2 = Book("My Book", "Meh", 120, 2.5)

print(b1)
Book(title='My Book', author='Meh', pages=120, price=2.5)
b1 == b2
True
b1.author
'Meh'
b1.book_infos()
'My Book $2.5'
b1.author = "YOU!"
print(b1)
Book(title='My Book', author='YOU!', pages=120, price=2.5)

Using post initialization

for example, we might want to create attributes that depends on the values of other attributes, but we can’t write init (the dataclass do it for us). The post_init is where you can add additionnal attr that might depend on other ones in your object.

from dataclasses import dataclass

@dataclass
class Book:
    title : str
    price : float
    
    def __post_init__(self):
        self.infos = f'{self.title} ${self.price}'

b1 = Book("War & Piece", 13.5)
print(b1.infos)
War & Piece $13.5

Using default values

Dataclasses provide the ability to define values for their attributes subject to some rules.

from dataclasses import dataclass

@dataclass
class Book:
    title : str = "No title"
    author : str = "No author"
    pages : int = 0
    price : float = 0.0


b1 = Book()
b1
Book(title='No title', author='No author', pages=0, price=0.0)

Be aware that attributes without default values have to come first

from dataclasses import dataclass

@dataclass
class Book:
    price : float
    title : str = "No title"
    author : str = "No author"
    pages : int = 0

b1 = Book(123.5)
b1
Book(price=123.5, title='No title', author='No author', pages=0)

if price is not declared first : TypeError: non-default argument ‘price’ follows default argument

An other solution is to use “field” :

from dataclasses import dataclass, field

@dataclass
class Book:
    title : str = "No title"
    author : str = "No author"
    pages : int = 0
    price : float = field(default=10.0)

b1 = Book(123.5)
b1
Book(title=123.5, author='No author', pages=0, price=10.0)

instead of the default values, you can also use a function outside the object definition

Immutable data classes

Occasionally you’ll want to create classes whose data can’t be changed : the data in them should be immutable. This is possible by specifiing an argument to the data class decorator.

The “frozen” parameter makes the class immutable

from dataclasses import dataclass

@dataclass(frozen=True)
class ImmutableClass:
    value1: str = "Value 1"
    value2: int = 0
        
    def some_meth(self, new_val):
        self.value2 = new_val
        
obj = ImmutableClass()
print(obj.value1)
Value 1
obj.some_meth(100)
# FrozenInstanceError: 
# cannot assign to field 'value2'

Various links and sources / credits: