Sammansättning över arv

Detta diagram visar hur ett djurs flug- och ljudbeteende kan utformas på ett flexibelt sätt genom att använda designprincipen komposition ö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 är solid , rörlig och synlig
  • klass Cloud - som är rörligt och synligt , men inte Solid
  • klass Byggnad - som är solid och synlig , men inte rörlig
  • klass Trap - som är Solid , men varken synlig eller rö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"

Se även