10.
Grunderna i ObjektOrienterad Design
· Grunderna i objektorienterad design.
· Abstraktion.
· Inkapsling.
· Klasshierarkier.
· Konsten att arbeta objektorienterat.
· Komposition kontra arv.
· Multippelt arv.
Grunderna i objektorienterad design.
Vi har nu gått igenom de grundläggande reglerna för programmeringsspråket C++ ur rent teknisk synvinkel. I detta och nästa kapitel kommer vi att diskutera objektorienterad programmering, vilket C++ egentligen är avsett för. Detta kapitel diskuterar principerna i objektorienterad programmering, och hur man tillämpar dem. Nästa kapitel innehåller ett exempel som tillämpar dessa principer.
Det första avsnitten i detta kapitel ger en överblick om objektorienterad programmering. Resten av kapitlet utvecklar detta till en diskussion om hur man designar ett objektorienterat program.
I traditionell procedurorienterad programmering innehöll programmet ett antal bearbetningssteg som skulle utföras, en logik av något slag. I det objektorienterade synsättet beskriver programmet hur ett antal objekt arbetar tillsammans. Det går visserligen att skriva procedurorienterade program m.h.a. C++, men om man arbetar objektorienterat utnyttjar man språket till fullo.
Det finns ett par viktiga begrepp i objektorienterad programmering. Det mest grundläggande är abstraktion, vilket underlättar själva programmeringsarbetet. Ett annat är inkapslingen (encapsulation), vilket underlättar ändringar och systemunderhåll. Slutligen har vi klasshierarkin, ett kraftfullt verktyg som underlättar tillägg och överblick. Ett objektorienterat språk innehåller just de verktyg som behövs för att implementera dessa begrepp.
Abstraktion.
Abstraktion handlar om att man ska kunna ignorera detaljerna när man arbetar med helheten. Ett programmeringsspråk anses generellt vara ett högnivåspråk om det möjlighet till en hög nivå av abstraktion. Ta t.ex. två program som utför samma uppgift, ett skrivet i assembler, ett i C. Assemblerprogrammet innehåller varje liten detalj datorn måste göra för att utföra uppgiften, trots att programmerare normalt sett inte är intresserade att påverka sådant som sker på den nivån. C-programmet beskriver datorns bearbetning på en mycket mer abstrakt nivå, och abstraktionen gör programmet tydligare, och lättare att förstå.
När man programmerar procedurorienterat har man viss möjlighet att skriva abstrakt, men objektorientrade programmeringsspråk innehåller mycket effektivare mekanism för abstraktion. För att inse detta behöver man betrakta olika slags abstraktion:
· Procedurabstraktion.
· Dataabstraktion.
· Klasser.
Procedurabstraktion.
Den vanligaste formen av abstraktion är procedurabstraktion, vilket låter programmeraren slippa tänka på de detaljer som ingår i en procedur när han använder den.
Proceduriell abstraktion kan implementeras på många olika nivåer. Man kan t.ex. beskriva ett program på så låg nivå som mnemonics, det som man använder i assembler, och betrakta t.o.m. varje bearbetningssteg i mikroprocessorn. Man kan å andra sidan beskriva bearbetningen i form av makroinstruktioner, vilket kan resultera i en abstraktionsnivå som ligger t.o.m. högre än C.
När man använder ett programmeringsspråk är man inte bunden till den abstraktionsnivå programmeringsspråket är gjort för. De flesta programmeringsspråk tillåter nämligen att man själv skapar funktioner (procedurer, subrutiner etc.), vilka utgör en abstraktion av det som de utför. I stället för att fundera över hur en uppgift löses varje gång, kan man ha löst den i en separat funktion, varvid abstraktionen går ut på att man anropar funktionen i stället för att koncentrera sig på den kod funktionen innehåller.
Man kan t.ex. ha behov av att jämföra två textsträngar med varandra, men om man programmerar i C finns inte den möjligheten inbyggd i språket. Ska man i så fall skriva en programslinga som jämför de i texterna ingående tecknen parvis varje gång man ska jämföra två texter blir programmet ganska detaljerat. Om man i stället deklarerar en funktion som jämför två valfria strängar och ger den ett lämpligt namn, kan man i sin programlogik använda detta namn i stället för att skriva programslingan. En jämförelse av två strängar kan då t.ex. göras på detta sätt:
if(Jamfor(cText1,
cText2)
{
// Texterna är lika.
...
}
else
// Texterna är olika.
...
}
De flesta kompilatorleverantörer har ‘samlat upp’ programmerarnas olika funktioner och levererar dessa med kompilatorn. Således levereras ett stort antal funktioner med C-kompilatorn, varav en del är skrivna i C medan andra är skrivna i assembler. Dessa funktioner höjer möjligheterna till abstraktion väsentligt. Det finns t.ex. jämförelsefunktioner som är ‘bättre’ än ovanstående, dels strcmp(), vilken levererar en lexikografisk jämförelse mellan strängarna, dels stricmp(), vilken gör samma jämförelse med den skillnaden att den bortser från skillnader mellan versaler och gemener.
Användning av funktioner handlar inte bara om att spara på skrivarbetet, det handlar även om att göra programmen mer överskådliga och lättlästa, och därigenom underlätta systemunderhåll och rättningar. Ett väl valt funktionsnamn, som förklarar vad som händer vid en viss punkt i programmet, är mycket lättare att förstå än ett antal programsatser som utför arbetet ‘på plats’. Tyvärr är inte alla funktionsnamn väl valda, C-programmerare tenderar att spara på skrivarbetet genom att förkorta namn till oigenkännlighet. Ovan nämnda funktioner borde hetat ‘StringCompare()’ respektive ‘StringCompareCaseInsensitive()’.
Användning av funktioner, procedurabstraktion, gör det lättare att skriva större program genom att det låter programmeraren tänka i termer som logiska opera-tioner i stället för att bara använda programmeringsspråkets inbyggda standardprogramsatser.
Dataabstraktion.
En annan sorts abstraktion är dataabstraktion, vilket friställer programmeraren från uppgiften att hålla reda på detaljer om variabler och datatyper. En dator arbetar t.ex. alltid med binära tal, vilka klumpas ihop till bytes, word och longwords. Alla dessa typer, utom de binära, representeras i assembler som hexadecimala tal. (Det finns dock assemblatorer som har bättre hantering.) I C har man tillgång till såväl heltal som flyttal, vilket är betydligt lättare att arbeta med än hexadecimala tal (om man inte är en dator). Assembler och C har inte några programinstruktioner för texthantering, medan Basic har en speciell datatyp för just detta ändamål. Jämför nedanstående operation, där man skapar en textsträng, och sedan förlänger den, så som den ser ut i C respektive Basic:
// C
char cNamn[31] = ”Karl”;
strcat(cNamn, ” Svensson”);
REM Basic
Namn$ = ”Karl”
Namn$ = Namn$ + ” Svensson”
Tack vare att vi utnyttjat procedurabstraktion i C-exemplet blev det inte mer skrivarbete än i Basicexemplet, men dataabstraktionen har givit oss en associativt tydligare syntax i Basicexemplet. Som synes behövde vi heller inte deklarera variabeln i Basic, vilket i och för sig också utgör en dataabstraktion, men det är inte säkert att den gör programmeringen tydligare för det. Generellt sett arbetar man med variabler i en mer teknisk synvinkel i C, och mer intuitivt i Basic.
Dataabstraktion medför alltid en viss grad av procedurabstraktion. När man arbetar med variabler utan att veta exakt hur de representeras i datorn, sköter programspråket om detaljerna kring lagring och bearbetning. Detta utgör i sig en procedurabstraktion. Det skulle t.ex. vara mycket komplicerat att utföra matematiska operationer på flyttal om man var helt hänvisad till att arbeta med binära eller hexadecimala värden. Detta slipper man i C.
De flesta programmeringsspråk har mycket mindre möjlighter att utöka graden av dataabstraktion än att utöka graden av procedurabstraktion. I C kan man använda strukturer och typdefinitioner för att skapa nya datatyper vilka har en viss grad av abstraktion. Vi kan t.ex. via en struktur skapa flera sammanhörande fält vid en enda deklaration. Personuppgifter kan grupperas:
struct person {
char cNamn[31];
char cAdress[31];
char cPostNummer[7];
char cOrt[15];
int iAlder;
};
Denna nya datatyp, ‘struct person’ kan användas för att skapa alla fem variabeldeklarationer i en enda deklarationssats:
struct person Medlem[100];
...varvid vi har tillgång till 100 stycken cNamn, 100 stycken cAdress etc.
Datatypen heter ‘struct person’, men även det namnet kan göras mer abstrakt m.h.a. C-instruktionen ‘typedef’:
typedef struct
person PERSON;
...varvid vi har en ny datatyp ‘PERSON’ med samma egenskaper. Vi kan nu i stället deklarera våra medlemmar på följande sätt:
PERSON Medlem[100];
...med exakt samma resultat. I detta fall slipper vi t.o.m. att tänka på nyckelordet ‘struct’. Detta utnyttjas t.ex. när man ska arbeta med filer. Stdio.h innehåller en struktur med tekniskt intressanta variabler som används när man pratar med dos om den fil man arbetar med. Strukturen heter ‘struct _iobuf’, och vi är egentligen inte intresserade av vad den innehåller, eftersom stdio.h innehåller alla funktioner vi behöver för att manipulera data i strukturen, och därmed behöver vi inte förstå hur den används. Vi behöver bara deklarera den, och för att vi ska slippa det tekniska namnet på strukturen finns det en typedef som deklarerar namnet ‘FILE’ åt oss, och det är mycket tydligare att se i ett program. Så här ser deklarationen ut (läggmärke till att typedef ‘vävts in’ i strukturdeklarationen):
typedef struct
_iobuf {
char __far *ptr;
int _cnt;
char __far *_base;
char _flag;
char _file;
} FILE;
Nu kan vi i vårt program lätt deklarera det vi behöver för att kommunicera med dos:
FILE *InFil;
...vilket är betydligt mer abstrakt än strukturdefinitionen ovan. Nu kan man skapa en FILE-pekare utan att veta hur den egentligen fungerar. Procedurabstraktionen vi får via de funktioner som använder pekaren, t.ex. fopen(), fgetc() etc. innebär att vi inte behöver veta något alls om hur den fungerar.
Observera att det går att skapa en struktur utan att skapa funktioner som använder dem. På så sätt kan man betrakta procedurabstraktion och dataabstraktion som två separata tekniker, trots att de tekniskt sett är två integrerade tekniker.
Klasser.
Det är här som objektorienterad programmering kommer in i bilden. Objektorienterade språk kombinerar procedurabstraktion med dataabstraktion i form av klasser. När man deklarerar en klass definierar man allt om ett objekt av högre nivå samtidigt, såväl data som procedurer. När man använder ett objekt som bygger på klassen kan man bortse från detaljer om inbyggda datatyper och operationer på dessa.
Betänk en enkel klass, som kan användas som mall för att skapa objekt, vilka representerar polygonala figurer. Man kanske tänker sig ett polygon som ett antal punkter representerade av talpar, punkternas koordinater. Ett polygon är dock mycket mer än ett antal punkter. Det har bl.a. storlek och form. Man skulle kanske vilja flytta det eller rotera det. Har man två polygon kanske man vill se om de skär varandra, beräkna union och snitt, eller jämföra deras form med varandra etc. Alla dessa egenskaper och operationer är betydelsefulla utan att man för den skull har behov av att i detalj studera hur det skulle utföras. Man kan tänka sig ett polygon utan att för den skull tänka på koordinater och hur de operationer som kan utföras på dem går till.
Genom att kombinera strukturabstraktion med dataabstraktion kan objektorienterade programmeringsspråk skapa en extra nivå mellan datorn och programmet. De högnivåobjekt man skapar har samma fördelar som flyttal och t.ex. printf() har över t.ex MOV-instruktioner i assembler, de gör det lättare att skriva långa, komplexa program.
Klasser kan också representera objekt som man normalt sett kanske inte skulle kalla datatyper. En klass kan t.ex. representera ett binärträd eller en länkad lista. Varje objekt behöver inte bara vara ett enkelt ‘löv’ på trädet, såsom det blir när man använder strukturer, varje objekt kan vara själva binärträdet, eller den länkade listan. Det är lika lätt att skapa flera binärträd och länkade listor som att skapa en enda. Det är bara att deklarera den med dess klass som modell, som datatyp. Observera att man inte alls behöver tänka på detaljerna kring dess konstruktion. Vilka egenskaper hos ett binärträd eller en länkad lista är man egentligen intresserad av? Möjligheten att snabbt hitta ett ‘löv’, att lägga till eller ta bort ‘löv’ etc. (‘Löv’ kan alltså vara vad som helst som kan lagras i ett binärträd eller en länkad lista.) Man är egentligen inte intresserad av binärträdets eller den länkade listans struktur, så länge som man har den funktionalitet objektet förväntas förse en med. Datat kan lagras som en äkta länkad lista, binärträd, vanlig vektor eller någon ny metod som vi inte känner till, det berör inte oss så länge vi kan använda objektet så som det är tänkt.
En dylik klass borde inte heta t.ex. ‘CBinaerTraed’ eller ‘CLaenkadLista’ eftersom dessa namn beskriver klassens interna metoder. Namn som i stället beskriver dess egenskaper utåt, som t.ex. ‘CSorteradLista’ eller ‘CObjektLista’ passar bättre.
Genom att använda abstrakta objekt i ett program i stället för att använda programmeringsspråkets inbyggda datatyper och instruktioner blir det oberoende av de detaljer som möjliggör bearbetningen. Detta leder till nästa fördel och grundsten hos objektorienterad programmering, nämligen inkapsling (encapsulation).
Inkapsling.
Att gömma den interna bearbetningen och de interna variablerna i en klass i syfte att möjliggöra eller framtvinga abstraktion kallas ‘inkapsling’ (encapsulation). Detta kräver att man drar en tydlig linje mellan klassens egenskaper utåt (interface) och dess interna bearbetning och data (implementation). De förstnämnda är tillgängliga för den programmerare som använder ett objekt (public) de sistnämnda är tillgängliga endast för den programmerare som skriver klassen (private). Klassens egenskaper utåt beskriver vad man kan göra med ett objekt, medan dess interna bearbetning beskriver hur den gör det. Denna åtskillnad möjliggör abstraktion genom att utåt uppvisa endast de egenskaper som är relevanta när man använder ett objekt baserat på klassen. Den interna bearbetningen och de interna variablerna har alltså kapslas in i klassen. Den programmerare som använder klassen ser alltså en abstrakt modell av dess bearbetning i stället för själva bearbetningen.
Ska inkapslingen göra skäl för namnet krävs att det data och de operationer som ska betraktas som inkapslade verkligen är deklarerade som privata medlemmar, eller skyddade dito (protected). Det förekommer att man ‘tummar’ på denna definition, men här diskuterar vi endast äkta vara.
Inkapsling förekommer inte bara i objektorienterad programmering. Man gömmer data även i funktioner och procedurer när man deklarerar lokala variabler. En del språk stöder även lokalt deklarerade funktioner (funktioner i funktioner), dock inte den kompilator vi använder här. Man brukar dela upp ett program i flera funktioner, s.k. procedurorienterad programmering, där varje procedur har ansvar för en viss bearbetning. Dessa procedurer har ett väldefinierat gränssnitt utåt, och man strävar efter att göra dem så självständiga som möjligt. Idealfallet är när modulerna inte har minsta kännedom om varandras interna struktur, utan använder endast gränssnittet för att kommunicera med varandra. Man strävar att minimera användning av globala variabler och datastrukturer i syfte att förhindra modulerna att påverka varandras interna arbete.
Att gömma data har sina fördelar. En av dem är som vi sett tidigare att man höjer abstraktionsnivån. En annan är att lokala förändringar i en modul eller klass inte föranleder att man måste införa ändringar på andra ställen i programmet. Ett program som inte har tydliga gränser mellan modulerna är känsligt för förändringar. Ändrar man på ett ställe kan detta göra att andra delar av programmet eller systemet upphör att fungera, och att man måste rätta och testa även dessa, vilket i sin tur kan skapa problem på ytterligare andra ställen etc. Ett program med ‘täta skott’ mellan modulerna är däremot lättare att rätta och underhålla. Ändringar har genomslag inom en begränsad modul, och påverkar inte övrig bearbetning. Om vi t.ex. använder en sorterad lista i vårt program, och den innehåller en länkad lista, så behöver vi inte ändra vårt program alls om man ändrar den sorterade listan till att använda ett binärträd i stället. Vårt program uppträder exakt likadant som tidigare, bortsett från att binärsökningen kanske blir snabbare än den länkade listans sökning, vilket ju inte har med programmets funktionalitet att göra.
Om man använder en modul (procedur eller funktion tillsammans med t.ex. en datastruktur) som hanterar t.ex. en lista med datauppgifter har man skaffat sig en begränsning. Man kan bara ha en lista. Detta går att lösa om man deklarerar datastrukturen lokalt, och ändrar proceduren eller funktionen till att använda pekare mot listan i stället. Härigenom blir naturligvis vår lista betydligt mer användbar. Denna vinst kostar dock en hel del extra arbete, tillsammans med att man måste ändra alla program som använder listan. Utan att behöva gå närmare in på alla konsekvenser av detta genomförande kan vi lungt konstatera att det blir mycket lättare att genomföra i C++. Här deklarerar vi bara en klass. Sedan kan vi skapa så många objekt vi vill, såväl lokala som globala. Varje objekt skapas och förstörs vid rätt tillfälle, och vi behöver t.ex. inte tänka på att frigöra reserverat minne innan vi lämnar en funktion eftersom lokala objekt automatiskt kör sin destruktor när funktionen går ur sitt sammanhang.
Utan att höja några pekfingrar eller starta en diskussion om rättvisan i att en programmerare får förtroendet att ‘pilla’ på vissa variabler medan en annan inte får det, ska vi ändå diskutera eventuella effekter av ‘obehörigt intrång’ i datastrukturer. Låt oss tänka oss att en programmerare använder en objektlista som någon annan har skrivit funktionerna till. Vår programmerare råkar känna till att objektlistan använder en vanlig vektor, d.v.s. en lista där alla element ligger i en lång rad i minnet. När man begär ett element ur listan gör man det via en funktion som returnerar adressen till objektet. Programmeraren ‘vet’ nu storleken på den struktur som representerar ett objekt i listan och vill nu ha ett objekt som ligger x antal steg längre fram i listan. För att slippa anropa någon funktion som hämtar nästa post x gånger tar han genvägen att öka pekarens värde med x och sedan hämta det objekt som ligger där. Det fungerar ju bra, och verkar oskyldigt. Nu är det bara det att han även bundit upp den andre programmerarens möjligheter att ändra i sin modul. Skulle han t.ex. vilja förbättra modulen genom att använda ett binärträd, kommer ‘vår’ programmerares program fortfarande att hoppa x poster fram i minnet, och där ligger det något helt annat. Konsekvenserna kan bli vad som helst.
Det är inte ovanligt att man söker sina egna genvägar, speciellt som det ibland kan vara tidskrävande att hitta, förstå och implementera de legala vägarna att manipulera datat. Är det bråttom att få programmet klart, och det är det ju alltid, så kan den mest nitiske programmeraren börja snegla på kända genvägar.
Om ovannämnda två programmerare dessutom är en och samma person, kan han snabbt ta beslutet att införa förbättringarna senare. Dock måste han då gå igenom alla program där han inte är helt säker på att han inte ‘fuskat’. Detta blir synnerligen tidskrävande.
Detta problem kan helt elimineras när man använder klasser, tack vare inkapslingen. Även om man inte kan garantera att klassens gränssnitt kommer att vara likadant för all framtid, kan man i de flesta fall införa förändringar i form av tillägg till gränssnittets komponenter i stället för att ändra i de befintliga. Därigenom kan de gamla programmen fortsätta att använda de gamla komponenterna medan de nya utnyttjar de nya finesserna.
Observera att inkapslingen inte helt garanterar dataintegriteten, vilket hela tiden påståtts i detta häfte. Avsikten är att förhindra oavsiktligt intrång. Man kan alltid använda pekare för att komma åt vad som helst i datorns minne. Det är därför bättre om de delar som kapslats in inte blir kända för dem som inte har med dem att göra. Man dokumenterar sina klassers gränssnitt när man ‘levererar’ dem till någon, medan man behåller dokumentationen som beskriver deras interna arbete ‘hemma’.
Klasshierarkier.
En finess med objektorienterade programmeringsspråk vilken helt saknas i alla procedurorienterade språk är möjligheten att definiera klasshierarkier. I C++ kan man ju definiera en klass som ärver av en annan klass. Datatyperna i C har däremot inget gemensamt, de är helt oberoende av varandra.
Att deklarera en gemensam basklass för flera andra klasser kan betraktas som en form av abstraktion. Basklassen blir ett sorts högnivåbegrepp för de andra klasserna. Den beskriver vad de andra klasserna har gemensamt så att man kan koncentrera sig på endast det, och bortse från de detaljer som skiljer objekten. Denna abstraktion går alltså ut på att minska antalet typer genom att generalisera dem, vilket man gör genom att endast betrakta det som de annars olika typerna har gemensamt. Det underlättar att tänka på t.ex. en personallista, som i övningsexemplet ‘firma’ än en lista med alla chefer, alla jobbare samt alla säljare. Vi vill ju i en sådan lista bara ha namn och anställningsnummer, och de ligger i basklassen.
En basklass är alltså en generalisering av en grupp klasser, medan en ärvande klass är en specialisering av en förälderklass och/eller en basklass. En ärvande klass identifierar en känd klasstyp och beskriver sina egna tilläggsegenskaper. Om vi åter tänker på exemplet ‘firma’ kan vi konstatera att en jobbare är en person, men har egenskaper som inte beskrivs i klassen CPerson, och därmed inte nödvändigtvis finns för alla anställda personer.
Man vinner två praktiska fördelar när man deklarerar en klasshierarki. Den ärvande klassen kan antingen använda förälderklassens/basklassens operationer, eller använda förälderklassens/basklassens gränssnitt. Dessa två fördelar utesluter visserligen inte varandra, men vanligtvis bygger man sin hierarki antingen för att använda operationerna, eller för att använda gränssnittet.
Ärva
operationer.
Om man skriver en klass och vill ha med funktionaliteten från en existerande klass kan man helt enkelt ärva från den existerande klass som innehåller den funktionalitet man är intresserad av. Det gjorde vi t.ex. när vi ärvde från CJobbare till CSaljare i syfte att beräkna timlönen m.h.a. en funktion i CJobbare, varefter provisionen beräknades i en funktion i CSaljare. Man kan på detta sätt spara onödigt upprepande av funktioner som utför samma sak, vilket man bör tänka på när man formger sin klasshierarki. Operationer som t.ex. är gemensamma för alla klasser skrivs en gån för alla i basklassen. Det blir därigenom lättare att införa rättningar, ändringar och tillägg i den funktionaliteten eftersom man bara behöver ändra på ett enda ställe.
Om man t.ex. skapar ett eller flera formulär där användaren ska fylla i olika typer av data i olika fält, kan man t.ex. skapa en klass för varje typ av datafält som motsvarar den typ av data fältet är avsett för, d.v.s. en klass för ett datafält avsett för namn, t.ex. CNamnFalt, en klass för ett datafält avsett för datum, t.ex. CDatumFalt, en klass för ett datafält avsett för valuta, t.ex. CKronOrenFalt etc. Man kommer dock ganska snart att se hur vissa grundläggande funktioner behövs i alla dessa klasser, t.ex. redigering, markörhantering etc. Då vore det en klar fördel att först deklarera en klass CFalt, vilken innehåller dessa funktioner. De andra klasserna kan ärva av CFalt, och lägga till sina specialfunktioner, t.ex. indatamask, gränsvärdeskontroller, automatisk redigering etc.
En klasshierarki avsedd för återanvändning av funktioner har de flesta funktioner i basklass och förälderklasser. Funktionerna ligger så nära hierarkiträdets rot som möjligt.
Ärva
gränssnitt.
En anna strategi är att ärva gränssnittet i stället för funktionerna. Då ärver man endast funktionernas namn, inte koden. Basklassen innehåller då funktioner avsedda att bytas ut, ofta virtuella sådana. Den ärvande klassen innehåller sedan den kod som den behöver. På så sätt får den ärvande klassen samma gränssnitt som basklassen/förläderklassen, men har olika funktionskod. De olika klasserna gör olika saker med samma funktioner.
På detta sätt har vi likhet på hög nivå. Den viktigaste fördelen är dock polymorfismen. Alla våra olika personer i projektet ‘firma’ kunde behandlas likadant med avseende på t.ex. löneutbetalning. Vår lista refererade till objekten med en pekare av basklassens typ, men när vi via pekaren anropade löneutbetalningsfunktionen reagerade objekten olika beroende på deras egentliga typ. Anropet var dock identiskt för alla objekt. Vi har vunnit en likhet i objektens gränssnitt för en funktionalitet som skiljer sig mellan de olika klasserna.
Exemplet med inmatningsfälten ovan kunde t.ex. ha en virtuell funktion GetValue() som implementeras olika i de olika objekten trots att alla fält anropas på samma sätt. Man kan därigenom lätt implementera en funktionalitet där användaren kan ‘hoppa’ fram och tillbaka mellan olika fält medan programmet hela tiden anropar GetValue() på samma sätt via samma pekare, trots att de olika objekten sedan genomför arbetet på olika sätt. Själva formuläret behöver då endast administrera de olika inmatningsfälten som en lista med objekt, i vilka GetValue() anropas för aktuellt objekt. Objekten behandlas då som CFalt-objekt, oavsett vad de egentligen har för typ. CNamnFalt, CDatumFalt eller CKronOrenFalt t.ex. behandlas alla som CFalt.
Vårt exempel med formuläret ovan använder liksom exemplet ‘firma’ båda metoderna, arv av funktioner och arv av gränssnitt. Man kan dock deklarera en basklass för endast arv av gränssnitt genom att deklarera den som en abstrakt basklass. De ärvande klasserna blir då som en fungerande version av den abstrakta modell som definierats i basklassen.
Vi kan alltså konstatera att klasser understöder abstraktion, inkapsling och hierarkier. Klasserna är det verktyg vi använder för att definiera en abstrakt datatyp, vilken även innehåller de operationer man kan utföra på ett objekt av datatypen. Klasser kan inkapslas, vilket delar upp programmet i avdelningar och ökar dess ‘lokalitet’, d.v.s. att ingående variabler och operationer är så isolerade att de kan ändras utan att påverka andra delar av programmet. Till sist ser vi även att klasser kan organiseras i hierarkier, vilket definierar deras inbördes förhållanden och minimerar upprepning av kod.
Det räcker alltså inte bara med att använda programmeringsspråket C++ för att det ska bli ett objektorienterat program. Man måste även utnyttja de ovan diskuterade möjligheterna i språket. I nästa avsnitt ska vi se vad man bör tänka på när man skapar ett äkta objektorienterat system.
Konsten att arbeta objektorienterat.
När man förr programmerade enligt den linjära metoden, ‘top-down’, började man med att specificera programmets avsedda funktion. Man började med att ställa sig frågan om vad programmet skulle utföra.
Sedan kom det procedurorienterade tänkandet där man delade upp denna funktion i mindre bearbetningssteg. Dessa beskrev man ofta med någon sorts pseudokod på en högre nivå. Processen fortsatte genom att man bröt ned dessa i allt mindre procedurer till man kom ned i nivå med de programinstruktioner programmeringsspråket tillhandahöll. Denna metod kallas procedurorienterad. Den behandlar ett program som en beskrivning av en process vilken bryts ned i underprocesser.
Objektorienterad design skiljer sig totalt från denna metod genom att man först struntar i vad programmet ska göra. Man intresserar sig inte ens för det data programmet ska bearbeta. Man börjar i stället med att analysera problemet med utgångspunkt från att det är ett system bestående av olika typer av objekt, och hur dessa sammarbetar och är beroende av varandra. Den första frågan blir alltså: ”Vad har vi för objekt här?” eller ”Vilka aktiva komponenter ingår i detta program?” Sedan fortsätter man med att fråga sig vilka relationer som gäller mellan dem, och hur de samarbetar.
Skillnaden är inte bara den totalt annorlunda utgångspunkten, man fortsätter också på ett helt annat sätt. Man börjar inte med en stor klass som sedan bryts ned i mindre klasser. Vid objektorienterad design brukar man arbeta på såväl hög som låg abstraktionsnivå samtidigt, genom hela projektet. Arbetet involverar följande punkter, vilka alltså inte är att betrakta som separata steg i utvecklingen:
· Identifiera vilka objekt som ska beskrivas, och därmed vilka klasser som behövs.
· Tilldela klasserna egenskaper i form av attribut och metoder.
· Identifiera objektens relation till varandra.
· Arrangera klasserna i en eller flera hierarkier.
Man brukar börja projektet med att utföra ovanstående punkter ett steg i taget, men man slutför dem inte fullt ut. Arbetet utgör i själva verket en iterativ process där man hela tiden återkommer till de olika punkterna för att arbeta vidare på dem, och de är alltså aktuella hela projekttiden igenom. Om man skulle försöka göra färdigt ett steg innan man tittade på de övriga skulle man inte ha stora chanser att producera en vettig klasshierarki. Man behöver helt enkelt ‘input’ från arbetet med de övriga punkterna. Första genomgången blir alltså endast en ‘grovskiss’. Det är dock viktigt redan där att man verkligen gör ett grundligt arbete om det fortsatta iterativa arbetet inte ska innehålla för många obehagliga överraskningar. Vi ska här nedan titta litet närmre på varje punkt för sig:
Identifiera vilka objekt som ska beskrivas, och därmed vilka klasser som behövs.
Den första punkten innefattar att man försöker bestämma vilka objekt programmet ska representera, och vilka klasser man behöver för att representera dem. Detta är inte lika lätt som det var att bestämma programmets funktion, vilken man ju gjorde tidigare. Det går inte att helt enkelt bryta ner problemet i procedurer, och definiera de funktioner och datastrukturer som behövs, för att till sist bygga klasserna på dem. Gör man detta har man inte arbetat objektorienterat, eftersom man återigen utgår från problemet, inte objekten. Klasserna måste vara de centrala enheterna i programmet.
En metod att skapa en lista med de klasser som behövs är att skriva en beskrivning av syftet med programmet. Sedan stryker man under alla substantiv, och för in dem i listan. Detta blir den första versionen av lista med ingående klasser. Denna enkla metod är helt beroende av hur väl beskrivningen utformats, men kan ibland vara den rätta metoden, eller åtminstone en bra utgångspunkt, speciellt för den som är ny på objektorienterat tänkande.
Det är lättare att identifiera klasserna om de ingående objekten är av konkret natur. Om man t.ex. vill skapa ett program för platsreservationer på ett flygbolag, skulle man lätt identifiera objekten flygplan och passagerare, och deklarera klasserna ‘CFlygplan’ och ‘CPassagerare’. Om man skapar ett operativsystem vill man säkert skapa klasser för olika enheter: diskar, skrivare etc.
Många program arbetar dock med mera abstrakta objekt, och då kan dessa vara svårare att identifiera. En kompilator kan t.ex. behöva en klass för syntaxkontroll. Denna kontroll kanske sker hierarkiskt och kan då heta t.ex. ‘CSyntaxTraed’. Ett operativsystem kan behöva en klass för att instansiera varje process som ett objekt: ‘CProcess’. Dessa klasserna kan vara både svåra att identifiera och att beskriva attribut och funktionalitet för.
Ännu mindre uppenbara klasser är sådana som t.ex. hanterar händelser i t.ex. ett händelsestyrt system. Man kan ha en klass ‘CTransaktion’ i ett banksystem, vilket kan representera att någon tar ett lån, gör en insättning eller någon annan vanlig transaktion i en bank. Transaktionen i sig själv kan sedan initiera andra bearbetningar. En klass ‘CKommando’ kan hantera användarens interaktion i ett operativsystem etc.
Ibland kan man se möjligheten till en hierarki för sina klasser. Om man funnit att man behöver en klass ‘CBinaerFil’ och en klass ‘CTextFil’ kanske man kan tänkas ärva dessa från en klass ‘CFil’. Det är dock inte alltid uppenbart när det är lämpligt med sådant arv. Ett banksystem kan t.ex. tänkas använda en enda klass ‘CTransaktion’ för alla händelser, eller skapa separata klasser ‘CLaan’, ‘CInsaettning’ etc. och låta dessa ärva från klassen ‘CTransaktion’. Liksom klasserna är sådana hierarkier föremål för eventuella framtida förändringar eller för att helt och hållet förkastas.
Meningen med alla ovanstående klasser är att de ska stå som modell för olika element i det problem man ska lösa. Programmet kan behöva en annan typ av klasser, nämligen klasser som implementerar de klasser vi redan identifierat. Det kan vara t.ex. en klass som administrerar en lista med de objket vi skapar från en av våra klasser. ‘CSorteradLista’ vi diskuterade förut kan t.ex. användas för att lagra objekt skapade från de olika klasserna. Vi skulle kunna skapa en lånelista för CInsaettning-objekt, en passagerarlista för CPassagerare-objekt etc. Vi ser nu att det finns ett behov, men själva listan var så abstrakt att vi kanske inte tänkte på den när vi identifierade objektet ‘passagerare’. När man går igenom ovanstående punkter för första gången är det i regel för tidigt att tänka på denna typ av relation, med undantag för de fall då man använder i förväg deklarerade standardklasser.
Tilldela klasserna egenskaper i form av attribut och metoder.
När man väl identifierat en klass blir nästa uppgift att avgöra dess ansvarsområde. De olika ansvarsområdena kan indelas i två kategorier:
1 Den information ett objekt baserat på denna klassen måste innehålla. (Vad ‘vet’ ett objekt som baseras på denna klass?)
2 De operationer ett objekt baserat på denna klassen kan utföra eller som kan utföras på objektet. (Vad kan detta objekt göra?)
Varje klass har ‘attribut’ vilka är de egenskaper eller karakteristika som beskriver den. Klassen CRektangel har t.ex. bredd och höjd, en bild har ett bildinnehåll, en CPerson har namn och anställningsnummer etc. för att ta bl.a. från några tidigare exempel. Varje objekt baserat på klassen måste själv ‘minnas’ sitt innehåll. Objektets ‘inställning’ är de värden dess medlemsvariabler innehåller i ett visst ögonblick. Ett CFile-objekt kan t.ex. innehålla filnamnet ‘minfil.txt’, tillgänglighet ‘read-only’ och positionen ‘43 bytes från filens början’. En del attribut kanske inte ändrar värde under objektets levnad, medan andra ständigt uppdateras. Ett attribut kan antingen vara lagrat i en medlemsvariabel eller be-räknas från andra medlemsvariabler etc. när det behövs.
Blanda inte ihop attribut och klasser. Det är meningslöst att definiera en klass för att beskriva ett enda attribut. En ‘CRektangel’ kan vara användbar, men klasserna ‘CLangd’ och ‘CBredd’ är antagligen onödiga. Om en klass’ ‘CForm’ enda uppgift är att hålla reda på vilken muspekare som ska vara synlig är det bättre att använda ett attribut för detta i den klass som hanterar fönstret. Om den däremot innehåller en bitmap som beskriver muspekarens utseende, och eventuellt diverse operationer som kan utföras på den kan det vara vettigt att deklarera den som en klass, men då vore det bättre att kalla den ‘CMusPekareBild’ eller ‘CGraphCursor’, eftersom ‘CForm’ kan vara ett namn man behöver till andra former i ett program.
Varje klass ‘beter’ sig på ett visst sätt. Detta beskriver hur de objekt som baseras på klassen samverkar med andra av objekt och hur dess ‘tillstånd’ ändras under denna samverkan. Klasser kan bete sig på många olika sätt. Ett CTime-objekt kan t.ex. visa nuvarande tillstånd, tiden den innehåller, utan att ändra det. En användare kan hämta eller lagra värden i en klass CStack, varvid dess interna tillstånd förändras. Ett polygon kan delvis sammanfalla med ett annat varvid ett tredje polygon uppstår, d.v.s. det skapas ett nytt objekt.
När man bestämmer vad en klass bör ‘veta’ och vad den bör kunna göra måste man betrakta den i sammanhang med programmet den ska användas i. Vilken roll spelar klassen? Programmet har i sig ansvar för diverse information och operationer, och dessa bör tilldelas olika klasser. Om det finns information eller operationer som ingen klass hanterar behövs det antagligen en ny klass. Det är också viktigt att programmets uppgifter fördelas någorlunda lika mellan de olika klasserna. Om en enda klass handhar lejonparten av ett programs uppgifter bör man antagligen dela upp den i mindre klasser. Omvänt gäller naturligtvis för en klass som inte gör någon egentlig nytta, den bör man nog ta bort.
När man håller på att tilldela en klass dess attribut och metoder ger det ofta en djupare insikt i vad som utgör en användbar klass. Om det är svårt att definiera en klass’ ansvarsområde representerar den antagligen inte en tillräckligt väl definierad del av programmet. Många av de klasser man tänkte sig i det första steget, när man första gången gick igenom den ovanstående listan, kanske kasseras på det här stadiet. Om vissa attribut och/eller metoder upprepas i flera klasser kan det hända att de representerar en användbar abstraktion man inte lagt märke till tidigare. Man kanske ska skapa en ny klass med bara dessa attribut och/eller metoder för att sedan använda den i de andra klasserna, genom arv eller komposition.
De programmerare som är ovana vid obejktorienterad programmering begår ofta misstaget att skapa klasser som egentligen bara är inkapsling av processer. I stället för att klasserna representerar objekt kommer de att representera ett block med funktioner i ett procedurorienterat program. Dessa ‘falska’ klasser kan lätt identifieras på detta stadium helt enkelt genom att de saknar attribut, medlemsfunktioner. En sådan klass har inget ‘tillstånd’, den innehåller inget data, bara funktionalitet. Om, när man definierar en klass ansvarsområde skulle beskriva den på samma sätt som man beskriver en funktion, så är det också en funktion, ingen klass. Man tänker kanske att ‘Denna klass tar emot ett argument och svarar med...’ och då beskriver man en funktion. En klass som bara har en metod, medlemsfunktion, är inte heller en klass.
När man väl definierat en klass attribut och dess beteende ser man ungefär vilka metoder som behövs i klassens gränssnitt. Dess beteende motsvarar vanligen dess medlemsfunktioner. Vissa attribut kräver metoder i gränssnittet för att läsa av eller ändra dem. Andra attribut märks endast på klassens beteende.
Exakt vilka metoder som behövs, vilka argument de tar och vilken returtyp de har blir inte helt bestämt förrän i slutet av utvecklingsarbetet. Dessutom koncentrerar man sig inte så mycket på klassernas användning i detta stadium. Det handlar t.ex. om att bestämma om ett attribut ska lagras i en medlems-variabel eller om det ska beräknas när det behövs, hur data ska representeras och hur man ska skriva metoderna.
Identifiera objektens relation till varandra.
Därefter är det dags att bestämma hur klasserna ska använda sig av varandras färdigheter. En del klasser kan klara sig på egen hand, medan de flesta är beroende av varandra. Klasser bygger på varandra respektive samarbetar med varandra.
Många klasser är helt beroende av andra klasser, och kan inte alls användas om de andra klasserna inte finns tillgängliga. Om vi tänker oss en klass ‘CTid’, som har till ansvar att lagra klockslag och datum, så kan den innehålla en metod som skapar en textsträng där datum och klockslag formaterats snyggt, så att det kan skrivas ut på skärmen. Om man då använder ett CText-objekt för detta ändamål, är man beroende av att den klassen finns tillhands. Klassen CTid kan då t.ex. innehålla en metod som tar en referens till CText-objekt som argument och anropar dess medlemmar efter behov, så att anropande klass/funktion får sitt objekt uppdaterat.
En klass kan även ha andra klasser inbäddade, vilket innebär att klassen har objekt av andra klasser som medlemsvariabler. En klass CCirkel kan t.ex. ha dels en medlemsvariabel som representerar cirkelns radie, dels använda ett objekt typ t.ex. ‘CKoordinat’ för att ta hand om cirkelns medelpunkt. Denna typ av relation kallas ‘containing relationship’, d.v.s. ett förhållande där en klass innehåller objekt av en annan klass. Detta kallas för att ‘komponera’ ihop klasserna, ‘composition’, vilket är en helt annan sak än att utnyttja klassarv.
De flesta förhållanden mellan klasser uppstår genom att en klass gränssnitt är beroende av en annan klass gränssnitt. Om man t.ex. i klassen CCirkel ovan vill implementera en metod ‘Medelpunkt()’ i gränssnittet, vilken returnerar ett CKoordinat-objekt, måste den som använder klassen CCirkel även ha tillgång till klassen CKoordinat för att kunna använda CCirkels gränssnitt. Det kan också vara så att en klass metoder är beroende av en annan klass utan att det involverar gränssnittet. Ta t.ex. en klass CTeleLista, som innehåller en privat medlem för själva listan. Det kan vara t.ex. ett objekt av klassen CSorteradLista. I detta fall behöver den som använder CTeleLista inte ha tillgång till CSorteradLista, i motsats till fallet med CCirkel och CKoordinat. Detta utgör i sig ytterligare en form av inkapsling, där man kan byta ut själva listan utan att det påverkar gränssnittet.
Medan man definierar relationerna mellan de olika klasserna är det vanligt att man omprövar en del av de beslut som tagits tidigare. Data som lagrats i en klass, eller funktionalitet som finns i den, kan t.ex. visa sig vara mer lämpligt att placera i en annan klass. Ge inte en klass för mycket information om sin omgivning. Har man t.ex. en klass ‘CBok’ och en klass ‘CBibliotek’ där man lagrar objekt av typen CBok är det inte nödvändigt att klassen CBok innehåller infor-mation om vilket bibliotek det rör sig om . Det finns i klassen CBibliotek. Genom att justera gränserna mellan de olika klasserna kan man förfina sin ursprungliga uppfattning om syftet med varje klass.
Man kan vara frestad att överutnyttja konceptet med vänklasser, speciellt när man i en klass behöver tillgång till innehållet i en annan, men denna mekanism bör utnyttjas sparsamt eftersom den förstör inkapslingen. Ändrar man sedan i den ena klassen kanske man blir tvungen att ändra i den andra också, och det är ju en av de saker vi vill undvika med objektorienterad programmering.
När man sedan funnit en klass relation till andra klasser kan man ytterligare bestämma klassens gränssnitt. man vet vilka attribut som kräver accessfunktioner för att ändra dem, och vilka som bara behöver läsas. Man har en bättre uppfattning om hur man ska dela upp klassens beteende i olika medlemsfunktioner.
Arrangera klasserna i en eller flera hierarkier.
Man skapar hierarkier som en förlängning av det första steget, där man identifierade vilka klasser som behövs, men för att kunna göra detta behövs att man gått igenom även ovanstående steg. Genom att tilldela klasser attribut och metoder får man en tydligare bild av deras likheter och olikheter. Genom att finna hur klasserna står i relation till varandra ser man vilka klasser som behöver implementera funktionalitet från andra klasser.
En indikation på att det kan vara lämpligt att skapa en hierarki kan vara att man ser behovet att använda en switch på ett objekts typ. Om man t.ex. har en klass ‘CKonto’ vilken innehåller en datamedlem ‘typ’, som talar om huruvida detta är ett lönekonto eller ett sparkonto, kan dess medlemsfunktioner behöva uppträda olika beroende på denna datamedlem. Vi kan tänka oss att räntan räknas per månad för sparkontot, men löpande för lönekontot. Då tänker man sig kanske i första hand att man använder en switch på typ för att styra fram till rätt bearbetning. Här skulle det kanske vara bättre att använda en klasshierarki med polymorfism. Vi kan från klassen CKonto ärva till klasserna ‘CLoneKonto’ respektive ‘CSparKonto’. Den metod som används vid ränteberäkning kan då deklareras som en virtuell metod i basklassen (CKonto), och implementera olika kod för motsvarande metoder i CLoneKonto och CSparKonto. Man kan sedan anropa den utan att behöva någon medlem för typ.
Det behöver å andra sidan inte vara nödvändigt med en hierarki bara för att man kan identifiera olika typer av objekt. Om dessa objekt inte skiljer sig med avseende på attribut och metoder finns det ingen anledning att skilja dem. Ta t.ex. en klass för bilar ‘CBil’. Det kan finnas personbilar, minibussar, lastbilar etc. men man vill egentligen bara ha med vissa attribut och metoder som inte är beroende av vad det är för typ av bil. Det kanske i stället är ett av dess attribut.
Komposition kontra arv.
Såväl komposition som arv medför att en klass kan använda metoder från en annan klass, men de olika klasserna har inte samma relation vid komposition som vid arv. Många programmerare väljer klassarv när de vill få tillgång till en annan klass metoder utan att tänka efter om ett arv verkligen motsvarar relationen mellan de två klasserna. Komposition använder man när en klass har en annan klass medan arv används när en klass är ett specialfall av en annan klass. T.ex. en cirkel är inte en sorts punkt, den har en punkt, nämligen en medelpunkt. En CSaljare har inte en CJobbare, han är det.
Ibland kan det vara svårare att avgöra om det rätta förhållandet motsvarar ett arv eller en komposition. Är t.ex. en stack en sorts lista med en speciell uppsättning metoder eller har den en lista. Är ett fönster en textbuffert som kan visa sig själv på skärmen eller innehåller fönstret en textbuffert? I sådana fall måste man se hur klassen passar ihop med andra klasser i projektet.
Om man ser ett behov av polymorfism är antagligen lösningen att använda arv. Här kan man använda basklasspekare för att kalla metoder i objekt av olika slag, vilket man inte kan när man använder komposition.
Vill man å andra sidan låna funktionalitet från en annan klass upprepade gånger är det möjligt att det är bättre med komposition. Om vi tänker oss en klass som sparar information om en fil, bl.a. datum då filen skapades, datum då filen senast ändrades samt datum då filen senast lästes, så är det enklast att använda tre objekt av klassen CDatum.
Att skapa klasser avsedda för arv.
När man bygger sin klasshierarki är det vanligt att man upptäcker att man behöver skapa nya klasser, ändra i befintliga samt att ta bort överflödiga klasser som tidigare deklarerats. De flesta klasser man identifierat i första steget är antagligen sådana man tänkt använda för att skapa objekt. Det var ju genom att identifiera objekten som man fann klasserna. I det vidare arbetet har det dock antagligen framkommit klasser som inte är lämpliga att skapa objekt på, att instantiera, ‘instantiate’. Därför kan många av de klasser som framkommer när man skapar hierarkin vara lämpliga att deklarera som abstrakta klasser.
När man deklarerar abstrakta klasser ökar man möjligheterna att återanvända dem. Man kan t.ex. skapa en abstrakt klass som man låter att stort antal klasser ärva från direkt. Om två eller flera av dessa klasser har gemensamma medlemmar som inte finns i de andra kan de dock inte implementeras i basklassen. Därigenom måste de implementeras upprepade gånger i alla de klasser som behöver dem. För att undvika detta kan man skapa en mellanklass, vilken även den kan vara abstrakt. Låt mellanklassen ärva av basklassen, lägga till de extra medlemmarna som behövs i vissa klasser, och låt dessa klasser ärva av mellanklassen i stället för direkt från basklassen. Detta ökar även möjligheterna när man senare vill utöka hierarkin ytterligare.
Man bör dock inte skapa abstrakta klasser i onödan. I extremfall kan man tänka sig en större mängd klasser som ärver av varandra, och som var och en endast lägger till ett minimum av funktionalitet. Om man sedan inte verkligen behöver alla mellannivåerna kommer klasshierarkin visserligen att fungera, men det blir en klumpig hierarki.
Det är önskvärt att man placerar gemensam funktionalitet så högt upp i hierarkin som möjligt, så att den kan användas överallt där den behövs. Man bör å andra sidan inte belasta en basklass med funktionalitet som endast få ärvande klasser utnyttjar. Attribut och metoder kan under utvecklingsarbetet flyttas upp eller ner i hierarkin, allteftersom man finjusterar den.
Arven påverkar inte bara klasshierarkins utformning. De kan även påverka utformningen av fristående klasser. Varje fristående klass kan senare komma att användas som basklass för andra klasser. Om det är någon annan programmerare som kan tänkas använda klassern som basklass är det viktigt att man varit noggrann när man bestämde vad som skulle ingå i gränssnittet och vad som skulle vara inkapslat. En klass har egentligen två slags ‘användare’. Å ena sidan har vi klasser och funktioner som använder klassen genom att skapa ett objekt, å andra sidan har vi klasser som ärver av den. När man skapar en klass måste man bestämma huruvida man vill skapa olika sorters gränssnitt för dessa två olika användartyper. Nyckelordet ‘protected:’ möjliggör för ärvande klasser att använda medlemmar trots att de inte är tillgängliga på objekten. Därigenom kan de två olika sorters kunderna få olika tillgänglighet, men bara på så sätt att en ärvande klass har tillgång till mer än ett objekt.
Man får se upp så att man inte använder ‘protected:’ på ett sådant sätt att dataintegriteten bryts. Om man i efterhand vill ändra från ‘protected:’ till ‘private:’ måste man ändra i alla ärvande klasser som refererade till dessa medlemmar, vilket kan bli ett omfattande arbete. Var därför försiktig med användningen av ‘protected:’.
Multippelt arv.
Multippelt arv kan vara användbart för att maximera återanvändning samtidigt som man undviker basklasser med onödig funktionalitet. Om man t.ex. har en abstrakt basklass för objekt som kan sorteras, ‘CSorterbartObjekt’, vilken har det gränssnitt en klass behöver för att kunna lagras i ett objekt av en klass för sorterad lista, låt oss kalla den ‘CSorteradLista’. Betänk sedan att det finns en liknande abstrakt basklass kallad ‘CSkrivbartObjekt’ som har ett interface användbart vid utskrifter. Man kan sedan ärva till klasser för såväl listor som utskrifter, utan att få onödiga komponenter i gränssnittet. Vill man sedan ha en klass som har såväl listfunktionalitet som utskriftsfunktionalitet kan man ärva av både CSorterbartObjekt och CSkrivbartObjekt.
Detta skulle vara svårt att åstadkomma med enbart enkelt arv. För att undvika att upprepa samma funktioner i flera olika klasser skulle man deklarera en basklass ‘CSorterbartSkrivbartObjekt’ och låta alla underliggande klasser ärva från denna. Vissa klasser måste sedan undertrycka de funktioner som har med utskrift att göra och andra måste undertrycka de som handhar sortering. En sådan hierarki skulle bli ‘topptung’, d.v.s. ha för mycket funktionalitet längst upp i hierarkin.
Virtuella klasser är vanliga i hierarkier med multippelt arv. En nackdel med virtuella basklasser, förutom den extra bearbetning som krävs, är att behovet av dem syns tydligt först när hierarkin är mer eller mindre färdig. Om vi tänker på exemplet ‘firma’ så var det ju så att vi inte behövde göra basklassen äkta virtuell förrän vi diskuterade hur en ‘CSaljChef’ skulle implementeras. Det var först då vi behövde multippelt arv. Om vi alltså skulle lägga till denna klass efter att vi skrivit ett antal program som använder de övriga klasserna blir man tvungen att modifiera hierarkin, och kompilera om alla färdiga program, och eventuellt modifiera dem. Om det blir praktiskt svårt att genomföra modifikationen kanske man måste söka en annan lösning än den med virtuella basklasser.
Såväl enkelt som multippelt arv används ofta på ett olämpligt sätt. Många lösningar som använder multippelt arv skulle de facto kunna lösas bättre m.h.a. komposition, eller med en kombination av komposition och enkelt arv. Man bör alltid undersöka möjligheterna till en sådan lösning innan man inför multippelt arv.