Arto Wikla 2011. Materiaalia saa vapaasti käyttää itseopiskeluun. Muu käyttö vaatii luvan.

11 Ohjelmointitekniikkaa: tiedostoja

(Muutettu viimeksi 22.10.2011 klo 12:15, sivu perustettu 22.11.2010. Arto Wikla)

Luvussa tutustutaan joihinkin tapoihihin lukea ja kirjoittaa tiedostoja. Pääpaino on tekstitiedostojen käsittelyssä, mutta myös ns. sarjallistamisesta ja binääritiedostoista nähdään esimerkkejä.

Virtojen uudelleenohjaus

Javan peruskalustoon kuuluva julkinen luokkamuuttuja System.out on ns. standarditulosvirta ja vastaavasti System.in on ns. standardisyöttövirta. Nämä tutuksi käyneet välineet oletusarvoisesti liittyvät näyttöön ja näppäimistöön. Tuttuun tapaan.

Standardivirrat voidaan kuitenkin komentorivillä ohjata liittymään myös tiedostoihin oletuskohteiden sijaan. Näin voidaan toteuttaa eräänlaisia suodattimia, (filttereitä, suotimia): Kun standardivirta liitetään tiedostoon, ohjelma enää kunnolla voi keskustella käyttäjän kanssa: Jos syötteet otetaan tiedostosta, näppäimistö ei ole käytössä, jos tulostus ohjataan tiedostoon, ohjelma ei tulosta mitään kuvaruudulle. Ja jos molemmat ohjataan, ohjelma on täysin vaitelias.

Standardisyöttövirran uudelleenohjaus

Laaditaan ihan tavalliseen tapaan ohjelma, jolla voi laskea syöttörivien määrän:

import java.util.Scanner;
public class LaskeRivit {
  private static Scanner lukija = new Scanner(System.in);
   public static void main(String[] args) {

     int lkm = 0;
     while (lukija.hasNextLine()) {
       lukija.nextLine();
       lkm = lkm + 1;
     }
     System.out.println(lkm);
   }
}

Kun tämä ohjelma suoritetaan tavalliseen tapaan, suoritus näyttää tällaiselta:

$ java LaskeRivit
eka rivi
toka        rivi
sdölköföflkö     tää on 3. rivi
4
5
alkaa varmaan riittää?
6
$

Ohjelma kirjotti siis vain esimerkin viimeisen rivin luvun. Käyttäjä ilmoitti "tiedoston loppumisen" kirjoittamalla ctrl-d (Windowsissa ctrl-z).

Ohjelma voidaan kuitenkin käynnistää myös ohjaamalla syöttövirraksi jokin tekstitiedostotiedosto:

$ java LaskeRivit < tiedosto.txt

Nyt tämä sama ohjelma lukeekin tiedoston tiedosto.txt kaikki rivit ja laskee niiden lukumäärän. Lopuksi ohjelma tulostaa kuvaruudulle kyseisen tiedoston rivien lukumäärän. Olemme siis tavallaan ohjelmoineet palvelun "laske tiedoston rivien lukumäärä"!

Standarditulosvirran uudelleenohjaus

Laaditaan nytkin tavalliseen tapaan ohjelma, joka lukee rivejä tiedoston loppuun (ctrl-d, ctrl-z) saakka ja kaiuttaa (so. tulostaa) jokaisen rivin, jolla on merkkijono "kissa" – hyväksytään kaiken kokoisin kirjaimin kirjoitetut kissat:

import java.util.Scanner;
public class EtsiKissarivit {
  private static Scanner lukija = new Scanner(System.in);
   public static void main(String[] args) {

     while (lukija.hasNextLine()) {
       String rivi = lukija.nextLine();
       String apurivi = rivi.toLowerCase();   //  samaistetaan isot ja pienet
       if (apurivi.indexOf("kissa") != -1)    //  kissa löytyi
         System.out.println(rivi);
     }
   }
}

Tavalliseen tapaan käynnistettynä ohjelman suoritus näyttää tällaiselta (koneen kirjoittamat tekstit kursiivilla):

$ java EtsiKissarivit
Kissatarina ohjelmoinnin avuksi.
Kissatarina ohjelmoinnin avuksi.
Asuisko meissä kaikissa pieni lehmä?
Asuisko meissä kaikissa pieni lehmä?
Niin kuulee joskus väitettävän.
Joskus kuitenkin epäilee,
olisiko se kuitenkin kissa?
olisiko se kuitenkin kissa?
Tuo turkissaan viihtyvä.
Tuo turkissaan viihtyvä.
Samantekevää.
$

"Kissan" sisältävät rivit siis vain kaiutettiin kuvaruudulle.

Käynnistetään sama ohjelma sitten uudelleen, siten että standarditulosvirta ohjataan tiedostoon tulokset.txt ja kirjoitetaan itse ohjelmalle sama tarina kuin edellä:

$ java EtsiKissarivit > tulokset.txt
Kissatarina ohjelmoinnin avuksi.
Asuisko meissä kaikissa pieni lehmä?
Niin kuulee joskus väitettävän.
Joskus kuitenkin epäilee,
olisiko se kuitenkin kissa?
Tuo turkissaan viihtyvä.
Samantekevää.
$

Ohjelma ei sano (eikä voikaan sanoa) käyttäjälle yhtään mitään, mutta suorituksen tuloksena syntyy uusi tekstitiedosto tulokset.txt:

Kissatarina ohjelmoinnin avuksi.
Asuisko meissä kaikissa pieni lehmä?
olisiko se kuitenkin kissa?
Tuo turkissaan viihtyvä.

Huom: Tulosvirtaa tiedostoon ohjatessa on syytä olla tarkkana: jos tulostiedoston niminen tiedosto on jo olemassa, se korvataan varoittamatta uudella!

Virtoja putkeen

Filttereitä voidaan myös yhdistää toisiinsa: liittää yhden ohjelman tulostukset seuraavan ohjelman syötteiksi. Esimerkkinä voisimme vaikka kirjoittaa edellä nähdyn kissatarinan kokonaisuudessaan tiedostoksi kissojako.txt:

Kissatarina ohjelmoinnin avuksi.
Asuisko meissä kaikissa pieni lehmä? 
Niin kuulee joskus väitettävän. 
Joskus kuitenkin epäilee, 
olisiko se kuitenkin kissa?
Tuo turkissaan viihtyvä.
Samantekevää.

Annetaan sitten komento:

$ java EtsiKissarivit < kissojako.txt | java LaskeRivit

Toki viimenenkin virta voitaisiin ohjata tiedostoon:

$ java EtsiKissarivit < kissojako.txt | java LaskeRivit > kissalaskennanTulos.txt

Pitkiä putkia

Toteutetaan filtteri, joka muokkaa standardisyöttövirran riveistä muunnettuja rivejä tulosvirtaan. Korvattava ja korvaava merkkijono annetaan komentoriviparametrina:

import java.util.Scanner;
public class Muuta {
  private static Scanner lukija = new Scanner(System.in);
   public static void main(String[] args) {
     if (args.length < 2) {
       System.out.println("Muuta-ohjelmalle pitää antaa 2 parametria!");
       return; // keskeytys!
     }
     while (lukija.hasNextLine()) {
       String rivi = lukija.nextLine();
       rivi = rivi.replace(args[0], args[1]);
       System.out.println(rivi);
     }
   }
}

Ohjelman suoritus tavalliseen tapaan, virrat oletusmerkityksissään (ohjelman tulosteet kursiivilla):

$ java Muuta a ä
aamuaurinko paistaa
äämuäurinko päistää
Laiva on lastattu kaalilla
Läivä on lästättu käälillä
$ java Muuta kissa koira
kissa kävelee aamutakissa
koira kävelee aamutakoira
$ java Muuta kk ""
takkatulen liekki loimuaa
taatulen liei loimuaa
rankka taakka kevenee
rana taaa kevenee
$ java Muuta "" "*"
miten nyt menee?
*m*i*t*e*n* *n*y*t* *m*e*n*e*e*?*
?
*?*
q w e r y
*q* *w* *e* *r* *y*
$

Filttereistä voidaan rakentaa myös pitkiä putkia. Tehdään esimerkkinä "salakirjoitus", jossa korvataan merkkijonoja seuraavaan tapaan:

$ java Muuta aa zq < viesti.txt | java Muuta o qw | java Muuta ll x > salaviesti.txt

Jos tiedosto viesti.txt sisältää tekstin

Ensi yönä kello 1:35 ensimmäinen ryhmä lähtee marssille kohti kaalimaata.
Joukkoon liittyy osastollinen citykaneja suuren tammen kohdalla kello 1:50.
Tasan kello 2:00 alkaa kaalien järsintä.

putken läpi saadaan tiedostoon salaviesti.txt sisältö:

Ensi yönä kexqw 1:35 ensimmäinen ryhmä lähtee marssixe kqwhti kzqlimzqta.
Jqwukkqwqwn liittyy qwsastqwxinen citykaneja suuren tammen kqwhdaxa kexqw 1:50.
Tasan kexqw 2:00 alkzq kzqlien järsintä.

Huomaa että kirjoittaessasi ohjelman käynnistyskomentoja ja komentoriviparametreja, keskustelet käyttöjärjestelmän komentotulkin kanssa! Joillakin ilmauksilla saattaa olla viesti käyttöjärjestelmälle: Esimekiksi Linuxin komentotulkille tähdellä (*) ja pisteellä (.) on erityismerkitys (kaikki nykyhakemiston tiedostot, nykyhakemisto, ...) Komentoriviparametreiksi tällaiset saadaan ympäröimällä ne lainausmerkein.

File-olio ei ole tiedosto...

Standardivirtoja ohjaillen voidaan tehdä kaikenlaista, mutta usein tilanne kuitenkin on sellainen, että ohjelman täytyy voida keskustella myös käyttäjän kanssa. Tällöin standardisyöttövirta ja standarditulosvirta eivät ole uudelleenohjattavissa. On luotava itse luotava syöttö- ja tulostustiedostoja.

Luokka File on tiedostojen käsittelyssä käyttökelpoinen työkalu. Sen avulla tiedostoja pääsee hallitsemaan: "onko olemassa", "nimetään uudelleen", "poistetaan", "mikä on pituus", ... Luokan ilmentymät, File-oliot eivät itse ole tiedostoja. Mutta niiden avulla ohjelman käyttöön saa käyttöjärjestelmän tuntemia tiedostonimiä, hakemistopolkuja ja sen semmoisia.

Luokalla on mm. konstruktori:

public File(String pathname)

Parametrina annetaan tiedostonimi, tarkemmin sanottuna ns. polkunimi. Ainoa virhe File-oliota luotaessa voi olla se, että todellisena parametrina on null. Siitä seuraa NullPointerException. Kyseessä on tarkistamaton poikkeus, joten siihen ei ole pakko varautua.

File-olion luonti ei vielä ole missään tekemisissä todellisten tiedostojen kanssa, olio on tavallaan vain konkreettisen tiedosto- tai hakemistonimen abstrakti vastine Java-ohjelmassa, "tiedostokahva".

Polkunimi (pathname) tarkoittaa tiedoston (tai hakemiston) nimeä mahdollisine hakemistopolkuineen. Esimerkiksi Unix/Linux-järjestelmissä ne voivat olla mm. seuraavanlaisia:

tiedosto
td.sto
hakemisto/alihakemisto/td.sto   (nykyhakemiston alihakemisto ...)
/hakemisto/alihakemisto/td.sto  (juurihakemiston alihakemisto ...)
../td.sto                       (ylemmän tason hakemiston td.sto)
../../alihak/td.sto             (.. jne...)

File-oliolle on sovellettavissa monia metodeita, mm.

public boolean exists()   // palauttaa true, jos tiedosto on olemassa
public long length()      // antaa tiedoston pituuden
public boolean renameTo(File dest) // uudelleennimeää, true jos onnistui
public boolean delete()   // poistaa tiedoston, true jos onnistui
public String getName()   // antaa tiedoston nimen
  ...

Aksessoreiden käytöstä saadaan joitakin esimerkkejä jatkossa.

Nimetty syöttötiedosto

Nimetyn tekstitiedoston lukeminen on helppoa, koska käytettävissä on se ihan sama Scanner-luokka, jonka ilmentymän avulla koko kurssin ajan on luettu standardisyöttövirtaa. Kaikki, mitä siitä on jo opittu, on sellaisenaan sovellettavissa erillosten tekstitiedostojen lukemiseen!

Oikeastaan ainoa uutuus on oppia, miten konkreettinen, fyysinen tiedosto kytketään ohjelman luettavaksi: Scanner-luokassa on mm. konstruktori:

public Scanner(File source) throws FileNotFoundException

Huom: Koska FileNotFoundException on tarkistettu poikkeus, konstruktorin kutsujan on se tavalla tai toisella hoideltava!

Huom: Scanner-luokalla on myös String-parametrinen konstruktori. Tuollainen Scanneri kuitenkin selaa yksittäistä merkkijonoa, ei nimettyä tiedostoa!

Esimerkki: Muokataan ylempänä nähty ohjelma tiedoston "kissa"-rivien suodatukseen sellaiseksi, joka kysyy tiedoston nimen:

import java.io.*;
import java.util.Scanner;

public class EtsiKissarivitTiedostosta {
  private static Scanner lukija = new Scanner(System.in);

   public static void main(String[] args) throws FileNotFoundException {

     System.out.println("Minkä tiedoston kissarivit haluat nähdä?");
     String tiedostonNimi = lukija.nextLine();

     File tiedostoKahva = new File(tiedostonNimi);

     if (!tiedostoKahva.exists()) {
      System.out.println("Tiedostoa "+ tiedostonNimi +" ei löydy!");
      return; // keskeytetään kaikki!
     }

     Scanner syottotiedosto = new Scanner(tiedostoKahva);

     while (syottotiedosto.hasNextLine()) {
       String rivi = syottotiedosto.nextLine();
       String apurivi = rivi.toLowerCase();   //  samaistetaan isot ja pienet
       if (apurivi.indexOf("kissa") != -1)    //  kissa löytyi
         System.out.println(rivi);
     }

     syottotiedosto.close(); // Huom.!!
  }
}

Suoritusesimerkki:

$ java EtsiKissarivitTiedostosta
Minkä tiedoston kissarivit haluat nähdä?
kissojako.txt
Kissatarina ohjelmoinnin avuksi.
Asuisko meissä kaikissa pieni lehmä? 
olisiko se kuitenkin kissa?
Tuo turkissaan viihtyvä.
$

Nimetty tulostustiedosto

On mukavaa voida uudelleenkäyttää jo opittua. Näin kävi syöttötiedoston lukemisessa ja näin käy onneksi myös tulostustiedostoon kirjoittamisessa.

Tulostustiedostoksi luokka PrintWriter on mitä mukavin, koska se tarjoaa System.out-kentän käytössä tutuksi tulleet metodit print ja println kuormitettuina monentyyppisille parametreille.

Luokan ilmentymiä eli tulostustiedostoja voidaan luoda mm. konstruktoreilla

public PrintWriter(String fileName) throws FileNotFoundException
public PrintWriter(File file)       throws FileNotFoundException

Tiedosto siis voidaan antaa tiedostonimenä tai tai File-oliona.

Poikkeus FileNotFoundException kuuluu tarkistettuihin poikkeuksiin, joten se on tavalla tai toisella käsiteltävä. Asia saattaa tosin uuden kirjoitettavan tekstitiedoston tapauksessa vaikuttaa hieman omituiselta... APIssa onkin selittelyn makua: "If the given file object does not denote an existing, writable regular file and a new regular file of that name cannot be created, or if some other error occurs while opening or creating the file".

PrintWriter-oliolla voidaan kirjoittaa tiedostoon aivan samaan tapaan kuin hyvin tutuksi tulleella System.out-kentän PrintStream-oliolla kirjoitetaan standarditulosvirtaan. Oma tulostustiedosto on aina muistettava sulkea PrintWriter-aksessorilla close(). Muuten voi käydä huonosti, vähintäänkin viimeinen tulostusrivi voi kadota...

Esimerkki: Laaditaan sovellus kirjoituksen tallentamiseen tekstitiedostoon:

import java.io.*;
import java.util.Scanner;

public class TekstiTallennin {
  private static Scanner lukija = new Scanner(System.in);

  public static void main(String[] args)  throws FileNotFoundException {

    System.out.println("Mihin tiedostoon kirjoittamasi teksti tallennetaan?");
    String tiedostonNimi = lukija.nextLine();

    File tiedostoKahva = new File(tiedostonNimi);

    if (tiedostoKahva.exists()) {
      System.out.println("Tiedosto "+tiedostonNimi+" on jo olemassa!");
      System.out.println("Haluatko korvata sen uudella (varmasti=kyllä/muu=ei)?");
      if (!lukija.nextLine().equals("varmasti")) {
        System.out.println("Vanhaa tiedostoa ei tuhottu.");
        return; // keskeytetään kaikki!
      }
    }

    PrintWriter tulos = new PrintWriter(tiedostoKahva);

    System.out.println("Kirjoittamasi teksti menee tiedostoon "+tiedostonNimi);
    System.out.println("(ctrl-d lopettaa!)\n");

    while (lukija.hasNextLine()) {
      String rivi = lukija.nextLine();
      tulos.println(rivi);
    }

    tulos.close();  // Huom!

    System.out.println("\nTekstisi on nyt tallessa tiedostossa "+ tiedostonNimi);
  }
}

[Sarjallistaminen – binääritiedostoja]

Olioiden sarjallistamiseksi kutsutaan olioarvojen kirjoittamista tulosvirtaan, vaikkapa tiedostoon. Sarjallistetut oliot voidaan sitten lukea tiedostosta oliomuuttujien arvoksi. Sarjallistetuista olioista rakentuvat tiedostot vastaavat muiden ohjelmointikielten ns. binääritiedostoja. Sarjallistamista käytetään myös olioita tietoliikenneyhteyksin koneelta koneelle siirrettäessä.

Olioarvojen lisäksi myös alkeistyyppisiä arvoja voidaan sarjallistaa.

Jos luokan ilmentymien halutaan sarjallistuvan, luokan on toteutettava rajapintaluokka Serializable, joka löytyy pakkauksesta java.io. Toteuttaminen on vaivatonta, koska tuo rajapintaluokka ei määrittele ensimmäistäkään metodia! Kyseessä onkin oikeastaan vain temppu, jolla Java-kääntäjälle kerrotaan, että luokan ilmentymille on syytä olla "määrämuotoinen" bittiesitys, jota käyttäen arvoja voidaan talletella ja lähetellä.

Tuttu luokka Kuulaskuri voidaan sarjallistaa yksinkertaisesti vain kirjoittamalla luokan otsikkoon implements-ilmaus. Luokka Serializable lötyy pakkauksesta java.io:

import java.io.*;
public class Kuulaskuri implements Serializable {
   private int kuu;   // sallitut arvot 1,..12 
   public Kuulaskuri() {kuu = 1;}
   public int moneskoKuu() {return kuu;}
   public void seuraavaKuu() {++kuu; if (kuu == 13) kuu = 1;}
}

Kirjoittaminen

Sarjallistettavia olioita voidaan kirjoittaa luokan ObjectOutputStream ilmentymään (tulostettava binääritiedosto), jonka konstruktorin parametriksi kelpaa luokan FileOutputStream ilmentymä. Viimeksimainitulla taas on mm. String- ja File-parametrinen konstruktori.

Huomaa miten tässä taas kerran "raskasta oliokalustoa" käytetään Javan valmiiden välineiden toteutuksessa: ObjectOutputStream-luokan konstruktorin muodollisen parametrin tyyppi on abstrakti luokka OutputStream, jonka ei-abstrakti aliluokka FileOutputStream tarjoaa mukavat konstruointivaihtoehdot: voidaan antaa joko File-olio ja tiedostonimi.

Binääritulostiedosto voidaan siis määritellä vaikkapa:

FileOutputStream   apuTied = new FileOutputStream("LaskuriTurva.oma");
ObjectOutputStream talteen = new ObjectOutputStream(apuTied);

Luokka ObjectOutputStream tarjoaa metodeita mm. alkeistyyppien binääritulostamiseen: writeDouble, writeInt, ym., ja metodin writeObject olioarvojen tulostamiseen.

Huom: ObjectOutputStream-tiedostolle on lopussa tehtävä flush()-operaatio, FileOutputStream-oliolle close()-operaatio. Tarkistetut poikkeukset on tavalla tai toisella käsiteltävä. Tiedostojen luonnissa voi tulla IOException ja FileNotFoundException.

Esimerkki – Kuulaskuri-olioita tiedostoon:

import java.io.*;
public class KuulaskuritTurvaan {

  public static void main(String[] args) throws Exception {  // laiskaa!

    FileOutputStream   apuTied = new FileOutputStream("LaskuriTurva.oma");
    ObjectOutputStream talteen = new ObjectOutputStream(apuTied);

    Kuulaskuri a = new Kuulaskuri();
    Kuulaskuri b = new Kuulaskuri();
    Kuulaskuri c = new Kuulaskuri();

    a.seuraavaKuu();
    b.seuraavaKuu(); b.seuraavaKuu();
    c.seuraavaKuu(); c.seuraavaKuu(); c.seuraavaKuu();

    System.out.println(
      a.moneskoKuu()+" "+b.moneskoKuu()+" "+c.moneskoKuu());

    talteen.writeObject(a);
    talteen.writeObject(b);
    talteen.writeObject(c);

    talteen.flush(); // !!
    apuTied.close(); // !!
  }
}

Tulostus:

2 3 4

Lukeminen

Sarjallistettuja oliota voidaan lukea hyvin samaan tapaan kuin niitä kirjoitetaan käyttäen luokkia ObjectInputStream ja FileInputStream:

FileInputStream   apuTied  = new FileInputStream("LaskuriTurva.oma");
ObjectInputStream tallesta = new ObjectInputStream(apuTied);

Lukemiseen löytyy vastaavia metodeja kuin tulostamiseen: readDouble, readInt, ..., readObject. Viimeksimainittua käytettäessä on tehtävä eksplisiittinen tyyppimuunnos, ellei arvon saajakin ei ole Object-tyyppinen.

Huom: FileInputStream-oliolle on syytä lopuksi tehdä close()-operaatio. Myös lukemisen tarkistettuihin poikkeuksiin on varauduttava.

Esimerkki – Kuulaskuri-olioita tiedostosta:

import java.io.*;
public class KuulaskuritTurvasta {

  public static void main(String[] args) throws Exception {  // laiskaa!

    FileInputStream   apuTied  = new FileInputStream("LaskuriTurva.oma");
    ObjectInputStream tallesta = new ObjectInputStream(apuTied);


    Kuulaskuri a, b, c;

    a = (Kuulaskuri)tallesta.readObject();  // tyyppimuunnos!
    b = (Kuulaskuri)tallesta.readObject();
    c = (Kuulaskuri)tallesta.readObject();

    System.out.println(
      a.moneskoKuu()+" "+b.moneskoKuu()+" "+c.moneskoKuu());

    apuTied.close(); // !!
  }
}

Tulostus:

2 3 4

Muutamia huomioita