Föreläsning 3.1: Datastrukturer, en översikt Hittills har vi i kursen lagt mycket fokus på algoritmiskt tänkande. Vi har inte egentligen ägna så mycket uppmärksamhet åt det andra som datorprogram också består, nämligen data. Man inser ganska snabbt att man inte kommer någon vidare stans utan variabler i sitt program. Nu är det dags att ta ett steg vidare med denna insikt. På den här föreläsningen skall jag ge en översikt över på vilka sätt man kan lagra data. Man brukar kalla detta för datastrukturer. Vilka datastrukturer har vi pratat om hittills? Egentligen kan man se de datatyper som vi träffat på som väldigt grundläggande "datastrukterer". Integer / Float Character Boolean 64 bitar = 8 byte 32 bitar = 4 byte 1 byte bit String char char char char char Med dessa bilder försöker jag förmedla att olika datatyper tar olika mycket plats. Vi kan börja med Character som borde vara tämligen enkel att förstå eftersom den representerar ett enskilt tecken t.ex. 'A'. Nu visar det sig att det finns många olika sätt att göra detta på och det kan faktiskt behövas olika mycket minne för olika tecken. Om man dock håller sig till de allra vanligaste tecknen, d.v.s 'A'-'Z', 'a'-'z', '.' '!' '?', '0'-'9' m.fl. så räcker det med 1 byte minne för att kunna representera alla dessa. Vad är då en byte? Tja, det är 8 bitar. En bit är det vi kallar den minsta enheten minne i datorn, d.v.s. en etta eller en nolla. Rent tekniskt så brukar man säga att ett symboliserar "ström på" och noll står för "ström av". Tänk dig en glödlampa: på = 1, av = 0. Med endast en sån lampa kan man inte symbolisera mer än två saker, men tänk om man hade fler? 0 1 0 1 0 1 1 1 Med 8 lampor kan man få till ganska många kombinationer. Vi kan faktiskt representera 256 olika situationer med en sådan uppsättning! Allt vi behöver göra är att bestämma vilken kombination som skall betyda vilket tecken. Ganska smart! Just denna kombination skulle faktiskt motsvara tecknet 'W'. Man kan ju även se sekvensen ovan som ett heltal i basen två. I vårt "vanliga" talsystem, d.v.s basen tio, har vi ju ental, tiotal, hundratal och så vidare. För att t.ex. representera 356 tar vi 3 * 100 + 5 * 10 + 6 * 1. Precis på detta sätt fungerar det i alla talbaser. I basen två har man då istället för 1, 10 och 100, faktorerna 1, 2, 4, 8, o.s.v. D.v.s de tal som man får när man tar 2⁰, 2¹, 2², 2³... Om man nu tänker på samma sätt som med exemplet i basen tio men för lamporna får vi: 0*2⁷ + 1*2⁶ + 0*2⁵ + 1*2⁴ + 0*2³ + 1*2² + 1*2¹ + 1*2⁰ = 87 (Alltså stod 'W' på den 87:e raden i teckentabellen...) Det är självklart inte så att det sitter en massa glödlampor i en dator. Men det finns små minneselement som kan lagra antingen 1 eller 0 på precis detta sätt. Klumpar vi ihop dessa så kan vi lagra precis vad vi vill. Vi har här sett ett exempel på hur 8 bitar (= 1 byte) kan användas för att antingen lagra ett tecken eller ett tal. Det beror helt på vilken datatyp som vi anser att just dessa minnesbitar har! För just datatypen Integer vill man dock vanligtvis ha mer än 1 byte eftersom tal vanligtvis kan bli mycket större än vad man kan representera med 1 byte. Om vi tar 4 byte (32 bitar), och dessutom låter en av bitarna tala om huruvida talet skall vara negativt eller positivt så kan man representera tal från ca minus två miljarder till ca plus två miljarder. Har man större eller mindre tal än så så får man helt enkelt använda sig av en annan datatyp. För reella tal inser man direkt att det blir problem. Även om vi begränsar oss till -N...+N så finns det ju ändå oändligt många tal däremellan om vi tänker på alla "decimaltal". För att representera oändligt många tal skulle det behövas oändligt mycket minne, något som datorer tyvärr inte har. Det enda rimliga är att helt enkelt klippa av en bit minne och sedan göra det bästa av situationen. Exakt hur man representerar ett reellt tal kan vi prata om någon senare gång. Om vi tittar på datatypen Boolean så är den lite speciell. Den används för att representera sant och falskt och är praktisk i många logiska sammanhang. Eftersom det bara är två värden som skall representeras så skulle det räcka med en enda bit för att fixa detta. I praktiken går detta egentligen inte, eftersom datorers arkitektur inte tillåter att man manipulerar enskilda bitar i minnet. När man hanterar minnet måste man hantera minst 1 byte, d.v.s 8 bitar. Därför tar själva datatypen boolean 1 byte minne, det är faktiskt bara 1 bit i denna byte som används. Vad det gäller String så ser man ju att denna egentligen inte är så "enkel" som de övriga. Det är en s.k. sammansatt datatyp. Vi vet sedan tidigare att String är en samling tecken, och är behändig när man vill lagra text. Vi kommer tillbaka till sammansatta datatyper strax. Alla dessa datatyper är toppen när man vill lagra enkla saker. T.ex. ett lotterinummer, veckans ord, gårdagens maxtemperatur. Vi tänker oss nu en situation där vi vill lagra ett mer komplext data, t.ex. datat som representerar en person, det kan ju bestå av flera delar: Namn Skonummer Kön Rutorna här kan få representera storleken på de datatyper som skall representera de olika delarna. För namnet är String bra, för skonummer duger en Float. Könet behöver bara kunna ha två värden, en boolean skulle kunna funka. Utan att göra något mer nu så kan vi faktiskt säga att detta är en datastruktur för en person. Det behöver alltså inte vara krångligare än att man har tre variabler. Det som är tråkigt är att programmeraren överallt i sitt program behöver komma ihåg att datastrukturen består av dessa tre. Det är förvisso inte så svårt, men om man har många datastrukturer, med väldigt många delar och det kanske inte är en själv som har skapat datastrukturen så blir detta väldigt bökigt - inte omöjligt, men bökigt. Det man egentligen skulle vilja göra är att klumpa ihop dessa tre, så att man inte kan tappa bort något på vägen. Då skulle man också kunna se det som ett data - datat för en person. Jag vill förtydliga detta genom att rita en ruta kring dessa tre variabler. Jag vill att dessa tre skall bilda en ny, sammansatt datatyp, så att jag hädan efter aldrig behöver vara tvungen att komma ihåg exakt hur den ser ut inuti. Just detta mantra har ni nu redan hört en gång. Jag vill inte behöva var tvungen att veta de exakta detaljerna över det jag använder. När vi hörde det sist handlade de om att skapa underprogram, så man slapp bekymra sig om detaljer i huvudprogrammet - man skapade abstraktion. Vi köra samma sak här faktiskt, fast vi gör det med data. Att klumpa ihop data på detta sätt kallas för att man gör en Post (på engelska "record"). Vår nya datatyp, som vi kan kalla för Person_Type, är alltså en Post med tre delar. När vi ändå håller på och fantiserar över vilka nya datastrukturer som vi skulle kunna hitta på så kan vi tänka oss att man skulle vilja föra ett register över olika personer. Man skulle då åter igen kunna klumpa ihop data som tidigare och skapa en ny form av post som innehåller ett stort antal personer. Det ända som hindrar oss är egentligen att komma på namnen på dessa delar i posten. Varje del av posten måste nämligen ha ett namn. Okej, vi skulle kunna köra på "Person1", "Person2", "Person3", o.s.v. men det visar sig att det finns ett smidigare sätt. Att klumpa ihop många av samma data är nämligen mycket vanligt. Det händer mycket ofta att man t.ex. vill lagra etthundra heltal. Eftersom det är så vanligt har man i de allra flesta programmeringsspråk en annan form av sammansättning som gör att man istället för att namnge delarna i datastrukturen, kan numrera dem med ett intervall. Detta kallas för ett fält (på engelska "array"). Vi kan illustrera det så här: 1 Namn Skonummer Kön 2 Namn Skonummer Kön 3 Namn Skonummer Kön ... N Namn Skonummer Kön Utöver att det är siffror, eller index som de kallas, finns det en annan skillnad jämt emot posten: det måste vara samma datatyp på varje index i fältet. Anledningen till detta är att det skall vara lika stort data på varje plats, eftersom datorn "hoppar" till rätt person genom att multiplicera storleken av en Person_Type med det indexet man vill komma åt. Man skall alltså inte glömma att en sådan datastruktur behöver en del minne. Hur mycket? Vi kan ju faktiskt räkna på det. Om vi antar att namnet är en sträng på 40 tecken, det blir 40 byte. Skonummret är en float á 8 byte, och könet är en boolean, vilket kostade 1 byte. Allt som allt är detta ca 50 byte. Om N är två miljoner blir detta alltså sammanlagt ca hundra miljoner byte. Är det mycket? Tja, det beror på vad man jämför med. Men man ska komma ihåg att ingenting är gratis när man börjar skapa datastrukturer. Värt att poängter är att jag nu ritade detta fält på ett lodrätt sätt. Men jag hade lika gärna ritat det vågrätt. Datorer gör ingen egentlig skillnad på detta, utan det får man hålla reda på själv. Om vi tänker efter en stund så inser vi nu att String självklart också är ett fält. Det är helt enkelt ett annat exempel på när man vill lagra en sekvens av en viss datatyp. Det spelar faktiskt ingen egentlig roll vad som lagras i ett fält. Alla fungerar på samma sätt. Vi skulle t.ex. kunna ha ett fält med sju flyttal för att representera en veckas temperaturer: 18.2 19.0 17.5 13.5 16.0 18.1 16.9 1 2 3 4 5 6 7 Man kan till och med tänka sig en datastruktur som är ett fält, som består av fält. Antag att vi skall göra ett lite tre-i-rad (eller tic-tac-toe som det också kallas). Då skulle det vara praktiskt med ett fält som ser ut på följande vis: 1 1 ' ' 1 'o' 1 'x' 2 'o' 2 'x' 2 'x' 3 'x' 3 ' ' 3 'o' 1 2 1 2 3 eller så här 3 2 3 1 2 3 1 2 3 Själva kryssen och ringarna skulle man kunna representera med tecken, eller kanske heltal. Det finns många andra sätt att rita upp en datastruktur som skulle vara lämplig för just detta ändamål. Oavsett vilket man använder så ser vi att detta koncept är så pass generellt att man även kan göra sammansatta datatyper av andra sammansatta datatyper. Men fält och poster kan vi alltså skapa oss precis hur komplexa strukturer som vi vill. Det enda som egentligen sätter gränsen är just hur mycket minne som dessa strukturer tar. För eller senare så kommer man komma till en punkt där man inte längre kan lagra allt sitt data i internminnet utan behöver pytsa ut saker på hårddisken. Det vi pratar om då är lagring av data på fil. En fil på datorns hårddisk består också av ettor och nollor. Det lättaste är att tänka sig det som en lång radda med ettor och nollor, tills det kommer ett filslut. F : 011001100110101010010010010100101001010101 f Exakt vad som ligger på filen beror ju på hur man tolkar datat. Om vi ser det här som characters så får vi alltså titta på 8 bitar i stöten. När vi pratar om textfiler så är det detta vi menar. Textfiler har har också det kravet att det skall vara ett radslutstecken precis innan filslutet, (om filen inte är tom d.v.s.). Om man bortser från textfiler och pratar om det vi brukar kalla för binärfiler finns det egentligen två typer. Den ena typer är sådana filer som i princip fungerar på samma sätt som textfiler, d.v.s. det är samma datatyp hela vägen i filen. T.ex. en fil med bara heltal. Den andra typen är sådan att det kan vara olika data i filen, då ser vi det mer som det vi kallar för en ström. Dessa två typer av filer kommer vi inte jobba särskilt mycket med, men det är bra att veta att de finns, och att det i grund och botten är samma principer som gäller för dessa som för textfiler. Oavsett vad det är för data som ligger på filen som måste man känna till formatet om man skall kunna tillgodogöra sig det data som finns lagrat däri. Det kan t.ex. vara så att man vet att det först kommer tre heltal och sedan ett tabulatortecken på varje rad. Eller att man vet hur långa raderna är, eller något annat. Ofta finns en hel del av informationen om hur filen skall tolkas i filens första del, i en s.k. "header". Detaljerna kring hur man läser och skriver till filer tar vi upp i C++ i nästa period. Om vi nu går tillbaka till just internminnet så skulle man kunna rita upp ett litet diagram över ett programs minnesanvändning: Använt Minne Program B Program A När det kommer till de datatyper och datastrukturer som vi hittills pratat om så har de alla den egenskapen att man måste deklarera i förväg hur mycket minne man behöver. Mer än så kan programmet helt enkelt inte hantera! Detta betyder också att operativsystemet måste låta programmet ha detta minne under hela sin körtid. Vill man köra fler program så gäller samma sak för dem, och minnet blir snabbt fullt. Om det kommer fler program som vill köra, så får de antingen vänta eller avbryta sin körning om det inte finns tillräckligt med minne för att de skall kunna starta. Den egentliga minnesanvändningen varierar antagligen mycket under programmets exekvering, både uppåt och nedåt, jag har ritat den med en streckad linje. Om man istället kunde få till så att program kunde få begära mer minne då de behövde det, och sedan lämna tillbaka minne då de var färdiga med det så kanske fler program skulle kunna köras samtidigt! Detta är vad vi brukar kalla för dynamiskt minne (till skillnad från statiskt minne som vi hittills har tittat på). För att åstadkomma detta kan man använda sig av pekare. En pekare är en datatyp som inte innehåller något egentligt data i sig, utan en referens till en plats i datorns minne (man brukar säga att den lagrar en adress) där det ligger data. Man kan rita upp det så här: I: 7 7 P: Här ser vi en vanlig heltalsvariabel I och en pekare P. Minnesplatsen som pekaren P refererar till ligger alltså utanför vårt program. När ett program startar brukar pekarna inte refererar någon särskild stans, utan det är först när programmet själv ber operativsystemet att låta P referera till en ny plats i minnet som detta sker. Detta kan ske när som helst under programmet! Efteråt kan programmet komma åt minnesplatsen genom att följa referensen i P. Naturligtvis är det också viktigt att programmet lämnar tillbaka minnet innan programmet kört klart. Annars kan operativsystemet missa att markera minnet som "ledigt" igen, och det kan bli minnesläckor. Att som i figuren ovan använda en pekare för att peka ut ett heltal är inte aktuellt så ofta. Man gör ju gärna på detta vis när datastrukturen är stor och onödig att ha i minnet hela tiden. Det är absolut vanligast att det man pekar på är en sammansatt datatyp. Om vi nu leker med tanken att det vi pekar på är en post så kan denna post, förutom annat data som man kanske vill lagra där också ha utrymme för en till pekare, ungefär så här: D: D: L: L: L: Då skulle den pekare också i sig kunna peka vidare till en ny sådan post, o.s.v. Detta blir som en kedja som kan bli så lång, eller så kort som man önskar just för ögonblicket. Vi brukar kalla detta för en länkad lista. "D" skulle ju kunna vara precis vilken annan datatyp som helst som man nu vill lagra många av. Lite förenklat kan man rita detta på följande sätt: L: Den sista pekaren i listan kommer ju inte att peka på en ny post, så vi ritar den som ett snett streck. Man säger även att detta är en "nullpekare", eller att "den pekar på null". Exakt vad detta betyder kommer vi tillbaka till. Man kan direkt tänka sig ett par varianter på sådana "länkade" strukturer. I listan ovan kan man ta sig till nästa post genom att följa pekaren, men det går inte att ta sig tillbaka. Det finns dock dubbellänkade listor som råder bot på detta. Där består varje post i listan av ett data och två pekare, en som pekar framåt till nästa post, och en som pekar bakåt till föregående. L: Den sista strukturen som kan vara roliga att känna till är en trädstruktur. Om vi tar en post som i föregående exemplet, d.v.s. en post med ett data och två pekare. Men vi låter pekarna peka på två helt nya poster, så kan vi få en helt annan struktur: T: Detta är en helt annan lagringsform som kallas för binärt träd. Det går att utnyttja till många olika saker. Ni kommer att få lära er mycket mer om det i senare kurser. Nu har vi pratat igenom alla datastrukturer som ni kommer stöta på i den här kursen, plus lite till. Vad man nu än skall göra, vilket problem man än har framför sig, så lönar det sig enormt att tänka igenom hur man skall lagra sitt data. Att rita upp datastrukturer är viktigt för att förklara för andra (och för sig själv!) hur man har tänkt att datat skall lagras. Ni kommer därför att få en inlämningsuppgift som handlar om just detta.