Grensesnitt

Relevant lesestoff fra offisiell tutorial: Hva er et grensesnitt, Om grensesnitt


Et grensesnitt (engelsk: interface) er den delen av en enhet som er eksponert for en bruker av enheten. For eksempel består grensesnittet til en datamaskin av en skjerm, mus og tastatur; grensesnittet til en bil består av rattet, girspaken og pedalene. Grensesnittet sier altså ingenting om hvordan datamaskinen eller bilen virker under panseret, det bare beskriver hvordan de brukes.

Et grensesnitt er også en abstraksjon. Når vi for eksempel beskriver en datamaskin som en skjerm, en mus og et tastatur, overser vi alle de kompliserte elektroniske komponentene som ligger under panseret; vi har abstrahert bort alt vi ikke har tilgang til, silk som silisium-bindinger, transistorer, kretskort, prosessorer, maskinkode og lignende. Men vi har også abstrahert bort noen elementer vi egentlig har tilgang til, som for eksempel nettverksporter, strømkabler, maskinens størrelse og vekt etc, men som vi ikke anser som relevante for den bruken av datamaskinen vi er interessert i akkurat nå.

Den samme tingen kan ofte abstraheres på ulike måter. For eksempel kan samme person i én sammenheng abstraheres til en student som kan melde seg opp i fag og ta eksamen, mens i en annen sammenheng er personen abstrahert til en brikke på et Ludobrett. Universiteter kan gi studenter karakter i fag, mens i Ludo kan spillere få trille terning og flytte seg fremeover på brettet. Universitetet og Ludo har altså ulike grensesnitt mot samme ting.

Samtidig kan ulike ting ofte abstraheres på samme måte. For eksempel kan både et fotografi og et maleri bli abstrahert til begrepet «vegghengt kunst». Begge kan henges på veggen ved hjelp av en spiker – de to tingene er altså forskjellige, men har samme grensesnitt og ser helt like ut fra veggen sitt perspektiv.

I objekt-orientert programmering består grensesnittet til et objekt av

  • instansvariablene og
  • metodersignaturene til objektet.

Selve koden som implementerer metodene er ikke en del av grensesnittet. Når vi om ikke lenge skal diskutere innkapsling skal vi også se at vi kan (og bør!) fjerne instansvariabler og metoder fra grensesnittet ved å sette private foran instansvariabelen eller metoden. Jo mindre komplisert grensenittet er, jo enklere er det å forstå og bruke!

I Java finnes det også et eget konsept som går under navnet interface. Dette innebærer dessverre at vi har overlastet det samme ordet med flere betydninger, og man må ofte tolke konteksten for å skjønne om vi sikter til den generelle forståelsen av grensesnitt som «de metoder vi har tilgjengelig», eller om vi sikter til Java sitt eget grensesnitt-konsept.

Å definere et grensesnitt i Java er en form for abstraksjon i en formell ramme. Man spesifiserer en kontrakt mellom ulike deler av programmet. Kontrakten spesifiserer hvilke metoder som finnes, og hva de skal gjøre. Den éne parten i kontrakten er kalleren som gir input til og benytter output fra metodene – den andre parten er leverandøren som implementerer metodene.

Hensikten er å skape høy modularitet i programkoden. Modulariteten oppnås ved å

  • skjule detaljer fra brukeren, og
  • la hver adskilte del av programmet ha et klart avgrenset ansvarsområde, og
  • ha så presise, abstrakte og enkle grensesnitt som mulig.

Fordelene med modulær kode er at man

  • kun trenger å tenke på en liten del av programmet om gangen, og
  • kan teste ulike deler av programmet uavhengig av hverandre, og
  • lettere kan gjenbruke deler av programmet andre steder, og
  • lettere kan samarbeide med andre om å utvikle ulike deler av programmet, og
  • lettere kan bytte ut deler av programmet uten å måtte endre resten av programmet.

Grensesnitt er også en mekanisme hvor statisk typede språk som Java kan oppnå gjenbruk av kode via polymorfisme.

Opprette et grensesnitt

Et grensesnitt (engelsk: interface) i Java består i hovedsak av:

interface CommandLineInterface {
  /** Press a key on the keyboard. */
  void pressKey(char key);

  /** Returns the content to be shown on the screen. */
  String screenContent();
}

Koden over betyr at vi

En javadoc -kommentar er en spesiell type kommentar i kildekoden som befinner seg like over en metodesignatur (eller en feltvariabel). Slike kommentarer begynner med /** og slutter med */. De kan være på én linje (som i CommandLineInterface -eksempelet over), eller de kan gå over flere linjer:

/**
 * Find the maximum number. The method accepts any number of
 * int arguments, or a single array of ints. Example usage:
 * <pre>
 * int result = findMax(1, 2, 3, 4, 5);
 * </pre>
 * or
 * <pre>
 * int[] args = {1, 2, 3, 4, 5};
 * int result = findMax(args);
 * </pre>
 *
 * @param nums  Array with the numbers to compare
 * @return  The maximum number
 */
public static int findMax(int... nums) {
  int max = nums[0];
  for (int num : nums) {
    if (num > max) {
      max = num;
    }
  }
  return max;
}

En javadoc-kommentar inneholder kontrakten til en metode. Selv om kontrakten ikke betyr noe som helst for datamaskinen når den kjører koden, har den stor betydning for hvordan mennesker tolker og bruker koden.

Javadoc-kommentarer skiller seg fra vanlige kommentarer ved at de automatisk blir en del av dokumentasjonen til kildekoden. De fleste kodeeditorer vil for eksempelt vise deg javadoc-kommentaren dersom du holder musepekeren i ro over et metodenavn. Det er dessuten mulig å automatisk generere nettsider som viser frem all javadoc, noe som ofte brukes i dokumentasjonsøyemed.

VSCode viser javadoc når musepekeren hviler på et metodekall

  • Man kan bruke et forenklet html-språk når man skriver javadoc, blant annet forstår den kommandoer som <p> for nytt avsnitt og <code></code> og <pre></pre> for å angi hvilken del av teksten som er eksempelkode.
  • På slutten av javadoc-kommentaren kan vi ha en @param for hver parameter som beskriver den, og en @return som beskriver hva returverdien er. Det er også mulig å ha @throws for å dokumentere hva slags krasjer som kan forekomme.

For grensesnitt er javadoc-kommentarer spesielt viktige, og det er vanlig at grensesnitt inneholder lange javadoc-kommentarer som presist beskriver hvilken oppførsel som er forventet av metodene. I vanlige klasser er det ikke nødvendig å skrive javadoc-kommentarer hvis man bruker @Override, da vil man arve javadoc-kommentaren fra grensesnittet man implementerer eller klassen man utvider. I praksis blir det ofte slik at grensesnittene inneholder mye javadoc, mens klasser inneholder relativt lite.

Implementere et grensesnitt

En klasse kan implementere et grensesnitt ved å bruke implements-nøkkelordet i klassedeklarasjonen. For eksempel:

class DummyShell implements CommandLineInterface {
  String screenContent = "$ ";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      screenContent += "\n$ ";
    } else {
      screenContent += key;
    }
  }

  @Override
  public String screenContent() {
    return screenContent;
  }
}

Ting å merke seg:

Bruke et grensesnitt

Vi kan bruke CommandLineInterface -typen som en hvilken som helst vanlig type for en variabel, for eksempel som typen til en parameter i en metode. Fra variabelen har vi tilgang til metodene definert i grensesnittet:

static void writeHelloWorld(CommandLineInterface cli) {
  cli.pressKey('h');
  // ...
}

Objekter i klassen DummyShell kan vi anse som CommandLineInterface-objekter, fordi klassen implementerer grensesnittet CommandLineInterface. Vi kan for eksempel opprette en variabel av typen CommandLineInterface og la den peke på et DummyShell-objekt:

CommandLineInterface dummy = new DummyShell();

interface CommandLineInterface {
  /** Press a key on the keyboard. */
  void pressKey(char key);

  /** Returns the content to be shown on the screen. */
  String screenContent();
}

class DummyShell implements CommandLineInterface {
  String screenContent = "$ ";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      screenContent += "\n$ ";
    } else {
      screenContent += key;
    }
  }

  @Override
  public String screenContent() {
    return screenContent;
  }
}

public class Main {
  public static void main(String[] args) {
    CommandLineInterface cli = new DummyShell();
    writeHelloWorld(cli);
    String screenContent = cli.screenContent();
    System.out.println(screenContent);
  }

  static void writeHelloWorld(CommandLineInterface cli) {
    cli.pressKey('h');
    cli.pressKey('e');
    cli.pressKey('l');
    cli.pressKey('l');
    cli.pressKey('o');
    cli.pressKey('\n');
    cli.pressKey('w');
    cli.pressKey('o');
    cli.pressKey('r');
    cli.pressKey('l');
    cli.pressKey('d');
    cli.pressKey('\n');
  }
}

Polymorfisme med grensesnitt

Når vi har en variabel av typen CommandLineInterface kan vi bruke den til å peke på objekter av flere ulike klasser, så lenge klassene bare implementerer grensesnittet CommandLineInterface. Koden som bruker variabelen trenger altså ikke å vite noe om hvilken klasse som objektet faktisk er i, og kan derfor gjenbrukes til ulike formål uten tilpasses hver enkelt klasse den skal jobbe med. Dette kalles polymorfisme.

For eksempel, la oss anta at vi har en annen klasse som også implementerer CommandLineInterface -grensesnittet, men annerledes:

/*
 * A shell that echoes all input to screen each time newline is pressed.
 */
class EchoShell implements CommandLineInterface {
  ArrayList<String> outputLines = new ArrayList<>();
  String currentLine = "";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      outputLines.add("$ " + currentLine);
      outputLines.add("Oh, an echo! listen: " + currentLine);
      currentLine = "";
    } else {
      currentLine += key;
    }
  }

  @Override
  public String screenContent() {
    String result = "";
    for (String line : outputLines) {
      result += line + "\n";
    }
    return result + "$ " + currentLine;
  }
}

Vi kan nå bruke EchoShell-objekter på samme måte som DummyShell-objekter uten å endre på koden som bruker CommandLineInterface-variabler (for eksempel forblir writeHelloWorld den samme uten endringer som er tilpasset du ulike klassene).

DummyShell dummy = new DummyShell();
EchoShell echo = new EchoShell();

writeHelloWorld(dummy);
writeHelloWorld(echo);

import java.util.ArrayList;

interface CommandLineInterface {
  /** Press a key on the keyboard. */
  void pressKey(char key);

  /** Returns the content to be shown on the screen. */
  String screenContent();
}

class DummyShell implements CommandLineInterface {
  String screenContent = "$ ";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      screenContent += "\n$ ";
    } else {
      screenContent += key;
    }
  }

  @Override
  public String screenContent() {
    return screenContent;
  }
}

/*
 * A shell that echoes all input to screen each time newline is pressed.
 */
class EchoShell implements CommandLineInterface {
  ArrayList<String> outputLines = new ArrayList<>();
  String currentLine = "";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      outputLines.add("% " + currentLine);
      outputLines.add("Oh, an echo! listen: " + currentLine);
      currentLine = "";
    } else {
      currentLine += key;
    }
  }

  @Override
  public String screenContent() {
    String result = "";
    for (String line : outputLines) {
      result += line + "\n";
    }
    return result + "% " + currentLine;
  }
}

public class Main {
  public static void main(String[] args) {
    DummyShell dummy = new DummyShell();
    EchoShell echo = new EchoShell();

    writeHelloWorld(dummy);
    writeHelloWorld(echo);

    String dummyContent = dummy.screenContent();
    System.out.println(dummyContent);

    String echoContent = echo.screenContent();
    System.out.println(echoContent);
  }

  static void writeHelloWorld(CommandLineInterface cli) {
    cli.pressKey('h');
    cli.pressKey('e');
    cli.pressKey('l');
    cli.pressKey('l');
    cli.pressKey('o');
    cli.pressKey('\n');
    cli.pressKey('w');
    cli.pressKey('o');
    cli.pressKey('r');
    cli.pressKey('l');
    cli.pressKey('d');
    cli.pressKey('\n');
  }
}

Implementere flere grensesnitt

Det er mulig for en klasse å implementere flere grensesnitt samtidig. For eksempel, la oss si at vi ønsker å dele opp grensesnittet CommandLineInterface i to, slik at vi har ett grensesnitt for å ta i mot tastetrykk, og ett grensesnitt for å hente ut skjermens innhold. Da kan vi lage to grensesnitt, og la DummyShell implementere begge:

interface KeypressReceiver {
  /** Press a key on the keyboard. */
  void pressKey(char key);
}

interface ScreenContentProvider {
  /** Returns the content to be shown on the screen. */
  String screenContent();
}

class DummyShell implements KeypressReceiver, ScreenContentProvider {
  // ...
}

Siden writeHelloWorld ikke benytter seg av screenContent -metoden, kan vi her bruke typen KeypressReceiver i stedet for CommandLineInterface:

static void writeHelloWorld(KeypressReceiver cli) {
  // ...
}

Grensesnittet KeypressReceiver er både enklerere og mer abstrakt enn CommandLineInterface var, og man kan derfor argumentere for at det er god stil å dele opp grensesnittet slik. Her må man riktignok også ta hensyn til at det ikke blir alt for mange ulike grensesnitt å ha oversikt over, og det er også fornuftig å samle relaterte metoder i samme grensesnitt.

interface KeypressReceiver {
  /** Press a key on the keyboard. */
  void pressKey(char key);
}

interface ScreenContentProvider {
  /** Returns the content to be shown on the screen. */
  String screenContent();
}

class DummyShell implements KeypressReceiver, ScreenContentProvider {
  String screenContent = "$ ";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      screenContent += "\n$ ";
    } else {
      screenContent += key;
    }
  }

  @Override
  public String screenContent() {
    return screenContent;
  }
}

public class Main {
  public static void main(String[] args) {
    DummyShell dummy = new DummyShell();
    writeHelloWorld(dummy);
    String screenContent = dummy.screenContent();
    System.out.println(screenContent);
  }

  static void writeHelloWorld(KeypressReceiver cli) {
    cli.pressKey('h');
    cli.pressKey('e');
    cli.pressKey('l');
    cli.pressKey('l');
    cli.pressKey('o');
    cli.pressKey('\n');
    cli.pressKey('w');
    cli.pressKey('o');
    cli.pressKey('r');
    cli.pressKey('l');
    cli.pressKey('d');
    cli.pressKey('\n');
  }
}

Arv mellom grensesnitt

Det er mulig for ett grensesnitt å arve et eller flere andre grensesnitt (også kalt å utvide andre grensesnitt). For eksempel, la oss samle alle metodene fra KeypressReceiver og ScreenContentProvider i ett grensesnitt, CommandLineInterface:

interface CommandLineInterface extends KeypressReceiver, ScreenContentProvider {
}

Selv om koden over føles litt for liten til å ha sin egen fil, er dette likefullt et lovlig og nyttig grensesnitt. Vi kan eventuelt legge til flere metoder om ønskelig i tillegg.

Ved å la DummyShell og EchoShell implementere CommandLineInterface i stedet for KeypressReceiver og ScreenContentProvider, har vi nå laget et hierarki av grensesnitt. DummyShell -objektet har fremdeles typen KeypressReceiver og all koden rundt vil derfor fungere som før.

Typer

Et hierarki av typer: et objekt har alle typene man kan nå ved å starte i sin egen klasse og så følge pilene oppover.

import java.util.ArrayList;

interface KeypressReceiver {
  /** Press a key on the keyboard. */
  void pressKey(char key);
}

interface ScreenContentProvider {
  /** Returns the content to be shown on the screen. */
  String screenContent();
}

interface CommandLineInterface extends KeypressReceiver, ScreenContentProvider {
}

class DummyShell implements CommandLineInterface {
  String screenContent = "$ ";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      screenContent += "\n$ ";
    } else {
      screenContent += key;
    }
  }

  @Override
  public String screenContent() {
    return screenContent;
  }
}

/*
 * A shell that echoes all input to screen each time newline is pressed.
 */
class EchoShell implements CommandLineInterface {
  ArrayList<String> outputLines = new ArrayList<>();
  String currentLine = "";

  @Override
  public void pressKey(char key) {
    if (key == '\n') {
      outputLines.add("% " + currentLine);
      outputLines.add("Oh, an echo! listen: " + currentLine);
      currentLine = "";
    } else {
      currentLine += key;
    }
  }

  @Override
  public String screenContent() {
    String result = "";
    for (String line : outputLines) {
      result += line + "\n";
    }
    return result + "% " + currentLine;
  }
}

public class Main {
  public static void main(String[] args) {
    DummyShell dummy = new DummyShell();
    EchoShell echo = new EchoShell();

    writeHelloWorld(dummy);
    writeHelloWorld(echo);

    String dummyContent = dummy.screenContent();
    System.out.println(dummyContent);

    String echoContent = echo.screenContent();
    System.out.println(echoContent);
  }

  static void writeHelloWorld(KeypressReceiver cli) {
    cli.pressKey('h');
    cli.pressKey('e');
    cli.pressKey('l');
    cli.pressKey('l');
    cli.pressKey('o');
    cli.pressKey('\n');
    cli.pressKey('w');
    cli.pressKey('o');
    cli.pressKey('r');
    cli.pressKey('l');
    cli.pressKey('d');
    cli.pressKey('\n');
  }
}