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

Interfaceskolan del 1:
Hur du använder interfaceklasser för färre fel i din objektorienterade programkod!

Vi börjar med tipset:

Ett interface är en basklass med bara strikt virtuella funktioner (pure virtual).
Interface gör det svårt att använda dina klasser på fel sätt:

  1. Använd interface för att stycka upp gränssnittet hos din klass i mindre delar.
  2. Skicka sedan runt ett interface istället för hela din klass så exponerar du bara precis de funktioner du vill!
Tips: Exponera bara valda delar av din klass med objektorienterade interface.



Kort om interface innan vi kör igång

Låt säga att du skriver ett program som hanterar en massa anställda. Det finns olika typer av anställda såsom chefer och programmerare, och de representeras av varsin klass. Varje anställd representeras av ett objekt. Gemensamt för alla anställda är att de kan beräkna sin lön med funktionen calculateSalary(). Man vill kunna lägga alla anställda i en lång lista (t.ex. en vektor) för att kunna beräkna allas lön inför lönedagen varje månad.

gränssnitt

För att kunna lägga de anställda i samma lista måste alla i listan ha samma typ. Denna typ måste förstå anrop till funktionen calculateSalary(). Vad vi behöver här är en basklass (se Kodexempel 1):

class EmployeeInterface
{
public:
    virtual ~EmployeeInterface() = 0;
    virtual double calculateSalary() const = 0;
};

Kodexempel 1: Gränssnitt som alla anställda måste uppfylla.

Ett interface är en basklass med bara strikt virtuella funktioner (eng: pure virtual, funktionshuvudet avslutas med = 0). Vårt interface EmployeeInterface beskriver vad alla anställda måste kunna göra, dvs beräkna sin egen lön. Generellt kan man säga att ett interface beskriver vad man kan göra utan att beskriva hur man ska göra det!

Eftersom vi inte talar om hur saker och ting ska göras finns inga implementationsdetaljer i ett interface: inga medlemsvariabler och inga funktionsdefinitioner (dvs implementation av funktion, känns igen på måsvingarna { } ). Det enda undantaget är destruktorn som måste ha en definition, annars får du en tillsägelse när du länkar ditt program. Basklassens destruktor anropas ju alltid sist, efter destruktorerna i nedärvda klasser.

Vi sammanfattar egenskaperna hos ett interface i Tabell 1:

Ett interface är en basklass med bara strikt virtuella funktioner.
Ett interface
säger vad man kan göra utan att tala om hur det ska göras!

  1. Vad betyder att interfacet deklarerar de funktioner som behövs.
  2. Att inte tala om hur betyder att interfacet inte innehåller några detaljer om implementationen (dvs inga definitioner).
Tabell 1: Vad är ett interface?

Vi låter vår chefsklass och vår programmerarklass ärva från EmployeeInterface. Sen låter vi listan innehålla pekare till EmployeeInterface. Vi låter pekarna peka på chefs- och programmerarobjekten och anropar calculateSalary() på varje pekare. Vi använder alltså samma anrop på alla pekare, men anropet slutar antingen i ett chefsobjekt eller ett programmerarobjekt. Detta kallas polymorfi som betyder "många former". Polymorfi är det som gör objektorienterad programmering så kraftfullt. Interface är polymorfi i dess renaste form (en hel klass skapad för polymorfi och inget annat).

Tre exempel på användningsområden

Man kan använda interface till många olika saker. Jag har valt att fokusera på tre konkreta exempel:

  1. Du vill göra det svårt att använda dina klasser på fel sätt! Du använder ett interface för att förtydliga kommunikationen mellan två klasser.
  2. Du vill undvika beroenden mellan dina klasser! Du använder interface för att bryta dessa beroenden.
  3. Du vill se till att din kod får färre fel och bättre kvalitet! Du använder interface för att öka testbarheten hos din kod.

Denna artikel kommer bara att behandla den första punkten av de tre: Du vill göra det svårt att använda dina klasser på fel sätt! De andra två punkterna kommer i en senare artikel. Nu kör vi.

Användningsområde 1: Du vill göra det svårt att använda dina klasser på fel sätt!

Låt säga att du har en klass med ett ganska stort gränssnitt. Din klass har många funktioner för olika användningsområden. Trots detta har din klass ett väldefinierat ansvar, så du är nöjd med dess design. Antag t.ex. att du håller på att skriva en databas som i Kodexempel 2:

class Database
{
public:
    // admin functions
    const AdminInfo & getAdminInfo() const;
    void format();

    // user functions
    void writeData(const Key &, const Data &);
    const Data & readData(const Key &) const;

private:
    // ... medlemsvariabler etc
};

Kodexempel 2: Headerfil (.h) för exempeldatabas. Funktionerna finns definierade i implementationsfilen (.cpp).

Vi ser att gränssnittet för databasen består av två delar: en administratörsdel och en användardel. Det är inte helt oproblematiskt som vi snart ska se.

Nu vill någon använda din databas. Han/hon vill skriva en funktion calculateSalary(const Key &employeeNumber, const Database &db) som slår upp den anställde i databasen och beräknar lönen. När vi skickar med databasen får den som implementerar calculateSalary() tillgång till både administratörsdelen och användardelen av gränssnittet. Det är här vi får problem. Naturligtvis är det olämpligt att calculateSalary() läser ut administratörsinformation med getAdminInfo() eller formaterar hårddisken med funktionen format() (om argumentet db inte hade varit const).

Funktionen calculateSalary() får helt enkelt för mycket makt om den får tillgång till hela databasen! Det är direkt olämpligt och väldigt lätt att göra fel. Det kan också hända att calculateSalary() använder sig av delar av administratörsfunktionerna som sedan behöver byta gränssnitt. Dessa ändringar kommer att slå på oväntade ställen i koden med ökade kostnader och risker som följd.

Stycka upp ditt gränssnitt i mindre delar

Så hur ger jag calculateSalary() tillgång till användardelen av gränsnittet utan att ge den tillgång till administratörsgränssnittet? Det finns flera sätt att angripa detta problem, men här handlar det förstås om interface.

Vi pratade om att ett interface beskriver vad som ska göras (men inte hur det ska göras). Alltså skapar vi ett interface som beskriver vad som finns tillgängligt när man som användare vill komma åt databasen (se Kodexempel 3):

class DatabaseUserInterface
{
    virtual ~DatabaseUserInterface() = 0;

    // user functions
    virtual void writeData(const Key &, const Data &) = 0;
    virtual const Data & readData(const Key &) const = 0;
};

Kodexempel 3: Interface som beskriver vilka funktioner i databasen en användare kan komma åt. Funktionerna writeData och readData saknar definition!

Lägg märke till att alla funktioner i interfacet är strikt virtuella. Lägg också märke till att vi måste ha en virtuell destruktor om vi vill använda vårt interface som basklass. Annars är vårt program ogiltigt (standarden säger "undefined behavior" vid delete på nedärv klass).

Det är knappt några ändringar alls som behövs i vår databasklass. Vi ärver bara in vårt nya interface (se Kodexempel 4). Vill vi vara extra tydliga kan vi ändra i Database och lägga till virtual på funktionerna writeData() och readData(), men det är valfritt. Även utan virtual blir writeData() och readData() virtuella eftersom de är virtuella i basklassen.

class Database :
    public DatabaseUserInterface    // ärv interface
{
public:
    // admin functions
    const AdminInfo & getAdminInfo() const;
    void format();

    // user functions, overridden from DbUserInterface
    virtual void writeData(const Key &, const Data &);
    virtual const Data & readData(const Key &) const;

private:
    /* medlemsvariabler etc */
};

Kodexempel 4: Uppdaterad headerfil för exempeldatabas.

Exponera precis så lite du behöver av ditt gränssnitt

Hur gör vi nu när vi vill använda vårt interface istället för hela databasklassen? Vi låter kompilatorn konvertera databasen till en referens till basklassen genom t.ex. Database db; DatabaseUserInterface &dbIf = db;. Utan att oroa oss kan vi sedan ge referensen dbIf till alla delar av koden som behöver användardelen av gränssnittet.

Om man bara har tillgång till interfacet har man inte så mycket val när man ska skriva calculateSalary och skapar därför en calculateSalary(const Key &employeeNumber, const DatabaseUserInterface &db). Man tvingas använda användardelen av gränssnittet och calculateSalary() kan nu inte komma åt funktionerna getAdminInfo() och format(). Din kod blir lättare att förstå tack vare det smalare gränssnittet och det blir väldigt mycket svårare att göra fel. Vi sammanfattar hur vi gått tillväga i Tabell 2:

Interface kan användas till många saker, bl.a. för att skriva mer lättanvända klasser:

  1. Använd interface för att stycka upp gränssnittet hos din klass i lämpliga delar.
  2. Skicka sedan runt ett interface istället för hela din klass så exponerar du bara precis de funktioner du vill!
Tabell 2: Exponera bara valda delar av din klass med objektorienterade interface.

CalculateSalary är ett bra exempel på hur statisk typning samarbetar med dynamisk uppslagning. Den statiska typen i calculateSalary är DatabaseUserInterface. Därför finns bara användardelen av gränssnittet tillgängligt i calculateSalary. I och med att readData är virtuell i DatabaseUserInterface kommer man få en dynamisk uppslagning (polymorfi). Kompilatorn ser att objektet bakom referensen db är av typen Database och därför anropas funktionen redaData i Database.

Om vi har delar av vår kod där vi bara får komma åt administratörsdelarna av databasen så går det att ordna förstås. Vi skapar ett interface DatabaseAdminInterface och ärver in även det (dvs vi får två basklasser till Database).

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

I nästa artikel pratar vi om hur man använder interface för att bryta beroenden, få bättre testbarhet och därmed bättre kvalitet på sin kod. Lycka till med dina interface!



Relaterade artiklar: