Hvordan bryte avhengigheter mellom klasser


mandag 18. mai 2009 OO Patterns C#

Tette koblinger er noe man snakker mye om innen utvikling av software - noe man bør unngå bl.a. fordi det gjør programmene vanskeligere å endre. Og at det vil komme endringer er det eneste sikre i ethvert softwareprosjekt.

I denne artikkelen forsøker jeg å illustrere et helt vanlig scenario med tett kobling mellom to klasser. Jeg snakker litt om hva to av SOLID-prinsippene sier om dette, og viser hvordan avhengighetene kan løses opp ved å benytte et par velkjente design patterns.

I dette fiktive eksempelet skal jeg implementere en del av programvaren til en mobiltelefon. Man må kunne taste inn et telefonnummer, og når nummeret er tastet skal telefonen ringe opp nummeret. Jeg ser da for meg at jeg behøver en klasse for å representere en knapp: Button. Når en knapp blir trykket på, sender den avgårde sifferet knappen representerer til en annen klasse som skal ringe nummeret; jeg kaller denne klassen for Dialer.

Dependencies1

Disse to klassene danner et typisk klient-tjener forhold: Button bruker Dialer som en tjeneste, og har en sterk knytning og avhengighet til den, f.eks. ved at den holder en referanse til et Dialer objekt, slik som dette:

public class Button

{

    private Dialer _dialer;

    public Button(Dialer dialer)

    {

        _dialer = dialer;

    }

}

Denne harde koblingen er lite fleksibel, og fører til kode som er vanskelig å endre eller utvide. Det finnes flere prinsipper innen objektorientering som kan brukes til å identifisere problemet. Ta for eksempel dette:

The Open/Close Principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. (spec)

Om du ikke kjenner til OCP fra før er det ikke helt lett å skjønne hva man mener med "åpen for utvidelse men lukket for endringer". Men se på Button-klassen. Den er som sagt hardt knyttet til Dialer, og skulle vi ønske å benytte en annen dialer, så må vi endre Button. Butten er altså ikke åpen for denne typen utvidelser.

Et annet prinsipp som sier noe om hva vi kan gjøre med dette er DIP:

The Dependency-Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend upon abstractions. (spec)

For å gjøre noe med dette kan vi bruke det vi kaller for STRATEGY PATTERN - vi definerer et interface som Button kan være avhengig av istedet. Mange ville f.eks. opprettet et IDialer interface, slik som dette:

public interface IDialer { /* some interface */ }

public class Dialer : IDialer { /* inplementation */ }

public class Button

{

    private IDialer _dialer;

    public Button(IDialer dialer)

    {

        _dialer = dialer;

    }

}

Nå kan man utvide med flere typer Dialers uten at man må endre Button-klassen. Men.., det er noe som skurrer litt. Button føles fortsatt for hardt knyttet mot dette Dialer-konseptet. Det kan jo godt tenkes knapper skal benyttes til helt andre ting enn å ringe (som å sende SMS, spille spill o.l.).

Løsningen er enkel men genial. Interfacer har nemlig mye mer med klienten i avhengighetsforholdet å gjøre (i dette eksempelet Button) enn med klassene som implementerer dem (Dialer). En Dialer er noe som er interessert i å få vite når en knapp blir trykket på. Hvis vi lar Button eie interfacet ved å døpe det om til å hete noe sånt som IButtonListener, og lar Dialer implementere dette, så åpner vi opp for å lage andre klasser som også kan lytte til knappetrykk - uten at de har noe med det å ringe å gjøre - og det uten at vi må modifisere hvordan knappen er implementert.

Dependencies2

public class Button

{

    private IButtonListener _buttonListener;

    public Button(IButtonListener buttonListener)

    {

        _buttonListener = buttonListener;           

    }

}

Nå er Button helt uavhengig av alt som har med Dialer å gjøre, selv om den under kjøring av programmet vil motta en referanse til et Dialer-objekt. Og fra knappens ståsted virker dette veldig fornuftig.

Dialer derimot er avhengig av å implementere IButtonListener. Vil vi ta løsningen vår et steg videre kan vi fjerne denne avhengigheten også. For å gjøre det kan vi bruke et annet mønster som kalles ADAPTER PATTERN. Det bruker man bl.a. om man vil skjule/konvertere et interface, f.eks. om man ikke har mulighet til å endre en klasse som Dialer, eller som i dette tilfellet når man ikke ønsker å rote til Dialer med referanser til Button-konseptet.

Dependencies3

Vi oppretter da en ButtonDialerAdapter-klasse. Denne adapteren lar vi arve fra IButtonListener, og det er den klassen Button nå vil få en instans av under kjøring. ButtonDialerAdapter vil i sin tur ha en referanse til Dialer, og oversetter knappetrykket til den korresponderende meldingen/funksjonskallet i Dialer.

public interface IButtonListener { /* some interface */ }

public class Dialer { /* implementation */ }

public class Button

{

    private IButtonListener _buttonListener;

    public Button(IButtonListener buttonListener)

    {

        _buttonListener = buttonListener;

    }

}

public class ButtonDialerAdapter : IButtonListener

{

    private Dialer _dialer;

    public ButtonDialerAdapter(Dialer dialer)

    {

        _dialer = dialer;

    }

}

Button har fortsatt bare en avhengighet mot IButtonListener, som gir fullstendig mening. Og Dialer har nå ingen avhengigheter mot noe som har med Button å gjøre. ButtonDialerAdapter avhenger av Dialer og IButtonListener. Ved å bruke strategy pattern og adapter pattern har vi tilfredstilt både Open/Close-prinsippet (OCP) og Dependendcy-Invertion (DIP).

OCP er på mange måter selve kjernen i objektorientert design. Ved å følge det kan man oppnå fleksibilitet, gjenbruk og vedlikeholdbar kode. Man sier også at å snu avhengighetene (DIP) er det som skiller objektorientert design fra prosedyre/transaksjonsbasert design.

Det er derimot ikke lurt å innføre abstraksjoner (interfacer og abstrakte klasser) over alt i koden. Utvikleren må passe på å bruke abstraksjoner de stedene i programmet som endres ofte. Robert C. Martin sier blant annet:

Confirming to OCP is expensive. It takes development time and effort to create the appropriate abstractions. These abstractions also increase the complexity of the software design. There is a limit to the amount of anstractions that the developers can afford. Clearly, we want to limit the application of OCP to changes that are likely.

Så hvordan bestemmer man seg for når man skal bruke prinsippene? Vi gjør nødvendige undersøkelser, vi stiller de nødvendige spørsmålene, og vi bruker vår erfaring og sunn fornuft. Og så venter vi til endringene inntreffer! Onkel Bob sier nemlig videre:

"Fool me once, shame on you. Fool me twice, shame on me." This is a powerful attitude in software design. To keep from loading our software with needless complexity, we may permit ourselves to be fooled once. This means that we initially write our code expecting it not to change. When a change occurs, we implement the abstractions that protect us from future changes of that kind. In short, we take the first bullet and then make sure that we are protected from any more bullets coming from that particular gun.

Men jeg tror likevel man må øve på å bruke disse prinsippene om man skal se behovet når det dukker opp. Og man må også kunne overføre erfaring om endringer fra et utviklingsprosjekt til et annet. For flere synspunkter på akkurat dette kan du se kommentarene etter posten min om når man skal bruke SOLID prinsippene.

Les også: T-Man tipser om specification pattern

Denne artikkelen ble til under inspirasjon fra Robert C. Martins bok Agile Principles, Patterns, and Practices in C# (2006).


comments powered by Disqus