Kapittel 11 Klasser og objekter


Da håper jeg du har blitt dreven på å jobbe med filer fra forrige kapittel. Nå skal vi lære om objektorientert programmering106. Ofte brukes forkortelsen OOP siden objektorientert programmering er en munnfull. Dette er et mønster for å strukturere programmene våre inn i klasser og objekter. Vi skal lære å lage våre egne klasser og objekter i Python. Ikke alle prosjekter bør nødvendigvis struktureres i mønsteret OOP, men ofte kan det være veldig nyttig. Uansett om du vil aktivt bruke OOP eller ikke, så hjelper dette kapittelet deg med å forstå Python bedre. I Python er nemlig alt i bunn og grunn klasser og objekter!

11.1 Introduksjon - En verden av klasser og objekter

La oss se litt nærmere på lister i Python for å motivere bruken av klasser og objekter. Vi har tidligere sett at vi kan opprette lister som heter kaffe og te i Python slik:

kaffe = ['Friele', 'Ali', 'Evergood']
te = ['Yogi', 'Twinings', 'Numi']

Både kaffe og te er forskjellige objekter107 som hører til klassen108 list. Dette kan vi se ved å bruke funksjonen type() som vi har lært om tidligere:

print(type(kaffe))
print(type(te))

Kjører du koden over vil du få skrevet ut <class 'list'> to ganger. Hva betyr dette? La oss starte med å forklare hva en klasse er.

Tenk på en klasse som en mal eller oppskrift på:

  • Hvilken informasjon objektene skal holde.

  • Hvilken atferd objektene skal kunne benytte seg av.

Så i vårt tilfelle spesifiserer list hvordan lister holder elementene sine, samt hvilke metoder du har tilgjengelig når du jobber med lister. Du husker sikkert fra kapittel 4 at vi kan bruke metoder som .append() og .sort() på lister. Det er klassen list som bestemmer dette.

Hva så med objekter? Objekter er spesifikke instanser av en klasse. Så både kaffe og te er objekter som er instanser av klassen list. Objekter holder informasjon og kan benytte seg av atferd i form av metoder.

En klasse har ofte opprettet flere forskjellige konkrete objekter i løpet av et program. Akkurat som du er vant med, opererer objektene uavhengig av hverandre:

kaffe.append('Kjeldsberg')

print(kaffe) # Skriver ut ['Friele', 'Ali', 'Evergood', 'Kjeldsberg']
print(te)    # Skriver ut ['Yogi', 'Twinings', 'Numi']

Det at du bruker metoden .append() på objektet kaffe endrer ikke objektet te overhodet. Hvert objekt er sin egen verden, men objekter som hører til samme klasse er strukturert likt.

Som du kanskje allerede forstår, er det ingenting spesielt med lister. Alt i Python er klasser og objekter! Jeg synes det er betryggende at så forskjellige ting som int og _io.TextIOWrapper som vi støtte på når vi leste filer i kapittel 10 er begge klasser. Både int og _io.TextIOWrapper spesifiserer informasjonen og atferden til sine objekter.

Du har kanskje lagt merke til at vi tidligere i boken har brukt begrepet datatyper når vi har snakket om ting som int, str, float, bool, og list. Nå snakker vi plutselig om klasser. Hva er egentlig forskjellen? I Python er datatyper og klasser i praksis det samme. Det vi tidligere kalte en datatype er faktisk også en innebygd klasse i Python. Når vi sier at 5 er av typen int, betyr det egentlig at 5 er et objekt laget ut fra klassen int.

I Python blir også de mest vanlige innebygde klassene som for eksempel int, str, float, bool, og list skrevet med små bokstaver. Men du husker kanskje at vi har brukt andre klasser som for eksempel Counter fra standardbiblioteket collections. Her ser du at dette er skrevet med stor forbokstav. Utenom de mest vanlige innebygde klassene er standarden fra PEP 8 at klasser skal skrives med stor bokstav for hvert nytt ord. Dermed er Student, KaffeKopp, og VerdensBestePappa anbefalte måter å skrive klassenavn på i Python når vi skal lage våre egne klasser.

Så langt har vi vært bundet til de innebygde klassene i Python. Vi har jobbet med alt fra ordbøker til flyttall. Men hva når de ikke strekker til? Da kan vi lage våre egne klasser og objekter som er helt tilpasset problemet vårt.

11.2 Vår første klasse

La oss hoppe i det og lage vår første klasse. Klassen skal representere en spillkarakter i et rollespill. Spillkarakteren skal ha et navn, et styrkenivå, et flaksnivå, og en sannhetsverdi for å representere hvorvidt spillkarakteren er snill eller slem. I tillegg skal klassen ha metoder for å:

  • Øke styrkenivået med en gitt verdi.

  • Vurdere om flaksnivået er over en gitt verdi.

  • Bytte parti. En slem karakter skal altså kunne byttes til å bli snill, mens en snill karakter skal kunne bli byttet til å bli slem.

Koden for å implementere dette som en klasse ser slik ut:

class SpillKarakter:
    """En klasse som representerer spillkarakterer i et rollespill."""

    def __init__(self, navn, styrke, flaks, er_snill):
        self.navn = navn
        self.styrke = styrke
        self.flaks = flaks
        self.er_snill = er_snill

    def øk_styrkenivået(self, nivå):
        self.styrke += nivå

    def vurdere_flaksnivået(self, grense):
        if self.flaks >= grense:
            return True
        return False

    def bytte_parti(self):
        self.er_snill = not self.er_snill

Når du ser på koden over så er det flere ting som er helt nye her. Her er det bare å trekke pusten, så skal jeg lede deg gjennom alt som er nytt:

  • Vi bruker nøkkelordet class for å lage en ny klasse. Det er sammenlignbart med at vi bruker def når vi lager en ny funksjon. Vår klasse heter SpillKarakter. Som jeg nevnte i seksjon 11.1 er klassenavn i Python skrevet med store forbokstaver i hvert ord. Dette skiller klassenavn fra funksjonsnavn og variabelnavn som begge er skrevet med små bokstaver og med tegnet _ imellom hvert ord. Innholdet i klassen er en kodeblokk som er flyttet til høyre slik som i funksjoner.

  • Den første linjen i klassen SpillKarakter er en dokumentasjonsstreng for klasser. Dette kan du bruke til å dokumentere hva klassen representerer og eventuelt mer detaljert informasjon om innholdet i klassen. I dette tilfellet har jeg bare føyd på en enkel dokumentasjonsstreng.

  • Selve innholdet i klassen består først og fremst av en spesiell funksjon som heter __init__(). Dette er et navn som Python kjenner igjen og som har en helt spesiell oppgave. Oppgaven til funksjonen __init__() er å initialisere objekter til klassen. Ofte kalles funksjonen __init__() for en konstruktør109. Hvert objekt vi skal lage fra klassen må spesifisere de fire verdiene navn, styrke, flaks, og er_snill for å opprettes. Som du ser inni funksjonen __init__() så vil hvert objekt ha de fire verdiene som innhold. Verdiene her kalles attributter110.

  • Til slutt oppretter vi funksjonene øk_styrkenivået(), vurdere_flaksnivået(), og bytte_parti(). Dette vil være metodene111 til objektene vi oppretter.

Det er kanskje fortsatt litt forvirrende hva self gjør i koden over. La oss først opprette noen objekter og leke litt med dem. Etter dette vil det være lettere å forklare hvilken oppgave self har når vi skriver våre egne klasser.

11.2.1 Opprette objekter

For å opprette objekter, i vårt tilfelle spillkarakterer, kan vi skrive følgende kode:

mario = SpillKarakter(navn='Mario', styrke=7, flaks=9, er_snill=True)
peach = SpillKarakter(navn='Prinsesse Peach', styrke=8, flaks=6, er_snill=True)
wario = SpillKarakter('Wario', 9, 4, False)

Her har vi laget tre kjente spillkarakterer fra Super Mario universet. For mario og peach opprettet vi spillkarakterene med nøkkelordbaserte argumenter slik som styrke=7. For wario benytter vi oss bare av posisjonsbaserte argumenter. Da er vi avhengig av at rekkefølgen vi plasserer argumentene i stemmer overens med rekkefølgen på parameterne i funksjonen __init__().

Etter å ha opprettet tre spillkarakterer kan vi leke oss litt med å hente ut informasjon fra dem. Dette kan vi gjøre ved å bruke følgende notasjon:

print(f'Karakterene sine navn er {mario.navn}, {peach.navn}, og {wario.navn}.')

samlet_styrkenivå_snille = mario.styrke + peach.styrke
samlet_styrkenivå_slemme = wario.styrke

if samlet_styrkenivå_snille > samlet_styrkenivå_slemme:
    print('Virker som de snille har overtaket!')

Som du ser av koden over kan vi hente attributtene til spillkarakterene ved å skrive mario.navn, peach.styrke, osv. Etter at attributtene er hentet kan du arbeide med dem slik du er vant med å arbeide med variabler. Du kan også endre disse verdiene etterpå slik:

peach.styrke = peach.styrke + 3
print(f'{peach.navn} sitt styrkenivå er nå hevet til {peach.styrke}!')

Selv om vi kan øke styrkenivået selv har vi også en metode vi kan bruke til dette, nemlig .øk_styrkenivået(). Den kan vi bruke slik for å gjøre det samme:

wario.øk_styrkenivået(2)
print(f'{wario.navn} sitt styrkenivå er nå hevet til {wario.styrke}!')

Hva er fordelen med å lage en metode som endrer en verdi i stedet for å bare gjøre det ved å endre attributtene direkte? Det er ingenting som hindrer oss i å skrive peach.styrke = -999 når vi jobber direkte med attributtene. Men i metoden .øk_styrkenivået() kan vi endre koden i klassen til å være dette:

def øk_styrkenivået(self, nivå):
    if nivå > 0:
        self.styrke += nivå
    else:
        print('Dette er ikke en gyldig verdi!')

Nå vil metoden .øk_styrkenivået() gjøre en sjekk på om vi faktisk prøver å øke styrkenivået med et positivt tall. Som du ser gir metoder oss større kontroll når vi ønsker å endre attributter. I seksjon 11.5 skal vi sette opp enda mer autovern her slik at forandring av attributter blir tryggere.

11.2.2 Hvordan fungerer self?

Du la kanskje merke til at alle metodene vi skrev i klassen SpillKarakter har self som første parameter. Hva betyr egentlig dette?

Når vi oppretter et objekt, for eksempel mario = SpillKarakter(...), så kaller Python automatisk på metoden __init__() i klassen vår for å lage objektet. Det første Python gjør da, er å sende inn objektet selv som første argument til metoden __init__(). Dette skjer automatisk i bakgrunnen uten at vi trenger å skrive det selv. Det samme skjer når vi kaller på en metode som mario.øk_styrkenivået(2). Python oversetter dette bak kulissene til følgende kode:

SpillKarakter.øk_styrkenivået(mario, 2)

Med andre ord er self bare et navn på objektet som metoden jobber med. Du kan tenke på self som en måte for objektet å referere til seg selv inne i metoden. Det er slik objektet vet hvilke attributter og verdier det skal jobbe med.

Inne i konstruktøren .__init__() brukes også self når vi for eksempel skriver self.styrke = styrke. Setningen self.styrke = styrke ber Python om å lagre verdien fra parameteren styrke i objektets eget minne. Verdien lagres under navnet styrke og ble sendt inn til metoden .__init__() da objektet ble opprettet. Dette er nødvendig, men kan virke litt tungvint i vårt eksempel. Fordelen med at Python krever dette, er at vi får muligheten til å legge inn ekstra logikk før vi lagrer verdiene. Kanskje vi for eksempel vil legge til litt tilfeldighet når vi oppretter en spillkarakter? Da kan vi skrive starten av klassen SpillKarakter slik:

from random import randint


class SpillKarakter:
    """En klasse som representerer spillkarakterer i et rollespill."""

    def __init__(self, navn, styrke, flaks, er_snill):
        self.navn = navn
        self.styrke = styrke + randint(1, 3)
        self.flaks = flaks + randint(1, 3)
        self.er_snill = er_snill

11.3 Dundermetoder og overskriving av operatorer

Så langt har vi sett hvordan vi kan skrive grunnleggende klasser og bruke dem til å opprette objekter. I Python har vi spesielle metoder som kan brukes for å legge til ekstra funksjonalitet til klasser. Dette kalles for dundermetoder112. Noen bruker også begrepet spesielle metoder113 for det samme. Her er tre eksempler slik at du kan se hvordan dundermetoder ser ut:

  • Metodenavnet .__str__() gir oss muligheten til å spesifisere hvordan informasjonen i objektet skal skrives ut til terminalen på en lesbar måte.

  • Metodenavnet .__eq__() gir oss muligheten til å spesifisere når to objekter av samme klasse skal bli sett på som like.

  • Metodenavnet .__add__() gir oss muligheten til å legge sammen to objekter som tilhører samme klasse.

Som du kan se så skrives dundermetoder med to understreker på hver side av et ord. Her er det helt nødvendig at du skriver nøyaktig str i metodenavnet .__str__() dersom du ønsker å spesifisere hvordan informasjonen i objektet skal skrives ut til terminalen på en lesbar måte.

I denne seksjonen skal du lære noen av de vanligste dundermetodene og forstå hvordan du kan bruke dem. For å se hvordan dundermetoder er nyttige skal vi skrive vår egen klasse for å representere et terningkast med forskjellige terninger.

11.3.1 Oppbygning av klassen for terningkast

La oss først lage en klasse som representerer grunnfunksjonaliteten til terningkast med forskjellige terninger:

from random import randint


class Terningkast:
    """En klasse som representerer terningkast."""

    def __init__(self, terninger, sider):
        self.terninger = terninger
        self.sider = sider
        self.verdier = [randint(1, sider) for _ in range(terninger)]

    def total_sum(self):
        return sum(self.verdier)

    def maks_verdi(self):
        return max(self.verdier)

    def min_verdi(self):
        return min(self.verdier)


terningkast_1 = Terningkast(terninger=5, sider=6)
print(f'Total sum: {terningkast_1.total_sum()}')
print(f'Største verdi: {terningkast_1.maks_verdi()}')
print(f'Minste verdi: {terningkast_1.min_verdi()}')

Koden for klassen Terningkast inneholder ikke noen nye konsepter. Les nøye gjennom den og forsikre deg om at du forstår hva som skjer. Hver gang du kjører koden over vil du få forskjellige tall skrevet ut til terminalen siden vi bruker funksjonen randint() fra standardbiblioteket random til å simulere tilfeldige verdier på terningene. La oss nå bruke dundermetoder til å forbedre klassen Terningkast.

11.3.2 Utskriving til terminalen

Et første problem med klassen Terningkast oppstår når du prøver å skrive ut objektet terningkast_1 for å se hvilke verdier terningene inneholder:

terningkast_1 = Terningkast(terninger=5, sider=6)
print(terningkast_1)

Kjører jeg koden over får jeg skrevet ut følgende informasjon til terminalen:

<__main__.Terningkast object at 0x00000177AEF1AC00>

Hva i all verden betyr dette? Når du skriver ut et objekt fra en klasse vil du, med mindre du har brukt en helt spesiell dundermetode, bare få informasjon om hvor i minnet objektet er lagret. Derfor vil du sikkert få en annen verdi enn 0x00000177AEF1AC00. Dette er ikke særlig nyttig hvis du ønsker å finne ut hvilke verdier de forskjellige terningene har.

En måte å ordne problemet på er å selv dykke ned i listen som er lagret i attributten .verdier slik:

terningkast_1 = Terningkast(terninger=5, sider=6)
for indeks, verdi in enumerate(terningkast_1.verdier):
    print(f'Terning {indeks + 1}: {verdi}')

Dette gir deg riktig svar! Likevel er det tungvint å skrive denne koden hver gang vi ruller et sett med nye terninger. En bedre måte er å bygge denne funksjonaliteten inn i klassen Terningkast slik at vi bare trenger å skrive ut objektet til terminalen for å finne informasjonen. Det kan vi gjøre med dundermetoden .__str__() slik:

from random import randint


class Terningkast:
    """En klasse som representerer terningkast."""

    def __init__(self, terninger, sider):
        self.terninger = terninger
        self.sider = sider
        self.verdier = [randint(1, sider) for _ in range(terninger)]

    # -- Andre metoder her -- 

    def __str__(self):
        utskrift = f'Terninger: {self.terninger}\nSider: {self.sider}'
        for indeks, verdi in enumerate(self.verdier):
            utskrift += f'\nTerning {indeks + 1}: Verdi {verdi}'
        return utskrift


terningkast_1 = Terningkast(terninger=5, sider=6)
print(terningkast_1)

Kjører du koden over et par ganger ser du at vi får en veldig oversiktlig utskrift av informasjonen som objektet terningkast_1 inneholder. Merk at vi ikke kaller dundermetoden __str__() selv, men at den automatisk blir brukt når vi skriver ut terningkast_1 til terminalen. En bra spesifisering av dundermetoden .__str__() gjør det mye enklere å få rask informasjon om objektet vi holder på med.

11.3.3 Sammenligning av terningkast

Så langt har vi ingen måte å sammenligne om et terningkast er større enn et annet. Prøv å kjøre følgende kode:

terningkast_1 = Terningkast(terninger=5, sider=6)
terningkast_2 = Terningkast(terninger=3, sider=8)
print(terningkast_1 > terningkast_2)

Her vil du få en TypeError som forklarer at vi ikke kan sammenligne to objekter som hører til klassen Terningkast. Vi kan legge til dundermetoden .__gt__() i klassen Terningkast for å gjøre sammenligning med > mulig. Før vi gjør dette må vi faktisk bestemme oss for hva det skal bety at et terningkast er større enn et annet terningkast. Her er det flere muligheter dersom vi tenker oss om:

  • Vi kan si at terningkast_1 er større enn terningkast_2 dersom summen av verdiene til terningene i terningkast_1 er større enn summen av verdiene til terningene i terningkast_2.

  • Vi kan si at terningkast_1 er større enn terningkast_2 dersom gjennomsnittet av verdiene til terningene i terningkast_1 er større enn gjennomsnittet av verdiene til terningene i terningkast_2. Merk at dette ikke er det samme som forrige punkt siden de to terningkastene trenger ikke ha samme antall terninger.

  • Vi kan si at terningkast_1 er større enn terningkast_2 dersom den største verdien på en terning i terningkast_1 er større enn den største verdien på en terning i terningkast_2.

Overbevis deg selv om at de tre mulighetene over vil gi forskjellige resultater i noen tilfeller. La oss implementere den første muligheten som baserer seg på summen av verdiene på terningene for å vise hvordan vi kan gjøre dette:

from random import randint


class Terningkast:
    """En klasse som representerer terningkast."""

    def __init__(self, terninger, sider):
        self.terninger = terninger
        self.sider = sider
        self.verdier = [randint(1, sider) for _ in range(terninger)]

    # -- Andre metoder her -- 
      
    def __gt__(self, annet_terningkast):
        if sum(self.verdier) > sum(annet_terningkast.verdier):
            return True
        return False


terningkast_1 = Terningkast(terninger=5, sider=6)
terningkast_2 = Terningkast(terninger=3, sider=8)
print(terningkast_1 > terningkast_2)

Som du kan se bruker dundermetoden .__gt__() parameterne self og annet_terningkast. Her refererer self til objektet selv som vanlig, mens annet_terningkast refererer til objektet du sammenligner med. Dermed vil terningkast_1 bli sendt til self mens terningkast_2 vil bli sendt til annet_terningkast når du skriver koden terningkast_1 > terningkast_2.

Dundermetoden .__gt__() står for greater than og representerer operasjonen >. Vi har også:

  • Dundermetoden .__lt__() står for less than og representerer operasjonen <.

  • Dundermetoden .__gte__() står for greater than or equal og representerer operasjonen >=.

  • Dundermetoden .__lte__() står for less than or equal og representerer operasjonen <=.

Du kan implementere alle fire metodene slik at du kan sammenligne to terningkast på alle forskjellige måtene om du ønsker. I tillegg har vi dundermetoden .__eq__() som på engelsk står for equals. Denne kan spesifisere om to objekter er like eller ikke. Igjen er det vi som må bestemme hva likhet betyr. Før vi gjør dette kan du prøve å sammenligne to terningkast uten å ha lagt til dundermetoden .__eq__():

terningkast_1 = Terningkast(terninger=5, sider=6)
terningkast_2 = Terningkast(terninger=5, sider=6)
print(terningkast_1 == terningkast_2)

I motsetning til sammenligningen vi gjorde tidligere med > så skaper ikke dette en kjørefeil. Likevel er resultatet ikke det du kanskje forventer. Når vi ikke har lagt til dundermetoden .__eq__() vil utsagnet terningkast_1 == terningkast_2 bare returnerer True dersom terningkast_1 og terningkast_2 er samme objekt i minnet. Altså er det ikke nok at terningene har de samme verdiene her siden terningkast_1 og terningkast_2 er forskjellige objekter siden vi opprettet dem hver for seg.

Denne standardoppførselen er ikke alltid like nyttig. Det er vanlig å overskrive standardoppførselen ved å bruke dundermetoden .__eq__():

from random import randint


class Terningkast:
    """En klasse som representerer terningkast."""

    def __init__(self, terninger, sider):
        self.terninger = terninger
        self.sider = sider
        self.verdier = [randint(1, sider) for _ in range(terninger)]

    # -- Andre metoder her -- 
      
    def __eq__(self, annet_terningkast):
        if (self.verdier == annet_terningkast.verdier 
            and self.sider == annet_terningkast.sider):
            return True
        return False


terningkast_1 = Terningkast(terninger=5, sider=6)
terningkast_2 = Terningkast(terninger=5, sider=6)
print(terningkast_1 == terningkast_2)

Vi krever her at både verdiene og antall sider på terningene er like. Kravet om at antall sider må være like er ikke nødvendig, men er noe vi kan kreve om dette representerer situasjonen bedre. Hvordan du skriver dundermetodene for å sammenligne objekter er helt avhengig av den virkelige situasjonen du prøver å representere.

11.3.4 Regneoperasjoner på terningkast

Når vi jobbet med lister kunne vi bruke symbolet + til å slå sammen to lister:

vokalister_kvinner = [
    'Courtney LaPlante',
    'Tatiana Shmayluk', 
    'Jessie Williams'
]
vokalister_menn = [
    'Sam Carter', 
    'Will Ramos', 
    'Kevin Ratajczak'
]

vokalister = vokalister_kvinner + vokalister_menn

I koden over er vokalister et nytt objekt som tilhører listeklassen. Vi kan prøve å legge sammen to objekter fra klassen Terningkast på samme måte:

terningkast_1 = Terningkast(terninger=5, sider=6)
terningkast_2 = Terningkast(terninger=5, sider=6)

begge_terningkast = terningkast_1 + terningkast_2

Som du ser hvis du kjører koden så får vi en TypeError med beskjed om at + ikke er støttet mellom objekter av klassen Terningkast. Dette gir mening, siden det ikke er åpenbart hva vi ønsker når vi snakker om å legge sammen to terningkast. For å spesifisere dette kan vi legge dundermetoden .__add__() til klassen Terningkast for å fikse dette:

from random import randint


class Terningkast:
    """En klasse som representerer terningkast."""

    def __init__(self, terninger, sider):
        self.terninger = terninger
        self.sider = sider
        self.verdier = [randint(1, sider) for _ in range(terninger)]

    # -- Andre metoder her -- 
      
    def __add__(self, annet_terningkast):
        if self.sider != annet_terningkast.sider:
            print('Terningene må ha likt antall sider for å legge dem sammen.')
            return None
        nytt_terningkast = Terningkast(
            terninger=self.terninger + annet_terningkast.terninger, 
            sider=self.sider
        )
        nytt_terningkast.verdier = self.verdier + annet_terningkast.verdier
        return nytt_terningkast


terningkast_1 = Terningkast(terninger=5, sider=6)
terningkast_2 = Terningkast(terninger=5, sider=6)

begge_terningkast = terningkast_1 + terningkast_2

Som du kan se av koden i metoden .__add__() så krever vi at begge terningkastene har terninger med like mange sider. Hvis ikke dette stemmer så returnerer vi bare verdien None og skriver en beskjed ut til terminalen. Her hadde det vært bedre om vi hadde frembringet en TypeError. Dette ville gitt en klarere beskjed om at noe er alvorlig galt. Vi skal lære å frembringe feil i seksjon 12.2.

Vi lager et nytt terningkast i variabelen nytt_terningkast som kombinerer de to terningkastene. Vi må sette attributten nytt_terningkast.verdier manuelt siden den først blir generert med automatiske tall når vi bruker Terningkast() til å konstruere et nytt terningkast. Dette er ikke det vi ønsker, så vi overskriver nytt_terningkast.verdier til å ha verdiene til de eksisterende terningene fra terningkast_1 og terningkast_2.

I tillegg til dundermetoden .__add__() for å addere objekter finnes også dundermetoden .__mul__() for å multiplisere objekter. Det er ikke så naturlig å multiplisere to terningkast i de fleste terningspill, så jeg dropper å implementere .__mul__(). Prøv likevel å tenke på andre situasjoner der det å multiplisere to objekter gir deg noe interessant.

Vi har nå gjennomgått mange av de vanligste dundermetodene i Python. Dundermetoder kan gjøre klassene dine mye mer brukervennlige. Det er en hel drøss med flere dundermetoder i Python som du kan ta en titt på her:

https://docs.python.org/3/reference/datamodel.html#special-method-names

Her er hele klassen Terningkast som vi har skrevet i denne seksjonen. Som du kan se blir det fort en del linjer med kode når vi skal skrive en brukervennlig klasse:

from random import randint


class Terningkast:
    """En klasse som representerer terningkast."""

    def __init__(self, terninger, sider):
        self.terninger = terninger
        self.sider = sider
        self.verdier = [randint(1, sider) for _ in range(terninger)]

    def total_sum(self):
        return sum(self.verdier)

    def maks_verdi(self):
        return max(self.verdier)

    def min_verdi(self):
        return min(self.verdier)
    
    def __str__(self):
        utskrift = f'Terninger: {self.terninger}\nSider: {self.sider}'
        for indeks, verdi in enumerate(self.verdier):
            utskrift += f'\nTerning {indeks + 1}: Verdi {verdi}'
        return utskrift
    
    def __gt__(self, annet_terningkast):
        if sum(self.verdier) > sum(annet_terningkast.verdier):
            return True
        return False
    
    def __eq__(self, annet_terningkast):
        if (self.verdier == annet_terningkast.verdier 
            and self.sider == annet_terningkast.sider):
            return True
        return False
      
    def __add__(self, annet_terningkast):
        if self.sider != annet_terningkast.sider:
            print('Terningene må ha likt antall sider for å legge dem sammen.')
            return None
        nytt_terningkast = Terningkast(
            terninger=self.terninger + annet_terningkast.terninger, 
            sider=self.sider
        )
        nytt_terningkast.verdier = self.verdier + annet_terningkast.verdier
        return nytt_terningkast

11.4 Mer stabile klasser

Så langt har vi sett på klasser som en oppskrift der hvert objekt har sine egne attributter og metoder. Attributtene lagrer informasjon om objektet, mens metodene kan endre tilstanden til objektet. Vi skal nå se på et par konsepter som gjør klassene våre mer stabile.

11.4.1 Klassevariabler

For å forklare behovet for klassevariabler så kan vi se på et helt konkret eksempel:

class InnbyggerBergen:
    """En klasse som representerer innbyggere i Bergen kommune."""

    def __init__(self, navn, alder):
        self.navn = navn
        self.alder = alder
        self.kommune = 'Bergen kommune'
        self.kommunenummer = '4601'
        self.fylke = 'Vestland'

    def __str__(self):
        return f'Innbyggeren {self.navn} i {self.kommune} er {self.alder} år.'


kari = InnbyggerBergen(navn='Kari', alder=41)
print(kari)
print(kari.kommunenummer)

Som du kan se representerer klassen InnbyggerBergen en innbygger i kommunen Bergen. Merk at inne i konstruktøren .__init__() så definerer vi fem attributter. De tre siste attributtene er litt forskjellige fra de to første. Både self.kommune, self.kommunenummer, og self.fylke blir satt til helt bestemte verdier som ikke avhenger av hvordan objektet blir opprettet. Det er unødvendig at alle objektene som blir opprettet har sine egne kopier av denne informasjonen.

I Python kan vi deklarere verdier utenfor konstruktøren .__init__() slik:

class InnbyggerBergen:
    """En klasse som representerer innbyggere i Bergen kommune."""

    kommune = 'Bergen kommune'
    kommunenummer = '4601'
    fylke = 'Vestland'

    def __init__(self, navn, alder):
        self.navn = navn
        self.alder = alder

    def __str__(self):
        return f'Innbyggeren {self.navn} i {self.kommune} er {self.alder} år.'


kari = InnbyggerBergen(navn='Kari', alder=41)
print(kari)
print(kari.kommunenummer)

Kjører du koden over vil du få nøyaktig det samme skrevet ut til terminalen. Forskjellen er at verdiene kommune, kommunenummer, og fylke er nå delt mellom alle objektene fremfor at objektene har sine egne kopier av verdiene. Vi kaller kommune, kommunenummer, og fylke for klassevariabler114.

Det er fordelaktig å bruke klassevariabler når bitene med informasjon ikke avhenger fra objekt til objekt. Hvorfor det?

  • For det første er det mer effektivt siden vi ikke unødvendig oppretter nye variabler hver gang vi oppretter et objekt.

  • For det andre gjør vi det klart for dem som leser koden at klassevariabler er like på tvers av alle objektene i klassen.

  • For det tredje må vi bare endre en enkelt verdi dersom vi ønsker å endre en klassevariabel. Det er ikke utenkelig at fylke vil endre seg, Bergen var tross alt en del av Hordaland fylke for ikke så lenge siden.

For å få tak i en klassevariabel kan vi bruke begge de to følgende måtene:

kari = InnbyggerBergen(navn='Kari', alder=41)

# Hente klassevariabelen via et objekt
print(kari.kommunenummer)

# Hente klassevariabelen direkte fra klassen
print(InnbyggerBergen.kommunenummer)

Så dersom du bare ønsker å hente verdien til en klassevariabel kan du bruke begge fremgangsmåtene. Det er derimot en stor fallgruve som venter når vi skal endre verdien til en klassevariabel. Hva tror du koden under gir deg?

kari = InnbyggerBergen(navn='Kari', alder=41)
lars = InnbyggerBergen(navn='Lars', alder=27)
kari.kommunenummer = '4602'

print(f'Kari sitt kommunenummer: {kari.kommunenummer}')
print(f'Lars sitt kommunenummer: {lars.kommunenummer}')

Her skulle du kanskje trodd at både Kari og Lars sitt kommunenummer var endret til verdien '4602'. Dette stemmer ikke! Når vi endrer en klassevariabel via et objekt så oppretter objektet bare en attributt som overskriver klassevariabelen for seg selv. Dette påvirker ikke andre objekter. For å endre klassevariabelen for alle objekter må vi bruke klassen selv slik:

kari = InnbyggerBergen(navn='Kari', alder=41)
lars = InnbyggerBergen(navn='Lars', alder=27)
InnbyggerBergen.kommunenummer = '4602'

print(f'Kari sitt kommunenummer: {kari.kommunenummer}')
print(f'Lars sitt kommunenummer: {lars.kommunenummer}')

Her er klassevariabelen kommunenummer faktisk endret. Dermed vil kommunenummer være endret for alle objekter av klassen. Dette er en vanlig fallgruve som skaper feil som er vanskelige å avdekke.

Selv om det er lett å skyte seg selv i foten ved å bruke klassevariabler på feil måte så er klassevariabler veldig nyttige når du blir stødig på dem. Når du oppretter en ny klasse så anbefaler jeg å vurdere hvilke av bitene med informasjon som varierer for hvert objekt. Bitene som varierer bør du sette som attributter i konstruktøren. De som ikke varierer bør du sette som klassevariabler.

11.4.2 Klassemetoder

Vi har sett at konstruktøren .__init__() gjør at vi kan opprette objekter med spesifikke verdier. Noen ganger er det likevel nyttig å ha flere forskjellige måter å opprette et objekt på. Dette kan klassemetoder115 hjelpe med. La oss se hvordan dette fungerer med å lage en klasse som representerer pizzabestillinger til en pizzarestaurant:

class Pizza:
    """En klasse som representerer pizzabestillinger."""

    kjerneingredienser = ['deig', 'tomatsaus']  

    def __init__(self, *fyll):
        self.fyll = list(self.kjerneingredienser) + list(fyll)
        
    def legg_til_fyll(self, nytt_fyll):
        self.fyll.append(nytt_fyll)

    def __str__(self):
        return f"Pizza med: {', '.join(self.fyll)}"


margherita_pizza = Pizza('ost')
hawaii_pizza = Pizza('ost', 'ananas', 'skinke')
vegetar_pizza = Pizza('spinat', 'avocado', 'oliven')

print(vegetar_pizza)
vegetar_pizza.legg_til_fyll('paprika')
print(vegetar_pizza)

Så langt inneholder koden over ingenting nytt du ikke kan fra før av. Merk at hver gang en bruker skal bestille en pizza så må han eller hun beskrive alle ingrediensene bortsett fra deig og tomatsaus, som er felles for alle pizzaene. Dette er jo ikke typisk slik du har bestilt pizza selv regner jeg med. Vanligvis har du ferdiglagde alternativer som Hawaii Pizza eller Vegetar Pizza som du kan velge. Vi kan legge til klassemetoder som gjør dette mulig:

class Pizza:
    """En klasse som representerer pizzabestillinger."""

    kjerneingredienser = ['deig', 'tomatsaus']  

    def __init__(self, *fyll):
        self.fyll = list(self.kjerneingredienser) + list(fyll)
        
    def legg_til_fyll(self, nytt_fyll):
        self.fyll.append(nytt_fyll)

    @classmethod
    def margherita(cls):
        return cls('ost')
    
    @classmethod
    def hawaii(cls):
        return cls('ost', 'ananas', 'skinke')
    
    @classmethod
    def vegetar(cls):
        return cls('spinat', 'avocado', 'oliven')

    def __str__(self):
        return f"Pizza med: {', '.join(self.fyll)}"

Før vi forklarer notasjonen @classmethod og cls kan vi se hvordan dette hjelper oss med å opprette pizzaer enkelt og greit:

margherita = Pizza.margherita()
hawaii_pizza = Pizza.hawaii()
vegetar_pizza = Pizza.vegetar()

Her oppretter vi nøyaktig de samme pizzaene som tidligere. Den store forskjellen er at brukeren ikke trenger å forstå hva som inngår i en Hawaii Pizza eller en Vegetar Pizza. Dermed overføres ansvaret for å spesifisere ingrediensene til klassen selv for utvalgte pizzaer.

Merk at det fortsatt er mulig for brukeren å bestille en pizza der de spesifiserer ingrediensene selv. Det er til og med mulig å velge en standard pizza først og deretter legge til en ekstra ingrediens:

vegetar_pizza = Pizza.vegetar()
vegetar_pizza.legg_til_fyll('paprika')
print(vegetar_pizza)

Dette har du sikkert vært borti selv når du bestiller en pizza. Ofte kan du betale 20 kroner ekstra for en til ingrediens eller for ekstra ost. La oss nå kort snakke om notasjonen til klassemetoder:

@classmethod
def vegetar(cls):
    return cls('spinat', 'avocado', 'oliven')

Notasjonen @classmethod er noe som kalles en dekoratør116 i Python og skrives med en alfakrøll etterfulgt av et navn. Dekoratører gir oss mulighet til å endre oppførselen til funksjoner. Vi skal lære mer om dekoratører i seksjon 13.5.

Spesifikt gjør @classmethod at metoden under hører til hele klassen. Det betyr at den første parameteren i metoden .vegetar() som vi kaller cls refererer til klassen Pizza. Dette er forskjellig for ordinære metoder der den første parameteren som vi kaller self referer til objektet selv.

Dette betyr at cls('spinat', 'avocado', 'oliven') oppretter et nytt Pizza-objekt med ingrediensene 'spinat', 'avocado', og 'oliven' i tillegg til kjerneingrediensene. Så når vi skriver linjen vegetar_pizza = Pizza.vegetar() så holder variabelen vegetar_pizza et Pizza-objekt med ingrediensene som vi har bestemt inngår i Vegetar Pizza.

Alternative konstruktører er ikke det eneste klassemetoder kan brukes til. Siden klassemetoder refererer til klassen via cls i sin første parameter kan de endre klassevariabler slik:

class Pizza:
    """En klasse som representerer pizzabestillinger."""

    kjerneingredienser = ['deig', 'tomatsaus']  

    def __init__(self, *fyll):
        self.fyll = list(self.kjerneingredienser) + list(fyll)
        
    # -- Andre metoder her -- 
    
    @classmethod
    def endre_kjerneingredienser(cls, *fyll):
        cls.kjerneingredienser = list(fyll)
    
    def __str__(self):
        return f"Pizza med: {', '.join(self.fyll)}"


Pizza.endre_kjerneingredienser('deig')
vegetar_pizza = Pizza('spinat', 'avocado', 'oliven')
print(vegetar_pizza)

Etter at vi har brukt klassemetoden .endre_kjerneingredienser('deig') på klassen Pizza så vil videre pizzaer ikke inneholde 'tomatsaus'. Dette er også en måte klassemetoder blir brukt på.

Min erfaring er likevel at alternative konstruktører er der klassemetoder brukes mest hyppig. Alternative konstruktører flytter kompleksiteten fra brukeren av klassen til den som skriver klassen. Dette er ofte lurt siden det er den som skriver klassen som har best kunnskap om hvordan klassen fungerer og hva den både kan og bør brukes til.

11.4.3 Statiske metoder

I tillegg til ordinære metoder og klassemetoder har vi også en tredje variant som heter statiske metoder117. Statiske metoder har verken tilgang til objektet eller klassen i parameterne sine, og opererer dermed relativt uavhengig av den andre informasjonen vi jobber med. Dette gjør statiske metoder passende for hjelpemetoder som ikke trenger å forstå helheten til situasjonen vi prøver å representere. For å illustrere dette kan vi skrive en klasse som representerer en ansatt i en bedrift.

class AnsattBedrift:
    """Klassen representerer en ansatt i en spesifikk bedrift."""

    def __init__(self, navn, timepris):
        self.navn = navn
        self.timepris = timepris

    def fakturer(self, timer):
        return timer * self.timepris


marius = AnsattBedrift(navn='Marius', timepris=200)
print(f'Faktura for 8 timer: {marius.fakturer(8)} kr')

Klassen fungerer helt fint slik den er. Sett nå at vi ønsker å utvide klassen til å også håndtere overtidsbetaling. La oss si at bedriften har en generell avtale om 50% tillegg for overtidsarbeid. Da legger vi dette til som en klassevariabel overtid_sats og utvider metoden .fakturer() slik:

class AnsattBedrift:
    """Klassen representerer en ansatt i en spesifikk bedrift."""
    overtid_sats = 1.5

    def __init__(self, navn, timepris):
        self.navn = navn
        self.timepris = timepris

    def fakturer(self, timer, overtid=False):
        if not overtid:
            sats = self.timepris
        else:
            sats = self.timepris * AnsattBedrift.overtid_sats
        return timer * sats


marius = AnsattBedrift(navn='Marius', timepris=200)
print(f'Faktura for 8 timer uten overtid: {marius.fakturer(8)} kr')
print(f'Faktura for 4 timer overtid: {marius.fakturer(4, overtid=True)} kr')

Utmerket! Sett at vi ønsker lage en egen funksjon for utregningen av overtidsbetalingen. Et første forsøk ser slik ut:

class AnsattBedrift:
    """Klassen representerer en ansatt i en spesifikk bedrift."""
    overtid_sats = 1.5

    def __init__(self, navn, timepris):
        self.navn = navn
        self.timepris = timepris

    def fakturer(self, timer, overtid=False):
        if not overtid:
            sats = self.timepris
        else:
            sats = self.beregn_overtid(self.timepris)
        return timer * sats
    
    def beregn_overtid(self, timepris):
        return timepris * AnsattBedrift.overtid_sats


marius = AnsattBedrift(navn='Marius', timepris=200)
print(f'Faktura for 8 timer uten overtid: {marius.fakturer(8)} kr')
print(f'Faktura for 4 timer overtid: {marius.fakturer(4, overtid=True)} kr')

Dette gikk greit nok, men noe er litt rart. Metoden .beregn_overtid() har self som første argument, men bruker aldri denne informasjonen. Det å gi funksjoner tilgang til mer informasjon enn de trenger er generelt dårlig praksis. Det er bedre å bruke dekoratøren @staticmethod for å gjøre metoden .beregn_overtid() til en statisk metode:

class AnsattBedrift:
    """Klassen representerer en ansatt i en spesifikk bedrift."""
    overtid_sats = 1.5

    def __init__(self, navn, timepris):
        self.navn = navn
        self.timepris = timepris

    def fakturer(self, timer, overtid=False):
        if not overtid:
            sats = self.timepris
        else:
            sats = self.beregn_overtid(self.timepris)
        return timer * sats
    
    @staticmethod
    def beregn_overtid(timepris):
        return timepris * AnsattBedrift.overtid_sats


marius = AnsattBedrift(navn='Marius', timepris=200)
print(f'Faktura for 8 timer uten overtid: {marius.fakturer(8)} kr')
print(f'Faktura for 4 timer overtid: {marius.fakturer(4, overtid=True)} kr')

Nå er virkelig .beregn_overtid() bare en hjelpefunksjon og den har ingen kunnskap om informasjonen til objektet marius. Dekoratøren @staticmethod gjør at metoden .beregn_overtid() verken har tilgang til det spesifikke objektet som kaller metoden eller klassen som helhet i parameterne sine. Dette gjør statiske metoder egnet til å være enkle hjelpemetoder som gjør beleilige utregninger.

Du lurer kanskje på hva som er forskjellen på å lage en statisk metode versus bare lage en totalt separert funksjon slik:

class AnsattBedrift:
    """Klassen representerer en ansatt i en spesifikk bedrift."""
    overtid_sats = 1.5

    def __init__(self, navn, timepris):
        self.navn = navn
        self.timepris = timepris

    def fakturer(self, timer, overtid=False):
        if not overtid:
            sats = self.timepris
        else:
            sats = beregn_overtid(self.timepris)
        return timer * sats


def beregn_overtid(timepris):
    return timepris * AnsattBedrift.overtid_sats


marius = AnsattBedrift(navn='Marius', timepris=200)
print(f'Faktura for 8 timer uten overtid: {marius.fakturer(8)} kr')
print(f'Faktura for 4 timer overtid: {marius.fakturer(4, overtid=True)} kr')

Her har vi heller implementert beregn_overtid() som en ordinær funksjon helt separert fra klassen AnsattBedrift. Dette er veldig likt som statiske metoder.

En forskjell på de to situasjonene er at en statisk metode hører til klassen, mens en separert funksjon ikke gjør det. Dette spiller en rolle når du skal importere klassen AnsattBedrift til et annet Python-program. Har du implementert .beregn_overtid() som en statisk metode innad i klassen AnsattBedrift så følger denne automatisk med når du importerer AnsattBedrift. Dersom du har laget beregn_overtid() som en helt separert funksjon må du importere denne separert for å få tak i funksjonaliteten.

Avgjørelsen om du skal lage en hjelpefunksjon som en statisk metode eller som en helt separert funksjon handler om hvor koblet logikken i hjelpefunksjonen er til klassen. Hvis det er bare klassen som trenger hjelpefunksjonen ville jeg implementert dette som en statisk metode. Dersom logikken i hjelpefunksjonen trengs andre steder enn i klassen ville jeg implementert dette som en separert funksjon.

11.5 Klasser som grensesnitt

Så langt har vi sett hvordan vi kan lage klasser og tilhørende objekter i Python. La oss nå snakke litt mer om hvordan vi kan gjøre det enklere for brukere å benytte seg av klassene våre.

For en analogi, tenk på en mikrobølgeovn som du finner på de fleste kjøkken. Du er brukeren av en mikrobølgeovn når du varmer opp en skål med restepasta. På mikrobølgeovnen er det kun et par sentrale ting du kan gjøre. Eksempler er:

  • Åpne og lukke døren til mikrobølgeovnen.

  • Stille intensiteten eller temperaturen.

  • Sette modusen, som for eksempel tining.

  • Sette hvor lenge mikrobølgeovnen skal stå på.

Dette gir deg et grensesnitt118 der det er helt klare oppgaver du kan gjøre. De færreste av oss vet egentlig lite om hvordan en mikrobølgeovn fungerer under panseret. Med et klart grensesnitt trenger du ikke å kunne noe særlig om mikrobølgeovner for å varme restepastaen din.

Tenk deg at i stedet for de fine knappene du har på utsiden av mikrobølgeovnen måtte du selv koblet sammen ledninger og justert komponenter for å varme opp restepastaen din:

  • Du måtte kunne mye mer om teknologien i mikrobølgeovner. Selv med denne kunnskapen ville det tatt lengre tid før du kunne kose deg med restepastaen din.

  • Det er en mye større mulighet for at du gjør noe alvorlig galt. Du kunne svidd restepastaen din, eller fått et skadelig elektrisk støt.

Situasjonen med mikrobølgeovnen har mye til felles med klasser og grensesnitt. Når du skriver en klasse ønsker du å gi brukere et forståelig grensesnitt de kan bruke for å jobbe med objekter. Brukere skal i liten grad måtte forstå tekniske detaljer under panseret. Du bør også begrense hvordan brukere jobber med og endrer attributter.

I denne seksjonen skal vi se på to måter du kan gjøre klassene dine mer som et grensesnitt.

11.5.1 Private attributter i Python

Noen programmeringsspråk, som for eksempel Java, kan deklarere attributter som private119. Brukeren av objektet kan ikke hente eller endre private attributter direkte. Målet er som regel å skjule attributter som kun er av interesse for den som har laget klassen. Dette gjør klassen som grensesnitt mer oversiktlig.

Python har ikke egentlig noen formell mekanisme som støtter private attributter. Likevel er det i Python en sterk konvensjon vi kan bruke for å indikere at en attributt er privat. Sett at vi har følgende klasse i Python som konverterer mengder i milliliter til teskjeer og spiseskjeer:

class KonvertererVæske:
    """En klasse som konverterer mengder væske."""

    def __init__(self, mengde_milliliter):
        self.mengde_milliliter = mengde_milliliter
        self._milliliter_til_teskje = 5
        self._milliliter_til_spiseskje = 15

    def hent_mengde_teskje(self):
        return int(self.mengde_milliliter / self._milliliter_til_teskje)
    
    def hent_mengde_spiseskje(self):
        return int(self.mengde_milliliter / self._milliliter_til_spiseskje)


konverterer = KonvertererVæske(mengde_milliliter=200)
print(f'Antall teskjeer blir: {konverterer.hent_mengde_teskje()}')
print(f'Antall spiseskjeer blir: {konverterer.hent_mengde_spiseskje()}')

Som du kan se av koden over så har KonvertererVæske en enkel oppgave. Den konverterer væske i milliliter til antall teskjeer eller antall spiseskjeer.

Merk at vi har en understrek etter punktumet når vi skriver self._milliliter_til_teskje = 5 og self._milliliter_til_spiseskje = 15. Dette er konvensjonen i Python på at attributtene _milliliter_til_teskje og _milliliter_til_spiseskje er private. En IDE som Visual Studio Code unnlater å ramse opp attributter som starter med _ når den gir deg forslag til autoutfylling. Som bruker av klassen KonvertererVæske trenger du ikke ha noe forhold til de spesifikke forholdtallene i de private attributtene. Du kan heller bare jobbe med metodene .hent_mengde_teskje() og .hent_mengde_spiseskje().

Det er viktig du innser at private attributter i Python bare er en konvensjon. Det er ingenting som hindrer deg i å skrive print(konverterer._milliliter_til_teskje) for å få tak i denne informasjonen. Du kan også enkelt få tak i alle attributter ved å skrive:

konverterer = KonvertererVæske(mengde_milliliter=200)
print(konverterer.__dict__)

Som du ser gir konverterer.__dict__ en ordbok som inneholder alle attributter og tilhørende verdier til objektet konverterer. Så private attributter i Python er ikke egentlig private. Likevel er konvensjonen med understrek _ i starten av navnet til attributten nyttig for å skape et enklere grensesnitt.

11.5.2 Kontroll over verdier

Det er ofte helt greit at objekter har attributter som er åpne og tilgjengelige:

class Person:
    """En klasse som representerer en person."""

    def __init__(self, navn, alder):
        self.navn = navn
        self.alder = alder

Likevel kan dette føre til data som ikke gir mening. Med klassen over kan en bruker opprette en person med negativ alder slik:

kristoffer = Person(navn='Kristoffer', alder=-3)

Dette er ikke særlig ønskelig. I første omgang kan vi legge inn logikk i konstruktøren for å hindre dette:

class Person:
    """En klasse som representerer en person."""
  
    def __init__(self, navn, alder):
        self.navn = navn
        if alder < 0:
            print('Alder ikke gyldig. Setter verdien til None.')
            self.alder = None
        else:
            self.alder = alder


kristoffer = Person(navn='Kristoffer', alder=-3)
print(kristoffer.alder)

Dette fungerer greit for opprettelse av objekter. Det er likevel ingenting som hindrer brukeren i å sette ugyldige verdier senere:

kristoffer = Person(navn='Kristoffer', alder=5)
print(kristoffer.alder)
kristoffer.alder = -3
print(kristoffer.alder)

For å hindre dette kan du bruke noen spesielle dekoratører i Python:

class Person:
    """En klasse som representerer en person."""
    
    def __init__(self, navn, alder):
        self.navn = navn
        self._alder = None        
        self.alder = alder        

    @property
    def alder(self):
        return self._alder

    @alder.setter
    def alder(self, ny_alder):
        if ny_alder < 0:
            print('Alder ikke gyldig.')
        else:
            self._alder = ny_alder

La oss sakte men sikkert forklare koden over. Merk først at i konstruktøren har vi både self._alder og self.alder. Det er den private attributten self._alder som vil holde den faktiske alderen. Hva gjør så self.alder og dekoratørene @property og @alder.setter?

  • Hver gang du har et utsagn som prøver å hente verdien lagret i self.alder vil metoden under dekoratøren @property bli utført.

  • Hver gang du har et utsagn som prøver å sette verdien lagret i self.alder vil metoden under dekoratøren @alder.setter bli utført.

La oss gå gjennom sakte hva som skjer når vi oppretter og jobber med et objekt. Først oppretter vi et objekt fra klassen Person:

kristoffer = Person(navn='Kristoffer', alder=5)

Innad i konstruktøren møter vi nå på utsagnet self.alder = alder. Dermed prøver vi å sette verdien lagret i self.alder, så metoden under dekoratøren @alder.setter blir utført. Siden alder=5 er gyldig vil self._alder bli satt til verdien 5. Sett nå at du prøver å hente denne verdien:

print(kristoffer.alder)

Siden vi prøver å hente verdien lagret i self.alder vil metoden under dekoratøren @property bli utført. Denne returnerer verdien i self._alder, nemlig verdien 5.

Sett til slutt at du ønsker å sette verdien kristoffer.alder:

kristoffer.alder = -3

Dette vil igjen føre til at metoden under @alder.setter blir utført. Siden ny_alder holder verdien -3 vil vi få skrevet ut feilmeldingen og beholder den forrige verdien vi hadde i self._alder. I vårt tilfelle vil kristoffer fortsatt ha alder 5.

På mange måter gjør bruk av dekoratørene @property og @alder.setter koden vår mer komplisert. Men sett fra brukeren sin side er koden veldig enkel. Du kan tilsynelatende jobbe direkte med attributten kristoffer.alder. I tillegg får du en feilmelding når en verdi som kristoffer.alder = -3 ikke er gyldig. Dette skaper et grensesnitt som er enkelt å forstå for brukeren.

Dekoratørene jeg har introdusert her er vanligvis ikke inkludert i en introduksjonsbok på Python. Jeg ville likevel vise deg helt grunnleggende hvordan du kan skrive dem siden de er svært nyttige. Det er mye mer du kan gjøre med @property og nærliggende dekoratører, men det jeg har vist over er det mest vanlige. Bruker du dem riktig kan de hindre brukere i å skyte seg selv i foten.

11.6 Arv

Vi skal nå gå kort gjennom arv120 og hvordan dette kan hjelpe deg med å redusere duplisering av kode. Arv er et ganske debattert konsept i programmering, og vi skal til slutt også ta opp noen kritikker av arv. La oss starte med en analogi som hjelper oss med å forstå hva arv går ut på.

Tenk deg at et nysgjerrig barn spør deg hva en elsykkel er. En måte du kan svare på er at en elsykkel har to hjul, har mulighet for å bremse, og er et fremkomstmiddel som baserer seg delvis på elektrisitet. Har du dårlig tid vil du heller sikkert si at en elsykkel er en sykkel som er delvis elektrisk. Dette var jo mye raskere å forklare! Her baserer du deg på at elsykler er en spesiell type sykkel.

  • Elsykler har egenskaper (attributter) som for eksempel pris, setemodell, og nåværende hastighet. Dette har også ordinære sykler. Noen av egenskapene til elsykler som motormodell og gjenværende batteri har derimot ikke ordinære sykler.

  • Elsykler har atferd (metoder) som bremse, akselerere, og skifte gir. Dette har også ordinære sykler. Atferden til elsykler for å lade batteriet har ikke ordinære sykler.

Hvordan kunne vi skrevet to klasser for sykler og elsykler? Hvis vi gjør som tidligere vil dette se slik ut:

class Sykkel:
    """En klasse som representerer en sykkel."""
    
    def __init__(self, pris, setemodell):
        self.pris = pris
        self.setemodell = setemodell
        self.hastighet = 0
        
    def akselerer(self, verdi):
        self.hastighet += verdi


class ElSykkel:
    """En klasse som representerer en elsykkel."""
    
    def __init__(self, pris, setemodell):
        self.pris = pris
        self.setemodell = setemodell
        self.hastighet = 0
        self.batteri = 0
        
    def akselerer(self, verdi):
        self.hastighet += verdi
        
    def lad_batteri(self, ladeverdi):
        self.batteri += ladeverdi

Klassene over er mildt sagt veldig forenklede representasjoner av virkeligheten. Med denne representasjonen vil du kunne lade en elsykkel ubegrenset mye, men aldri bruke opp batteriet. I tillegg vil du kunne akselerere uten problemer til du suser forbi et jagerfly.

Til tross for enkelheten kan vi allerede se at det er mye gjenbruk av kode når vi skriver klassen ElSykkel. Vi må definere attributtene pris, setemodell, og hastighet i klassen ElSykkel på samme måte som i klassen Sykkel. I tillegg er metoden .akselerer() helt lik i klassen ElSykkel som i klassen Sykkel. Hvordan kan vi unngå dette?

Ta en titt på den følgende koden som et alternativ:

class Sykkel:
    """En klasse som representerer en sykkel."""
    
    def __init__(self, pris, setemodell):
        self.pris = pris
        self.setemodell = setemodell
        self.hastighet = 0
        
    def akselerer(self, verdi):
        self.hastighet += verdi


class ElSykkel(Sykkel):
    """En klasse som representerer en elsykkel."""
    
    def __init__(self, pris, setemodell):
        super().__init__(pris, setemodell)
        self.batteri = 0
        
    def lad_batteri(self, ladeverdi):
        self.batteri += ladeverdi

Som du ser ble klassen ElSykkel vesentlig kortere. Her er det to forskjeller i koden i forhold til tidligere:

  • Vi skriver class Elsykkel(Sykkel) fremfor class Elsykkel. Dette indikerer at vi ønsker å arve funksjonalitet fra klassen Sykkel. Vi bruker begrepet superklasse121 for klassen Sykkel og begrepet subklasse122 for klassen ElSykkel.

  • Inni konstruktøren .__init__() til klassen ElSykkel bruker vi linjen super().__init__(pris, setemodell). Funksjonen super() i Python refererer til superklassen. I vårt eksempel er dette klassen Sykkel. Vi kaller altså Sykkel sin konstruktør .__init__() og setter inn verdiene pris og setemodell.

Vi kan nå lage objekter fra klassen ElSykkel på samme måte som tidligere:

elsykkel = ElSykkel(pris=30_000, setemodell='Fizik')

print(f'Prisen til elsykkelen er: {elsykkel.pris}')
print(f'Setemodellen til elsykkelen er: {elsykkel.setemodell}')

elsykkel.akselerer(verdi=10)
print(f'Hastigheten til elsykkelen er: {elsykkel.hastighet}')

elsykkel.lad_batteri(ladeverdi=30)
print(f'Batterimengden til elsykkelen er: {elsykkel.batteri}')

Som du kan se så vil ikke sluttbrukeren merke at du har brukt arv. Arv er en måte å hindre duplisering av kode, men fra brukerens perspektiv endrer det ingenting.

Dersom subklassen ElSykkel ønsker å overskrive en attributt eller metode som kommer fra superklassen er dette fullt mulig. Hvis en subklasse definerer en metode som også finnes i superklassen, blir subklassens metode brukt. I mer kompliserte situasjoner er det ofte noen metoder en subklasse ønsker å arve direkte, mens andre metoder det er ønskelig å skrive selv.

Det er fullt mulig at mer enn en klasse arver fra klassen Sykkel samtidig. Her er et annet eksempel som representerer en terrengsykkel:

class Terrengsykkel(Sykkel):
    """En klasse som representerer en terrengsykkel."""

    def __init__(self, pris, setemodell, dempingstype):
        super().__init__(pris, setemodell)
        self.dempingstype = dempingstype
    
    def beskriv(self):
        return f'''Terrengsykkel med {self.dempingstype}-demping, 
            setemodell {self.setemodell}, og pris {self.pris} kr.'''

I tillegg kan arv strekke seg over flere nivåer. Vi kan lage en underklasse av klassen ElSykkel igjen om vi ønsker:

class ElTerrengsykkel(ElSykkel):
    """En klasse som representerer en elektrisk terrengsykkel."""

    def __init__(self, pris, setemodell, dempingstype):
        super().__init__(pris, setemodell)
        self.dempingstype = dempingstype
    
    def beskriv(self):
        return f'''El-terrengsykkel med {self.dempingstype}-demping, 
            batteri {self.batteri}%, setemodell {self.setemodell}, 
            og pris {self.pris} kr.'''

Når et program benytter seg av arv i stor grad for å representere forskjellige objekter via klasser får vi et hierarki av klasser.

Det kan nå virke som om arv er den anbefalte måten å strukturere prosjekter som benytter seg av objektorientert programmering. Dette er et debattert tema, og det er mange som er kritisk til om arv virkelig lever opp til alt skrytet det har historisk fått. Hva kan være problematisk med arv?

  • Arv skaper en tett kobling mellom superklasser og subklasser. I vårt tilfelle blir klassen ElSykkel bundet tett til klassen Sykkel. Forandringer i superklassen Sykkel vil nå muligens bli reflektert i subklassen ElSykkel. Dette kan skape feil og krever at vi ser flere objekter i sammenheng med hverandre hele tiden.

  • Man kan arve mer enn man trenger. I vårt eksempel var alle attributtene og metodene til superklassen Sykkel nyttige i subklassen ElSykkel. Ofte i mer kompliserte situasjoner er det flere attributter og metoder som ikke gir mening for subklassen å arve. Dette kan skape et mindre intuitivt grensesnitt for subklasser.

  • Det kan være uklart om metoder på subklasser er skrevet i klassen selv eller arvet. Dette fører til skjult oppførsel, og i kompliserte hierarkier med arv kan dette introdusere kompleksitet.

Det store spørsmålet er om du bør bruke arv eller ikke? Dessverre er det ikke så lett å gi et klart svar på dette. Min mening er at dersom arv betydelig forenkler koden din kan dette være et fint verktøy. Likevel er det ofte at arv skaper mange avhengigheter mellom klasser som til slutt koster mer å vedlikeholde enn hva det er verdt. Dersom du bruker arv forsiktig og er klar over fallgruvene vil arv være et nyttig verktøy i verktøykassen din.

11.7 Prosjekt - Studenter som skal ta fag på et universitet

I dette prosjektet skal vi se hvordan objektorientert programmering kan hjelpe oss med å representere studenter som tar fag på et universitet. Det å gå rolig gjennom dette vil hjelpe deg med å bygge opp objektorienterte systemer.

11.7.1 Oppsett

Først av alt må vi finne ut hvilke klasser vi trenger å skrive. Siden det er snakk om studenter som melder seg opp til fag trenger vi to klasser:

  • Klassen Student skal representere en student. Her vil vi lagre personinformasjon om studenten og hvilke fag studenten er meldt opp i. En student bør både kunne melde seg opp i fag og melde seg av fag.

  • Klassen Fag skal representere et fag. Her vil vi lagre informasjon om faget og hvilke studenter som er meldt opp i faget. Et fag bør kunne melde opp studenter i faget sitt og melde av studenter fra faget sitt.

Som du ser ovenfor er klassene Student og Fag veldig bundet til hverandre. Dette er ikke via arv som vi diskuterte i seksjon 11.6. Bindingen er ved at hver student har en liste med fag de er meldt opp i, og at hvert fag har en liste med studenter som er meldt opp i faget. Dette kalles for komposisjon123 og er ofte et alternativ til arv der den ene klassen ikke er en spesialisert del av den andre klassen.

Vi kommer til å angripe problemet slikt:

  • Vi lager først et utkast til klassen Student isolert fra klassen Fag.

  • Vi lager deretter et utkast til klassen Fag isolert fra klassen Student.

  • Vi binder de to klassene sammen.

11.7.2 Klassen for studenter

Vi starter med å tenke på hva en student trenger å holde av informasjon. Navnet på studenten er nødvendig, men også fødselsnummer er lurt siden to studenter kan ha samme navn. På større universiteter som Universitetet i Oslo eller NTNU skjer dette støtt og stadig.

I tillegg må hver student ha en liste med fag de er meldt opp i. Siden vi ikke har opprettet klassen Fag enda representerer vi bare fag som en liste med strenger for nå.

Her er starten på klassen Student:

class Student:
    """Representerer studenter på et universitet som kan ta fag."""

    def __init__(self, navn, fødselsnummer):
        self.navn = navn
        self.fødselsnummer = fødselsnummer
        self.fag = []

Det neste vi trenger er en metode for å melde en student opp i et fag. Siden vi bare representerer fag som strenger foreløpig er denne veldig enkel:

class Student:
    # -- Resten av klassen her --
    
    def meld_på_fag(self, fag):
        if not fag in self.fag:
            self.fag.append(fag)
        else:
            print(f'Student {self.navn} er allerede oppmeldt i {fag}.')

På samme måte trenger vi en metode som melder en student ut av et fag:

class Student:
    # -- Resten av klassen her --
    
    def meld_av_fag(self, fag):
        if fag in self.fag:
            self.fag.remove(fag)
        else:
            print(f'Student {self.navn} er ikke oppmeldt i {fag}.')

Til slutt kan det være greit å implementere dundermetoden .__str__() som vi så i seksjon 11.3. Dette gir oss en mer forståelig utskrift til terminalen av en student:

class Student:
    # -- Resten av klassen her --
    
    def __str__(self):
        if not self.fag:
            return f'Student {self.navn} er ikke oppmeldt i noen fag.'
        beskrivelse = f'Student {self.navn} er oppmeldt i fagene:'
        for indeks, fag in enumerate(self.fag):
            beskrivelse += f'\nFag {indeks + 1}: {fag}'
        return beskrivelse

For å teste koden vår så langt kan vi opprette en student og prøve å bruke metodene vi har laget:

børre = Student(navn='Børre Børresen', fødselsnummer='18098843575')
print(børre)
børre.meld_på_fag('Introduksjon til astronomi')
print(børre)
børre.meld_av_fag('Introduksjon til astronomi')
print(børre)
børre.meld_av_fag('Avansert logistikk')

Da har vi det vi trenger fra klassen Student i isolasjon. La oss nå hoppe over til klassen Fag for å sette den opp i isolasjon. Deretter skal vi binde de to klassene sammen.

11.7.3 Klassen for fag

Hva trenger et fag å holde av informasjon? Et fag har typisk et navn, en fagkode, og en beskrivelse. I et realistisk system hadde det nok vært mye mer informasjon enn dette som et fag må holde, men for vårt prosjekt er dette fint.

I tillegg må hvert fag holde en liste over studenter som er meldt opp i faget. Siden vi skal først skrive klassen Fag i isolasjon fra klassen Student så representerer vi bare studenter som en liste med strenger for nå.

Her er starten på klassen Fag:

class Fag:
    """Representerer fag som studenter kan melde seg opp i."""

    def __init__(self, fagnavn, fagkode, beskrivelse):
        self.fagnavn = fagnavn
        self.fagkode = fagkode
        self.beskrivelse = beskrivelse
        self.studenter = []

Vi trenger så en metode for å kunne melde opp studenter i faget:

class Fag:
    # -- Resten av klassen her --
    
    def meld_opp_student(self, student):
        if not student in self.studenter:
            self.studenter.append(student)
        else:
            print(f'Student {student} er allerede oppmeldt i {self.fagnavn}.')

I tillegg trenger vi en metode for å kunne melde av studenter fra faget:

class Fag:
    # -- Resten av klassen her --
    
    def meld_av_student(self, student):
        if student in self.studenter:
            self.studenter.remove(student)
        else:
            print(f'Student {student} er ikke oppmeldt i {self.fagnavn}.')

Til slutt kan det også for klassen Fag være greit å skrive dundermetoden .__str__() for å sikre at utskriften er tydelig.

La oss her bare skrive ut navnet på faget og fagkoden. I motsetning til en student som mest sannsynlig er meldt opp i under 10 fag så kan et fag ha mange hundre studenter som er meldt opp i faget. Dette er litt mye å skrive ut til terminalen:

class Fag:
    # -- Resten av klassen her --
    
    def __str__(self):
        return f'{self.fagnavn} med fagkode {self.fagkode}.'

For å teste koden vår så langt kan vi opprette et fag og prøve å bruke metodene vi har laget:

logistikk = Fag(
    fagnavn='Introduksjon til logistikk',
    fagkode='LOG101',
    beskrivelse='Kurset gir en introduksjon til moderne logistikk...')
print(logistikk)
logistikk.meld_opp_student('Børre Børresen')
print(logistikk.studenter)
logistikk.meld_av_student('Børre Børresen')
print(logistikk.studenter)
logistikk.meld_av_student('Atle Arnesen')

Da har vi det vi trenger av klassen Fag i isolasjon. Nå er det på tide å binde sammen de to klassene Student og Fag.

11.7.4 Binde sammen klassene

Her kan det virke som det ikke krever noe arbeid å binde sammen klassene. Kan vi ikke bare legge inn objekter av klassen Fag i studentene sin liste over fag? Det kan vi, men vi må passe på å ikke introdusere feil her.

La oss opprette et fag og en student, og deretter melde studenten opp i faget:

logistikk = Fag(
    fagnavn='Introduksjon til logistikk',
    fagkode='LOG101',
    beskrivelse='Kurset gir en introduksjon til moderne logistikk...')
børre = Student(navn='Børre Børresen', fødselsnummer='18098843575')
børre.meld_på_fag(logistikk)

Kjører du koden over virker alt tipp topp. Studenten børre blir nå meldt opp i faget logistikk. Problemet blir derimot klart når vi skriver ut hvilke studenter som er med i faget logistikk:

# Dette listen er fortsatt tom
print(logistikk.studenter)

Hva er det som har skjedd her? Når du bruker metoden .meld_på_fag() på studenten børre så blir faget logistikk lagt til listen over fag børre.fag. Det er derimot ingenting som endrer listen over studenter logistikk.studenter. Dermed forblir denne tom.

Feilen ovenfor er vanlig å gjøre. Denne feilen fører til at informasjonen som er lagret i børre og logistikk ikke passer helhetlig sammen lengre. Det er umulig i praksis for en student å være meldt opp i et fag som ikke har noen studenter.

For å fikse dette må vi sikre at børre blir lagt til i listen over studenter logistikk.studenter. Dette kan vi gjøre ved å bruke metoden .meld_opp_student() i klassen Fag innad i metoden .meld_på_fag() i klassen Student:

class Student:
    # -- Resten av klassen her --
    
    def meld_på_fag(self, fag):
        if not fag in self.fag:
            self.fag.append(fag)
            fag.meld_opp_student(self)
        else:
            print(f'Student {self.navn} er allerede oppmeldt i {fag}.')

Dette fikser problemet! Kjører du den samme koden som tidligere og skriver ut logistikk.studenter så vil du få at den inneholder studenten børre.

På samme måte må vi sikre at når vi melder av en student fra et fag blir faget sine studenter også endret. Dette gjør vi ved å endre metoden .meld_av_fag() i Klassen Student tilsvarende slik:

class Student:
    # -- Resten av klassen her --
    
    def meld_av_fag(self, fag):
        if fag in self.fag:
            self.fag.remove(fag)
            fag.meld_av_student(self)
        else:
            print(f'Student {self.navn} er ikke oppmeldt i {fag}.')

Går vi over til klassen Fag så burde vi jo også sikre at en student ikke kan bli meldt opp i et fag uten at listen med fag studenten er meldt opp i blir endret. Her er det naturlig å prøve å skrive følgende kode:

class Fag:
    # -- Resten av klassen her --
    
    def meld_opp_student(self, student):
        if not student in self.studenter:
            self.studenter.append(student)
            student.meld_på_fag(self)
        else:
            print(f'Student {student} er allerede oppmeldt i {self.fagnavn}.')

Her introduserer vi også en feil som er fort gjort. Hva tror du blir skrevet ut til terminalen her?

logistikk = Fag(
    fagnavn='Introduksjon til logistikk',
    fagkode='LOG101',
    beskrivelse='Kurset gir en introduksjon til moderne logistikk...')
børre = Student(navn='Børre Børresen', fødselsnummer='18098843575')
børre.meld_på_fag(logistikk)

Du vil få en beskjed om at Børre allerede er meldt opp i logistikkfaget. Hvorfor det?

Koden børre.meld_på_fag(logistikk) kaller metoden .meld_på_fag() i klassen Student. Denne metoden kaller metoden .meld_opp_student() i klassen Fag og informasjonen blir rettet begge steder. Men metoden .meld_opp_student() i klassen Fag kaller igjen metoden .meld_på_fag() i klassen Student. Når dette skjer vil du få skrevet ut beskjeden om at Børre allerede er meldt opp i logistikkfaget.

Hvordan kan vi fikse dette? Her er det mange måter, men den enkleste for oss akkurat nå er å la metoden .meld_opp_student() i klassen Fag endre studentens liste med fag direkte. Vi kan skrive følgende:

class Fag:
    # -- Resten av klassen her --
    
    def meld_opp_student(self, student):
        if not student in self.studenter:
            self.studenter.append(student)
            student.fag.append(self)
        else:
            print(f'Student {student} er allerede oppmeldt i {self.fagnavn}.')

På denne måten unngår vi problemet. Vi kan gjøre det samme i metoden .meld_av_student() slik:

class Fag:
    # -- Resten av klassen her --
    
    def meld_av_student(self, student):
        if student in self.studenter:
            self.studenter.remove(student)
            student.fag.remove(self)
        else:
            print(f'Student {student} er ikke oppmeldt i {self.fagnavn}.')

En siste ting som er litt irriterende er at det er lett for å legge til en streng som et fag. På samme måte er det lett for å legge til en streng som en student til et fag. Her ser du hvor naturlig dette ser ut:

børre.meld_på_fag('Introduksjon til logistikk')
logistikk.meld_opp_student('Børre Børresen')

Prøver du å kjøre koden over vil dette ikke gå, og du vil få en AttributeError. Du kan selv prøve å jakte på nøyaktig hvor i koden programmet krasjer. Problemet er at koden vår forventer en helt spesifikk klasse, men vi bruker strenger.

Heldigvis i Python har vi en innebygd funksjon som heter isinstance(). Denne kan brukes til å sjekke om noe et objekt tilhører en spesifikk klasse:

print(isinstance(børre, Student)) # True
print(isinstance('Børre Børresen', Student)) # False

Vi kan bruke funksjonen isinstance() til å sikre koden vår bedre. Her bruker vi den i klassen Student til å sikre at det er faktiske fag vi melder en student opp til:

class Student:
    # -- Resten av klassen her --

    def meld_på_fag(self, fag):
        if isinstance(fag, Fag) and fag not in self.fag:
            self.fag.append(fag)
            fag.meld_opp_student(self)
        elif not isinstance(fag, Fag):
            print(f'Faget {fag} er ikke et gyldig fag.')
        else:
            print(f'Student {self.navn} er allerede oppmeldt i {fag}.')

    def meld_av_fag(self, fag):
        if isinstance(fag, Fag) and fag in self.fag:
            self.fag.remove(fag)
            fag.meld_av_student(self)
        elif not isinstance(fag, Fag):
            print(f'Faget {fag} er ikke et gyldig fag.')
        else:
            print(f'Student {self.navn} er ikke oppmeldt i {fag}.')

Vi kan også legge til funksjonen isinstance() i metodene .meld_opp_student() og .meld_av_student() i klassen Fag:

class Fag:
    # -- Resten av klassen her --

    def meld_opp_student(self, student):
        if isinstance(student, Student) and not student in self.studenter:
            self.studenter.append(student)
            student.fag.append(self)
        elif not isinstance(student, Student):
            print(f'Student {student} er ikke en gyldig student.')
        else:
            print(f'Student {student} er allerede oppmeldt i {self.fagnavn}.')

    def meld_av_student(self, student):
        if isinstance(student, Student) and student in self.studenter:
            self.studenter.remove(student)
            student.fag.remove(self)
        elif not isinstance(student, Student):
            print(f'Student {student} er ikke en gyldig student.')
        else:
            print(f'Student {student} er ikke oppmeldt i {self.fagnavn}.')

Hvis du nå prøver å kjøre koden under vil programmet ikke krasje, og du vil få feilmeldinger som peker på hva problemet er:

børre.meld_på_fag('Introduksjon til logistikk')
>>> Faget Introduksjon til logistikk er ikke et gyldig fag.

logistikk.meld_opp_student('Børre Børresen')
>>> Student Børre Børresen er ikke en gyldig student.

Det er fort gjort at man blir helt forelsket i isinstance() og lignende funksjoner for å sikre hver minste detalj ved klasser vi skriver. Dette øker antall linjer kode og kompleksiteten til koden, så det er ikke gratis å gjøre dette. Du bør vurdere om det er nyttig å legge til funksjoner som isinstance() for å sikre koden din bedre.

Her er den fullstendige koden til dette prosjektet:

class Student:
    """Representerer studenter på et universitet som kan ta fag."""

    def __init__(self, navn, fødselsnummer):
        self.navn = navn
        self.fødselsnummer = fødselsnummer
        self.fag = []

    def meld_på_fag(self, fag):
        if isinstance(fag, Fag) and fag not in self.fag:
            self.fag.append(fag)
            fag.meld_opp_student(self)
        elif not isinstance(fag, Fag):
            print(f'Faget {fag} er ikke et gyldig fag.')
        else:
            print(f'Student {self.navn} er allerede oppmeldt i {fag}.')

    def meld_av_fag(self, fag):
        if isinstance(fag, Fag) and fag in self.fag:
            self.fag.remove(fag)
            fag.meld_av_student(self)
        elif not isinstance(fag, Fag):
            print(f'Faget {fag} er ikke et gyldig fag.')
        else:
            print(f'Student {self.navn} er ikke oppmeldt i {fag}.')

    def __str__(self):
        if not self.fag:
            return f'Student {self.navn} er ikke oppmeldt i noen fag.'
        beskrivelse = f'Student {self.navn} er oppmeldt i fagene:'
        for indeks, fag in enumerate(self.fag):
            beskrivelse += f'\nFag {indeks + 1}: {fag}'
        return beskrivelse


class Fag:
    """Representerer fag som studenter kan melde seg opp i."""

    def __init__(self, fagnavn, fagkode, beskrivelse):
        self.fagnavn = fagnavn
        self.fagkode = fagkode
        self.beskrivelse = beskrivelse
        self.studenter = []

    def meld_opp_student(self, student):
        if isinstance(student, Student) and not student in self.studenter:
            self.studenter.append(student)
            student.fag.append(self)
        elif not isinstance(student, Student):
            print(f'Student {student} er ikke en gyldig student.')
        else:
            print(f'Student {student} er allerede oppmeldt i {self.fagnavn}.')

    def meld_av_student(self, student):
        if isinstance(student, Student) and student in self.studenter:
            self.studenter.remove(student)
            student.fag.remove(self)
        elif not isinstance(student, Student):
            print(f'Student {student} er ikke en gyldig student.')
        else:
            print(f'Student {student} er ikke oppmeldt i {self.fagnavn}.')

    def __str__(self):
        return f'{self.fagnavn} med fagkode {self.fagkode}.'

Som du kan se så var det ikke så mange nye kodelinjer som måtte på plass for å binde de to klassene Student og Fag sammen. Likevel er det fort gjort å glemme slikt hvis vi ikke tester koden vår jevnlig. I seksjon 12.6 skal vi se nærmere på automatiske tester slik at vi enda lettere kan oppdage feil tidlig.

11.8 Oppgaver

Oppgave 1

Hva gjør følgende kode? Beskriv hvert steg!

class KaffeKopp:
    """En klasse som representerer en kaffekopp."""

    def __init__(self, eier, volum):
        self.eier = eier
        self.volum = volum
        self.innhold = 0

    def fyll_på(self):
        """Fyller koppen helt opp til maks volum."""
        self.innhold = self.volum
        print(f'''{self.eier} fyller koppen helt opp. 
            Nå er det {self.innhold} ml i koppen.''')

    def drikk(self, mengde):
        """Drikk en viss mengde kaffe. Stopper på 0 om koppen er tom."""
        if mengde > self.innhold:
            print(f'{self.eier} drikker hele innholdet i koppen.')
            self.innhold = 0
        else:
            self.innhold -= mengde
            print(f'''{self.eier} drikker {mengde} ml kaffe. 
            Nå er det {self.innhold} ml igjen.''')

    def vis_status(self):
        """Skriver ut status for koppen."""
        if self.innhold == 0:
            print(f'Koppen til {self.eier} er tom.')
        elif self.innhold < self.volum / 2:
            print(f'Koppen til {self.eier} har bare {self.innhold} ml igjen.')
        else:
            print(f'Koppen til {self.eier} har hele {self.innhold} ml igjen.')


benjamin_kopp = KaffeKopp('Benjamin', 250)

benjamin_kopp.vis_status()
benjamin_kopp.fyll_på()
benjamin_kopp.drikk(100)
benjamin_kopp.vis_status()
benjamin_kopp.drikk(200)
benjamin_kopp.vis_status()

Oppgave 2

Forestill deg at du har fått jobb i en bank, og sjefen ber deg om å lage en liten prototype av hvordan en bankkonto kan fungere i Python. Banken skal bruke dette som en veldig enkel simulator for å teste kunder som setter inn og tar ut penger.

Lag en klasse som heter BankKonto. Klassen skal ha følgende:

Attributter:

  • eier (navn på personen som eier kontoen).

  • saldo (hvor mye penger som står på kontoen, starter på 0).

Metoder:

  • sett_inn(beløp) – øker saldo med beløp.

  • ta_ut(beløp) – reduserer saldo med beløp, men bare hvis det er nok penger på kontoen. Hvis ikke skal programmet skrive ut meldingen: 'Ikke nok penger på kontoen!'.

  • vis_saldo() – skriver ut hvor mye penger som er på kontoen, for eksempel: 'Saldo til Benjamin er 600 kr!'.

Opprett et objekt av klassen BankKonto og bruk metodene over til å demonstrere hvordan bankkontoen fungerer.

Oppgave 3

Nedenfor ser du en enkel klasse som representerer bøker i et bibliotek:

class Bok:
    """En klasse som representerer bøker, pluss annen relatert informasjon."""

    def __init__(self, tittel, forfatter, sjanger, bibliotek, adresse):
        self.tittel = tittel
        self.forfatter = forfatter
        self.sjanger = sjanger
        self.bibliotek = bibliotek
        self.adresse = adresse
    
    def hent_full_tittel(self):
        return f'{self.tittel} av {self.forfatter}'
    
    def standard_bok():
        return Bok(
            'Ukjent',
            'Jane Doe',
            'Diverse',
            'Sentralbiblioteket',
            'Hovedgata 1'
        )
    
    def beregn_forsinkelsesgebyr(dager, dagsgebyr=5):
        return dager * dagsgebyr

Dersom du ser nøye etter så blander denne klassen sammen informasjon som gjelder hver enkelt bok, informasjon som gjelder hele biblioteket, og funksjonalitet som egentlig ikke trenger å vite noe om verken bok eller bibliotek. Din oppgave er å rydde opp i dette!

  • Hvilke attributter bør gjøres om til klassevariabler, og hvorfor?

  • Metoden .standard_bok() er ment å være en alternativ konstruktør. Skriv den om til en klassemetode.

  • Metoden .beregn_forsinkelsesgebyr() regner ut gebyret basert på antall dager og en sats. Bør dette være en vanlig metode, en klassemetode eller en statisk metode?

Oppgave 4

Din lokale dyrehage har fått et nytt datasystem som skal holde styr på dyrene. For å slippe å skrive samme kode flere ganger, ønsker de å bruke arv.

  • Lag en superklasse Dyr som har attributtene navn (en streng) og alder (et heltall). Superklassen Dyr skal ha en metode .lag_lyd() som skriver ut den generelle beskjeden 'Dette dyret lager en lyd!'.

Lag tre subklasser som arver fra Dyr:

  • Subklassen Løve arver fra Dyr og overskriver metoden .lag_lyd() slik at den skriver ut 'Løven brøler!'.

  • Subklassen Papegøye arver fra Dyr og får en ekstra attributt favorittuttrykk. Når vi kaller .lag_lyd(), skal papegøyen si favorittuttrykket sitt.

  • Subklassen Pingvin arver fra Dyr og har en metode .svøm() som skriver ut 'Pingvinen svømmer i bassenget!'.

Opprett ett objekt av hver av klassene over og test metodene deres.

Oppgave 5 (Utfordrende)

I seksjon 11.3 jobbet vi med dundermetoden .__str__(), som lar oss skrive ut et objekt på en lesbar måte til terminalen. Python har også en annen dundermetode med et lignende formål som heter .__repr__().

Undersøk selv hva som er forskjellen mellom .__str__() og .__repr__(). Skriv gjerne ned med egne ord hva du finner. Reflekter over i hvilke situasjoner det er nyttig å implementere begge metodene i en klasse.