Sammansättning över arv
Komposition över arv (eller sammansatt återanvändningsprincip ) i objektorienterad programmering (OOP) är principen att klasser ska uppnå polymorft beteende och kodåteranvändning genom sin sammansättning (genom att innehålla instanser av andra klasser som implementerar önskad funktionalitet) snarare än arv från en bas eller föräldraklass. Detta är boken OOP en ofta uttalad Design princip för , som i den inflytelserika Patterns (1994) .
Grunderna
En implementering av komposition framför arv börjar vanligtvis med skapandet av olika gränssnitt som representerar de beteenden som systemet måste uppvisa. Gränssnitt kan underlätta polymorft beteende. Klasser som implementerar de identifierade gränssnitten byggs och läggs till i affärsdomänklasser efter behov. Således realiseras systembeteenden utan arv.
Faktum är att affärsdomänklasser alla kan vara basklasser utan något arv alls. Alternativ implementering av systembeteenden åstadkommes genom att tillhandahålla en annan klass som implementerar det önskade beteendegränssnittet. En klass som innehåller en referens till ett gränssnitt kan stödja implementeringar av gränssnittet – ett val som kan fördröjas till körning .
Exempel
Arv
Ett exempel i C++ följer:
class Object { public : virtual void update () { // no-op } virtual void draw () { // no-op } virtual void collide ( Object objects []) { // no-op } }; klass Visible : public Object { Model * model ; public : virtual void draw () åsidosätt { // kod för att rita en modell vid positionen för detta objekt } } ; class Solid : public Object { public : virtual void collide ( Objektobjekt []) åsidosätter { // kod för att leta efter och reagera på kollisioner med andra objekt } } ; class Movable : public Object { public : virtual void update () override { // code to update position of this object } } ;
Anta sedan att vi också har dessa konkreta klasser:
- klassspelare
-
som ärsolid
,
rörlig
och synlig - klass
Cloud
- som ärrörligt
ochsynligt
, men inteSolid
- klass
Byggnad
- som ärsolid
ochsynlig
, men interörlig
- klass
Trap
- som ärSolid
, men varkensynlig
ellerrörlig
Observera att multipelt arv är farligt om det inte implementeras noggrant eftersom det kan leda till diamantproblemet . En lösning på detta är att skapa klasser som VisibleAndSolid
, VisibleAndMovable
, VisibleAndSolidAndMovable
, etc. för varje nödvändig kombination; detta leder dock till en stor mängd upprepad kod. C++ använder virtuellt arv för att lösa diamantproblemet med multipelt arv.
Komposition och gränssnitt
C++-exemplen i det här avsnittet visar principen att använda komposition och gränssnitt för att uppnå kodåteranvändning och polymorfism. På grund av att C++-språket inte har ett dedikerat nyckelord för att deklarera gränssnitt, använder följande C++-exempel arv från en ren abstrakt basklass . För de flesta ändamål är detta funktionellt likvärdigt med gränssnitten på andra språk, som Java och C#.
Introducera en abstrakt klass som heter VisibilityDelegate
, med underklasserna NotVisible
och Visible
, som ger ett sätt att rita ett objekt:
0
class VisibilityDelegate { public : virtual void draw () = ; }; class NotVisible : public VisibilityDelegate { public : virtual void draw () override { // no-op } }; class Visible : public VisibilityDelegate { public : virtual void draw () override { // kod för att rita en modell vid positionen för detta objekt } } ;
Introducera en abstrakt klass som heter UpdateDelegate
, med underklasserna NotMovable
och Movable
, som ger ett sätt att flytta ett objekt:
0
class UpdateDelegate { public : virtual void update () = ; }; class NotMovable : public UpdateDelegate { public : virtual void update () override { // no-op } }; class Movable : public UpdateDelegate { public : virtual void update () override { // code to update position of this object } };
Introducera en abstrakt klass som heter CollisionDelegate
, med underklasserna NotSolid
och Solid
, som ger ett sätt att kollidera med ett objekt:
0
class CollisionDelegate { public : virtual void collide ( Objektobjekt [] ) = ; }; class NotSolid : public CollisionDelegate { public : virtual void collide ( Object objects []) override { // no-op } }; class Solid : public CollisionDelegate { public : virtual void collide ( Objektobjekt []) åsidosätt { // kod för att leta efter och reagera på kollisioner med andra objekt } } ;
Till sist, introducera en klass som heter Object
med medlemmar för att kontrollera dess synlighet (med en VisibilityDelegate
), rörlighet (med en UpdateDelegate
) och soliditet (med en CollisionDelegate
). Den här klassen har metoder som delegerar till sina medlemmar, t.ex. update()
anropar helt enkelt en metod på UpdateDelegate
:
class Object { VisibilityDelegate * _v ; UpdateDelegate * _u ; CollisionDelegate * _c ; public : Object ( VisibilityDelegate * v , UpdateDelegate * u , CollisionDelegate * c ) : _v ( v ) , _u ( u ) , _c ( c ) {} void update () { _u -> update (); } void draw () { _v -> draw (); } void kolliderar ( Objektobjekt []) { _c -> kolliderar ( objekt ) ; } };
Då skulle betongklasser se ut så här:
class Player : public Object { public : Player () : Object ( new Visible (), new Movable (), new Solid ()) {} // ... }; class Smoke : public Object { public : Smoke () : Object ( new Visible (), new Movable (), new NotSolid ()) {} // ... };
Fördelar
Att gynna komposition framför arv är en designprincip som ger designen högre flexibilitet. Det är mer naturligt att bygga affärsdomänklasser av olika komponenter än att försöka hitta en gemensamhet mellan dem och skapa ett släktträd. Till exempel delar en gaspedal och en ratt väldigt få gemensamma egenskaper , men båda är viktiga komponenter i en bil. Vad de kan göra och hur de kan användas till nytta för bilen är lätt att definiera. Sammansättning ger också en mer stabil affärsdomän på lång sikt eftersom den är mindre benägen för familjemedlemmarnas egenheter. Med andra ord är det bättre att komponera vad ett objekt kan göra ( har-a ) än att utöka vad det är ( är-a ).
Initial design förenklas genom att identifiera systemobjektbeteenden i separata gränssnitt istället för att skapa en hierarkisk relation för att fördela beteenden mellan affärsdomänklasser via arv. Detta tillvägagångssätt tillgodoser lättare framtida kravförändringar som annars skulle kräva en fullständig omstrukturering av affärsdomänklasser i arvsmodellen. Dessutom undviker den problem som ofta är förknippade med relativt små förändringar av en arvsbaserad modell som inkluderar flera generationer av klasser. Sammansättningsrelationen är mer flexibel eftersom den kan ändras under körning, medan subtypningsrelationer är statiska och behöver omkompileras på många språk.
Vissa språk, särskilt Go och Rust , använder uteslutande typkomposition.
Nackdelar
En vanlig nackdel med att använda komposition istället för arv är att metoder som tillhandahålls av enskilda komponenter kan behöva implementeras i den härledda typen, även om de bara är vidarebefordransmetoder (detta är sant i de flesta programmeringsspråk, men inte alla; se § Undvika nackdelar ). Däremot kräver arv inte att alla basklassens metoder återimplementeras inom den härledda klassen. Snarare behöver den härledda klassen bara implementera (åsidosätta) metoder som har ett annat beteende än basklassmetoderna. Detta kan kräva betydligt mindre programmeringsansträngning om basklassen innehåller många metoder som ger standardbeteende och endast ett fåtal av dem behöver åsidosättas inom den härledda klassen.
Till exempel, i C#-koden nedan ärvs variablerna och metoderna för basklassen Employee
av underklasserna HourlyEmployee
och SalariedEmployee .
Endast metoden Pay()
behöver implementeras (specialiserad) av varje härledd underklass. De andra metoderna implementeras av själva basklassen och delas av alla dess härledda underklasser; de behöver inte implementeras på nytt (åsidosättas) eller ens nämnas i underklassdefinitionerna.
// Basklass offentlig abstrakt klass Anställd { // Egenskaper skyddad sträng Namn { get ; set ; } protected int ID { get ; set ; } skyddad decimal PayRate { get ; set ; } protected int HoursWorked { get ; } // Få lön för den aktuella löneperioden public abstract decimal Pay (); } // Härledd underklass public class HourlyEmployee : Employee { // Få lön för den aktuella löneperioden public override decimal Pay () { // Arbetad tid är i timmar retur HoursWorked * PayRate ; } } // Härledd underklass offentlig klass Avlönad Anställd : Anställd { // Få lön för den aktuella löneperioden offentlig åsidosättande decimal Lön () { // Lönesatsen är årslön istället för timlön avkastning HoursWorked * PayRate / 2087 ; } }
Att undvika nackdelar
Denna nackdel kan undvikas genom att använda egenskaper , mixins , (typ) inbäddning eller protokolltillägg .
Vissa språk tillhandahåller specifika sätt att mildra detta:
- C# tillhandahåller standardgränssnittsmetoder sedan version 8.0 som gör det möjligt att definiera kropp till gränssnittsmedlem.
- D tillhandahåller en explicit "alias detta"-deklaration inom en typ som kan vidarebefordra till den varje metod och medlem av en annan innesluten typ.
- Dart tillhandahåller mixins med standardimplementationer som kan delas.
- Go -typ inbäddning undviker behovet av vidarebefordransmetoder.
-
Java tillhandahåller standardgränssnittsmetoder sedan version 8. Project Lombok stöder delegering med
@Delegate-
kommentaren på fältet, istället för att kopiera och underhålla namnen och typerna av alla metoder från det delegerade fältet. - Julia- makron kan användas för att generera vidarebefordransmetoder. Det finns flera implementeringar som Lazy.jl och TypedDelegation.jl.
- Kotlin inkluderar delegeringsmönstret i språksyntaxen.
- PHP stöder egenskaper , eftersom PHP 5.4.
-
Raku tillhandahåller en
handtagsegenskap
för att underlätta vidarebefordran av metoder. - Rust ger egenskaper med standardimplementeringar.
- Scala (sedan version 3) tillhandahåller en "export"-sats för att definiera alias för utvalda medlemmar av ett objekt.
- Swift- tillägg kan användas för att definiera en standardimplementering av ett protokoll på själva protokollet, snarare än inom en enskild typs implementering.
Empiriska studier
En studie från 2013 av 93 Java-program med öppen källkod (av varierande storlek) fann att:
Även om det inte finns någon stor möjlighet att ersätta arv med sammansättning (...), är möjligheten betydande (medianen på 2 % av användningarna [av arvet] är endast intern återanvändning, och ytterligare 22 % är endast extern eller intern återanvändning). Våra resultat tyder på att det inte finns något behov av oro angående missbruk av arv (åtminstone i Java-programvara med öppen källkod), men de lyfter fram frågan om användning av sammansättning kontra arv. Om det finns betydande kostnader förknippade med att använda arv när sammansättning kan användas, så tyder våra resultat på att det finns någon anledning till oro.
— Tempero et al. , "Vad programmerare gör med arv i Java"