Innkapsling
Innkapsling innebærer at vi skjuler detaljer om hvordan noe er konstruert, og i stedet tilbyr et forenkelt grensesnitt som gjør det mulig å bruke dette «noe» uten å bry oss om hvordan det virker på innsiden. Innkapsling har som formål å gi konstruksjonen høy modularitet.
En konstruksjon er modulær dersom den er bygget opp av «byggeklosser» som uavhengig av hverandre kan
- forstås,
- endres,
- feilsøkes,
- erstattes og
- testes.
Hver enkelt enhet i seg selv bør også ha begrenset kompleksitet, være lett å forstå for én person, og ha étt klart og tydelig formål. I objekt-orientert programmering deler vi programmene våre opp i klasser og pakker, og vi ønsker å redusere kompleksiteten i relasjonene mellom disse.
Innkapsling hjelper til ved å skjule detaljer og redusere kompleksitetsnivået en klient til vår kode trenger å forholde seg til. Klienten har som regel egne behov og andre faktorer som øker kompleksiteten i sin kode, så jo mindre de trenger å vite om vår kode, jo bedre. Dette gjelder like sterkt dersom klienten er en fremtidig versjon av oss selv som hvis det er helt andre.
Pakker
Et større java-prosjekt er vanligvis organisert i et hiearki av pakker. Tenk på en pakke som en mappe som inneholder java-konsepter som klasser og grensesnitt, i tillegg til at den kan inneholde andre pakker. Vi bruker pakker for
- å organisere koden vår,
- for å unngå navnekonflikter,
- for å importere kun det vi har behov for, og
- for å innkaplse kode med bruk av tilgangsmodifikatorer.
I toppen av en java-fil deklarerer vi hvilken pakke filen tilhører.
package no.uib.ii.inf101.coursenotes.encapsulation;
public class MyClass {
// ...
}
Pakkenavn er en streng som består av et eller flere ord, separert med punktum. For eksempel, no.uib.ii.inf101.coursenotes.encapsulation
er et gyldig pakkenavn som kunne vært egnet for å samle all kode fra disse kursnotatene du leser akkurat nå. Pakkenavn er
- vanligvis skrevet med kun små bokstaver, og
- det er vanlig å bruke omvendt domenenavn som begynnelse av pakkenavnet.
For eksempel er inf101.ii.uib.no domenenavnet til våre nettsider, og det er derfor naturlig å bruke no.uib.ii.inf101
som begynnelse på pakkenavnene våre.
For å gjøre eksempelene våre litt mindre verbose bruker vi ofte kortere pakkenavn på disse nettsidene og i kurset for øvrig. Det går fint så lenge vi ikke planlegger å distribuere koden vår til bruk av andre som et tredjepartsbibliotek.
Tilgangsmodifikatorer
Tilgangsmodifikatorer er spesielle kodeord som brukes for å angi tilgangsnivået til en metode eller feltvariabel. De er:
Modifikator | Klasse | Pakke | Subklasse | Alle |
---|---|---|---|---|
public |
Ja | Ja | Ja | Ja |
protected |
Ja | Ja | Ja | Nei |
~ ingen modifikator ~ (aka «pakke-privat»/standard/default) |
Ja | Ja | Nei | Nei |
private |
Ja | Nei | Nei | Nei |
public
Betyr at metoden eller feltvariabelen er tilgjengelig overalt.
For eksempel, la oss si at vi har klassene Person
og Main
i henholdsvis pakke no.uib.a
og no.uib.b
. Klassen Person
lar sine feltvariabler og metoder være public
:
package no.uib.a;
public class Person {
public String name;
public int age;
public Persion(String name, int age) {
this.name = name;
this.age = age;
}
public boolean isAdult() {
return age >= 18;
}
}
package no.uib.b;
import no.uib.a.Person;
public class Main {
public static void main(String[] args) {
Person p = new Person("Ola", 15);
// Fordi feltvariablene er public, kan vi bruke (og endre!) dem her
// (dette er sannsynligvis ikke ønskelig)
p.name = "Kari";
p.age = 20;
// Fordi metoden isAdult er public, kan vi bruke den her
boolean foo = p.isAdult();
System.out.println(foo);
}
}
I main-metoden lager vi et objekt av klassen Person
, og vi kan bruke feltvariabelen age
og metoden isAdult()
, selv om metoden Main
-klassen ligger i en annen pakke og heller ikke er en subklasse av Person
.
Metoder og feltvariabeler som er protected
er tilgjengelig for alle klasser i samme pakke, og for alle subklasser av klassen som metoden eller feltvariabelen ligger i.
For eksempel, la oss si at vi har klassene Person
, Wizard
og Main
i henholdsvis pakke no.uib.a
, no.uib.b
og no.uib.c
. Klassen Person
lar sin feltvariabel age
være protected
. Dette innebærer at den kan brukes av klassen Wizard
som er en subklasse av Person
, men ikke i klassen Main
som ikke er en subklasse av Person
:
package no.uib.a;
public class Person {
public String name;
protected int age;
public Persion(String name, int age) {
this.name = name;
this.age = age;
}
public int getAge() {
return age;
}
}
package no.uib.b;
import no.uib.a.Person;
public class Wizard extends Person {
/** Give one year of this wizard's lifetime to the person */
public void giveAYearTo(Person p){
this.age -= 1;
p.age += 1;
}
}
package no.uib.c;
import no.uib.a.Person;
import no.uib.b.Wizard;
public class Main {
public static void main(String[] args) {
Person kari = new Person("Kari", 20);
Wizard harry = new Wizard("Harry", 20);
// Vi kan IKKE gjøre dette, siden age er protected:
// harry.age += 1;
// kari.age -= 1;
// Men vi kan gjøre dette, siden giveAYearTo er public:
harry.giveAYearTo(kari);
System.out.println("Harry is now " + harry.getAge() + " years old");
System.out.println("Kari is now " + kari.getAge() + " years old");
}
}
Dersom en metode eller feltvariabel ikke har angitt hverken public
, protected
eller private
, innebærer dette såkalt «pakke-privat» innkapsling (også kjent som standard/default innkapsling). Det betyr at metoden eller variabelen er tilgjengelig innefor samme pakke, men ikke utenfor.
For eksempel, dersom vi har klassene Person
og PersonFactory
i pakke no.uib.a
, mens vi har klassen Main
i pakken no.uib.b
, vil en pakke-privat konstruktør til Person
være tilgjengelig i PersonFactory
, men ikke i Main
:
package no.uib.a;
public class Person {
private String name;
// Se: konstruktøren er pakke-privat
Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
package no.uib.a;
/**
* A factory object can only create the same person once. This could be
* useful for preventing the existence of two equal but different objects.
*/
public class PersonFactory {
private Map<String, Person> persons = new HashMap<>();
public Person getOrCreatePerson(String name) {
if (persons.containsKey(name)) {
return persons.get(name);
}
else {
// Siden PersonFactory er i samme pakke som Person,
// er det OK at vi kaller Person sin konstruktør her:
Person p = new Person(name);
persons.put(name, p);
return p;
}
}
}
package no.uib.b;
public class Main {
public static void main(String[] args) {
// Dette vil IKKE fungere, siden Person-konstruktøren er pakke-privat,
// samtidig som Main ikke er i samme pakke som Person:
// Person ola1 = new Person("Ola");
// Person ola2 = new Person("Ola");
// System.out.println(ola1 == ola2); // false
// Dette vil derimot fungere, siden getOrCreatePerson er public:
PersonFactory personFactory = new PersonFactory();
Person kari1 = personFactory.getOrCreatePerson("Kari");
Person kari2 = personFactory.getOrCreatePerson("Kari");
System.out.println(kari1 == kari2); // true
}
}
En variabel eller metode som er private
er kun tilgjengelig i den samme klassen hvor den ble definert. Dette er den strengeste innkapslingen, og bør alltid være standardvalget for variabler og metoder med mindre det er en god grunn til å velge noe annet.
Dersom vi har to klasser Person
og Main
i samme pakke, kan vi ikke bruke private feltvariabeler fra Person
i Main
:
class Person {
private String name;
Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
public class Main {
public static void main(String[] args) {
Person p = new Person("Ola");
// Dette vil IKKE fungere, siden name er private:
// p.name = "Kari";
// Dette vil derimot fungere, siden getName er public:
System.out.println(p.getName());
}
}
Tommelfingerregeler for tilgangsmodifikatorer:
- Benytt
private
som standard, og øk tilgangsnivået gradvis dersom det er nødvendig. - Eksponér heller en metode enn en feltvariabel. Det er bedre at feltvariabler forblir
private
, og at tilgang til informasjon i stedet går via metoder (for eksempel gettere og settere).
Dersom feltvariablene dine ikke er private, blir de en del av informasjonen klienter til koden din må forholde seg til. Dette kan fungere fint helt til du ønsker å endre hvordan du implementerer klassen din; da vil du ødelegge klientene sin kode om du gjør endringer med variabelen.
Man kan alltid oppnå det samme som å ha en eksponert felvariabel ved å la feltvariabelen i stedet være private
og heller eksponere en getter og en setter. En getter er en metode som returnerer verdien til en feltvariabel, mens en setter er en metode som endrer verdien til en feltvariabel. For eksempel:
// Denne klassen har en public feltvariabel
class Person {
public String name;
}
// Denne klassen har gettere og settere for feltvariabelen
// i stedet, mens feltvariabelen er private. Dette gir bedre
// innkapsling og fleksibilitet for fremtidige endringer.
class Person {
private String name;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}
Dersom du bruker gettere og settere i stedet for å eksponere feltvariabler, øker du innkapslingen av implementasjonen din, og gir deg selv mulighet til å maskere endringer i fremtiden.
Gettere og settere har også andre fordeler som man kan velge å benytte seg av.
- I en setter kan du validere at feltvariabelen får en gyldig tilstand (for eksempel kan vi sjekke at verdien ikke er
null
). - I en setter kan du konvertere input til et foretrukket format (for eksempel endre en streng til kun små bokstaver).
- I en getter kan du konvertere svaret til et foretrukket format (for eksempel endre en streng til kun små bokstaver eller noe helt annet).
- Du kan ha ulike tilgangsnivåer på getter og setter. For eksempel kan getter være public mens setter er pakke-privat.
- Du kan la klassen implementere ulike grensesnitt, hvor for eksempel et av grensesnittene kun har gettere. Les mer om dette i avsnittet om restriktive typer og innkapsling med grensesnitt
Ulempene med gettere og settere er at de tar opp plass i klassen vår, og at syntaksen blir litt mer verbos. Ikke veldig alvorlige ulemper altså, men plass og lesbarhet har likevel verdi. Derfor kan det være greit å eksponere variabler i sjeldne tilfeller. Da bør klassen feltvariablene tilhører enten
-
være en enkel «hjelpeklasse» som ikke er public og som kun blir brukt svært lokalt – slik at det er naturlig å endre klientkoden samtidig som man endrer hjelpeklassen; eller
-
man har tenkt svært nøye gjennom saken i samråd med en erfaren utvikler, og funnet ut at klassen aldri i fremtiden noensinne vil ha behov for endre å seg.
I tillegg til å kunne bruke tilgangsmodifikatorer på metoder og feltvariabler, kan vi også bruke dem på klasser, grensesnitt og lignende. Å ha en klasse som ikke er public vil skjule hele klassen og typen den definerer fra dem som ikke har høyt nok tilgangsnivå.
Merk at det kun er mulig å ha én public klasse per fil, og at denne klassen må ha samme navn som filen. Det er uansett god stil at man kun har én klasse per fil, samme om den er public eller ikke (unntak for nøstede klasser).
Restriktive typer og innkapsling med grensesnitt
Det er mulig å styrke innapslingen ytterligere ved å benytte restriktive typer. Dette innebærer at klienten benytter en variabeltype som kun inneholder et lite utvalg av metodene som egentlig er tilgjengelige på objekter i den aktuelle klassen. Denne restriktive typen er typisk definert av et grensesnitt som er implementert av klassen direkte eller indirekte.
For eksempel, la oss si at vi har en klasse Person
som har public metoder getName
og setName
. Noen klienter av Person
-objekter er kun interessert i getName
. Vi kan da la Person
implementere et «restriktivt grensesnitt» ReadOnlyPerson
som kun inneholder getName
, og la de nevnte klientene benytte ReadOnlyPerson
-variabler i stedet for Person
-variabler.
public interface ReadOnlyPerson {
String getName();
}
public class Person implements ReadOnlyPerson {
private String name;
public Person(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
// Klient benytter restriktivt grensesnitt som type,
// dette øker innkapslingen og dermed modulariteten:
ReadOnlyPerson p = new Person("Ola");
// Dette vil IKKE fungere, siden ReadOnlyPerson ikke definerer setName:
// p.setName("Kari");
// Dette VIL fungere, siden getName er definert i ReadOnlyPerson:
System.out.println(p.getName());
}
}
Mønseteret med å bruke restriktive grensesnitt kan som eksempelet over demonstrerer benyttes for å lage «read-only» -type for en gitt klasse. Da oppnår man en slags «kvasi-uforanderlighet» for objektene i den aktuelle klassen, noe som reduserer relasjons-kompleksiteten mellom klassene betydelig.
Gode råd
For implementøren (deg som skriver en klasse):
- Bruk strengeste mulige tilgangsmodifikatorer for feltvariabler og metoder, såfremt det fremdeles er hensiktsmessig i forhold til bruken av klassen. Med andre ord, la
private
være standardvalget med mindre du har en god grunn til å gjøre noe annet. Det er enklere å øke tilgangsnivået senere enn å snevre det inn. - Ikke la feltvariabler være eksponert. Bruk heller gettere og settere.
- Når du utvikler en klasse med objekter som kan muteres, vurder om du også bør opprette et restriktivt grensesnitt for klassen som ikke inneholder muterende metoder. Dette er spesielt relevant for «generelle» klasser som er tenkt brukt av mange ulike klienter.
- Bruk heller flere små og enkle grensesnitt enn få store og komplekse grensesnitt.
For klienten (deg som bruker objekter i andre klasser):
- Benytt den mest restriktive typen du kan for variabler og parametre. Bruk en type som gir deg tilgang til det du trenger, men helst ikke mye mer enn det; jo høyrere opp i typehierarkiet, jo bedre.
- Det gir alltid bedre innkapsling å bruke et grensesnitt som variabeltype enn en klasse.
- For eksempel: bruker du en ArrayList som variabeltype? Sannsynligvis er det bedre å bruke List. Bruker du List som variabeltype, men det eneste du gjør er å iterere gjennom listen? Da er Iterable et enda bedre valg.
- Det er du som klient som definerer hvilket grensesnitt du trenger. Det er opp til implementøren å tilpasse seg grensesnittet, ikke omvendt.