Kurssin viimeisimmän version löydät täältä: O1: 2024
- CS-A1110
- Kierros 11
- Luku 11.3: Vähän tiedostojen käsittelystä
Luku 11.3: Vähän tiedostojen käsittelystä
Tästä sivusta:
Pääkysymyksiä: Miten lataan dataa (teksti)tiedostosta ohjelman käsiteltäväksi? Tai tallennan dataa tiedostoon?
Mitä käsitellään? Tekstitiedoston lukeminen ja kirjoittaminen.
Sivurooleissa: iteraattorit, try
, finally
. Lopussa muitakin
bonusaiheita.
Mitä tehdään? Luetaan. Vapaaehtoinen ohjelmointiharjoitus.
Suuntaa antava työläysarvio:? Tunti. (Tai voit ohittaa kokonaan.)
Pistearvo: Vapaaehtoinen osio; ei pisteitä.
Oheisprojektit: Files (uusi)
Muuta: Yhdessä esimerkissä käytetään o1.play
-funktiota, mutta
äänien kuunteleminen on tässä luvussa toissijaista.
Johdanto
Tiedostojen käyttö ohjelmissa
Tähän asti kurssilla et ole itse kirjoittanut koodia, joka käyttäisi tietokoneen levylle tallennettuja tietoja tai tallentaisi tietoja kiintolevyn tiedostoihin. Eräät valmiina annetut ohjelmakomponentit tosin ovat näin tehneet: esimerkiksi DNA-tietoja käsitellyt ohjelma luvusta 5.6 osasi lukea syötteensä tekstitiedostosta, samoin Stars-projekti ja RobotTribes.
Tiedostojen käsitteleminen on todellisissa käyttösovelluksissa erittäin yleistä. Syitä on monenlaisia; tässä eräitä:
- Halutaan, että ohjelma voi tallentaa dokumentteja (esim. tekstidokumentti, PowerPoint-esitys, kokemuspäiväkirjaan tehdyt merkinnät) siten, että ne säilyvät kiintolevyllä tms. massamuistissa myös ohjelman käyttökertojen välissä ja niitä voi palata muokkaamaan.
- Halutaan, että ohjelma osaa ottaa tiedostoon kirjatut tiedot syötteekseen (esim. tallennettu mittausdata) ja laskea tuloksia niiden perusteella.
- Halutaan, että käyttäjä voi tehdä sovelluksen käyttämiseen liittyviä asetuksia (esim. kieliasetus). Asetukset tallennetaan tiedostoon ja ladataan aina, kun sama käyttäjä käynnistää sovelluksen.
- Halutaan tallentaa tietokonepelin tilanne ja ladata se käyttäjän pyytäessä.
Tässä luvussa tutustut tiedostojen käsittelemiseen vain lyhyesti. Tarkastelu on käytännöllinen: näet muutaman perustyökalun, jota tiedostojen käsittelemiseen voi Scala-ohjelmissa käyttää. Lisää aiheesta opit muilla ohjelmointikursseilla tai omin toimin vaikkapa nettilähteiden avulla.
I/O-kirjastoista
On tapana sanoa, että kun tiedostoa käytetään ohjelman syötteenä, ohjelma lukee (read) tiedostoa. Vastaavasti tallennettaessa tietoa ohjelma kirjoittaa tiedostoon (write, joissakin yhteyksissä myös print). Lukemiseen ja kirjoittamiseen viitataan usein termillä I/O (sanoista input/output). Yleistermi I/O kattaa tiedostojen käsittelyn lisäksi myös muunlaisen vuorovaikutuksen ohjelman ja muiden tahojen välillä, kuten ruudulle tulostamisen ja tietoverkon käytön.
Ohjelmointikielten peruskirjastoista löytyy käytännössä aina jonkinlainen I/O-ohjelmakirjasto,
jolla voi käsitellä tiedostoja. Scalan I/O-kirjasto löytyy arvattavan nimisestä pakkauksesta
scala.io
.
Koska Scalasta voi helposti käyttää myös Java-kielen kirjastoja (luku 5.4), joihin
lukeutuu kelvollinen I/O-kirjasto, ei erillisen monipuolisen I/O-kirjaston laatiminen
Scala-ohjelmointia varten ole toistaiseksi noussut kovin korkealle Scala-kieltä kehittävien
tahojen asialistalla. Pakkauksessa scala.io
on
ainoastaan muutama suhteellisen yksinkertainen työkalu muutamaan suhteellisen yksinkertaiseen
perustarpeeseen kuten tekstitiedostojen lukemiseen. Muihin tarpeisiin Scala-ohjelmissa
käytetään toistaiseksi esimerkiksi pakkausta java.io
tai jotakin muuta kirjastoa.
Tekstitiedostoista
Tietokannat
Vaihtoehdon tavallisille tiedostoille tarjoavat tietokannat (database), joita käyttämällä voidaan helpottaa, tehostaa ja turvata tiedon käsittelyä. Tietokannoista on Aallossakin erillisiä kursseja.
Tiedostoihin, kuten tietokoneen muistiin muutenkin, voi tallentaa dataa monessa eri muodossa (luku 5.4). Tässä luvussa pitäydymme ns. plain text -tekstitiedostoissa, joihin tallennetut bitit on tarkoitus tulkita kirjoitusmerkkeinä.
Tekstitiedostojen hyviin puoliin lukeutuu se, että niitä voi helposti muokata erilaisissa editoriohjelmissa (esim. Emacs, Notepad tai Eclipse). Monissa I/O-kirjastoissa on välineitä, jotka sopivat nimenomaan tekstitiedostojen käsittelyyn.
Tiedoston lukeminen, esimerkki 1/7
Tavoite: luetaan muutama merkki
Olkoon meillä olemassa tekstitiedosto nimeltä example.txt
, jolla on seuraava sisältö:
This is the first line from the file.
This is the second one.
This third line is the last.
Tällainen esimerkkitiedosto löytyy projektista Files, josta myös löytyvät kaikki tämän luvun esimerkkiohjelmat.
Laaditaan aluksi ohjelma, joka lukee tiedostoa merkki kerrallaan ja raportoi muutaman ensimmäisen lukemansa merkin. Ohjelman tuloste on tällainen:
The first character in the file is T.
The second character in the file is h.
The third character in the file is i.
The fourth character in the file is s.
The fifth character in the file is .
The sixth character in the file is i.
Ratkaisu: fromFile
ja next
scala.io
-pakkauksessa on luokka Source
, joka kuvaa dataa, jonka ohjelma lukee jostakin
lähteestä, esimerkiksi tiedostosta. Luokalla on myös kumppaniolio, joka tarjoaa erilaisia
tehdasmetodeita; yksi näistä on nimeltään fromFile
. Tämä metodi luo Source
-olion, joka
saa datansa tiedostosta. Käytetään sitä:
import scala.io.Source
object ReadingExample1 extends App {
val file = Source.fromFile("example.txt")
println("The first character in the file is " + file.next() + ".")
println("The second character in the file is " + file.next() + ".")
println("The third character in the file is " + file.next() + ".")
println("The fourth character in the file is " + file.next() + ".")
println("The fifth character in the file is " + file.next() + ".")
println("The sixth character in the file is " + file.next() + ".")
file.close()
}
file
viittaa ohjelmassamme läpikäyjäolioon, jollaista
sanotaan iteraattoriksi (iterator). Iteraattori liittyy
johonkin tietolähteeseen (tiedostoon, alkiokokoelmaan tms.) ja
osaa käydä sen sisällön kerran läpi.next
-metodilla.
Tässä siis ensin saadaan ensimmäinen tiedostoon tallennetuista
merkeistä (Char
-tyyppinen arvo).next
-metodia kutsuttaessa. Uudet
next
-kutsut palauttavat seuraavia merkkejä tiedostosta.close
-metodikutsulla.example.txt
löytyy ohjelman työhakemistosta eli
juuri siitä kansiosta, jossa ohjelmaa ajetaan. Eclipsessä projektia
ajettaessa työhakemisto on projektin juurikansio. (Voitaisiin myös
käyttää absoluuttista tiedostopolkua.)Tiedoston lukeminen, esimerkki 2/7: virhetilanteet
I/O-ohjelmointiin liittyy kiinteästi ajonaikaisten virhetilanteiden mahdollisuus.
Esimerkiksi mikä tahansa äskeisen ohjelman file.next()
-kutsuista epäonnistuu, mikäli
tiedoston sisältämään dataan ei päästäkään käsiksi. Näin voi käydä vaikkapa silloin,
jos kyseessä on muistitikulla oleva tiedosto, ja joku vetää tikun irti juuri, kun pitäisi
lukea lisää dataa. Tällöin next
-metodikutsu tuottaa tyyppiä IOException
olevan
ajonaikaisen virhetilanteen, joka katkaisee esimerkkikoodimme suorituksen. Jäljelläolevia
rivejä ei suoriteta lainkaan.
Tavoite: suljetaan tiedosto aina
Yllä jo todettiin, että I/O-ohjelmoinnin "hygieniaan" kuuluu vapauttaa varatut resurssit,
kun niitä ei enää tarvita. Esimerkissämme tämä tarkoittaa close
-metodin kutsumista.
Äskeisessä ohjelmassa on close
-kutsu. Se kuitenkin jää suorittamatta, mikäli jokin
next
-kutsuista katkaisee koodin suorittamisen poikkeustilanteen johdosta.
On hyvä opetella alusta pitäen huolehtimaan tiedoston sulkemisesta aina siinäkin
tapauksessa, että lukemisen aikana tapahtuu virhe. Siihen on montakin tapaa; tässä
käsittelemme niistä try
-rakenteen.
Ratkaisu: try
ja finally
Seuraava ohjelma toimii muuten samoin kuin edellinen mutta huolehtii tiedoston sulkemisesta myös virheen sattuessa.
import scala.io.Source
object ReadingExample2 extends App {
val file = Source.fromFile("example.txt")
try {
println("The first character in the file is " + file.next() + ".")
println("The second character in the file is " + file.next() + ".")
println("The third character in the file is " + file.next() + ".")
println("The fourth character in the file is " + file.next() + ".")
println("The fifth character in the file is " + file.next() + ".")
println("The sixth character in the file is " + file.next() + ".")
} finally {
file.close()
}
}
try
-sanalla ja aaltosulkeilla merkitään lohko, jonka koko ohjelmakoodi
suoritetaan — tai ainakin yritetään suorittaa — ihan tavalliseen
tapaan. Lohkon suorittaminen päättyy kesken, jos syntyy ajonaikainen
poikkeustilanne. Tällöin katkeaisi koko tämän ohjelmamme suoritus siihen
paikkaan, mutta...try
-lohkon perään voi kirjoittaa finally
-lohkon. Se määrittelee,
mitä tehdään try
-lohkon jälkeen kaikissa tapauksissa, myös silloin,
jos try
-lohkon suorittaminen katkesi poikkeustilanteeseen.try
-lohkon sisällön suorittaminen onnistunut tai ei."Entä virheisiin reagoiminen?
finally
-lohko siis mahdollistaa sen, että tietyt käskyt suoritetaan kaikissa tapauksissa,
sattui virhe tai ei. Vaan entä jos haluat suorittaa tietyt käskyt vain siinä tapauksessa,
että tiettyä koodinpätkää suoritettaessa tapahtui ajonaikainen virhe? Tällöin voisi
esimerkiksi tulostaa käyttäjän näkyviin jonkin virheilmoituksen.
Käyttämämme rakenne ei myöskään estä ohjelmaa kaatumasta virheeseen finally
-lohkon
suoritettuaan. Entä jos haluaisit virheen sattuessa jatkaa ohjelman suoritusta tavalla
tai toisella? (Tämä voi sovelluksesta ja virheestä riippuen olla mahdollista.)
Näihin on toki konstinsa, joista yksi on se, että try
-rakenteeseen voi liittää
catch
-lohkon, joka määrää, mitä virheen sattuessa tehdään. (Tuota aihetta käsitellään
toisilla ohjelmointikursseilla kuten Ohjelmointistudiot 1
ja 2. Löytänet lisätietoja
helposti myös verkkohaulla.)
Tiedoston lukeminen, esimerkki 3/7
Tavoite: luetaan kukin merkki
Edellinen esimerkkiohjelma tulosti vain tiedoston kuusi ensimmäistä merkkiä. Laaditaan nyt ohjelma, joka tulostaa jokaisen merkin. Kun tiedoston sisältö on kuten edellä, näyttää nyt laadittavan ohjelman tuloste tältä:
Character #1 is T.
Character #2 is h.
Character #3 is i.
Character #4 is s.
Character #5 is .
Character #6 is i.
Character #7 is s.
Character #8 is .
Character #9 is t.
[Tulostetta lyhennetty tästä välistä.]
Character #82 is s.
Character #83 is .
Character #84 is t.
Character #85 is h.
Character #86 is e.
Character #87 is .
Character #88 is l.
Character #89 is a.
Character #90 is s.
Character #91 is t.
Character #92 is ..
Ratkaisu: Source
-olio for
-silmukassa
import scala.io.Source
object ReadingExample3 extends App {
val file = Source.fromFile("example.txt")
try {
var charCount = 1 // askeltaja: 1, 2, ...
for (char <- file) {
println("Character #" + charCount + " is " + char + ".")
charCount += 1
}
} finally {
file.close()
}
}
for
-silmukalla. Tämä vastaa sitä,
että toistuvasti pyydettäisiin iteraattorilta seuraavaa arvoa
next
-metodilla, kunnes läpikäytäviä arvoja ei enää ole jäljellä.Iteraattoreista ja for
-silmukoista
Yllä kerrottiin, että iteraattori on olio, joka osaa käydä jonkin datan kertaalleen läpi.
Myös alkiokokoelmalta voi pyytää kokoelman sisältöä läpikäyvän iteraattorin. Kulissien takana Scalan kokoelmat käyttävätkin iteraattoriolioita tarjotakseen tuttuja palveluita.
Sen lisäksi, että iteraattorioliolla on pääsy läpikäytävään dataan, sen tehtävänä on pitää kirjaa yhdestä tuon datan läpikäyntikerrasta eli iteraatiosta. Tapa, jolla tästä pidetään kirjaa, riippuu kokoelmasta. Vaikkapa puskuria läpikäytäessä iteraattori voi pitää sisällään kirjaa seuraavaksi käsiteltävästä indeksistä.
Alkiokokoelmaa läpikäydessä sovellusohjelmoijan ei yleensä tarvitse luoda iteraattorioliota erillisellä käskyllä. Tämä ei johdu siitä, etteikö iteraattorioliosta olisi tällöin hyötyä, vaan siitä, että esimerkiksi Scala-työkalut huolehtivat tällöin iteraattorin luomisesta automaattisesti. Voimme tarkastella asiaa lyhyen yleissivistävästi.
Tässä ensin tutunlainen for
-silmukka, joka tulostaa vektorin sisällön.
// Versio 1
val lukuja = Vector(10, 5, 2, 4)
for (luku <- lukuja) {
println(luku)
}
Vektorilla on iterator
-metodi, joka luo uuden kyseistä vektoria läpikäyvän
iteraattoriolion (vrt Source.fromFile
). Iteraattori osaa antaa vektorin
sisällöstä alkion kerrallaan sekä pitää kirjaa siitä, missä alkiossa ollaan
menossa. Seuraava koodi tekee saman kuin edellinenkin.
// Versio 2
val lukuja = Vector(10, 5, 2, 4)
val iteraattori = lukuja.iterator
for (luku <- iteraattori) {
println(luku)
}
Saman tekee tämäkin koodinpätkä, jossa iteraattorin next
ja hasNext
-metodeita
käytetään suoraan sen sijaan, että tuo työ jätettäisiin for
-silmukan huoleksi:
// Versio 3
val lukuja = Vector(10, 5, 2, 4)
val iteraattori = lukuja.iterator
while (iteraattori.hasNext) {
println(iteraattori.next())
}
Luvusta 6.3 tiedämme, että Scalan for
-silmukan toteutuksessa käytetään
foreach
-metodia apuna. Esimerkiksi yllä olevan Version 1 for
-silmukka
on vain erilainen tapa kirjoittaa foreach
-käsky. Scala-kääntäjä huolehtii
siitä, että Versio 1 itse asiassa tarkoittaa käytännössä tätä:
// Versio 4
val lukuja = Vector(10, 5, 2, 4)
lukuja.foreach( println(_) )
Tässä siis kutsutaan vektoriolion foreach
-metodia, mutta miten se
on toteutettu? Metodin kuuluu käydä läpi vektorin oma sisältö, ja
siihen tarkoitukseen vektori luo itseään läpikäyvän iteraattorin
ja kutsuu sen foreach
-metodia; iteraattori huolehtii läpikäynnin
etenemisestä. Versio 4 yllä toimii siis oleellisesti samoin kuin
seuraava viitosversio:
// Versio 5
val lukuja = Vector(10, 5, 2, 4)
lukuja.iterator.foreach( println(_) )
Syvemmälle iteraattorien toteutukseen emme nyt mene. Nämä esimerkit
kuitenkin valottavat hieman sitä, mitä tapahtuu, kun käytät ohjelmassasi
for
-silmukkaa kokoelman läpikäymiseen.
Tiedoston lukeminen, esimerkki 4/7
Tavoite: kerätään koko tiedoston sisältö muuttujaan
Entä jos haluaisimme kerätä koko tiedoston sisällön ja välittää sen parametriksi jollekin
funktiolle? Ehkä esimerkiksi lukisimme tiedostoon tallennetut nuotit ja välittäisimme
kappaleen o1.play
-funktiolle.
Tehdään juuri niin. Yksi tarkoitukseen sopiva esimerkkitiedosto on Files-projektin
running_up_that_hill.txt
.
Ratkaisu: mkString
import scala.io.Source
import o1.play
object ReadingExample4 extends App {
val file = Source.fromFile("running_up_that_hill.txt")
try {
val entireContents = file.mkString
play(entireContents)
} finally {
file.close()
}
}
mkString
-metodi (luku 4.2) kuin
alkiokokoelmilla. Tässä sen palautusarvona saadaan merkkijono,
jossa on tiedoston koko sisältö rivinvaihtomerkkeineen kaikkineen.Tiedoston lukeminen, esimerkki 5/7
Tavoite: numeroidaan tiedoston rivit
Laaditaan ohjelma, joka tulostaa tiedoston sisältämät rivit numeroituina:
1: This is the first line from the file.
2: This is the second one.
3: This third line is the last.
Kohti ratkaisua: getLines
Voitaisiin tietysti käsitellä tiedoston sisältöä merkki kerrallaan (kuten esimerkeissä 1–3) tai muodostaa monirivinen merkkijono, jota sitten jatkokäsiteltäisiin (esimerkkiä 4 mukaillen). Kuitenkin tekstitiedostoa on luonnollista lukea ja käsitellä rivi kerrallaan, mikä onnistuukin helposti.
Tehdään ennen varsinaista ratkaisua pieni kokeilu. Koetetaan tulostaa tiedoston kaksi ensimmäistä riviä tähän tapaan:
This is the first line from the file.
This is the second one.
Tällaisen tulosteen voi tuottaa seuraavalla koodinpätkällä:
val tiedosto = Source.fromFile("example.txt")
val tiedostoRiveittain = tiedosto.getLines
println(tiedostoRiveittain.next())
println(tiedostoRiveittain.next())
getLines
-metodi palauttaa toisen iteraattoriolion, joka
tukeutuu alkuperäiseen "merkki kerrallaan" -iteraattoriolioon
(johon file
-muuttuja viittaa) ja komentaa sitä. Tällä toisella
iteraattorioliolla sisällön voi käydä läpi riveittäin...next
-metodi pyytää file
-iteraattorilta merkkejä
aina rivinvaihtomerkkiin asti. Palautusarvona saadaan kaikki nämä
merkit eli yksi rivi. (Palautusarvo on tyyppiä String
eikä
Char
kuten alkuperäisellä iteraattorilla.)next
-käsky palauttaa seuraavan rivin.Ratkaisu
Tässä kokonainen ohjelma, joka tulostaa kaikki rivit numeroituina, kuten haluttiin:
import scala.io.Source
object ReadingExample5 extends App {
val file = Source.fromFile("example.txt")
try {
var lineNumber = 1 // askeltaja
for (line <- file.getLines) {
println(lineNumber + ": " + line)
lineNumber += 1
}
} finally {
file.close()
}
}
getLines
ia kutsumalla saadaan.Tässä vielä toinen tapa toteuttaa äskeisen try
-lohkon sisältö. Alla on käytetty
askeltajamuuttujan sijaan zipWithIndex
-metodia (luku 8.4):
for ((line, index) <- file.getLines.zipWithIndex) {
println((index + 1) + ": " + line)
}
Tiedoston lukeminen, esimerkki 6/7
Tavoite: ensin rivit, sitten niiden pituudet
Otetaan tavoitteeksi tuottaa tiedoston perusteella seuraavanlainen tuloste:
LINES OF TEXT:
1: This is the first line from the file.
2: This is the second one.
3: This third line is the last.
LENGTHS OF EACH LINE:
37 characters
23 characters
28 characters
THE END
Ratkaisuyritys: kaksi kertaa getLines
Ongelmaan löytyy yksinkertainen ratkaisumalli:
- Käydään läpi tiedoston kaikki rivit tulostaen ne numeroituina.
- Sitten käydään rivit uudestaan läpi tulostaen kunkin pituus.
Yritetään muuttaa tämä ajatus suoraan Scalaksi. Laaditaan kaksi peräkkäistä silmukkaa.
Kummassakin kutsutaan getLines
-metodia ja käydään rivit läpi yksitellen:
import scala.io.Source
object ReadingExample6 extends App {
val file = Source.fromFile("example.txt")
try {
println("LINES OF TEXT:")
var lineNumber = 1 // askeltaja
for (line <- file.getLines) {
println(lineNumber + ": " + line)
lineNumber += 1
}
println("LENGTHS OF EACH LINE:")
for (line <- file.getLines) {
println(line.length + " characters")
}
println("THE END")
} finally {
file.close()
}
}
Kuitenkaan tämän ohjelman tuloste ei miellytä:
LINES OF TEXT:
1: This is the first line from the file.
2: This is the second one.
3: This third line is the last.
LENGTHS OF EACH LINE:
THE END
Pituudet jäivät kokonaan tulostumatta!
Tiedoston lukeminen, esimerkki 7/7
Tavoite: selvitetään edellisen esimerkin bugi
Edellisen esimerkin toimimattomuus juontuu siitä, että iteraattori osaa käydä käsittelemänsä datan läpi ainoastaan yhden kerran. Iteraattori pitää kirjaa siitä, missä ollaan menossa, mutta ei palaa taaksepäin.
Esimerkiksi fromFile
-metodin palauttama iteraattoriolio (jota myös getLines
in kautta
epäsuorasti käytämme) osaa käydä tiedoston sisällön läpi alusta loppuun vain kertaalleen.
Kun jälkimmäisessä silmukassa kutsutaan getLines
-funktiota, saadaan rivit vain siitä
kohdasta eteenpäin, johon aiemmin jäätiin. Koska ensimmäinen silmukka oli jo ehtinyt
tiedoston loppuun, ei toiselle silmukalle jäänyt käsiteltäviä rivejä lainkaan.
Yksi ratkaisutapa olisi pyytää iteraattori käskyllä Source.fromFile("example.txt")
kahteen kertaan, kerran kummankin silmukan alussa. Kumpikin silmukka siis lukisi koneelle
tallennetun tiedoston erikseen, toisistaan riippumattomasti. Tämän ratkaisumallin
heikkoutena on, että ohjelmamme lukisi tiedoston kahdesti aivan turhaan, ja tiedostojen
lukeminen massamuistista on huomattavan hidasta verrattuna tyypillisten ohjelmointikielen
käskyjen suorittamiseen.
Toinen tapa on ottaa koko tiedoston sisältö talteen alkiokokoelmaan, jonka voi sitten käydä läpi niin monta kertaa kuin on tarvis. Tämän ratkaisumallin heikkoutena on se, että tiedoston koko sisältö on otettava talteen keskusmuistiin.
Muitakin on
Keksitkö muita ratkaisutapoja?
Alla on esimerkki jälkimmäisestä ratkaisutavasta.
Ratkaisu: toVector
import scala.io.Source
object ReadingExample7 extends App {
val file = Source.fromFile("example.txt")
try {
val vectorOfLines = file.getLines.toVector
println("LINES OF TEXT:")
var lineNumber = 1 // askeltaja
for (line <- vectorOfLines) {
println(lineNumber + ": " + line)
lineNumber += 1
}
println("LENGTHS OF EACH LINE:")
for (line <- vectorOfLines) {
println(line.length + " characters")
}
println("THE END")
} finally {
file.close()
}
}
mkString
esimerkissä 4 yllä). Nyt tiedoston sisältöön pääsee
käsiksi Vector[String]
-tyyppisen muuttujan avulla ja sitä voi
käsitellä ohjelmassa kuin mitä tahansa muutakin vektoria.Tiedoston kirjoittaminen
Tiedostojen kirjoittaminen onnistuu helposti tarkoitukseen laadittua luokkaa käyttäen.
Tekstitiedostojen kirjoittamiseen sopii esimerkiksi java.io
-pakkauksen luokka
PrintWriter
.
Alla on pieni esimerkkiohjelma, joka käyttää PrintWriter
-luokkaa tulostaakseen
tekstitiedostoon allekkain kymmenentuhatta satunnaislukua väliltä 0–99.
import java.io.PrintWriter
import scala.util.Random
object WritingExample extends App {
val fileName = "random.txt"
val file = new PrintWriter(fileName)
try {
for (n <- 1 to 10000) {
file.println(Random.nextInt(100))
}
println("Created a file " + fileName + " that contains pseudorandom numbers.")
println("In case the file already existed, its old contents were replaced with new numbers.")
} finally {
file.close()
}
}
PrintWriter
-olion voi luoda näin. Parametriksi annetaan
kirjoitettavan tiedoston nimi. Huom! Tämän niminen tiedosto
luodaan, jos sitä ei ennestään ollut olemassa. Jos taas oli,
niin tiedoston koko vanha sisältö tuhoutuu, ja ohjelman
kirjoittama uusi sisältö korvaa sen.println
-metodilla voi kirjoittaa tiedostoon yhden rivin
tekstiä.Kokeile
Tämäkin ohjelma löytyy Files-projektin pakkauksesta o1.io
. Kokeile ajaa se.
Syntyvä tekstitiedosto ei tule Eclipsessä näkyviin, ellet ensin päivitä Package Explorer-näkymää. Klikkaa Files-projektia oikealla napilla ja valitse Refresh, niin uusi tiedostokin ilmestyy luetteloon. Katso sen sisään, niin huomaat, että tiedostossa on pitkä luettelo satunnaislukuja.
Voit kokeilla ajaa ohjelman uudestaan. Vanhat luvut korvautuvat uusilla.
Tiedostonkäsittelyharjoitus
Laadi ohjelma, joka pyytää käyttäjältä tiedoston nimen ja lukee nimetystä tiedostosta desimaalilukuja ja laskee niiden perusteella muutaman tilastotiedon: lukujen lukumäärä, keskiarvo, mediaani sekä yleisimmän luvun esiintymien lukumäärä.
Syötetiedostossa on oltava desimaalilukuja (myös kokonaisluvut kyllä kelpaavat) omilla riveillään. Esimerkki:
10.4
5
4.12
10.9
10.9
Jos projektissa on tiedosto kokeilu.txt
, jossa on nuo viisi riviä, niin
ohjelman tulisi toimia tähän tapaan. Huomaa, että tiedoston nimi on käyttäjän antama
syöte, ei ohjelman tuloste.
Please enter the name of the input file: kokeilu.txt Count: 5 Average: 8.264 Median: 10.4 Highest number of occurrences: 2
Pohja ohjelmalle löytyy Files-projektin tiedostosta FileStatistics.scala
.
Kirjoita koodisi sinne.
Käytä try
–finally
-rakennetta ja muista sulkea tiedosto close
-käskyllä.
Voit käyttää syötteenä WritingExample
-ohjelman tuottamaa satunnaislukutiedostoa.
Kuinka asianmukaisilta näennäissatunnaislukujen tilastotiedot näyttävät?
A+ esittää tässä kohdassa tehtävän palautuslomakkeen.
Scala-temppunurkka: lisää tietotyypeistä
Seuraavat bonusaiheet eivät ole välttämättömiä tiedostojen käsittelemiseksi, mutta ne voivat kätevöittää sitä ja opettaa lisää Scalan tyyppijärjestelmästä yleisemminkin.
Johdanto: mikä useAndClose
?
Otetaan virikkeeksi tämä aiemman kurssilaisen havainto:
useAndClose
-metodista, joka on
tuolla O1Library-projektissa, että mitä siinä tapahtuu, mutta
kysymystä väsätessä ja asiaa samalla tutkien taisinkin päästä jo
jonkin verran kärryille.Opiskelija huomasi, että muutamassa kurssin valmiissa projektissa on tiedostojen
lukemisessa käytetty apuna O1Library-pakkauksesta löytyviä työkaluja. Yksi niistä
useAndClose
-niminen funktio, jota voi käyttää esimerkiksi näin:
useAndClose(textFile)( _.getLines.toVector )
useAndClose
ottaa kaksi parametria (kahdessa erillisessä
luettelossa vähän kuin tabulate
; luku 6.1).close
-metodia.useAndClose
palauttaa tuloksenaan näin muodostetun vektorin.Funktio useAndClose
on eräs Scala-kielinen toteutus ajatukselle, joka tunnetaan
nimellä loan pattern: funktio hallinnoi resurssin käyttöä ja huolehtii sen
sulkemisesta. Tämän funktion kutsujan ei tarvitse itse erikseen käyttää esimerkiksi
try
- ja finally
-rakenteita tai kutsua tiedoston close
-metodia kuten
olemme tässä luvussa tehneet, vaan tuo toiminnallisuus on toteutettu valmiiksi
useAndClose
-funktioon.
Tämän apufunktion toteutus löytyy O1Library-projektin pakkauksesta o1.util
.
Alla esitellään sen toteutus, jonka ymmärtäminen vaatii uusia käsitteitä.
useAndClose
-funktion toteutus
def useAndClose[Resource <: Closeable, Result](resource: Resource)(operation: Resource => Result) = {
try {
operation(resource)
} finally {
resource.close()
}
}
useAndClose
on yleiskäyttöinen. Se ei rajaa
tiukasti, millaisille resursseille se toimii tai millaisen
tuloksen se palauttaa, vaan nämä kaksi seikkaa ovat funktion
tyyppiparametreja. Yllä olevassa esimerkissä Resource
oli
tiedosto ja Result
oli merkkijonoja sisältävä vektori,
mutta useAndClose
toimii muillekin tyypeille.Resource
eli esimerkiksi tiedosto.Resource
-tyyppisen parametrin ja palauttaa Result
-tyyppisen
arvon. Esimerkissämme se oli nimetön funktio _.getLines.toVector
.useAndClose
suorittaa parametriksi saamansa toimenpiteen
parametriksi saamalleen resurssille. Se huolehtii
try
–finally
-rakenteella resurssin sulkemisesta.Ylempänä useAndClose
-funktion esiin nostanut kysyjä jatkoi:
Resource <: Closeable
tarkalleen ottaen tekee?Aloitetaan siitä, mitä Closeable
tuossa esimerkissä tarkoittaa ja palataan sitten
siihen, mikä on merkinnän <:
tarkoitus Scalassa.
Tyyppialias
Samassa pakkauksessa o1.util
on tällainen määrittely:
type Closeable = { def close(): Unit }
Koodissa on kaksi "outoa juttua":
type
?Katsotaan näistä ensin type
. Se on Scalan avainsana, jolla voi määritellä
tyyppialiaksen (type alias) eli nimen jollekin tietotyypille. Alla on ihan
yksinkertainen irtoesimerkki tyyppialiaksesta; määritellään vaikkapa Int
-tyypille
toinen nimi:
type Kokonaisluku = Intdefined type alias Kokonaisluku
Nyt Kokonaisluku
tarkoittaa samaa tyyppiä kuin Int
ja voimme esimerkiksi käyttää
sitä funktion määrittelyssä:
def isompi(luku: Kokonaisluku) = luku + 1isompi: (luku: Kokonaisluku)Int
Tuolle funktiolle voi antaa parametriksi ihan tavallisen Int
-arvon:
isompi(10)res0: Int = 11
Ilmeisesti siis käsky type Closeable = { def close(): Unit }
määrittelee, että
Closeable
on nimi jonkinlaiselle tyypille, mutta millaiselle?
Rakenteellinen tyyppi
Seuraava REPL-kokeilu auttaa ymmärtämään äskeisen koodin. Käytetään apuna tätä miniluokkaa:
class River(val name: String, val length: Int) { override def toString = "the river " + name }defined class River
Jokiolioilla on pituus. Myös alkiokokoelmilla kuten vektoreilla on pituus. Paljon muuta yhteistä noilla olioilla ei olekaan, mutta jo tuo riittää siihen, että voimme tehdä funktion, joka käsittelee "sellaisia olioita joilla on pituus" ja joka toimii sekä jokiolioille että vektoreille:
import scala.language.reflectiveCallsimport scala.language.reflectiveCalls def reportLength(somethingThatHasALength: { def length: Int }) = { println("The object is " + somethingThatHasALength) println("Its length is " + somethingThatHasALength.length) }reportLength: (somethingThatHasALength: AnyRef{def length: Int})Unit
length
, joka on kokonaisluku.length
-arvo metodin rungossa.Käyttöesimerkkejä:
reportLength(new River("Amazon", 6437))The object is the river Amazon Its length is 6437 reportLength(Vector(123, 456))The object is Vector(123, 456) Its length is 2
Palataan aiempaan koodiin:
type Closeable = { def close(): Unit }
Tässä määritellään, että Closeable
on nimi Unit
-arvoisen
close
-metodin sisältävien olioiden tyypille. Tällaisia
ovat muiden muassa tiedostoista dataa tuovat Source
-iteraattorit.
Tyyppirajaus
Palataan vielä taaksepäin useAndClose
-funktioon:
def useAndClose[Resource <: Closeable, Result](resource: Resource)(operation: Resource => Result) = {
try {
operation(resource)
} finally {
resource.close()
}
}
Resource
on määritelty
yläraja (upper bound). Määrittely tarkoittaa,
että tyypin Resource
tulee olla Closeable
tai jokin sen alatyyppi. Tässä voisi lukea
myös Resource <: { def close(): Unit }
.useAndClose
-funktiota
käytettäessä ensimmäiseksi parametriksi voi
antaa esim. tiedostoiteraattorin, koska sillä
on close
-metodi, mutta ei vaikkapa
merkkijonoa tai lukua, koska niillä ei ole.finally
-lohkon metodikutsu on sallittu
mainittujen tyyppimäärittelyjen perusteella.Äskeisessä koodissa siis Resource
viittaa mihin tahansa tyyppiin,
jota käytetään useAndClose
n ensimmäisessä parametrissa. Kunhan
tuo tyyppi on ylärajan mukainen, niin se kelpaa. Tässä tapauksessa
kelpaa tiedostoiteraattorin sijaan mikä vain muukin tyyppi, kunhan
close
-metodi löytyy.
class Vault(val number: Int) { def close() = { println(s"Vault $number: Clank!") } }defined class Vault useAndClose(new Vault(111))( _ => println("War never changes.") )War never changes. Vault 111: Clank!
Ylärajojen lisäksi Scalan tyyppijärjestelmässä on myös paljon muita ominaisuuksia, joihin voit tutustua esimerkiksi kirjavinkkisivulta aloittamalla, jos siltä tuntuu. Myös kevään jatkokurssilla tulee niistä puhetta.
Vapaaehtoista pohdittavaa/selvitettävää: millä tavoin useAndClose
olisi huonompi, jos se olisi määritelty alla olevalla yksinkertaisemmalla
tavalla, ilman tyyppirajausta?
// Yllä käytetty versio:
def useAndClose[Resource <: Closeable, Result](resource: Resource)(operation: Resource => Result) = // ...
// Toinen, huonompi, versio:
def useAndClose[Result](resource: Closeable)(operation: Closeable => Result) = // ...
Yhteenvetoa
I/O-työkalut pakkauksessa o1.util
O1Library-projektin pakkauksessa o1.util
on muutamia apufunktioita yksinkertaisiin
tiedostonkäsittelytarpeisiin. Ne on toteutettu
scala.io
- ja java.io
-peruskirjastoja käyttäen.
- Ohjelmointikielten peruskirjastoista löytyy välineitä syötteen lukemiseen ja tulosteen kirjoittamiseen eli I/O:hon. Eräs usein tarvittu I/O:n muoto on tiedostojen käsittely.
- Pakkaus
scala.io
tarjoaa välineitä muun muassa tekstitiedostojen lukemiseen. - Tiedostoja käsitellessä on aina syytä huomioida poikkeustilanteiden mahdollisuus.
- Lisää I/O:sta, tiedostoista ja poikkeustenkäsittelystä muilla ohjelmointikursseilla ja muista lähteistä.
- Lukuun liittyviä termejä sanastosivulla: I/O eli input/output; tiedosto, tekstitiedosto; ajonaikainen virhe, poikkeustenkäsittely.
Palaute
Huomaathan, että tämä on henkilökohtainen osio! Vaikka olisit tehnyt lukuun liittyvät tehtävät parin kanssa, täytä palautelomake itse.
Tekijät
Tämän oppimateriaalin kehitystyössä on käytetty apuna tuhansilta opiskelijoilta kerättyä palautetta. Kiitos!
Kierrokset 1–13 ja niihin liittyvät tehtävät ja viikkokoosteet on laatinut Juha Sorva.
Kierrokset 14–20 on laatinut Otto Seppälä. Ne eivät ole julki syksyllä, mutta julkaistaan ennen kuin määräajat lähestyvät.
Liitesivut (sanasto, Scala-kooste, usein kysytyt kysymykset jne.) on kirjoittanut Juha Sorva sikäli kuin sivulla ei ole toisin mainittu.
Tehtävien automaattisen arvioinnin ovat toteuttaneet: (aakkosjärjestyksessä) Riku Autio, Nikolas Drosdek, Joonatan Honkamaa, Jaakko Kantojärvi, Niklas Kröger, Teemu Lehtinen, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä ja Aleksi Vartiainen.
Lukujen alkuja koristavat kuvat ja muut vastaavat kuvituskuvat on piirtänyt Christina Lassheikki.
Yksityiskohtaiset animaatiot Scala-ohjelmien suorituksen vaiheista ovat suunnitelleet Juha Sorva ja Teemu Sirkiä. Niiden teknisen toteutuksen ovat tehneet Teemu Sirkiä ja Riku Autio käyttäen Teemun toteuttamia Jsvee- ja Kelmu-työkaluja.
Muut diagrammit ja materiaaliin upotetut vuorovaikutteiset esitykset on laatinut Juha Sorva.
O1Library-ohjelmakirjaston ovat kehittäneet Aleksi Lukkarinen ja Juha Sorva. Useat sen keskeisistä osista tukeutuvat Aleksin SMCL-kirjastoon.
Opetustapa, jossa käytämme O1Libraryn työkaluja (kuten Pic
) yksinkertaiseen graafiseen
ohjelmointiin on saanut vaikutteita tekijöiden Flatt, Felleisen, Findler ja Krishnamurthi
oppikirjasta How to Design Programs sekä Stephen Blochin oppikirjasta Picturing Programs.
Oppimisalusta A+ on luotu Aallon LeTech-tutkimusryhmässä pitkälti opiskelijavoimin. Pääkehittäjänä toimii tällä hetkellä Jaakko Kantojärvi, jonka lisäksi järjestelmää kehittävät useat tietotekniikan ja informaatioverkostojen opiskelijat.
Kurssin tämänhetkinen henkilökunta on kerrottu luvussa 1.1.
fromFile
ottaa parametriksi tiedoston nimen ja palauttaaSource
-tyyppisen olion, joka pääsee käsiksi tuon tiedoston sisältöön.