Ohjelmoinnin jatkokurssi

Matti Paksula, Arto Vihavainen, Matti Luukkainen

Abstrakti Luokka

Abstrakti luokka on periytymiseen ja abstrahointiin liittyvä apuväline. Abstraktista luokasta ei voida tehdä ilmentymiä, eli olioita, mutta sen voi periä. Abstraktin luokan tunnistaa määreestä abstract sen määrittelyssä. Luodaan abstrakti luokka Korjaaja.

public abstract class Korjaaja {
  protected String korjattava;
  
  public Korjaaja(String korjattava) {
    this.korjattava = korjattava;
  }
  
  public void setKorjattava(String korjattava) {
    this.korjattava = korjattava;
  }
  
  public String getKorjattava() {
    return korjattava;
  }
}

Ilmentymän luonti luokasta Korjattava ei ole mahdollista, eli seuraava konstruktorikutsu ja viitteen asetus ei toimi.

Korjaaja mattiV = new Korjaaja("abstraktinen, sellainen, joka ei ole kouriintuntuva eikä konkreettinen");

Abstrakteilla luokilla määritellään yleiskäyttöinen runko runko sen periville luokille. Esimerkiksi abstraktista luokasta Korjaaja voidaan periä luokka KokeenKorjaaja. Luokka KokeenKorjaaja korjaa kokeita, ja sillä on abstraktin luokan Korjaaja määrittelemien metodien lisäksi metodi korjaa(). Abstraktin luokan periminen seuraa normaalin periytymisen sääntöjä, eli kutsumme yläluokan parametrillista konstruktoria super()-kutsulla. Myös yläluokan muuttujat ja metodit olisivat saatavilla.

public class KokeenKorjaaja extends Korjaaja {
  public KokeenKorjaaja(String korjattava) {
    super(korjattava);
  }

  public void korjaa() {
    this.korjattava += "\n - Pisteet " + Math.min(this.korjattava.length / 4, 60);
  }
}

Metodi korjaa() korjaa kokeen, eli tässä tapauksessa määrittelee siitä saatavat pisteet. Luokkakirjaston Math metodi min() palauttaa pienemmän kahdesta parametrista, eli kokeesta voi saada maksimissaan 60 pistettä. Luokasta KokeenKorjaaja voi tehdä ilmentymän normaalilla olion luontikutsulla.

String teksti = "An abstract class is designed only as a parent class " + 
  "from which child classes may be derived.";
KokeenKorjaaja mattiP = new KokeenKorjaaja(teksti);
mattiP.korjaa();
System.out.println(mattiP.getKorjattava());

Yllä oleva esimerkki luo ilmentymän luokasta KokeenKorjaaja ja kutsuu sen korjaa()-metodia. Esimerkin tulostus on seuraavanlainen:

An abstract class is designed only as a parent class from which child classes may be derived.
 - Pisteet 23

Abstrakteja luokkia käytetään tapauksissa joissa useat luokat tarvitsevat samaa rakennuspiirrustusta, mutta rakennuspiirrustukset itsessään eivät kuitenkaan ole sellaiset, joista haluttaisiin luoda konkreettinen ilmentymä.

Abstrakti Metodi

Abstraktin luokan metodit voidaan määritellä abstrakteiksi, jolloin niihin ei määritellä toteutusta. Abstrakti metodi määritellään avainsanalla abstract ja antamalla metodin paluuarvo, nimi ja parametrit. Esimerkiksi abstrakti metodi piirra().

public abstract void piirra();

Abstraktin metodin määrittely on lähes kuin rajapintametodin määrittely. Sillä ei ole aaltosuluilla rajattavaa metodirunkoa, ja metodimäärittely päättyy puolipisteeseen. Luodaan abstrakti luokka Hahmo, jolla on abstrakti metodi piirra(). Jokainen luokkaa Hahmo laajentava ei abstrakti luokka joutuu toteuttamaan oman piirra()-metodinsa.

public abstract class Hahmo {
  protected String nimi;
  
  public Hahmo(String nimi) {
    this.nimi = nimi;
  }
  
  public String getNimi() {
    return this.nimi;
  }
  
  public void setNimi(String nimi) {
    this.nimi = nimi;
  }
  
  public abstract void piirra();
}

Abstraktin metodin määrittelyssä ei siis määritellä metodin toteutusta, vaan vain tyyppi, nimi ja parametrit. Abstraktin luokan perivät ei abstraktit luokat joutuvat toteuttamaan abstraktit metodin. Jos abstrakti luokka perii abstraktin luokan, ei sen tarvitse toteuttaa yläluokan abstraktia metodia. Esimerkiksi seuraava luokka Elvis määrittelee myös Elviksen piirtämisen.

public class Elvis extends Hahmo {
  public Elvis(String nimi) {
    super(nimi);
  }

  public void piirra() {
    System.out.println("G   __");
    System.out.println("\\\\  ,,)_");
    System.out.println(" \\'-\\( /");
    System.out.println("  \\ | ,\\");
    System.out.println("   \\|_/\\\\");
    System.out.println("   / _ '.D");
    System.out.println("  / / \\ |");
    System.out.println(" /_\\  /_\\");
    System.out.println("'-    '-");
    System.out.println("\"" + this.nimi + "\"");
  }
}

Ylimääräiset kenoviivat \ ennen kenoviivoja ja lainausmerkkejä " johtuu siitä, että osa merkeistä toimii erikoismerkkeinä. Esimerkiksi lainausmerkki aloittaa ja lopettaa merkkijonon. Asettamalla kenoviivan ennen erikoismerkkiä, esimerkiksi lainausmerkkiä, tulostetaan kenoviivaa seuraava erikoismerkki normaalina merkkinä. Luodessamme luokan Elvis ilmentymän ja kutsuessamme sen piirra()-metodia seuraavasti saamme luontikappaleen alla olevan tulosteen.

Elvis e = new Elvis("Matti L");
e.piirra();
G   __
\\  ,,)_
 \'-\( /
  \ | ,\
   \|_/\\
   / _ '.D
  / / \ |
 /_\  /_\
'-    '-
"Matti L"

Voimme vastaavasti luoda luokan Janis (Jänis), jolla on oma piirra()-metodinsa.

public class Janis extends Hahmo {

  public Janis(String nimi) {
    super(nimi);
  }

  public void piirra() {
    System.out.println("(\\___/)");
    System.out.println("(='.'=)");
    System.out.println("(\")_(\")");
  }
}

Jäniksen piirra()-metodikutsu luo seuraavanlaisen tulosteen:

(\___/)
(='.'=)
(")_(")

Lisätään jänikselle vielä rajapinta Liikkuva.

public interface Liikkuva {
  public void liiku();
  public void liiku(int montaKertaa);
}

Ja muutetaan jäniksen toteutusta siten, että piirretty jänis oikeasti liikkuu.

public class Janis extends Hahmo implements Liikkuva {
  private int sijainti;

  public Janis(String nimi) {
    super(nimi);
    this.sijainti = 0;
  }

  public void piirra() {
    tulostaTyhjaa(); System.out.println("(\\___/)");
    tulostaTyhjaa(); System.out.println("(='.'=)");
    tulostaTyhjaa(); System.out.println("(\")_(\")");
  }
  
  private void tulostaTyhjaa() {
    for(int i = 0; i < sijainti; i++) {
      System.out.print(" ");
    }
  }

  public void liiku() {
    this.sijainti++;    
  }

  public void liiku(int montaKertaa) {
    for(int i = 0; i < montaKertaa; i++) {
      liiku();
    }
  }
}

Kokeile minkälaisella koodilla saat jäniksen liikkumaan seuraavasti!

(\___/)
(='.'=)
(")_(")
 (\___/)
 (='.'=)
 (")_(")
      (\___/)
      (='.'=)
      (")_(")

Rajapinnat Abstraktissa Luokassa

Abstraktille luokalle voidaan myös tehdä sopimus rajapintojen toteuttamisesta, aivan kuten kaikille muille luokille. Jos abstraktille luokalle määritellään rajapinta, ei rajapinnan metodeja ole kuitenkaan pakko toteuttaa abstraktin luokan sisällä. Tällöin abstraktin luokan perivä (ei abstrakti) luokka joutuu toteuttamaan rajapinnan määrittelemät metodit. Siirretään yllä määritelty Liikkuva-rajapinta abstraktiin luokkaan Hahmo.

public abstract class Hahmo implements Liikkuva {
  protected String nimi;
  
  public Hahmo(String nimi) {
    this.nimi = nimi;
  }
  
  public String getNimi() {
    return this.nimi;
  }
  
  public void setNimi(String nimi) {
    this.nimi = nimi;
  }
  
  public abstract void piirra();
}

Koska Liikkuva-rajapinnan metodit ovat vain sopimuksia metodien toteuttamisesta, aivan kuten abstraktit metoditkin, ei rajapinnan metodeja tarvitse määritellä erikseen Hahmo-luokassa. Tällöin, samoin kuin abstraktien metodien tapauksessa, perivät luokat joutuvat toteuttamaan määritellyt metodit.

Esimerkiksi Hahmo-luokan toteuttava luokka Elvis jouduttaisiin joko muuttamaan abstraktiksi tai määrittelemään rajapinnan vaatimat metodit. Abstraktin luokan ei tarvitse sisältää sovittujen metodien toteutuksia, kun taas normaalissa luokassa niiden on oltava joko kyseisessä luokassa tai periytymisen kautta. Lisätään Elvikselle myös liikkumismetodi.

public class Elvis extends Hahmo {
  private boolean vasen;

  public Elvis(String nimi) {
    super(nimi);
    this.vasen = true;
  }

  public void piirra() {
    if (vasen) {
      piirraVasen();
    } else {
      piirraOikea();
    }
    System.out.println("\"" + this.nimi + "\"");
  }

  private void piirraVasen() {
    System.out.println("G   __");
    System.out.println("\\\\  ,,)_");
    System.out.println(" \\'-\\( /");
    System.out.println("  \\ | ,\\");
    System.out.println("   \\|_/\\\\");
    System.out.println("   / _ '.D");
    System.out.println("  / / \\ |");
    System.out.println(" /_\\  /_\\");
    System.out.println("'-    '-");
  }

  private void piirraOikea() {
    System.out.println("   __    G");
    System.out.println("   _(,,  //");
    System.out.println("   \\ )/-'/");
    System.out.println("   /, | /");
    System.out.println("  //\\_|/");
    System.out.println(" D.' _ \\");
    System.out.println("  | / \\ \\");
    System.out.println("  / \\  / \\");
    System.out.println("   -'   -'");
  }

  public void liiku() {
    this.vasen = !this.vasen; // käänteinen totuusarvo huutomerkin avulla!
  }

  public void liiku(int montaKertaa) {
    System.out.println("Sori, boogie toimii askel kerrallaan.");
  }
}

Kuten seuraavasta esimerkistä ja tulosteesta huomaamme, Elviksemme osaa diskotanssin alkeet (jo ennen diskoa!).

Elvis e = new Elvis("Matti L");
e.piirra();
e.liiku();
e.piirra();
e.liiku();
e.piirra();
G   __
\\  ,,)_
 \'-\( /
  \ | ,\
   \|_/\\
   / _ '.D
  / / \ |
 /_\  /_\
'-    '-
"Matti L"
     __   D
   _(,,  //
   \ )/-'/
   /, | /
  //\_|/
 G.' _ \
  | / \ \
  / \  / \
   -'   -'
"Matti L"
G   __
\\  ,,)_
 \'-\( /
  \ | ,\
   \|_/\\
   / _ '.D
  / / \ |
 /_\  /_\
'-    '-
"Matti L"

Polymorfismi

Polymorfismi on neljäs olio-ohjelmoinnin peruskäsitteistä kapseloinnin, abstrahoinnin ja periytymisen lisäksi. Polymorfismilla tarkoitetaan olioiden monimuotoisuutta, eli oliolla voi olla monta eri tyyppiä. Eri tyypit, joita olio voi edustaa, koostuvat sen perintöhierarkiasta ja toteutetuista rajapinnoista. Olemme jo sivunneet polymorfismia, vaikkakin salaa. Viime viikon materiaalissa olevan Kahvilaskuri-luokan metodi lisaaArvoon()-ottaa parametrikseen Laskuri-tyyppisen olion, joka siis voi olla mikä tahansa olio, mikä toteuttaa Laskuri-rajapinnan.

Yllä olevalla Elvis-luokallamme on neljä eri tyyppiä:

Luokasta Elvis luodun olion voi siis antaa neljänä erityyppisenä parametrina. Elvis-olion voisi siis antaa kaikille neljästä seuraavasta metodista.

public void metodi(Elvis elvis) { ... }
public void metodi(Hahmo hahmo) { ... }
public void metodi(Liikkuva liikkuva) { ... }
public void metodi(Object object) { ... }

Kussakin tapauksessa annetulle oliolle voi kutsua vain siihen tyyppiin liittyviä metodeja, vaikka itse annettu viite oikeasti viittaisikin Elvis-tyyppiseen olioon. Esimerkiksi metodikutsu public void metodi(Liikkuva l), jolle annetaan parametrina Elvis-tyyppinen olio, tietää vain Liikkuva-rajapinnan määrittelevät metodit liiku() ja liiku(int montaKertaa). Seuraava metodikutsu yrittää liikuttaa Liikkuva-tyyppistä oliota yhteensä 5 kertaa.

public void liikuta(Liikkuva liikkuva) {
  // mahdollisia kutsuja siis vain
  liikkuva.liiku();
  // ja 
  liikkuva.liiku(4);
  // jossa parametrina annettu luku voi olla mikä tahansa kokonaisluku
}

Toisaalta, koska Liikkuva-rajapinta ei tiedä Elvis-luokan muista metodeista, ei esimerkiksi seuraava olisi mahdollista (vaikka parametrina annettaisiin Elvis-luokasta luotu olio!).

public void liikuta(Liikkuva liikkuva) {
  liikkuva.piirra(); // ei mahdollinen kutsu, sillä Liikkuva rajapinnassa ei määritelty piirra()-metodia
}

Luokalle Hahmo taas metodin piirra() kutsuminen olisi mahdollista. Huomaa että koska Hahmo on abstrakti luokka, kutsutaan oikeasti sen perivän luokan määrittelemää metodia piirra(). Esimerkiksi seuraavalle metodille voisi antaa minkä tahansa Hahmo-luokan perivän luokan ilmentymän.

public void piirraHahmo(Hahmo hahmo) {
  hahmo.piirra();
}

Voisimme antaa yllä olevalle metodille sekä Elvis-tyyppisiä olioita, että Jänis-tyyppisiä olioita, sillä ne molemmat perivät Hahmo-luokan, jolloin Hahmo on yksi niiden mahdollisista muodoista. Yllä olevaa metodia kutsuttaessa seuraavasti saisimme alla olevan esimerkin jälkeen olevan tulosteen.

Elvis elmeri = new Elvis("Matti L");
Janis jano = new Janis("Matti P");

piirraHahmo(elmeri);
System.out.println();
piirraHahmo(jano);
G   __
\\  ,,)_
 \'-\( /
  \ | ,\
   \|_/\\
   / _ '.D
  / / \ |
 /_\  /_\
'-    '-
"Matti L"

(\___/)
(='.'=)
(")_(")

Viitetyyppisen muuttujan tyyppi kertoo siis sen mitä metodeja ja attribuutteja muuttujalla on käytössä. Monimuotoisuutensa takia viite voi osoittaa oikeasti erityyppiseen olioon, kuin mikä tyyppi oliolle on annettu. Tutkitaan vielä seuraavaksi Jänis-oliota, joka annetaan parametriksi metodille joka ottaa Hahmo-tyyppisiä muuttujia parametrikseen. Metodilla kaannaNimi() käännetään hahmon nimi ympäri.

public void kaannaNimi(Hahmo hahmo) {
  String hahmonNimi = hahmo.getNimi();
  char[] merkit = new char[hahmonNimi.length()];
  for (int i = 0; i < hahmonNimi.length(); i++) {
    merkit[i] = hahmonNimi.charAt(hahmonNimi.length() - 1 - i);
  }
  hahmo.setNimi(new String(merkit));
}

Kun annamme Jänis-olion kaannaNimi()-metodille, viittaa hahmo-viite metodin sisällä vieläkin Jänis-olioon, vaikka sen tyypiksi tuleekin metodin sisällä Hahmo. Jänis-olio on siis monimuotoinen, ja siihen voi viitata myös Hahmo-tyyppisellä viitteellä.

Janis jano = new Janis("Matti P");
System.out.println(jano.getNimi());
kaannaNimi(jano);
System.out.println(jano.getNimi());
Matti P
P ittaM

Hajautustaulu (HashMap)

Hajautustaulu on yksi Javan yleishyödyllisistä tietorakenteista. Hajautustaulun ideana on laskea oliota kuvaavalle avaimelle, esimerkiksi ihmisen nimi, yksilöivä arvo. Tätä yksilöivää arvoa voidaan käyttää taulukon indeksinä, johon olion viite tallennetaan. Kun hajautustaulusta haetaan avaimen perusteella, löydetään suoraan taulun indeksi jossa olioviite on. Javan luokka HashMap kapseloi hajautustaulun toteutuksen, ja tarjoaa valmiit metodit sen käyttöön.

Hajautustaulu ottaa kaksi tyyppiparametria, avaimen tyypin ja tallennettavan olion tyypin. Seuraava esimerkki käyttää avaimena Integer-tyyppistä oliota, ja oliona String-tyyppistä oliota.

HashMap<Integer, String> numerot = new HashMap<Integer, String>();
numerot.put(1, "Yksi");
numerot.put(2, "Kaksi");

String merkkijono = numerot.get(1);
System.out.println(merkkijono);
merkkijono = numerot.get(42);
System.out.println(merkkijono);

Esimerkissä siis luodaan hajatustaulu, jonka avaimena on kokonaisluku (huomaa että avaimet ovat myös aina viitetyyppisiä muuttujia), ja tallennettavana oliona merkkijono. Hajautustauluun lisätään tietoa put()-metodilla, joka ottaa parametreikseen viitteet avaimeen ja tallennettavaan olioon. Metodi get()-palauttaa annettuun avaimeen liittyvän viitteen.

Koska hajautustauluun ei ole lisättyä oliota avaimelle 42, palauttaa get()-metodi avaimelle 42 null-viitteen. Esimerkin tulostus on siis seuraavanlainen.

Yksi
null

Hajautustaulussa tietty avain osoittaa aina tiettyyn paikkaan, jolloin avain ei voi osoittaa kahteen eri olioon samalla aikaa. Jos samalla avaimelle tallennetaan uusi olio, poistuu vanhan olion viite hajautustaulusta.

HashMap<Integer, String> numerot = new HashMap<Integer, String>();
numerot.put(1, "Yksi");
numerot.put(2, "Kaksi");
numerot.put(1, "Iiso yksi!");

String merkkijono = numerot.get(1);
System.out.println(merkkijono);
merkkijono = numerot.get(42);
System.out.println(merkkijono);

Koska avain 1 asetetaan uudestaan, on yllä olevan esimerkin tulostus seuraavanlainen.

Iiso yksi!
null

Kirjojen haku hajautustaulun avulla

Tutkitaan hajautustaulun toimintaa yksinkertaisen kirjastoesimerkin avulla. Kirjastosta voi hakea kirjoja kirjan nimellä, nimi toimii siis avaimena. Jos annetulle nimelle löytyy kirja, saadaan siihen liittyvä viite ja samalla kirjan tiedot. Toteutetaan ensiksi esimerkkiluokka Kirja, jolla on attribuutteina nimi ja sisältö.

public class Kirja {
  private String nimi;
  private String sisalto;

  public Kirja() {  
  }

  public Kirja(String nimi, String sisalto) {
    this.nimi = nimi;
    this.sisalto = sisalto;
  }

  public String getNimi() {
    return nimi;
  }

  public void setNimi(String nimi) {
    this.nimi = nimi;
  }

  public String getSisalto() {
    return sisalto;
  }

  public void setSisalto(String sisalto) {
    this.sisalto = sisalto;
  }
    
  public String toString() {
    String palautus = "Nimi: " + this.nimi + "\n"
      + "Sisältö: " + this.sisalto;
    return palautus;
  }
}

Luodaan seuraavaksi hajautustaulu, joka ottaa avaimekseen kirjan nimen, eli String-tyyppisen olion, ja tallettaa viitteitä Kirja-olioihin.

HashMap<String, Kirja> kirjahakemisto = new HashMap<String, Kirja>();

Yllä oleva hajautustaulu siis käyttää avaimena String-oliota. Hajautustaulu käyttää avaimen arvon laskemiseen Object-luokassa määriteltyä hashCode()-metodia, jonka perivän luokat voivat ylikirjoittaa. Emme kuitenkaan tutustu hajautustaulun toteutukseen tarkemmin tällä kurssilla.

Laajennetaan esimerkkiä siten, että kirjahakemistoon lisätään kaksi kirjaa, Matti L:n Matkat ja Matti P:n Tarinat.

Kirja mattiLMatkat = new Kirja();
mattiLMatkat.setNimi("Matti L:n Matkat");
mattiLMatkat.setSisalto("Eräänä rauhallisena iltana Saksassa, poliisi lähestyi tuttavallisesti minua..");

Kirja mattiPTarinat = new Kirja();
mattiPTarinat.setNimi("Matti P:n Tarinat");
mattiPTarinat.setSisalto("Palatessani eräältä lomalta ikkuna oli säpäleinä ja kodistani oli siivottu vanha elektroniikka..");

HashMap<String, Kirja> kirjahakemisto = new HashMap<String, Kirja>();
kirjahakemisto.put(mattiLMatkat.getNimi(), mattiLMatkat);
kirjahakemisto.put(mattiPTarinat.getNimi(), mattiPTarinat);

Nyt kirjahakemistosta voi hakea kirjan nimellä kirjoja. Seuraavan esimerkin ensimmäinen haku ei tuota osumaa, ja hajautustaulu palauttaa null-viitteen. Kirja "Matti L:n Matkat" kuitenkin löytyy.

Kirja k = kirjahakemisto.get("Matti V:n Jorinat");
System.out.println(k);
System.out.println();
k = kirjahakemisto.get("Matti L:n Matkat");
System.out.println(k);
null

Nimi: Matti L:n Matkat
Sisältö: Eräänä rauhallisena iltana Saksassa, poliisi lähestyi tuttavallisesti minua..

Hajautustaulu on hyödyllinen silloin kun tiedetään millaista tietoa halutaan hakea. Avaimet ovat aina yksilöllisiä, joten saman avaimen taakse ei voi tallettaa montaa eri oliota. Tallennettava olio voi toki olla lista tai toinen hajautustaulukko!

Kirjasto

Yllä olevan kirjahakemiston ongelmana on se, että kirjoja haettaessa täytyy muistaa kirjan nimi merkki merkiltä oikein. Javan valmis String-luokka tarjoaa meille välineet tähänkin. Metodi toLowerCase() muuttaa merkkijonon kirjaimet pieniksi, ja metodi trim() poistaa merkkijonon alusta ja lopusta tyhjät merkit (esimerkiksi välilyönnit).

String teksti = "  JEEEEEEEeeeEE";
teksti = teksti.toLowerCase(); // teksti nyt "  jeeeeeeeeeeee"
teksti = teksti.trim() // teksti nyt "jeeeeeeeeeeee"

Luodaan luokka Kirjasto, joka kapseloi hajautustaulun siten, että avaimien kirjainkoolla ei ole väliä. Lisätään Kirjasto-luokalle myös metodit lisaaKirja(Kirja kirja) ja poistaKirja(String kirjanNimi).

public class Kirjasto {
  private HashMap<String, Kirja> hakemisto;
  
  public Kirjasto() {
    hakemisto = new HashMap<String, Kirja>();
  }
  
  public void lisaaKirja(Kirja kirja) {
    String avainNimi = kirja.getNimi();
    avainNimi = avainNimi.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
    avainNimi = avainNimi.trim(); // poistetaan tyhjät merkit alusta ja lopusta
    
    if(hakemisto.containsKey(avainNimi)) {
      System.out.println("Kirja on jo kirjastossa!");
    } else {
      hakemisto.put(avainNimi, kirja);
    }
  }
  
  
  public void poistaKirja(String kirjanNimi) {
    kirjanNimi = kirjanNimi.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
    kirjanNimi = kirjanNimi.trim(); // poistetaan tyhjät merkit alusta ja lopusta
    
    if(hakemisto.containsKey(kirjanNimi)) {
      Kirja poistettava = hakemisto.get(kirjanNimi);
      hakemisto.remove(kirjanNimi);
    } else {
      System.out.println("Kirjaa ei löydy, ei voida poistaa!");
    }
  }
}

Yllä olevasta kirjastosta puuttuu vielä lista kaikista kirjoista, mikä on kirjastoille hyvin tärkeää. Käytetään tuttua luokkaa ArrayList kirjojen listaamiseen. Joudumme myös muuttamaan kirjojen lisäystä ja poistoa siten, että viitteet poistetaan kummastakin tietorakenteesta.

public class Kirjasto {
  private HashMap<String, Kirja> hakemisto;
  private ArrayList<Kirja> kirjat;
  
  public Kirjasto() {
    hakemisto = new HashMap<String, Kirja>();
    kirjat = new ArrayList<Kirja>();
  }
  
  public void lisaaKirja(Kirja kirja) {
    String avainNimi = kirja.getNimi();
    avainNimi = avainNimi.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
    avainNimi = avainNimi.trim(); // poistetaan tyhjät merkit alusta ja lopusta
    
    if(hakemisto.containsKey(avainNimi)) {
      System.out.println("Kirja on jo kirjastossa!");
    } else {
      hakemisto.put(avainNimi, kirja);
      kirjat.add(kirja);
    }
  }
  
  public void poistaKirja(String kirjanNimi) {
    kirjanNimi = kirjanNimi.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
    kirjanNimi = kirjanNimi.trim(); // poistetaan tyhjät merkit alusta ja lopusta
    
    if(hakemisto.containsKey(kirjanNimi)) {
      Kirja poistettava = hakemisto.get(kirjanNimi);
      kirjat.remove(poistettava);
      hakemisto.remove(kirjanNimi);
    } else {
      System.out.println("Kirjaa ei löydy, ei voida poistaa!");
    }
  }
}

Kirjojen listaaminen ArrayListin avulla jää lukijan itse toteutettavaksi. Toteutetaan vielä kirjan hakutoiminnallisuus siten, että kirjaa haetaan hajautusrakenteesta sen nimellä.

public Kirja haeKirja(String kirjanNimi) {
  kirjanNimi = kirjanNimi.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
  kirjanNimi = kirjanNimi.trim(); // poistetaan tyhjät merkit alusta ja lopusta
  return hakemisto.get(kirjanNimi);    
}

Yllä oleva metodi palauttaa haetun kirjan jos sellainen löytyy, muulloin null-arvon. Voimme myös käydä kaikki hakemiston avaimet läpi yksitellen, etsien esimerkiksi alkuosaa kirjan nimestä. Tällä tavalla etsiessä menetämme kuitenkin hajautustaulun nopeusedun, sillä huonoimmassa tapauksessa joudumme käymään kaikkien kirjojen nimet läpi.

public Kirja haeKirja(String kirjanNimi) {
  kirjanNimi = kirjanNimi.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
  kirjanNimi = kirjanNimi.trim(); // poistetaan tyhjät merkit alusta ja lopusta
  for(String nimi: hakemisto.keySet()) {
    if(nimi.startsWith(kirjanNimi)) {
      return hakemisto.get(nimi);
    }
  }
  return null;
}

Yksi ohjelmoinnin periaatteista on ns. DRY-periaate (Don't Repeat Yourself), jolla pyritään välttämään saman koodin olemista useassa paikassa. Merkkijonon pieneksi muuttaminen ja trimmaus, eli tyhjien merkkien poisto alusta ja lopusta, toistuu useasti kirjastoluokassamme. Siistitään Kirjasto-luokkaa siten, että kirjan nimen siistiminen tehdään erillisessä metodissa. Lisätään metodi siisti(), joka ottaa parametrina String-olion, ja palauttaa oliosta siistityn version.

private String siisti(String siistittava) {
  siistittava = siistittava.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
  return siistittava.trim(); // poistetaan tyhjät merkit alusta ja lopusta
}

Luokka Kirjasto vielä kokonaisuudessaan.

public class Kirjasto {
  private HashMap<String, Kirja> hakemisto;
  private ArrayList<Kirja> kirjat;
  
  public Kirjasto() {
    hakemisto = new HashMap<String, Kirja>();
    kirjat = new ArrayList<Kirja>();
  }
  
  public void lisaaKirja(Kirja kirja) {
    String avainNimi = kirja.getNimi();
    avainNimi = siisti(avainNimi);
    
    if(hakemisto.containsKey(avainNimi)) {
      System.out.println("Kirja on jo kirjastossa!");
    } else {
      hakemisto.put(avainNimi, kirja);
      kirjat.add(kirja);
    }
  }
  
  public void poistaKirja(String kirjanNimi) {
    kirjanNimi = siisti(kirjanNimi);
    
    if(hakemisto.containsKey(kirjanNimi)) {
      Kirja poistettava = hakemisto.get(kirjanNimi);
      kirjat.remove(poistettava);
      hakemisto.remove(kirjanNimi);
    } else {
      System.out.println("Kirjaa ei löydy, ei voida poistaa!");
    }
  }
  
  // kirjojen läpi käyminen yksi kerrallaan jätettiin pois Kirjasto-toteutuksesta
  public Kirja haeKirja(String kirjanNimi) {
    kirjanNimi = siisti(kirjanNimi);
    return hakemisto.get(kirjanNimi);    
  }
  
  private String siisti(String siistittava) {
    siistittava = siistittava.toLowerCase(); // muunnetaan nimi pieniksi kirjaimiksi
    return siistittava.trim(); // poistetaan tyhjät merkit alusta ja lopusta
  }
}

Tiedostoon kirjoittaminen

Tutustuimme viime viikolla tiedoston lukemiseen, joka tapahtui luokkien Scanner ja File avulla. Tiedostoon kirjoittamiseen on myös apuvälineet. Java tarjoaa luokan FileWriter, jolla voi kirjoittaa tekstiä tiedostoon. Luokan FileWriter konstruktori on kuormitettu, eli sillä on monta eri konstruktoria. Käytetään tiedoston nimen parametrina saavaa konstruktoria.

FileWriter kirjoittaja = new FileWriter("tiedosto.txt");
kirjoittaja.write("Hei tiedosto!");
kirjoittaja.close(); // sulkemiskutsu sulkee tiedoston ja varmistaa kirjoitettu teksti menee tiedostoon

Esimerkissä kirjoitetaan tiedostoon "tiedosto.txt" merkkijono "Hei tiedosto!". Tiedosto löytyy Files-välilehdeltä projektin tiedostoista. Tiedostoon kirjoittaminen voi aiheuttaa poikkeustilanteen, eli heittää poikkeuksen. Emme varaudu poikkeuksiin vielä, vaan annamme metodille määreen throws Exception. Metodi, jolle annetaan parametrina kirjoitettavan tiedoston nimi ja kirjoitettava sisältö voisi näyttää seuraavalta.

public static void kirjoitaTiedostoon(String tiedostonNimi, String teksti) throws Exception {
  FileWriter kirjoittaja = new FileWriter(tiedostonNimi);
  kirjoittaja.write(teksti);
  kirjoittaja.close();
}

Metodin (myös main()), joka kutsuu metodia kirjoitaTiedostoon() täytyy joko varautua poikkeukseen, tai heittää poikkeus. Luodaan vielä main()-metodi jossa kutsutaan kirjoitaTiedostoon()-metodia. Huomaa että myös main()-metodille on määritelty throws Exception-määre.

public static void main(String[] args) throws Exception {
  kirjoitaTiedostoon("paivakirja.txt", "Rakas päiväkirja, tänään oli kiva päivä.");
}

Yllä olevaa metodia kutsuttaessa luodaan tiedosto "paivakirja.txt", ja kirjoitetaan siihen teksti "Rakas päiväkirja, tänään oli kiva päivä.". Jos tiedosto on jo olemassa, pyyhkiytyy vanhan tiedoston sisältö uutta kirjoittaessa. Metodilla append() voidaan lisätä olemassaolevan tiedoston perään tekstiä, jolloin olemassaolevaa tekstiä ei poisteta. Seuraava metodi lisaaTiedostoon() lisää annetun tekstin tiedoston loppuun.

public static void lisaaTiedostoon(String tiedostonNimi, String teksti) throws Exception {
  FileWriter kirjoittaja = new FileWriter(tiedostonNimi);
  kirjoittaja.append(teksti);
  kirjoittaja.close();
}

Kirjoitus ja Lukeminen

Tehdään vielä pieni yhteenveto kirjoituksesta ja lukemisesta. Tiedoston voi lukea Scanner-luokan avulla jolle annetaan parametrina File-tyyppinen olio. Tiedoston kirjoitus taas tapahtuu FileWriter-luokan avulla. Tehdään vielä esimerkki jossa kirjoitetaan ensiksi tiedostoon, ja sitten luetaan tiedoston sisältö. Käytetään lukijan luonnissa FileWriter-luokan konstruktoria, joka saa parametrikseen File-olion.

File tiedosto = new File("tiedosto.txt");
FileWriter kirjoittaja = new FileWriter(tiedosto);
kirjoittaja.write("Heippa vaan!");
kirjoittaja.close();

Scanner lukija = new Scanner(tiedosto);
System.out.println(lukija.nextLine());

Esimerkin tulostus on Heippa vaan!