Python utilitza "duck typing", pero també pots utilitzar "nominative typing" per validar codi.

Introducció

Tal com vam veure a Objectes, Python utlitza el que es coneix com "duck typing", de la mateixa manera que ho fa Javascript, Go o altres llenguatges.

Pert tant, Python té tipus "flexibles" i una variable pot tenir un objecte de qualsevol tipus.

Aquesta flexibilitat, fa que llenguatges com Python i Javascript siguin molt útils, sense les greus limitacions que tenen llenguatges com Java en la reutilització de llibreries, l'herència de classes, la dificultat de composar objectes, etc.

Però aquesta flexibilitat a vegades pot causar problemes perquè l'objecte que té la variable no és del tipus que esperem perquè no té la propietat corresponent.

Això es soluciona amb tests, però normalment els programadors, i no diguem els alumnes, no veuen la necessitat de fer tests a menys que els obliguis.

Per solventar aquesta situació, desde fa poc temps Python ha introduït els "type hints".

Però el primer llenguatge a fer això va ser Typescript amb Javascript, quan aquest estava molt qüestionat, i ara està tan viu que és un dels llenguatges més importants que has d'apendre.

Duck typing

Python és un llenguatge que utiliza "duck typing" ( o tipus dinàmics).

Duck typing és una aplicació del duck test per determinar si un objecte es pot utilitzar per un propòsit particular.

El "duck test" diu que si un objecte camina com una ànec i fa "cuac" com un ànec, llavors ha de ser un ànec.

Per tant, un objecte és d'un tipus concret si té tots les propietats que demana aquell tipus.

A continuació tens 3 objectes de classes diferents que són de tipud poden nadar, però només 1 pot volar i només 1 pot parlar:

Però per tu qui s'assembla més a l'ànec de l'esquerra, l'ànec "Donald" o el pinguï?

En canvi, altres lleguatges fan servir "nominative typing", en que un objecte és d'un tipus concret perqué tu has dit expressament que era d'aquell tipus (o si es pot associar aquell tipus mijançant mecanismes com l'herència) sense importar els atributs que té.

A continuació tens dues classes diferents que tenen el mateix mètode swim:

class Duck:
    def swim(self):
        print("Duck swimming")

class Whale:
    def swim(self):
        print("Whale swimming")

Si creas un objecte Duck i un objecte Whale, i només t'interessa tractar objectes que puguin nadar, en aquest cas concret el dos objecte són del mateix tipus perquè tots dos tenen el mètode swim:

animals = [Duck(),Whale()]

for animal in animals:
    animal.swim()

I els dos poden nadar:

> ptyhon3 test.py
Duck swimming
Whale swimming

Però que passa si un ànec també pot volar?

class Duck:
    def swim(self):
        print("Duck swimming")

    def fly(self):
        print("Duck flying")

class Whale:
    def swim(self):
        print("Whale swimming")

animals = [Duck(),Whale()]

for animal in animals:
    animal.swim()

Doncs no passa res si només vols que l'objecte nadi 🐳 🦆 !

> ptyhon test.py
Duck swimming
Whale swimming

Pero si vols que la ballena també voli ..

for animal in animals:
    animal.swim()
    animal.fly()

Python et dirà que al ballena no pot volar, no perqué Python sàpiga que le ballenes no poden volar, sinó perquè l'objecte 'Whale' no té l'atribut 'fly':

> python test
Duck swimming
Duck flying
Whale swimming
Traceback (most recent call last):
  File "/home/box/test/app/test.py", line 15, in <module>
    animal.fly()
    ^^^^^^^^^^
AttributeError: 'Whale' object has no attribute 'fly'

Mypy

Mypy is an optional static type checker for Python that aims to combine the benefits of dynamic (or "duck") typing and static typing. Mypy combines the expressive power and convenience of Python with a powerful type system and compile-time type checking. Mypy type checks standard Python programs; run them using any Python VM with basically no runtime overhead.

Instal.la l'extensió "MyPy Type Checker":

I elimina l'extensió "Pylance" si la tens instal.lada!

També pots instal.lar Mypy amb Poetry i verificar el codi desde la linia d'ordres:

TODO

Type hint

Els "type hints", o anotacions, són una anotació especial que permet anotar una variable amb el tipus que tu vulguis.

Per tant, són tipusnominatius ("nominative typing"): un objecte és d'aquell tipus perqué tu has dit que és d'aquell tipus.

Per exemple, pots anotar la variable x amb el tipus bool:

x: bool
x = True
x = 1 ## Type error
print(x)

I mypy t'indicarà un error si a la variable x li assignes un int:

Però aquest error l'indica mypy, no és un error de Python.

Pots executar el codi sense problemes:

> python test.py
1

A Python li importa un rabe, són anotacions.

Però les anotacions no només són per indicar que una variable és d'una classe.

Per exemple, pots dir que una variable és inmutable:

from typing import Final

num: Final = 3
num = 4

I mypy et dirà que és un error modificar el valor de la variable x, però per Python no existeixen variables inmutables.

I precaució, ves amb compte amb el tipus bool, perquè en realitat està implementat com un int: False és 0, i True és 1 quan es converteixen en números:

x: int = 3
x = "hola" # Error de validació
x = True

print(x + 1)

I ja podem tornar al nostre exemple del principi, el de l'ànec i la ballena.

Com que tens activat mypy, l'editor et dirà que: "object" has no attribute "swim".

Si anotes la llista amb tipus list[Duck], mypy et dirà que l'error és afegir una ballena a una llista en que només haurien d'haver ànecs:

I en aquest cas mypy i Pyhon estaran d'acord en que el codi esà mal escrit:

> python test.py
Duck swimming
Duck flying
Whale swimming
Traceback (most recent call last):
  File "/home/box/test/app/test.py", line 15, in <module>
    animal.fly()
    ^^^^^^^^^^
AttributeError: 'Whale' object has no attribute 'fly'

Però tal com indica l'error estan d'acord per motius diferents:

  1. mypy diu que és un error afegir una ballena a una llista en que només hi hauria d'haver ànecs
  2. Python diu que és un error cridar la funció fly en un objecte que no té aquest atribut.

Però que passa si no utilizes el mètode fly?

for animal in animals:
    animal.swim()

Doncs que:

  1. mypy segurirà dient que és un error afegir una ballena a una llista en que només hi hauria d'haver ànecs
  2. A Python, mentres que l'objecte tingui el mètode swim(), tant li fa que sigui una ballena, com un ànec, com una sardina.
> ptyhon test.py
Duck swimming
Whale swimming

I llavors, quina utilitat tenen els type "hints" ... 😕 ?

Moltíssima!! Els editors et poden dir quins mètodes pots utilitzar i les eines t'avisen de que t'has equivocat.

Ajuda

A continuació tens un exemple:

def get_full_name(first_name, last_name):
    full_name = f"{first_name.title()} {last_name.title()}"
    return full_name

print(get_full_name("gemma", "vila"))

Si executes aquest programa, tens aquest resultat:

Gemma Vila

L'única dificultat que té aquest codi és saber que la classe str té el mètode title() per convertir la primera lletra a majúscula.

I segur que ningú ho sabies, i d'aquí pocs dies segurament no recordes com es deia.

Normalment la situació és aquesta:

def get_full_name(first_name, last_name):
    full_name = f"{first_name.

Com es diu el mètode que transforma la primera lletra en majúscula: upper, uppercase, first_uppercase, capitalize, ... ?

Si demanes a la IDE que t'ajudi amb Ctrl+Space, no et pot ajudar:

En canvi, si utilitzes "type hints" en els paràmetres de la funció, llavors si que et pot ajudar sense inventar-se res:

def get_full_name(first_name: str, last_name: str):
    full_name = f"{first_name.

La IDE mira quins són els mètodes de la classe str i te'ls mostra:

L'únic que has de fer es escollir el mètode que necessites.

Errors

Els "type hints" també permeten detectar errors en el codi que has escrit.

Per exemple, aquest codi té un error i mypy no el pot detectar:

from math import pi

def area(radius):
    return pi * radius ** 2

area("3")

Si vull ajuda de mypy necessito declarar tipus nominatius:

from math import pi

def area(radius: int) -> float:
    return pi * radius ** 2

area("3")

Com que el paràmter radius està anotat amb el "tipus" int, l'editor t'avisa de que no pots cridar la funció area amb un str:

Si vols pots fer que aquest codi funcioni amb un str:

Aquest codi, encara que funcioni, mai l'has d'escriure:

from math import pi

def area(radius: str) -> float:
    return pi * float(radius) ** 2

area("3")

L'error és bastant evident, però la majoria no ho són.

A continuació tens un exemple:

from math import pi

def printArea(radius):
    area = pi * radius ** 2
    print("area: " + area)

printArea(4)

Digues si el codi és correcte o no sense executar el codi.

Anota el paràmetre amb el tipus corresponent.

from math import pi

def printArea(radius: int):
    area = pi * radius ** 2
    print("area: " + area)

printArea(4)

mypy informa d'aquest error: Unsupported operand types for + ("str" and "float")

Per tant, tens 2 opcions:

  1. Convertir l'int en un str: print("area: " + str(area))
  2. Utilitzar un f-string: print(f"area: {area}")

Tipus genèrics

Tots els tipus que tenen tipus interns s'anomenen tipus genèrics.

Normalment els tipus gènerics són contenidors: un objecte que té com a propòsit contenir altres objectes.

List

Per exemple, si tens una llista i només vols que tingui objectes str, la pots anotar amb list[str].

El tipus és list, i el tipus intern (o paràmetre de tipus) és str

Diem paràmetre perqué el tipus list, en cap cas la classe list, necessita un paràmetre de tipus, igual que una funció necessita almenys un paràmetre.

A continuació tens un exemple:

def process_items(items: list[int]):
    for item in items:
        print(item)

En aquest cas, estas dient que la variable items ha de ser una list i que tots els items de la llista han de ser int.

Si executes la funció amb una llista en que no tots són int ...

def process_items(items: list[int]):
    for item in items:
        print(item)

process_items([1,"a"])

"mypy" indica l'error: List item 1 has incompatible type "str"; expected "int"

Però el codi s'executarà sense error perquè la funció print pot imprimir per pantalla qualsevol objecte.

> python3 test.py
1
a

Però si modifiques la funció process_items tal com es mostra a continuació:

def process_items(items: list[int]):
    print(sum(items))


process_items([1, "a"])

Llavors si que es produrirà un error:

> python3 test.py
    print(sum(items))
          ^^^^^^^^^^
TypeError: unsupported operand type(s) for +: 'int' and 'str'

Tuple

Una tupla també es pot anotar amb un tipus.

A continuació tens un exemple:

person: tuple[str,int,bool]
person = ("David", 51, False)
person = ("Gemma","Vila",True) # Error de validació

I si enlloc de tuples utilitzes una typing.NamedTuple per donar més sentit als valors ...

from typing import NamedTuple

class Person(NamedTuple):
    name: str
    age: int
    married: bool


person: Person
person = Person("David", 51, False)
person = Person("Gemma", "Vila", True)  # Error de validació

Dict

Per anotar un dict has de passar 2 paràmetres de tipus: el primer per les claus, el segon pels valors.

items: dict[str, float] = {}
items["apple"] = 0.45
items["orange"] = "Very expensive" # Error de validació

Union

La unió de tipus permet que una variable pugui gestionar més d'un tipus a la vegada sense que les classes tinguin una classe en comú.

Per exemple, una variable pot referenciar el tipus str o el tipus int amb aquesta anotació:

x: bool | int = 3
x = True

x = "hola!" # Error de validació

Null

Els valors nuls són un dels grans problemes de la programació,

La majoria de llenguatges tenen problemes per gestionar els valors nuls perquè són rígids.

Però amb un llenguatge que permet un tipus "union" es resol el problema.

Per exemple tinc una variable name de tipus str:

name: str = None # Error de validació
print(f"Hello, {name}")

I aquesta variable sempre ha de tenir un nom!

En canvi puc declarar una variable name en que el valor potser no existex:

name: str | None = None
print(f"Hello, {name}")

Un exemple absurd? No, quan no saps si el valor existirà o no existirà.

Per example, pots dissenyar una classe Person amb name i phone, on només name és obligatori:

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    phone: str | None = None


def call(person:Person):
    if person.phone is None:
        print(f"{person.name} has not phone")
    else:
        print(f"Calling {person.name} ...")

david = Person("David")
call(david)

Però None que vol dir: que la persona no té teléfon o que no sabem quin número de teléfon té?

Pots anotar la variable phone amb l'unió de tipus str | None | bool que recull les tres opcions:

  • str - El telèfon de la persona
  • None - La persona no té telèfon
  • bool - Hem preguntat a la persona si té telefon: si no hem preguntat, llavaorphone == False
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    phone: str | None | bool = False


def call(person:Person):

    if person.phone == False:
        print(f"We don't know if {person.name} has a phone")
    elif person.phone is None:
        print(f"{person.name} has not phone")
    else:
        print(f"Calling {person.name} ...")

call(Person("David"))
call(Person("Eva", None))

Activitat

Implementa aquesta funció, que reb com a paràmetre un dict i tornar com a resultat la tupla amb el valor més alt o None si el dict està buit:

def more_expensive(items: dict[str, float]) -> tuple[str, float] | None :

A continuació tens un codi d'ajuda:


def more_expensive(items: dict[str, float]) -> tuple[str, float] | None:
    pass


assert more_expensive({}) == None
assert more_expensive({"apple": 0.45, "orange": 0.34}) == ("apple", 0.45)

def more_expensive(items: dict[str, float]) -> tuple[str, float] | None:

    result: tuple[str, float] | None = None

    for name, price in items.items():
        if result is None:
            result = (name, price)
        else:
            if price > result[1]:
                result = (name, price)

    return result


assert more_expensive({}) == None
assert more_expensive({"apple": 0.45, "orange": 0.34}) == ("apple", 0.45)

Herència

L'herència de classes és útil fins a cert límit i en uns contextos concrets.

I la prova més clara són l'enorme quatitat d'exemples absurds per explicar l'herència.

Per exemple, pots tenir dos "data class" que no tenen res a veure un amb l'altre:

from dataclasses import dataclass
import datetime

@dataclass
class Person:
    name: str
    birthdate: datetime.date | None = None
    married: bool | None = None


@dataclass
class Organization:
    name: str
    number_of_employees: int | None

Però que passa si resultat que en un ús concret per mi són el mateix?

Per exemple, per al meu programa els dos són clients, i de totes les seves caracteístiques només m'interessa el nom.

La sólució amb herència és crear una classe pare abstracta amb l'atribut name i que les dos classes heretin de la principal:

classDiagram
direction TD

class Client {
    name: str
}

class Person {
    birthdate: date | None
    married: bool | None
}

class Organization {
    number_of_employees: int | None
}

Client <|-- Person
Client <|-- Organization

Però això és una mala solució si aquestes dades s'han d'utitlizar d'altre manera o s'han de relacionar amb altres dades per usos diferents.

Amb una unió de tipus pots crear un tipus Client amb TypeAlias per tractar les dos dades a la vegada:

from dataclasses import dataclass
import datetime
from typing import TypeAlias

@dataclass
class Person:
    name: str
    birthdate: datetime.date | None = None
    married: bool | None = None


@dataclass
class Organization:
    name: str
    number_of_employees: int | None

Client: TypeAlias = Person | Organization

I pots utilitzar el tipus Client sense problemes i accedir a l'atribut name directament, perque Python treballa amb "duck typing":

client: Client = Person("David")
print(client.name)

L'únic que importa és que tingui un atribut name!

Composició

Un dels principis bàsics de programació és composar objectes, enlloc de crear relacions d'herència.

I si un mòdul, de la mateixa aplicació o d'una altra aplicació, vol afegir nous atributs al tipus Client?

Llavors has de crear la classe Client:

@dataclass
class Client:
    id: int
    party: Person | Organization
    payment: str
    credit: float

client: Client = Client(1, Person("David"))

Tipus dinàmics

El tipus "union" són molt utils quan has de crear tipus per APIs amb tipus dinàmics, i és el motiu que són un part integral de TypeScript

Python no utilitza tipus dinàmics.

Activitat

Imagina't un aplicació d'un Institut o Universitat.

Cada situació concreta necessita una solució concreta, i només hi ha bones solucions, mai existeix la solució perfecta!

I el sistema de dades mai està en memòria, sinó en la base de dades!

Un sistema d'objectes amb estat serveix per dissenyar un intefície gràfica o simular objectes reals, mai per dissenyar un sistema de dades!

En el nostre sistema tenim registrat persones:

classDiagram
direction LR

class Person {
    name: str
    birthdate: date | None
}

Escriu el codi corresponent:

from dataclasses import dataclass
import datetime

@dataclass
class Person:
    name: str
    birthdate: datetime.date | None = None

Una matèria l'ha de donar una persona de l'institut, però pot ser qualsevol persona?

@dataclass
class Subject:
    name: str
classDiagram
direction LR

class Person {
    name: str
    birthdate: date | None
}

class Subject {
    name: str
}

class Deparment {
    name: str
}

class Teacher

Teacher --> Person
Teacher --> Deparment

class Course {
    year: int
}

class Assignment


Assignment --> Teacher
Assignment --> Course
Assignment --> Subject


Ets un professor de l'Institut perqué ets una persona assignada a un departament (tens aquest atribut)

@dataclass
class Teacher:
    person: Person

# ...

I qualsevol persona de l'institut pot ser alumne d'una matèria?

És necessari crear la classe estudiant?

classDiagram
direction LR

class Person {
    name: str
    birthdate: date | None
}

class Subject {
    name: str
}

class Enrollment

Enrollment --> Person
Enrollment --> "1..*" Subject

@dataclass
class Enrollment:
    person: Person
    subjects: list[Subject]