5.3 Arv og `super()`

La to klasser dele kode ved arv, med `super().__init__` — mønsteret som også bærer ODESolver-subklassene i Del 7.

45 min
6 oppgaver
Arv`super()`
Din fremgang i kapitlet
0 / 6 oppgaver
Forkunnskaper — sist du var her. Dette kapitlet står på kap. 5.1 (konstruktør og kallbar instans) og kap. 5.2 (__str__). Her er hele grunnlaget i én kjørt snutt:

class Parabel:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    def __call__(self, x):
        return self.a*x**2 + self.b*x + self.c

    def __str__(self):
        return f'{self.a}*x**2 + {self.b}*x + {self.c}'

p = Parabel(1, -2, 1)
print(p)
print(p(3))

Utskrift:

1*x**2 + -2*x + 1
4

Tre ting fra utskriften du bør ha helt klart før du leser videre:

1. __init__ lagrer det faste — her de tre koeffisientene, med self.a = a og så videre.
2. __call__ regner ut funksjonsverdien av det som varierer, x. Instansen er dermed kallbar: p(3) er lovlig.
3. __str__ returnerer en f-streng, og den brukes av print(p).

Nå skal vi lage en klasse til — en linje — som er nesten identisk med denne. Og da er det arv som sparer oss for å skrive det samme to ganger.

Løkke 1 — To klasser som deler nesten all kode (~15 min)

Problemet: samme kode to ganger

Du har klassen Parabel for annengradsfunksjonen

p(x)=ax2+bx+cp(x) = ax^2 + bx + c

Nå trenger du også en klasse Linje for

(x)=bx+c\ell(x) = bx + c

Du kunne skrevet den fra bunnen. Men se hva du da måtte gjentatt: en konstruktør som lagrer koeffisienter, en __call__ som regner ut en verdi, en __str__ som formaterer. Nesten hver linje ville vært en kopi.

Og en linje er en parabel — nemlig den der a=0a = 0. Det er nettopp den situasjonen arv er laget for: to klasser der den ene er et spesialtilfelle av den andre.

Arv: `class Sub(Base):`

Å la en ny klasse overta alt en eksisterende klasse har. Du skriver basisklassens navn i parentes etter subklassens navn:

class Linje(Parabel):
    pass

Nå har Linje allerede __init__, __call__ og __str__ — alle metodene fra Parabel, uten at du har skrevet en linje av dem.

Retningen er verdt å merke seg: subklassen får alt fra basisklassen, aldri motsatt. Parabel vet ingenting om at Linje finnes.

Basisklasse (superklasse)

Klassen det arves fra — Parabel i class Linje(Parabel):. «Basisklasse» og «superklasse» er to navn på det samme; boka bruker «basisklasse».

Basisklassen er den generelle: den kan alt de andre kan. I Del 7 er ODESolver basisklassen, og den inneholder hele løkka som løser en differensiallikning.

En basisklasse kan godt være ubrukelig alene. ODESolver er nettopp det: den mangler ett stykke, og hver subklasse fyller det stykket på sin måte.

Subklasse

Klassen som arver — Linje i class Linje(Parabel):. Den er det spesielle tilfellet, og den inneholder bare det som skiller den fra basisklassen.

Det er dette som gjør subklasser korte, og det er derfor eksamensoppgaver liker dem: du skriver fem linjer og får femti.

En subklasse kan gjøre tre ting: arve en metode uendret, overstyre en metode (skrive sin egen med samme navn), og legge til helt nye metoder eller attributter.

`super().__init__(...)`

Kallet som lar subklassens konstruktør bruke basisklassens konstruktør. super() betyr «basisklassen min», og super().__init__(0, b, c) kjører altså Parabel.__init__ med de argumentene.

class Linje(Parabel):
    def __init__(self, b, c):
        super().__init__(0, b, c)

Nå får instansen alle de tre attributtene self.a, self.b og self.c — det er basisklassens konstruktør som setter dem — men brukeren av Linje trenger bare oppgi to tall.

Legg merke til at self ikke skrives i kallet. super().__init__(0, b, c), ikke super().__init__(self, 0, b, c).

📜Subklasse-malen: `super().__init__` FØRST

Mønsteret du skal kunne skrive uten å tenke, og som gjelder både for Linje(Parabel) her og for ODESolver-subklassene i Del 7:

class Sub(Base):
    def __init__(self, nye_argumenter):
        super().__init__(...)     # 1. basisklassens konstruktør, FØRST
        self.nytt = nye_argumenter  # 2. deretter det som er nytt

    def metode(self, x):          # 3. til slutt: overstyr det som skal være annerledes
        return ...

Tre regler ligger i malen:

1. super().__init__(...) står først i subklassens konstruktør. Alt det basisklassen skal sette opp, skal være på plass før du bygger videre.
2. Bare det nye skrives i subklassen. Kopierer du en metode uendret fra basisklassen, har du misforstått hele poenget.
3. Overstyring skjer ved navn. Skriver du en metode med samme navn som en i basisklassen, er det din som gjelder for instanser av subklassen.

På eksamen er dette et poengrikt sted, fordi malen er kort og gjenkjennelig. Sensor ser umiddelbart om strukturen er riktig.

✏️Eksempel 1: `Linje` som subklasse av `Parabel` (sjanger G, arv-varianten)

Klassen Parabel representerer p(x)=ax2+bx+cp(x) = ax^2 + bx + c med konstruktør, __call__ og __str__. Skriv en subklasse Linje som representerer (x)=bx+c\ell(x) = bx + c, ved å arve fra Parabel og sette a=0a = 0.

class Parabel:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    def __call__(self, x):
        return self.a*x**2 + self.b*x + self.c

    def __str__(self):
        return f'{self.a}*x**2 + {self.b}*x + {self.c}'

class Linje(Parabel):
    def __init__(self, b, c):
        super().__init__(0, b, c)

p = Parabel(1, -2, 1)
l = Linje(2, 1)

print(p(3))
print(l(3))
print(l)
print(l.a, l.b, l.c)

Utskrift:

4
7
0*x**2 + 2*x + 1
0 2 1

Hele subklassen er tre linjer, og den inneholder ingen matematikk. Se hva som skjedde:

- __call__ er arvet. l(3) kjører Parabel.__call__, som regner 032+23+1=70\cdot 3^2 + 2\cdot 3 + 1 = 7.
- __str__ er arvet. Derfor skrives linja med et 0*x**2-ledd foran. Det er litt stygt, og vi retter det om litt ved å overstyre metoden.
- Alle tre attributtene finnes, også l.a, som er 0 — satt av basisklassens konstruktør gjennom super()-kallet.

Uttelling: de to poengbærende linjene er class Linje(Parabel): og super().__init__(0, b, c). En besvarelse som i stedet kopierer hele Parabel og setter a = 0 får langt mindre uttelling, selv om den regner riktig — oppgaven ba om arv.

📝Oppgave 1

(Innstegsoppgave, sjanger A — kodesporing, altså «hva skriver programmet ut?».) Skriv utskriften nøyaktig, med linjeskift slik terminalen viser den.

class Parabel:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    def __call__(self, x):
        return self.a*x**2 + self.b*x + self.c

    def __str__(self):
        return f'{self.a}*x**2 + {self.b}*x + {self.c}'

class Linje(Parabel):
    def __init__(self, b, c):
        super().__init__(0, b, c)

l = Linje(-1, 4)
print(l(0))
print(l(2))
print(l.a)

Løkke 2 — Overstyring: å gjøre én ting annerledes (~15 min)

Overstyring av en metode

Å skrive en metode i subklassen med samme navn som en i basisklassen. Da er det subklassens versjon som brukes for instanser av subklassen.

Vi så at Linje arvet en __str__ som skrev 0*x**2 + 2*x + 1. Skriver vi vår egen __str__ i Linje, får vi den vi vil ha:

class Linje(Parabel):
    def __init__(self, b, c):
        super().__init__(0, b, c)

    def __str__(self):
        return f'{self.b}*x + {self.c}'

Merk at overstyring ikke fjerner noe fra basisklassen. Parabel-instanser bruker fortsatt sin egen __str__. Det er bare Linje-instanser som får den nye.

Regelen Python følger, er «se i egen klasse først, og i basisklassen etterpå».

✏️Eksempel 2: Overstyre `__str__` og legge til en ny metode

Utvid Linje slik at utskriften blir 2*x + -6 i stedet for parabelformen, og legg til en metode nullpunkt som returnerer xx-verdien der linja krysser xx-aksen.

class Parabel:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    def __call__(self, x):
        return self.a*x**2 + self.b*x + self.c

    def __str__(self):
        return f'{self.a}*x**2 + {self.b}*x + {self.c}'

class Linje(Parabel):
    def __init__(self, b, c):
        super().__init__(0, b, c)

    def __str__(self):
        return f'{self.b}*x + {self.c}'

    def nullpunkt(self):
        return -self.c/self.b

p = Parabel(1, -2, 1)
l = Linje(2, -6)

print(p)
print(l)
print(l(3))
print(l.nullpunkt())

Utskrift:

1*x**2 + -2*x + 1
2*x + -6
0
3.0

Tre ting skjer her, og de er hver sin type endring en subklasse kan gjøre:

- __str__ er overstyrt. print(l) bruker Linjes versjon, mens print(p) fortsatt bruker Parabels. Samme metodenavn, to klasser, og Python velger etter hvilken klasse instansen tilhører.
- __call__ er fortsatt arvet. Vi rørte den ikke, så l(3) regner videre med a=0a = 0.
- nullpunkt er helt ny. Den finnes bare i Linjep.nullpunkt() ville gitt AttributeError, og det er riktig, for en parabel har ikke nødvendigvis ett nullpunkt.

Utskriften 2*x + -6 er litt klønete med det doble fortegnet. Det er et typisk formateringsvalg du kan nevne i en kommentar på eksamen — det koster ikke poeng, men det viser at du ser det.

📝Oppgave 2
Eksamensnivå, sjanger G

En klasse Rette representerer en rett linje y=ax+by = ax + b med a og b i konstruktøren og en __call__ som gir funksjonsverdien.

a) Skriv klassen Rette.

b) Skriv en subklasse Konstant for en vannrett linje y=by = b, der bare b gis når objektet lages. Bruk arv.

c) Overstyr __str__ i Konstant slik at utskriften blir bare tallet, uten x-ledd.

Løkke 3 — Å utvide: subklassen med noe mer (~15 min)

Utvide en klasse med nye attributter

Subklassen kan ha attributter basisklassen ikke har. Mønsteret er super()-kallet først, og de nye attributtene etterpå:

class Vec3D(Vec2D):
    def __init__(self, x, y, z):
        super().__init__(x, y)
        self.z = z

Nå har instansen tre attributter: x og y fra basisklassen, z fra subklassen.

Dette er den vanligste formen for arv i praksis, og den viser hvorfor rekkefølgen betyr noe: super()-kallet fyller grunnmuren, og først da bygger du videre på den.

`isinstance(obj, Base)` med arv
isinstance svarer True også når objektet tilhører en subklasse av klassen du spør om. Er v en Vec3D, er både isinstance(v, Vec3D) og isinstance(v, Vec2D) sanne.

Det følger av hvordan arv skal leses: en Vec3D er en Vec2D med et ledd mer. Motsatt vei holder det ikke — en Vec2D er ingen Vec3D.

I praksis brukes dette i __mul__ og __add__, der du vil godta alle vektorer og ikke bare den ene klassen. Da tester du mot basisklassen.

✏️Eksempel 3: `Vec3D` som subklasse av `Vec2D` (eksamensnivå)

Du har klassen Vec2D med __str__, __add__ og __mul__ (indreprodukt) fra kap. 5.2. Skriv Vec3D som en subklasse: den skal ha en tredje komponent z, og de tre spesialmetodene skal ta hensyn til den.

class Vec2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f'({self.x}, {self.y})'

    def __add__(self, other):
        return Vec2D(self.x + other.x, self.y + other.y)

    def __mul__(self, other):
        return self.x*other.x + self.y*other.y

class Vec3D(Vec2D):
    def __init__(self, x, y, z):
        super().__init__(x, y)
        self.z = z

    def __str__(self):
        return f'({self.x}, {self.y}, {self.z})'

    def __add__(self, other):
        return Vec3D(self.x + other.x, self.y + other.y, self.z + other.z)

    def __mul__(self, other):
        return self.x*other.x + self.y*other.y + self.z*other.z

u = Vec3D(1, 2, 3)
v = Vec3D(4, -1, 0)
w = Vec2D(1, 1)

print(u + v)
print(u * v)
print(w + w)
print(isinstance(u, Vec3D), isinstance(u, Vec2D), isinstance(w, Vec3D))

Utskrift:

(5, 1, 3)
2
(2, 2)
True True False

Dette er en oppgavetype som ser stor ut og er liten. Legg merke til arbeidsdelingen:

- Konstruktøren er tre linjer: super().__init__(x, y) setter x og y, og self.z = z legger til det nye.
- De tre spesialmetodene er overstyrt, fordi alle tre må ha med z-leddet. Her er det ikke noe å arve.
- Vec2D er urørt. w + w bruker fortsatt basisklassens __add__ og returnerer en Vec2D.

Siste linje viser isinstance-regelen: u er både en Vec3D og en Vec2D, mens w bare er en Vec2D.

Ærlig vurdering av arv her: når alle metodene må overstyres, sparer arven deg bare for konstruktøren. Det er helt legitimt å nevne i en kommentar at gevinsten er liten i dette tilfellet — men skriv likevel arven, for det er det oppgaven ber om, og strukturen er det sensor ser etter.

Uttelling: super().__init__(x, y) pluss self.z = z er det ene poengbærende punktet; at __add__ returnerer Vec3D(...) og ikke Vec2D(...) er det andre — bytter du de to, får du en vektor som mister z-komponenten, og det er en feil som forplanter seg.

📝Oppgave 3

(Midtveisnivå, sjanger A — kodesporing.) Klassene Vec2D og Vec3D fra eksempel 3 er definert. Hva skriver programmet ut?

class Vec2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f'({self.x}, {self.y})'

    def __add__(self, other):
        return Vec2D(self.x + other.x, self.y + other.y)

    def __mul__(self, other):
        return self.x*other.x + self.y*other.y

class Vec3D(Vec2D):
    def __init__(self, x, y, z):
        super().__init__(x, y)
        self.z = z

    def __str__(self):
        return f'({self.x}, {self.y}, {self.z})'

    def __add__(self, other):
        return Vec3D(self.x + other.x, self.y + other.y, self.z + other.z)

    def __mul__(self, other):
        return self.x*other.x + self.y*other.y + self.z*other.z

u = Vec3D(2, 0, 1)
v = Vec3D(1, 5, -1)
print(u + v)
print((u + v) * u)
print(isinstance(u + v, Vec2D))

📝Oppgave 4
Eksamensnivå, sjanger G
En bakteriekultur i et laboratorium modelleres med eksponentiell vekst,

N(t)=N0ertN(t) = N_0 e^{rt}

a) Skriv en klasse Eksponentiell med N0N_0 og rr i konstruktøren, en __call__ som gir bestanden ved tiden t, og en __str__ som skriver modellen på formen N(t) = 100*exp(0.3*t).

b) Kulturen får etter hvert begrenset plass. Skriv en subklasse Begrenset som i tillegg har en bærekapasitet K, og der __call__ returnerer den minste av bestanden fra basisklassen og K. Bruk super() både i konstruktøren og til å hente basisklassens funksjonsverdi.

c) Skriv ut begge modellene ved t=0,5,10t = 0, 5, 10 med N0=100N_0 = 100, r=0,3r = 0{,}3 og K=1000K = 1000.

📝Oppgave 5
Eksamensnivå, sjanger G

Radioaktivt henfall følger N(t)=N0eλtN(t) = N_0 e^{-\lambda t}, der λ\lambda er henfallskonstanten. Mange kilder oppgir i stedet halveringstiden TT, og sammenhengen mellom de to er λ=ln2/T\lambda = \ln 2 / T.

Programmet under regner riktig, men de to klassene er skrevet hver for seg og deler nesten all kode:

from math import exp, log

class Nedbrytning:
    def __init__(self, N0, lam):
        self.N0 = N0
        self.lam = lam

    def __call__(self, t):
        return self.N0*exp(-self.lam*t)

    def __str__(self):
        return f'N(t) = {self.N0}*exp(-{self.lam}*t)'

class Halveringstid:
    def __init__(self, N0, T):
        self.N0 = N0
        self.lam = log(2)/T

    def __call__(self, t):
        return self.N0*exp(-self.lam*t)

    def __str__(self):
        return f'N(t) = {self.N0}*exp(-{self.lam}*t)'

a) Skriv om paret slik at Halveringstid bruker arv. Ingen metode skal stå to ganger.
b) Hvilke to metoder har Halveringstid etter omskrivingen, hvor er de definert, og hvor mange linjer er klassen krympet til?
c) Karbon-14 har halveringstid 5730 år. Vis at 1000 kjerner gir 500 igjen etter én halveringstid og 250 etter to. Skriv også ut objektet selv, og forklar hvorfor tallet i den utskriften ser ut som det gjør.

📝Oppgave 6
Eksamensnivå, sjanger O

Programmet under skal lage en linje som spesialtilfelle av en parabel, men det krasjer. Finn alle feilene, si hva hver av dem gjør, og skriv programmet riktig.

class Parabel:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    def __call__(self, x):
        return self.a*x**2 + self.b*x + self.c

class Linje(Parabel):
    def __init__(b, c):
        super().__init__(self, 0, b, c)

    def __str__(self):
        print(f'{self.b}*x + {self.c}')

l = Linje(2, 1)
print(l)
print(l(3))

API- og konstruksjonsliste

Begrepsbank

Begrepsbanken er flashcard-/repetisjonsstoff — den gjentar det du nettopp har lest. Hopp trygt over ved førstegangslesing; tidsanslaget for kapitlet gjelder kjernestoffet.

Arvede metoder

Metodene subklassen får uten å skrive dem. De kjører med subklasseinstansens egne attributter.

Det er dette som gjør arv nyttig: Linje arvet __call__ fra Parabel, og den regnet riktig, fordi self.a var 0 for linja.

Praktisk konsekvens for sporing: når du leter etter en metode og ikke finner den i klassen, skal du lete i basisklassen. Python gjør nøyaktig det samme.

Rekkefølgeregelen: `super()` først
super().__init__(...) skal være første linje i subklassens konstruktør. Deretter kommer subklassens egne tilordninger.

Begrunnelsen er at basisklassen kan sette opp ting subklassen bygger videre på. Snur du rekkefølgen, risikerer du at basisklassens konstruktør overskriver det du nettopp satte — og feilen er stille, den gir ingen feilmelding.

Regelen er billig å følge og dyr å bryte. Gjør den til en vane, så slipper du å vurdere den i hver oppgave.

Når arv lønner seg

Når to klasser deler mesteparten av koden, og den ene er et spesialtilfelle av den andre. «Er en» er testen: en linje er en parabel med a=0a = 0, en Vec3D er en Vec2D med et ledd mer.

Arv lønner seg ikke når klassene bare tilfeldigvis har noen like metodenavn. Da får du en kunstig basisklasse, og koden blir vanskeligere å lese, ikke lettere.

I IN1900 er svaret enkelt: bruk arv når oppgaveteksten ber om det. Den ber om det i to situasjoner — spesialtilfelle-klasser i Del 5, og ODESolver-subklasser i Del 7.

`AttributeError` etter glemt `super()`-kall

Feilmeldingen AttributeError: 'Linje' object has no attribute 'a' betyr at koden leste et attributt som aldri ble satt.

I arv-sammenheng er årsaken nesten alltid den samme: subklassens konstruktør satte sine egne attributter, men glemte super().__init__(...), så basisklassens attributter mangler.

Diagnosen tar tre sekunder når du kjenner mønsteret: står navnet i feilmeldingen blant det basisklassens konstruktør skulle satt, er det super()-kallet som mangler.

Felles grensesnitt

At flere klasser har metoder med samme navn og samme bruk. Da kan kode som bruker dem, behandle dem likt uten å vite hvilken klasse den har fått.

Vi har alt sett det: funksjonen maksverdi(f, a, b, n) fra kap. 5.1 kaller bare f(x), og virker derfor for hver klasse som har en __call__ — enten den er en Gauss, en Parabel eller en Linje.

Dette er hele ideen bak ODESolver-hierarkiet i Del 7: alle løserne har set_initial_condition og solve, så koden som bruker dem, ser identisk ut uansett hvilken metode du valgte.

Å arve `__call__`

Spesialmetoder arves som alle andre metoder. Har basisklassen en __call__, er subklasseinstansene også kallbare — uten at du skriver noe.

Det er nettopp det som gjorde Linje til en fullverdig funksjonsklasse på tre linjer. l(3) virket, fordi Parabel.__call__ var arvet.

Samme gjelder __str__, __add__ og __mul__. Vil du at subklassen skal oppføre seg annerledes, overstyrer du — ellers arver du.

Subklasseinstansen er også en basisklasseinstans

En Vec3D er en Vec2D. Det betyr at et Vec3D-objekt kan sendes overalt der koden forventer en Vec2D, og at isinstance(v, Vec2D) er sann.

Motsatt vei gjelder det ikke: isinstance(w, Vec3D) er falsk for en ren Vec2D, siden den mangler z.

I __add__ og __mul__ er dette verdt å vite når du tester typer: test mot basisklassen hvis du vil godta hele familien, mot subklassen hvis du bare vil godta den ene.

Repetisjon — fem spørsmål før du går videre

Dette kapitlet er skrevet av Anthropics toppmodeller (Claude Opus og Claude Fable) og er foreløpig ikke manuelt gjennomgått — kvalitetskontrollen gjøres av uavhengige KI-agenter, og innmeldte feil rettes fortløpende. Funnet en feil? Meld fra, så retter vi den. Les mer om hvordan innholdet lages.

Skolesaga er en uavhengig læringsressurs og er ikke tilknyttet eller godkjent av Universitetet i Oslo. Dette er ikke offisielt studiemateriell. Les mer.