Förberedelsematerial 4
Innehåll
I Tema 3, och speciellt pythonuppgifterna kapitel 13, tittade vi på funktioner som kunde returnera andra funktioner och skapa closures. Funktioner som kunde innehålla och isolera tillstånd mellan anrop. Ett annat sätt att åstadkomma samma sak är med hjälp av klasser och objekt, och den här approachen är mer typisk i Python. I detta förberedelsematerial ska vi titta närmare på de här koncepten och börja närma oss vad som brukar kallas för objektorienterad programmering.
Klasser och objekt
Vi har egentligen arbetat med klasser och objekt ända sedan vi skrev våra första rader Python-kod. I Python är nämligen alla värden objekt, och alla objekt är instanser av någon klass. Speciellt kan vi säga att alla datatyper i Python är implementerade som klasser. När vi skapar en lista med my_list = [1, 2, 3] så skapar vi egentligen ett objekt av klassen list.
I många situationer har vi arbetat med våra objekt utan att behöva tänka på att de är instanser av klasser. När vi har skrivit t.ex. my_list.append(4) så har vi anropat särskilda funktioner som existerar inne i objekten. Dessa särskilda funktioner kallas metoder och är definierade i klassdefinitionen för den klass som objektet är en instans av. I fallet med my_list.append(4) så anropar vi metoden append som är definierad i klassen list.
Egna klasser och objekt
Klasser är inte begränsade till de inbyggda, primitiva, datatyperna i Python. Här kommer vi att fokusera på att skapa egna klasser och objekt, men det är värt att notera att det finns oerhört många klasser i olika moduler i Python-standarden och ännu fler i tredjepartsbibliotek. Att använda och skapa klasser är en grundläggande del av att programmera i Python.
Vi kommer att titta på ett exempel på en klass som representerar en kö. Vi väljer just en kö eftersom vi tittat på flera kö-exempel tidigare och förhoppningsvis är någorlunda bekanta med hur de fungerar. Vi antar att vår klass ska heta Queue och att den ska ha två metoder: enqueue, som lägger till ett element sist i kön, och dequeue, som tar bort och returnerar det första elementet i kön. Se illustrationen till höger.
Vi kan skapa ett objekt av klassen Queue genom att “anropa klassen som om det vore en funktion”. Detta skapar ett nytt objekt av klassen, och vi kan sedan använda objektet genom att anropa dess metoder.
|
|
Notera att vi interagerar med objektet my_queue genom att anropa dess metoder enqueue och dequeue med punktnotation. Detta är det typiska sättet att arbeta med objekt i Python och vi har som sagt redan gjort detta med vissa av de inbyggda datatyperna i Python. Men hur skapar vi den här klassen Queue och definierar dess metoder?
Klassdefinition
Vi definierar en klass i Python med nyckelordet class, följt av klassens namn och ett kolon. Innehållet i klassen definieras sedan i ett indenterat kodblock, på ungefär samma sätt som när vi definierar funktioner. Vår klass Queue kan vi definiera på följande sätt:
|
|
Den som läste avsnittet om meddelandehantering i kapitlet om closures och namnrymder känner kanske igen mönstret. Där hade vi en make_queue-funktion som skapade en kö och returnerade en funktion som kunde ta emot meddelanden, som i sin tur kunde användas för att anropa de nästlade funktionerna enqueue och dequeue.
Här använder vi istället en klass för att kapsla in tillståndet (listan _values) och metoderna (enqueue och dequeue) som manipulerar detta tillstånd.
Vi behöver inte heller någon separat funktion för att göra själva meddelandehanteringen, istället sköts det automatiskt av Python när vi anropar metoder på objekt av klassen Queue.
Klassdefinitionen kan ses som en mall för att skapa objekt av klassen Queue. Varje gång vi skapar ett nytt objekt av klassen, t.ex. med my_queue = Queue(), så skapas en ny instans av klassen med sitt eget tillstånd (en egen lista _values). Vi kan alltså jämföra klassdefinitionen med make_*-funktionerna som vi använde för att skapa closures.
Metoder och attribut
I vår klass Queue har vi definierat tre metoder: __init__, enqueue och dequeue. Metoder är alltså funktioner som är definierade inuti en klass och som kan anropas på och genom objekt av den klassen. Det är metoder som definierar objektets beteende och hur det interagerar med omvärlden och det är genom metoder som vi manipulerar objektets tillstånd.
Ska vi vara riktigt noggranna så pratar vi om instansmetoder här, eftersom metoderna är bundna till specifika objekt (instanser) av klassen. Det finns andra typer av metoder också, t.ex. klassmetoder och statiska metoder, men dessa går vi inte in på nu och säger man bara “metod” utan förled så menar man i princip alltid instansmetod.
Attribut, eller mer specifikt instansattribut, är variabler som är bundna till ett objekt och som lagrar dess tillstånd. Ett annat ord för attribut är instansvariabel och vi använder dessa termer ungefär lika ofta. Från en metodkropp kan vi komma åt och manipulera objektets attribut genom att använda self.attributnamn och anropa andra metoder i objektet med self.metodnamn().
Precis som för metoder så finns det även klassattribut men även här menar vi oftast instansattribut när vi bara säger “attribut”. Klassattribut är variabler som är bundna till själva klassen och delas mellan alla objekt av klassen. Vi kan skapa klassattribut genom att tilldela variabler direkt i klassdefinitionen, utanför några metoder. Vi går dock inte in närmare på klassattribut här.
I klassdefinitionen ovan ser vi att metoderna __init__, enqueue och dequeue alla har en parameter som heter self. Det första argumentet till alla metoder i en klass är alltid en referens till det objekt som metoden anropas på. Parametern för detta argument kallas traditionellt self i Python (och flera andra språk som Rust, Swift, Perl, Ruby och språket som populariserade objektorientering: Smalltalk). I Python använder vi nästan alltid namnet self för denna parameter, även om det i strikt mening bara är en konvention (och det finns några strikt specificerade undantag, men de går vi inte in på här).
(I många språk, speciellt de språk där den här referensen inte explicit finns med i parameterlistan för metoder utan implicit existerar inne i alla objekt och är ett nyckelord i språkets syntax, heter den här specialreferensen this. Det gäller t.ex. för C++, Java, PHP, JavaScript, C# och det absolut första objektorienterade programmeringsspråket: Simula 67. Det förekommer även andra namn men this och self är de absolut vanligaste.)
När vi anropar en metod på ett objekt med hjälp av punktnotation, t.ex. my_queue.enqueue(10), så skickas objektet my_queue automatiskt som det första argumentet till metoden enqueue. I detta fall motsvarar det alltså att anropa Queue.enqueue(my_queue, 10). Vi kan jämföra det med hur vi anropar append-metoden på en lista, t.ex. my_list.append(4). I det fallet blir det egentligen list.append(my_list, 4) under huven.
Vi kan alltså tänka på det som att self är en referens till den namnrymd som tillhör det specifika objektet på samma sätt som en closure har tillgång till den lokala omgivningen där den skapades. Detta gör att vi kan använda self inuti metoderna för att komma åt och manipulera objektets tillstånd, dvs. dess attribut (i vårt fall listan _values).
Instansiering och initiering
När vi skapar ett nytt objekt av en klass så säger vi att vi instansierar klassen, dvs. skapar en instans av klassen. Här instansierar vi klassen Queue genom att anropa den som en funktion:
>>> my_queue = Queue()
När vi instansierar en klass så anropas automatiskt klassens __init__-metod. Denna är en så kallad konstruktor som används för att initiera objektets tillstånd. Dvs. sätta eventuella instansattribut till sina startvärden, skapa eventuella objekt som ska finnas i objektet, osv. Även om det inte är ett krav så tilldelar vi vanligtvis alla attribut i __init__-metoden så att vi ska ha något ställe i koden där vi kan se alla attribut som objektet har. Ibland vill vi dock sätta vissa attribut till None i __init__ och bara tilldela riktiga värden vid behov i andra metoder.
I vårt exempel ovan så anropas alltså __init__-metoden när vi skapar objektet my_queue. Detta innebär att koden inuti __init__-metoden körs, och i vårt fall skapas en tom lista _values som kommer att användas för att lagra värdena i kön.
Men vänta lite - var kommer self ifrån? Vi sa ju att det första argumentet till alla metoder är en referens till objektet som metoden anropas på, men det finns ju inget objekt ännu när vi anropar Queue() för att skapa ett nytt objekt?
Regeln för hur objekt skapas i Python är dock att först skapas ett tomt objekt av klassen, och sedan anropas __init__-metoden på detta objekt för att initiera dess tillstånd. Det nya objektet skickas alltså som det första argumentet till __init__ och blir self.
Vi kan alltså tänka oss att anropet my_queue = Queue() egentligen motsvarar följande steg:
>>> temp_queue = object.__new__(Queue) # Skapar ett tomt objekt av klassen Queue
>>> Queue.__init__(temp_queue) # Initierar objektets tillstånd
>>> my_queue = temp_queue # Tilldelar det nya objektet till my_queue
Detta sker dock bakom kulisserna och det enda vi egentligen behöver bry oss om just nu är just __init__-metoden och att den anropas automatiskt när vi kör Queue() för att skapa ett nytt objekt av klassen.
Inkapsling och abstraktion
Notera att vi kallade vår lista _values med ett inledande understreck. Detta är en konvention i Python som indikerar att attributet är avsett att vara “privat” och inte användas direkt utanför klassen. Vi säger alltså att det inte är meningen att man någonsin ska skriva my_queue._values för att komma åt listan. Istället ska man använda klassens metoder för att interagera med objektets tillstånd. En generell minnesregel är att attribut och metoder som börjar med ett understreck bara ska förekomma direkt efter self..
Vi kan kontrastera detta mot metoderna enqueue och dequeue som är publika. Dvs. dessa metoder är tänkta att användas utanför klassdefinitionen och tillsammans utgör de gränssnittet (public interface) för klassen Queue. Mao. det är genom dessa metoder som användare av klassen ska interagera med objekt av klassen Queue.
Tillsammans kallas det här sättet att “gömma undan” objektets interna tillstånd och endast exponera ett kontrollerat gränssnitt för att bearbeta det för inkapsling (encapsulation). Inkapsling är ett grundläggande koncept inom objektorienterad programmering som hjälper till att skydda objektets integritet och säkerställa att dess tillstånd endast ändras på förutsägbara sätt.
Om vi t.ex. skulle tillåta direkt åtkomst till _values så skulle användare av klassen kunna manipulera listan på sätt som bryter mot kö-konceptet genom att lägga till eller ta bort element på fel positioner. T.ex. my_queue._values.insert(1, 30), som lägger till värdet 30 på index 1 även om det inte är sista positionen, eller my_queue._values.pop(1), som tar bort och returnerar värdet på index 1, vilket inte är den första positionen.
(Att understreck indikerar att en variabel eller metod är “privat” är bara en konvention och Python har ingen strikt åtkomstkontroll på det sätt som många andra objektorienterade språk har. Har du programmerat i ett språk som C++, Java eller C# kanske du är van vid att använda nyckelord som private eller public för att specificera åtkomstnivåer för attribut och metoder. I Python litar vi istället på konventioner och dokumentation för att kommunicera avsikten med åtkomstnivåer. Vill man verkligen försvåra åtkomsten av inkapslade variabler och funktioner kan man använda namn-mangling med dubbla understreck, t.ex. __values eller använda sig av closures istället.)
Olika typer av klasser
Klassen Queue ovan är ett exempel på en enkel klass som kapslar in tillstånd och beteende för en kö. Fokuset här är att åstadkomma ett visst beteende (en kö) och att dölja implementationen av detta beteende bakom ett rent gränssnitt.
I många fall skapar vi dock klasser som är mer fokuserade på att representera data, t.ex. en klass Person som har attribut som name, age och address, och kanske några metoder för att manipulera eller hämta denna data. Sådana klasser kallas ofta för dataklasser (data classes) eller värdeobjekt (value objects). Vi kan alltså tänka oss att Person-klassen är mer fokuserad på att representera data, medan Queue-klassen är mer fokuserad på att åstadkomma ett visst beteende (en kö).
Det är ingen syntaktisk skillnad mellan dessa olika typer av klasser i Python, utan det är mer en fråga om design och avsikt. Ofta kan en klass ha både beteende och data, men det är bra att vara medveten om vilken roll en viss klass har i din applikation och designa den därefter.
Vår klass Person skulle kunna se ut så här:
|
|
Här har vi inga metoder för att manipulera objektets tillstånd, utan bara en konstruktor som initierar objektets attribut. I praktiken har vi egentligen inte skapat något beteende alls, utan bara en struktur för att lagra data. Vi skulle lika gärna kunnat använda en dict. I praktiken vill vi dock ofta lägga till metoder för att manipulera eller hämta data på olika sätt, t.ex. en metod för att uppdatera adressen eller en metod för att skriva ut personens information i ett visst format.
Person-klass som beräknar ålder
Vi kan också tänka oss att vi istället för att lagra åldern direkt vill lagra födelsedatum och sedan beräkna åldern dynamiskt med en metod. Här använder vi klassen date från pythonmodulen datetime för att hantera datum:
|
|
Klassen skulle kunna användas så här, där date(1990, 5, 15) skapar ett datumobjekt som representerar den 15 maj 1990:
>>> p = Person('Alice', date(1990, 10, 15), '123 Main St')
>>> p.name
'Alice'
>>> p.age()
35
>>> p.address
'123 Main St'
Person-klass för att hantera intressen
Säg att istället för ålder är vi intresserade av att lagra och hantera en persons intressen.
|
|
Vi kan också tänka oss metoder för att ta bort intressen, eller för att kontrollera om en person har ett visst intresse, men här tittar vi bara på några grundläggande metoder för att hantera intressen. Vi skulle också kunna tänka oss en klass Interest som används för att representera intressen, men för vårt exempel använder vi bara strängar. Klassen skulle kunna användas så här:
>>> p1 = Person('Easley')
>>> p1.add_interest('rocketry')
>>> p1.add_interest('programming')
>>> p1.add_interest('math')
>>> p2 = Person('Goldberg')
>>> p2.add_interest('programming')
>>> p2.add_interest('objects')
>>> p2.add_interest('graphical user interfaces')
>>> p3 = Person('Hamilton')
>>> p3.add_interest('programming')
>>> p3.add_interest('software engineering')
>>> p3.add_interest('rocketry')
>>> p1.common_interests(p2)
{'programming'}
>>> p2.common_interests(p1)
{'programming'}
>>> p1.common_interests(p3)
{'rocketry', 'programming'}
(För den nyfikne: Annie Easley, Adele Goldberg och Margaret Hamilton var alla pionjärer inom programmering.)
Den viktigaste insikten här är att vi kan skapa de klasser som bäst representerar de koncept och strukturer som är relevanta för vår applikation. Ibland är det lämpligt att skapa klasser som representerar data, ibland klasser som representerar beteende, och ibland en kombination av båda. Vi såg att vi kan definiera klassen Person med helt olika syften i åtanke, för att skapa helt olika abstraktioner. Genom att använda klasser kan vi skapa tydliga och väldefinierade strukturer för vår kod som gör den mer läsbar, lättare att underhålla och lättare att återanvända eller vidareutveckla.
Detta verkar omständligt, vad är poängen?
Det kan tyckas omständligt att definiera t.ex. en klass Queue med metoder för att lägga till och ta bort element, när vi lika gärna skulle kunna använda en lista direkt och använda dess inbyggda metoder append och pop(0) för att simulera en kö. Varför inte bara göra det?
I första hand så handlar det om att skapa en tydlig och väldefinierad abstraktion för en kö. Genom att använda en klass kan vi definiera exakt hur en kö ska fungera. Dvs. vilka operationer som är tillåtna, och hur dessa operationer påverkar kön. Detta gör koden som behöver det här beteendet mer läsbar och lättare att förstå, eftersom vi kan använda meningsfulla namn som enqueue och dequeue istället för att direkt manipulera en lista. Vi behöver inte komma ihåg att just den där listan ska behandlas som en kö, medan den här andra listan kanske ska behandlas som en stack eller bara en vanlig lista. Detta hjälper oss att lyfta blicken från detaljerna och fokusera på större problem.
Samtidigt handlar det om att skapa en tydlig och väldefinierad struktur för vår kod. Genom att använda en klass kan vi kapsla in all logik som rör köhantering på ett ställe, vilket gör koden mer modulär, lättare att förstå och lättare att återanvända. Dessutom kan vi enkelt ändra implementationen av kön utan att påverka den kod som använder den, så länge vi inte förändrar det publika gränssnittet.
Att använda en klass ger oss också möjlighet att lägga till mer funktionalitet i framtiden, som att kontrollera eller begränsa storleken på kön, lägga till prioritering av element eller implementera olika typer av köer (t.ex. prioriterade köer) utan att behöva ändra den kod som redan använder klassen.
Så även om det vid första anblick kan verka enklare att använda en lista direkt, ger en klass oss mer flexibilitet och kontroll över hur vi hanterar köer i vår applikation. Det är just detta som är poängen med objektorienterad programmering: att skapa strukturer som är lätta att använda, förstå och underhålla över tid.
Vi ska dock inte sticka under stol att det verkliga värdet kan vara svårt att inse i en introduktionskurs i programmering. När våra program inte är mer än max något hundratal rader så spelar det sällan någon större roll hur vi strukturerar vår kod, och de här extra lagren av abstraktion kan verka som att de bara krånglar till det. I kodbaser med tiotusentals eller hundratusentals rader kod, som underhålls och vidareutvecklas av många olika personer över lång tid, så blir det dock snabbt helt avgörande att ha en tydlig struktur och bra inkapsling av olika delar av koden för att undvika att det blir fullständigt kaos. Objektorientering är ett av de mest populära verktygen för att åstadkomma detta.
Klassdiagram
För att visualisera klasser och deras relationer används ofta så kallade klassdiagram. Dessa är en del av UML (Unified Modeling Language) som är ett grafiskt sätt att beskriva system och program. Just klassdiagram används för att beskriva strukturen i ett objektorienterat system och är kanske den mest använda typen av diagram inom UML. UML var ursprungligen utvecklat för att beskriva system i sin helhet och skulle gå att direkt “kompileras” till verklig kod. Numera används UML snarare för att planera nya system samt för att dokumentera och kommunicera struktur och funktionalitet i redan existerande kodbaser.
Klassdiagram illustrerar klasser som rektanglar, med klassens namn högst upp, följt av dess attribut och metoder. Relationer mellan klasser visas med linjer och pilar som indikerar olika typer av relationer. Vi kommer att återkomma till relationer mellan klasser längre fram i kursen, nu fokuserar vi på hur en enskild klass kan representeras i ett klassdiagram.
Ofta visas bara de publika attributen och metoderna i klassdiagram, medan privata attribut och metoder utelämnas för att hålla diagrammet överskådligt. Poängen med den här visualiseringen är dock att förklara för andra (och sig själv) hur olika klasser hänger ihop och vilka relationer som finns mellan dem. Det betyder att det kan få variera exakt hur noggrann man behöver vara, och huruvida privata delar av klasserna ska visas eller inte. Som med all kommunikation är det viktigaste att mottagaren får den information hen behöver.
Ett klassdiagram följer normalt sett mönstret till höger. I det översta fältet står klassens namn. Ibland förekommer även ytterligare information här (oftast inom guillemets, dvs. franska citattecken eller “gåsögon”, « ») men det är ovanligt i enklare klassdiagram och exakt vad den här informationen betyder kan variera.
I fältet i mitten visas klassens attribut, dvs. dess instans- och klassvariabler. Ofta med deras datatyper efter ett :, och ibland med standardvärden efter ett =. Eventuella klassvariabler är ofta understrukna för att skilja dem från instansvariabler. Attributen listas ofta i alfabetisk ordning, men det händer också att man delar upp dem i grupper baserat på deras syfte, deras åtkomstnivå (publika vs privata) eller huruvida de är klass- eller instansvariabler.
I fältet längst ner visas klassens metoder. Ofta tar man med metodernas signatur, dvs. deras parametrar och returtyp. När funktioner/metoder inte returnerar något värde så utelämnas ofta returtypen. Ibland, när funktioner kan anropas på mer än ett sätt (t.ex. med olika antal eller typer av argument), så visas flera signaturer för samma metod.
Metoderna inleds ofta med konstruktorn, som normalt sett illustreras så som den anropas, inte som den definieras. Dvs. vi skriver Klassnamn(parametrar), inte __init__(parametrar). Vanligtvis skrivs inte self-parametern med i parameterlistan när det handlar om Python-klasser eftersom den är underförstådd när det handlar om en instansmetod.
Efter konstruktorn listas sedan de andra metoderna i klassen, ofta i alfabetisk ordning, men det händer också att man delar upp dem i grupper baserat på deras funktionalitet, deras åtkomstnivå (publika vs privata) eller om de är instansmetoder, klassmetoder eller statiska metoder. Vi har ju dock bara pratat om instansmetoder här.
I språk som har tydligare åtkomstkontroll än Python ser man ofta +-tecken framför attribut och metoder som är publika, dvs. att de kan nås utanför klassen. På samma sätt indikerar --tecknet att de är privata, dvs. att de inte kan nås utanför klassen. I UML-diagram för klasser i Python används dock sällan dessa tecken eftersom åtkomstkontrollen är mer informell och indikeras med ett _ i början av namnet istället. Det blir alltså uppenbart vilka delar som ska betraktas som publika respektive privata genom namngivningskonventionen.
Queue-klass i ett klassdiagram
Ett konkret exempel med vår Queue-klass kan se ut som i diagrammet till höger. Här har vi tagit med både publika och privata delar av klassen för att illustrera hela strukturen. Vi har också tagit med signaturerna för metoderna, dvs. deras parametrar och returtyp. Notera att vi inte har tagit med self-parametern i metodernas parameterlistor eftersom den är underförstådd. Metoden enqueue returnerar inget värde och därför har vi inte angett någon returtyp för den. Konstruktorn Queue() visas som den anropas, inte som den definieras med __init__, och inte heller denna har någon angiven returtyp eftersom det är underförstått att den returnerar en instans av klassen Queue.
Person-klass i ett klassdiagram
Ett annat exempel, med vår Person-klass som hanterar intressen, kan se ut som i diagrammet till höger. Även här har vi tagit med både publika och privata delar av klassen samt metodernas signatur. Precis som ovan har vi utelämnat den underförstådda self-parametern samt returtyp för add_interest som inte returnerar något. Konstruktorn visas som den anropas, inte som den definieras med __init__, och inte heller denna har någon angiven returtyp eftersom det är underförstått att den returnerar en instans av klassen Person.
Object-closure duality
Vi ska avsluta detta förberedelsematerial med en liten zen-liknande koan om den nära relationen mellan objekt och closures. Den ursprungliga källan till denna historia är Anton van Straaten och dess kontext finns för den nyfikne att läsa här.
Om det här avsnittet om objekt har hjälpt dig att förstå closures bättre, eller om din tidigare förståelse av closures har hjälpt dig att förstå det här avsnittet om objekt bättre, så har båda avsnitten uppnått sina syften. Om inte, så kan du åtminstone trösta dig med att du inte är ensam och att förståelsen kommer med tid och övning. Och när du väl har förstått det ena, så kommer du snart att förstå det andra.
The venerable master Qc Na was walking with his student, Anton. Hoping to prompt the master into a discussion, Anton said “Master, I have heard that objects are a very good thing - is this true?” Qc Na looked pityingly at his student and replied, “Foolish pupil - objects are merely a poor man’s closures.”
Chastised, Anton took his leave from his master and returned to his cell, intent on studying closures. He carefully read the entire “Lambda: The Ultimate…” series of papers and its cousins, and implemented a small Scheme interpreter with a closure-based object system. He learned much, and looked forward to informing his master of his progress.
On his next walk with Qc Na, Anton attempted to impress his master by saying “Master, I have diligently studied the matter, and now understand that objects are truly a poor man’s closures.” Qc Na responded by hitting Anton with his stick, saying “When will you learn? Closures are a poor man’s object.” At that moment, Anton became enlightened.
Quiz
Testa din förståelse av materialet med tillhörande quiz
Sidansvarig: Johan Falkenjack
Senast uppdaterad: 2025-10-08
