Programmering nivå 2

Kap 3.9 – Generiska klasser och metoder

Skriv återanvändbar kod som kan fungera med flera olika datatyper utan att tappa tydlighet.

Mål med lektionen

När du har arbetat klart med denna lektion ska du:

  • Förstå vad generisk kod betyder i Python.
  • Kunna skriva enkla generiska funktioner och metoder med TypeVar.
  • Kunna skapa en enkel generisk klass med Generic.
  • Förstå när generiska klasser och metoder gör kod mer återanvändbar och testbar.

Så här lär du dig bäst

Börja med att jämföra två funktioner som gör samma sak för olika datatyper. Fundera sedan på vad som egentligen är gemensamt. Generiska typer är inte främst till för att göra koden svårare, utan för att beskriva tydligare vilka typer som hänger ihop.

Centrala begrepp

  • Generisk kod: kod som kan användas med flera datatyper.
  • Typvariabel: en symbolisk typ, ofta T, som betyder "samma typ som används här".
  • TypeVar: används för att skapa en typvariabel i Python.
  • Generic: används när en klass ska vara generisk.
  • Typhint: en markering som hjälper läsaren och editorn förstå vilka typer koden arbetar med.

Varför behövs generiska typer?

Tänk att du vill skriva en funktion som hämtar första värdet ur en lista. Funktionen fungerar på samma sätt oavsett om listan innehåller tal, text eller objekt.

def first_text(values: list[str]) -> str:
    return values[0]

def first_number(values: list[int]) -> int:
    return values[0]

Det här ger duplicerad kod. Logiken är identisk. Det enda som skiljer är typen. Med en generisk funktion kan vi beskriva samma idé en gång.

Steg 1: importera TypeVar

from typing import TypeVar

TypeVar finns i Pythons inbyggda modul typing. Importen hämtar verktyget och gör namnet TypeVar tillgängligt i filen. Importen skapar ännu ingen typvariabel.

Steg 2: skapa typvariabeln T

T = TypeVar("T")

TypeVar("T") skapar en typvariabel. Resultatet sparas under Python-namnet T till vänster. Texten "T" är namnet som typverktyg använder för typvariabeln. Man brukar ge båda samma namn.

T är en platshållare för en typ. Den kan motsvara str i ett anrop och int i ett annat. Den lagrar inte vanliga värden som "hej" eller 10.

Steg 3: skriv den generiska funktionen

from typing import TypeVar

T = TypeVar("T")

def first(values: list[T]) -> T:
    return values[0]

Läs funktionshuvudet del för del

  • values är parameterns namn.
  • Kolonet efter values börjar parameterns typhint.
  • list[T] betyder en lista vars element har typen som T representerar.
  • -> T betyder att funktionen returnerar ett värde av samma typ.

Samma T kopplar ihop listans elementtyp och returtypen. Typhintarna beskriver sambandet; de omvandlar inga värden.

Funktionen använder values[0] och förutsätter därför att listan innehåller minst ett element. En tom lista skulle ge IndexError. TypeVar skyddar inte mot det; den beskriver bara typernas samband.

Steg 4: anropa funktionen

name = first(["Anna", "Bo", "Cia"])
score = first([10, 20, 30])

print(name)
print(score)
Anna
10

I det första anropet motsvarar T typen str. I det andra motsvarar T typen int.

Generisk funktion med flera parametrar

Funktionen contains_value() svarar på frågan: Finns det här värdet i den här listan? Vi vill kunna använda samma funktion för exempelvis namn, heltal och andra typer utan att skriva en särskild funktion för varje typ.

from typing import TypeVar

T = TypeVar("T")

def contains_value(value: T, values: list[T]) -> bool:
    return value in values

print(contains_value("Anna", ["Anna", "Bo"]))
print(contains_value(3, [1, 2, 3]))
print(contains_value(8, [1, 2, 3]))

Vad gör koden?

  • value: T är värdet som funktionen ska söka efter.
  • values: list[T] är listan som funktionen ska söka i.
  • Samma T används på båda platserna. Det visar att sökvärdet och listans element ska ha samma typ.
  • -> bool visar att svaret alltid är True eller False.
  • Uttrycket value in values undersöker om värdet finns i listan.
True
True
False

"Anna" finns i den första listan och 3 finns i den andra. Talet 8 finns inte i den sista listan, så det anropet ger False.

Varför används TypeVar här?

Utan TypeVar kan vi fortfarande skriva en funktion som körs, men typhintarna beskriver inte lika tydligt att parametrarna måste höra ihop. T kopplar samman typerna:

  • Skickar vi in en str ska den andra parametern vara en list[str].
  • Skickar vi in en int ska den andra parametern vara en list[int].

Det hjälper läsaren, editorn och typkontrollverktyg att upptäcka tveksamma anrop, till exempel att söka efter ett heltal i en lista som är avsedd för strängar. TypeVar utför däremot inte själva sökningen och stoppar normalt inte felaktiga typer när Python-programmet körs. Det är operatorn in som gör sökningen.

Tydligt exempel: bevara typen från indata till resultat

Funktionen repeat() upprepar ett värde och returnerar en lista. Samma funktion ska fungera för många typer, men editorn ska fortfarande kunna veta exakt vad den returnerade listan innehåller.

from typing import TypeVar

T = TypeVar("T")

def repeat(value: T, times: int) -> list[T]:
    return [value] * times

temperatures = repeat(18.5, 3)
names = repeat("Anna", 2)

print(temperatures)
print(names)
[18.5, 18.5, 18.5]
['Anna', 'Anna']
  • value: T betyder att värdet kan ha olika typer i olika anrop.
  • -> list[T] betyder att listan innehåller samma typ som value.
  • repeat(18.5, 3) får returtypen list[float].
  • repeat("Anna", 2) får returtypen list[str].

Den konkreta fördelen med TypeVar

Vi skulle kunna använda typen object, eftersom alla Pythonvärden är objekt:

def repeat(value: object, times: int) -> list[object]:
    return [value] * times

temperatures = repeat(18.5, 3)  # list[object]

Koden fungerar, men returtypen blir bara list[object]. Editorn har då tappat informationen att listan faktiskt innehåller float.

Med TypeVar kopplas returtypen till typen på argumentet:

def repeat(value: T, times: int) -> list[T]:
    return [value] * times

temperatures = repeat(18.5, 3)  # list[float]

Nu vet editorn att elementen är float och kan föreslå float-metoder samt varna om koden försöker använda elementen som en olämplig typ. Vi får alltså både en återanvändbar funktion och en exakt returtyp.

Använd generisk kod när samma återanvändbara funktion eller klass ska bevara ett tydligt samband mellan flera typer. Använd vanlig kod när lösningen är liten, endast används på ett ställe eller blir lättare att förstå utan generics. Generics förändrar inte vad algoritmen gör; de gör typförhållandet tydligare och ger bättre stöd från editorn och typkontrollverktyg.

Fördjupning: listor med blandade typer

Det här avsnittet bygger vidare på grunderna ovan. En lista kan också vara avsedd att innehålla flera tillåtna typer.

mixed_values: list[int | str | float] = [10, "hej", 3.5]

first_value = first(mixed_values)

int | str | float är en union och betyder att varje element får vara int, str eller float. Alla tillåtna typer ska skrivas i typhinten. Typhinten kräver däremot inte att minst ett värde av varje typ finns i listan. Även [10, 20] är därför tillåten av samma typhint.

Om listan senare även ska få innehålla bytes måste det anges i typhinten, exempelvis list[int | str | float | bytes]. Utan en uttrycklig typhint kan en typkontroll ibland härleda typer från värdena, men det är inte samma sak som att typer automatiskt läggs till i den skrivna koden.

När den blandade listan skickas till first() kan T motsvara unionen int | str | float. Returtypen blir då också int | str | float, även om det första värdet i just detta exempel är heltalet 10.

Typhintar kör inte programmet åt dig

list[T], -> T och andra typhintar är främst hjälp för människor, editorer och typkontrollverktyg. Python stoppar normalt inte automatiskt ett anrop med fel typ när programmet körs. Koden behöver fortfarande testas och vid behov kontrollera värden själv.

Generisk klass

En generisk klass passar när klassen ska kunna lagra eller hantera olika typer av objekt, men med samma logik. Här är en enkel låda som kan innehålla vad som helst.

Generic importeras från typing. När klassen ärver från Generic[T] talar vi om att hela klassen använder typvariabeln T. Då kan attribut, parametrar och returtyper i klassens metoder kopplas till samma typ.

from typing import Generic, TypeVar

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, content: T):
        self._content = content

    def get(self) -> T:
        return self._content

text_box: Box[str] = Box("Hej")
number_box: Box[int] = Box(42)

print(text_box.get())
print(number_box.get())

Box[str] betyder en låda för text. Därför är både parametern content, attributet _content och returvärdet från get() kopplade till str. Box[int] skapar motsvarande samband för heltal. Python tvingar normalt inte detta vid körning, men typhintarna hjälper programmeraren, editorn och testningen.

Exempel: register i ett skolsystem

Ett skolsystem kan behöva separata register för elever, lärare och kurser. Alla registren behöver samma grundfunktioner, till exempel att lägga till objekt och hämta alla objekt. I stället för att skriva tre nästan identiska registerklasser kan vi återanvända Register[T].

from typing import Generic, TypeVar

T = TypeVar("T")

class Register(Generic[T]):
    def __init__(self):
        self._items: list[T] = []

    def add(self, item: T) -> None:
        self._items.append(item)

    def all_items(self) -> list[T]:
        return self._items

class Student:
    def __init__(self, name: str):
        self.name = name

class Teacher:
    def __init__(self, name: str):
        self.name = name

class Course:
    def __init__(self, title: str):
        self.title = title

student_register: Register[Student] = Register()
teacher_register: Register[Teacher] = Register()
course_register: Register[Course] = Register()

student_register.add(Student("Anna"))
teacher_register.add(Teacher("Erik"))
course_register.add(Course("Programmering 2"))

first_student = student_register.all_items()[0]
first_teacher = teacher_register.all_items()[0]
first_course = course_register.all_items()[0]

print(first_student.name)
print(first_teacher.name)
print(first_course.title)
Anna
Erik
Programmering 2

Vad vinner vi på detta?

  • Register behöver bara skrivas och testas en gång.
  • student_register.add() är avsedd för Student-objekt.
  • student_register.all_items() får returtypen list[Student].
  • Lärarregistret och kursregistret återanvänder samma logik men behåller sina egna typer.

Det här är användbart i ett verkligt skolsystem eftersom programmet hanterar flera slags objekt med samma registerfunktioner. Om registren hörde till helt separata program skulle en gemensam generisk klass inte nödvändigtvis vara motiverad.

Interaktiv övning: generiska typer

Välj exempel och kör koden. Lägg märke till att T används för att visa vilka typer som hör ihop: listans innehåll, värdet som skickas in och värdet som returneras.

Välj ett exempel och tryck på Kör exempel.

Programmering 2-koppling: generiska typer ändrar inte själva körningen av Python-koden här. De gör däremot sambandet mellan typer tydligare för programmeraren och editorn.

När ska du använda generiska klasser och metoder?

  • När samma logik ska fungera för flera datatyper.
  • När du vill undvika duplicerade funktioner som bara skiljer sig i typ.
  • När du vill att editorn ska kunna ge bättre stöd och varna för fel typ.
  • När du bygger egna samlingar, register, köer eller lagringsklasser.

När behövs det inte?

  • När koden bara används på ett enda ställe.
  • När en vanlig funktion eller klass är tydligare.
  • När generiska typer gör lösningen svårare att läsa än problemet kräver.

Öva själv

  1. Skriv en generisk funktion last(values) som returnerar sista värdet i en lista.
  2. Skapa en generisk klass Pair som lagrar två värden av samma typ.
  3. Skapa en klass Student och använd Register[Student] för att lagra elever.
  4. Fundera: när blir generisk kod tydligare än duplicerad kod?

När du har försökt själv kan du ladda ner ett lösningsförslag: kap3-9-generics-losningsforslag.py

Sammanfattning

  • Generisk kod kan återanvändas med flera datatyper.
  • TypeVar beskriver en typ som återkommer på flera platser.
  • Generic[T] används när en klass ska fungera med valfri men sammanhängande typ.
  • Generiska klasser och metoder kan göra kod mer läsbar, testbar och återanvändbar.

Tillbaka till Kapitel 3