Note til Programmeringsteknologi Akademiuddannelsen i Informationsteknologi Objektorienteret design med arv og polymorfi: Substitutionsprincippet Composite Design Pattern Finn Nordbjerg Side 1
Objektorienteret design og arv Nærværende note beskriver nærmere et princip for godt design i forbindelse med brug af arv og polymorfi i programmering. Princippet kaldes substitutionsprincippet og er oprindeligt introduceret af Babara Liskov [Liskov]. Endvidere gives et eksempel på design pattern'et Composite. Indledning I objektorienteret analyse anvendes specialisering/generalisering ved strukturering mellem klasser bl.a. for at genbruge beskrivelser af egenskaber og adfærdsmønstre. Disse strukturer kan, som bekendt, overføres til programmet ved at udnytte et objektorienteret programmerings-sprogs (OOPL) understøttelse af arv og polymorfi. Arv og polymorfi er imidlertid generelle mekanismer i OOPL, som kan anvendes til at opnå kodegenbrug - også når der ikke tale om specialisering/generalisering. En ukritisk brug af arv og polymorfi, hvor der kun fokuseres på kodegenbrug, og det glemmes, at arv skal ses som typespecialisering, fører erfaringsmæssigt til dårligt designede programmer, som er vanskelige at overskue og vedligeholde, stik modsat målet med objektorienteret programmering. Substitutionsprincippet Substitutionsprincippet siger, at arv skal anvendes på en sådan måde, at man altid kan substituere et objekt af en nedarvet klasse, hvor et objekt af forfaderklassen bruges. Dette princip følger af, at arv skal ses som typespecialisering, dvs. at en nedarvet klasse skal have samme egenskaber og adfærd på det logiske niveau som forfaderklassen, og derfor kan udfylde dennes plads. Da nedarvning i objektorienterede programmeringssprog normalt betyder, at alt hvad der er defineret i forfaderklassen også - automatisk - er defineret i den nedarvede klasse, så er det i forbindelse ved omdefinering af arvede operationer, at det er vigtigt at overholde substitutionsprincippet. Hvis en programstump, som anvender en operation defineret på forfaderklassen, skal kunne fungere lige så godt, hvis der i stedet udføres en omdefineret version af operationen defineret i den nedarvede klasse, så skal arvingens omdefinerede version mindst opfylde den gamles specifikation. Substitutionsprincippet kan defineres mere præcist ved at tage udgangspunkt i en klasses specifikation. Antag, at vi har to klasser ClassA og ClassB, hvor ClassB er en arving til ClassA. Vi har følgende definitioner i C#: Side 2
public class ClassA // diverse definitioner public virtual void Pip() // PRE: p // POST: q -- p og q er betingelser //end Pip //end ClassA public class ClassB: ClassA // diverse definitioner public override void Pip() //ClassB omdefinerer Pip // PRE: p' // POST: q' -- p' og q' er nye betingelser //end Pip //end ClassB Hvis substitutionsprincippet skal overholdes, dvs. at objekter af type ClassB skal kunne anvendes i stedet for objekter af type ClassA, må ClassB.pip() s specifikation opfylde: p p, dvs. hvis ClassA.Pip() s PRE er opfyldt, så er ClassB.Pip() s det også q q, dvs. resultatet af ClassB.Pip() opfylder mindst samme betingelser som resultatet af ClassA.Pip() Substitutionsprincippet er overholdt, hvis man ved omdefinering af operationer kun afsvækker PRE-betingelser og strammer POST-betingelser. Et eksempel Antag, at vi i forbindelse med udarbejdelse af en grafisk editor har fundet en klasse Shape, som beskriver en vilkårlig lukket figur i et vindue på skærmen. I C# er Shape defineret som følger: public class Shape private int x,y; // figurens position private Colour color; //figurens farve //øvrige attributter Side 3
public void MoveTo(int newx, int newy) //PRE: 0 <= newx <= maxx AND 0 <= newy <= maxy, // hvor maxx og maxy angiver vinduets maksimum // POST: x'=newx AND y'=newy public virtual float Area() //PRE: none //POST: Area'= figurens areal med 4 decimalers nøjagtighed // beregnet efter en eller anden tilnærmet metode //end Shape Antag videre, at vi har fundet behov for en klasse Circle, som definerer det grafiske objekt cirkel. Det er naturligt at definere Circle som en specialisering af Shape. I C# som en arving: public class Circle: Shape private int r; //radius - x, y og color arves public override float Area() //øvrige operationer - MoveTo() arves //end circle For en cirkel er vi i stand til at give en bedre implementation af Area() vha den velkendte formel: a= π r 2. Area() s POST-betingelse og substitutionsprincippet forhindrer os imidlertid i at anvende fx. 3.14 som tilnærmelse for π, idet Circle.Area() skal opfylde kravet om 4 decimalers nøjagtighed. Derimod må vi gerne anvende 3.1415927 som π, for i så fald kan vi beregne arealet med en nøjagtighed på 6 decimaler, hvilket er en strammere betingelse end 4 decimaler. Dette er vigtigt af hensyn til programmers modificérbarhed. Lad os antage, at vi har en stump program, som beregner et sammensat billedes areal ved at sweepe gennem en sekvens af objekter af typen Shape og kalde deres Area(). Denne programstump skal kunne fungere ligeså godt som tidligere, (dvs. med mindst samme nøjagtighed), hvis der i sekvensen substitueres objekter af type Circle ind i stedet for objekter af type Shape. Antag nu, at vi ønsker at omdefinere MoveTo(), så det er muligt at flytte cirkler udenfor vinduet, dvs. vi vil ændre Shape.MoveTo() s PRE-betingelse, så Circle.MoveTo() får PRE-betingelse none. Dette er i overenstemmelse med substitutionsprincippet, idet none er en svagere betingelse end den oprindelige. Det vil da heller ikke genere programmer, som er baseret på, at objekter kun kan flyttes indenfor vinduet, at de nu også - i andre sammenhænge - kan flyttes uden for vinduet. Side 4
Omvendt ville det være et problem, hvis vi bestemte os til, at cirkler kun kan holde til i vinduets nederste halvdel, og derfor gav Circle.MoveTo() PRE-betingelsen: 0<=newX<=maxX AND 0<=newY<=(maxY / 2), idet newy<=(maxy / 2) er en stærkere betingelse end newy<=maxy. Dette ville også give problemer for programmer, som regner med, at objekter kan fare rundt i hele vinduet, hvis der substitueres cirkelobjekter ind. Bemærk, at i C# er det kun muligt at omdefinere operationer, som i baseklassen er defineret virtual. Dvs., at man skal være meget forudseende (nærmest synsk) eller også altid definere metoder virtual, med mindre effektivitetshensyn tvinger en til at bruge statisk binding. Ved omdefinering skal nøgleordet override anvendes. Et designeksempel Til slut et eksempel, som illustrerer god brug af arv i forbindelse med objektorienteret design. Vi fortsætter med at betragte vores grafiske editor: Vi har fundet klasser som Circle, Box, Triangle, Position, Picture og Shape. Klassen Position beskriver på passende måde en figurs position, fx. ved et koordinatsæt, et omskrevet rektangel eller noget andet. Klassen Picture beskriver en sammensat figur, og kan således indeholde en række forskellige figurerer, hvoraf nogle kan være cirkler, trekanter mv., men også selv være sammensatte. Dvs. at Picture aggregerer Shape. Klassen Shape er en generalisering af de konkrete figurer og samler fælles egenskaber og adfærd. Klassen er abstrakt, dvs der kan ikke forekomme objekter af denne klasse, bla. fordi vi ikke er i stand til at implementere operationer til at skjule og vise generelle figurer, kun de konkrete så som cirkler og trekanter. Klassen aggregerer en Position, som nedarves til de konkrete figurer. Følgende struktur er fundet: Side 5
I C# kunne (dele af) klassedefinitionerne se ud som følger: namespace Composite public class Position private int x, y; public Position(int x, int y) this.x = x; this.y = y; public int X get return x; set x = value; public int Y get return y; set y = value; abstract public class Shape protected Position pos; //figurens position protected char color; //figurens farve public Position Position get throw new System.NotImplementedException(); Side 6
set //øvrige attributer public virtual void MoveTo(Position newpos) // PRE none // POST pos'=newpos public abstract void Show(); // Abstrakt operation // - kan ikke implementeres for en vilkårlig figur. // PRE none // POST figuren er tegnet public abstract void Hide(); // Abstrakt operation // - kan ikke implementeres for en vilkårlig figur. // PRE none // POST figuren er skjult // øvrige operationer //end Shape public class Circle: Shape private int r; //radius //øvrige attributter - pos og color arves public override void Show() //PRE none //POST cirklen er tegnet //Denne operation kan nu implementeres for en cirkel //ved hjælp af en passende grafikrutine. Side 7
public override void Hide() //PRE none //POST cirklen er skjult //Denne operation kan nu implementeres for en cirkel //ved hjælp af en passende grafikrutine. // øvrige operationer - MoveTo() arves //end Circle; //andre figurer kan defineres på tilsvarende måde public class Picture: Shape //passende repræsentation af en samling af figurer: private ArrayList shapes; public Shape Shape get throw new System.NotImplementedException(); set // constructor // operationer til at tilføje og slette figurer mv. public override void Show() //PRE none //POST den sammensatte figur er tegnet foreach(shape s in shapes) s.show(); Side 8
public override void Hide() //PRE none //POST den sammensatte figur er skjult foreach(shape s in shapes) s.hide(); public override void MoveTo(Position newpos) //PRE none //POST pos'=newpos foreach(shape s in shapes) s.moveto(newpos); //end Picture Som det (forhåbentlig) ses er dette design meget modificérbart: nye operationer som Rotate() og Scale() kan defineres efter samme opskrift som MoveTo(). Nye figurtyper, fx. Ellipsis defineres som arving til Shape og implementerer Show() og Hide() på passende vis. Alt andet er uændret. Krav om ny funktionalitet og håndtering af nye objekttyper fører kun til tilføjelser til ikke ændringer i den eksisterende kode! Dette er stærkt! Læg mærke til, at designets styrke hviler på den abstrakte klasse Shape. Den er et eksempler på, hvad der nogle gange kaldes "geniale abstrakte" klasser, dvs. klasser som ikke kommer direkte fra objekter i problemområdet. I dette tilfælde er klassen ret oplagt, og der kræves næppe den store genialitet for at finde den. Men bemærk, at det ofte er denne type klasser, som gør et design virkeligt modificérbart, og at det kræver om ikke genialitet, så i hvert fald kreativitet, erfaring og talent af designeren at finde disse klasser. I sandhedens interesse skal det nævnes, at det ikke er denne forfatter, som er ophavsmand til eksemplet og den tilhørende geniale klasse. Det findes i [Bar-David] i en lignende udgave i C++. Endelig skal det nævnes, at designet er en konkretisering af et generelt mønster - et såkaldt design pattern, som kan anvendes i alle problemstillinger, der omhandler rekursive listestrukturer. Problemet er kendt under navnet "den generelle stykliste problematik". Mønstret er beskrevet i [Gamma], der indeholder en række andre almindeligt forekommende designmønstre. Referencer [Liskov]: Babara Liskov: "Data Abstraction and Hierarchy". OOPSLA 87 Addendum to the Procedings. Side 9
[Bar-David]: Tsvi Bar-David: "Object-Oriented Design for C++". Prentice-Hall 1993. [Gamma]: E. Gamma, R. Helm, R. Johnson, J. Vlissides: "Design Patterns - Elements of Resuable Object- Oriented Software". Addison-Wesley 1995. Side 10