This is an archived page. Back to the blog.
C++-arkivet
 

Interfaceskolan del 2:
Infria nyårslöftet, gör dig av med dina beroenden!

Vi börjar med tipset:

Använd interfaceklasser för att göra klasser ovetandes om varandra. Du får färre beroenden i ditt program och därmed:

  • kortare kompileringstider
  • klasser som är lättare att återanvända
  • kod med bra struktur som är lätt att bygga vidare på
Tips: Använd interfaceklasser för att bryta beroenden mellan klasser.

Och en användbar observation om beroenden:

Klassen A definieras i a.hpp och klassen B definieras i b.hpp.
Filen a.hpp måste göra #include på b.hpp när:

  1. Klassen A använder B som basklass.
  2. Klassen A har en medlemsvariabel som är en instans av B.
  3. Klassen A använder medlemsfunktion eller -variabel eller annan del av B.
I övriga fall kan du undvika #include mha forwarddeklaration!
Tips: De tre situationer som medför att du måste inkludera en annan fil.



Olika beroenden

Vad är ett beroende? Kort kan man säga att X beror på Y om X måste ändras när Y ändras. Ett klassiskt beroende är mellan objektfil (t.ex. a.obj) och headerfil (t.ex. a.hpp). Ändrar du på headerfilen a.hpp (= Y) måste implementationsfilen a.cpp kompileras om och då skapas en ny objektfil a.obj (= X). Ett annat exempel är beroendet mellan ditt exekverbara program och dina objektfiler. Ändras din objektfil a.obj (= Y) måste du länka om ditt program för att skapa a.exe (= X).

Vi ser att man kan få kedjor av beroenden: om vi ändrar i a.hpp ovan måste a.cpp kompileras om för att skapa a.obj och då måste a.exe länkas om. Varje beroende man skapar ökar kompileringstiden. Har man få filer kanske detta inte upplevs som ett problem, men tänk på att obehaget skalar linjärt. Har du tio implementationsfiler som gör #include "a.hpp" när klassen A bara används i en cpp-fil så tar din kompilering tio gånger så lång tid när du ändrar a.hpp. Det är därför man brukar rekommendera att ha en klass per headerfil, att samla enum:ar i små filer m.m. Ju mindre innehåll i varje headerfil, desto färre onödiga beroenden.

ankare och kedja

När uppstår beroenden?

Den typ av beroenden vi beskrev ovan uppstår när man gör #include. Som vi såg är beroenden någonting vi vill undvika. Ibland är vi dock tvungna att inkludera andra filer. Antag att vi har två klasser A och B i filerna a.hpp och b.hpp. Tabell 1 listar de tre situationer där a.hpp måste inkludera b.hpp:

Klassen A definieras i a.hpp och klassen B definieras i b.hpp.
Filen a.hpp måste göra #include på b.hpp när:

  1. Klassen A använder B som basklass.
  2. Klassen A har en medlemsvariabel som är en instans av B.
  3. Klassen A använder medlemsfunktion eller -variabel eller annan del av B.
Tabell 1: De tre situationer som medför att du måste göra #include på en annan fil.

I de fall som inte räknas upp i Tabell 1 behöver a.hpp inte göra #include på b.hpp! Istället lägger vi till en (framåt)deklaration av klassen B i a.hpp genom att skriva class B; (engelska: forward declare). Vi tar ett exempel: Antag att klassen A deklarerar en medlemsfunktion B bar(void), dvs en funktion som returnerar ett objekt av typen B. Det räcker då att vi deklarerar B! Funktionen bar implementerar vi sedan i a.cpp. Eftersom bar skapar en instans av B så måste vi ha tillgång till Bs definition. Därför måste a.cpp inkludera b.hpp.

Vi har alltså fått bort beroendet från a.hpp till b.hpp. Men beroendet mellan klassen A och klassen B har inte försvunnit, det har bara flyttat sig. Nu är det istället a.cpp som beror på a.hpp. Det är i och för sig en förbättring eftersom vi förmodligen förkortar våra kompileringstider. 

Vissa typer av beroenden hindrar oss från att skriva bra kod. Och som vi såg tar framåtdeklarationen inte bort alla beroenden. Vill vi verkligen ha bort beroendet så måste vi ta till andra medel. Så hur gör vi?

Vissa beroenden kan vi inte leva med!

Först måste vi förstå varför det är så viktigt att kunna ta bort beroendet mellan två klasser. Det gör vi lättast med ett exempel. Låt säga att du vill skriva ett program som ska prata med andra program över internet över något protokoll (t.ex. HTML, SIP eller dyl.). Du vill att ditt program ska kunna skicka förfrågningar mha ditt protokoll och sedan ta emot svar. Du inser att du förmodligen vill skriva fler program i framtiden som pratar samma protokoll. Därför behöver du protokollkod som är skriven för att återanvändas.

För enkelhetens skull låter vi ditt program representeras av en klass Application och din protokollkod av en klass Protocol. Applikationsklassen ber protokollklassen att skicka en förfrågan. Det tar sedan en stund tills svaret kommer. Under tiden applikationen väntar måste den kunna skicka fler förfrågningar (omsända gamla förfrågningar som inte fått något svar eller skicka nya till andra program). Du kan därför inte låta ditt program vara inaktivt tills det fått svar. Istället får protokollklassen kontakta applikationen när den fått svar!

Protokollklassen ska alltså prata med applikationsklassen (t.ex. genom att anropa en funktion gotResponse). Vi får därmed ett beroende. Det spelar ingen roll om protocol.hpp gör #include på application.hpp eller om den deklarerar med class Application;. Protokollklassen är ändå låst till precis en applikationsklass som heter Application. Din kod går inte att återanvända på ett lätt sätt. Så vad göra? Och då äntligen till ämnet: interfaceklasser!

Interfaceklasser bryter beroendet

I den första delen i artikelserien om interfaceklasser pratade vi om vad ett interface var. Interfacet beskriver vad som ska göras, men inte hur det ska göras. I detta fall vet vi att någon vill ha ett anrop (engelska: callback) när ett svar kommer. Vi vill dock undvika att bestämma vem som ska ta emot anropet. Vi inför en interfaceklass ProtocolCallback:

class ProtocolCallback
{
public:
    virtual ~ProtocolCallback() = 0;
    virtual void gotResponse() = 0;
};

Kodexempel 1: Gränssnitt för protokollklassen.

Vi låter sedan applikationen ärva från ProtocolCallback:

class Application : public ProtocolCallback
{
public:
    virtual void gotResponse();
    /* ... */
};

Kodexempel 2: Applikationen uppfyller det gränssnitt protokollinterfacet kräver.

Applikationen skickar sin förfrågan genom att anropa en funktion sendRequest i protokollklassen. För att protokollklassen ska veta vem som vill ha svaret måste vi hjälpa till. Vi skickar med ett argument av typen ProtocolCallback. Resultatet blir något i stil med:

class Application : public ProtocalCallback
{
public:
    void run()
    {
        // skicka med applikationen som argument
        protocol.sendRequest(*this);
    }
    /* ... */
private:
    Protocol protocol;
};

Kodexempel 3: Applikationen vill ha svaret när den skickar en förfrågan över protokollet. Därför skickar den med sig själv till protokollklassen.

När protokollklassen tar emot anropet till sendRequest sparar den undan en referens till klassen ProtocolCallback. I detta fall döljer sig ett objekt av typen Application bakom referensen. På så sätt blir protokollklassen helt ovetandes om applikationen och din kod kan återanvändas! Referensen används sedan för att hitta tillbaka till den som vill ha svaret.

Dela upp ditt program i lager

Mellan applikationsklassen och protokollklassen finns alltså bara beroenden i ena riktningen. På samma sätt kan man titta på beroenden mellan alla klasser i ditt program. Förhoppningsvis hittar du då klasser som beror på få eller inga andra klasser. De brukar ofta vara klasser som många andra delar av ditt program använder, såsom hjälpklasser för stränghantering eller felutskrift.

Vi tänker oss dessa hjälpklasser som byggstenar som resten av ditt program vilar på. Ovanpå dessa klasser bygger vi nya hjälpklasser och längst upp lägger vi vår applikation. Observera att inga beroenden får finnas mellan en nivå av byggklossar och de nivåer som ligger ovanpå. Vi kallar dessa nivåer för lager (engelska: layers).

Det understa lagret (lager 0) är helt fristående från resten av programmet. Därför går det även att återanvända om det är bra skrivet. Lager 1 ligger ovanpå lager 0 (och behöver lager 0 för att kompilera). Därmed kan lager 1 återanvändas, bara man har tillgång till lager 0.

Det är nyttigt att tänka igenom alla dina klasser innan du skapar ditt program! Märker du t.ex. att alla klasser hamnar i samma lager (t.ex. för att alla klasser beror på varandra i en cirkel) bör du fundera på varför. Om t.ex. hjälpklasserna för felutskrifter beror på applikationen är det något som inte står rätt till. Kanske behöver du införa ett interface för att bryta beroendet! Observera att det inte är ovanligt med beroenden inom ett och samma lager, t.o.m. cirkulära beroenden.

Använd interfaceklasser för att göra klasser ovetandes om varandra. Du får färre beroenden i ditt program och därmed:

  • kortare kompileringstider
  • klasser som är lättare att återanvända
  • kod med bra struktur som är lätt att förstå och bygga vidare på
Tabell 2: Använd interfaceklasser för att bryta beroenden mellan klasser.

Till sist

Du har nu tagit bort alla beroenden från ditt protokoll till din applikation. Men det ska erkännas att din .exe-fil fortfarande beror på klassen Application. Det du har vunnit är att du kan byta ut Application mot något annat. I nästa artikel om interfaceklasser ska vi prata om varför det är så bra att kunna byta klasser. Artikeln handlar om hur du får bättre kvalitet på dina program genom bättre testbarhet!

Hoppas att detta hjälper dig skriva bättre program i framtiden.

Lycka till att bli av med dina beroenden!



Relaterade artiklar: