Ohjelmoinnin peruskurssi Y2, kurssimateriaali

4.1. Tiedostojen kirjoittaminen ja lukeminen

«  4. Kierros 4 (14.2.2016 kello 23:59)   ::   Etusivulle   ::   4.2. Merkkijonojen muodostaminen metodilla str.format  »

4.1. Tiedostojen kirjoittaminen ja lukeminen

Esitietokurssilla CSE-A1111 Ohjelmoinnin peruskurssi Y1 tutustuttiin jonkin verran tekstitiedostojen käsittelyyn. Jos perusteet ovat päässeet unohtumaan, voi käydä läpi Y1-kurssin kurssimonisteen luvun 6.2: "Tekstitiedostojen käsittely".

Tässä luvussa perehdymme aiheeseen hieman tarkemmin. Tutustumme myös merkkijonojen muotoiluun, jota tulostuksen yhteydessä usein tarvitaan. Ulkoisena lähdemateriaalina on

Nämä lähtee kannattaa pitää mielessä ohjelmointitehtäviä ja omaa projektia tehtäessä.

Tekstitiedoston lukeminen

Tässä luvussa käsittelemme erilaisia tapoja lukea tekstitiedostoja. Aloitamme yksinkertaisimmasta, eli tiedoston lukemisesta rivi kerrallaan. Sen jälkeen tutustumme tietyn merkkimäärän lukemiseen tiedostosta ja koko tiedoston lukemiseen kerrallaan.

Tekstitiedoston lukeminen rivi kerrallaan

Helpoin tapa tekstitiedoston lukemiseen lienee rivi kerrallaan. Yleinen muoto Pythonissa tälle on seuraava:

file = open(path)
for line in file:
    ... # Tehdään jotain linelle

Käsiteltävä tiedosto on siis ensin avattava Pythonin sisäänrakennetulla funktiolla open, minkä jälkeen voimme iteroida for-lauseella tiedoston rivien ylitse. Voisimme myös eksplisiittisesti lukea tiedostosta rivi kerrallaan seuraavasti:

file = open(path)
line = file.readline()
while line != '':
   ...
   line = file.readline()

Metodi file.readline() palauttaa tyhjän merkkijonon '' tiedoston päättyessä.

Rivin lopussa on rivinvaihtomerkki!

Tekstitiedoston rivit erotetaan toisistaan rivinvaihtomerkeillä. Readlinen palauttaman rivin lopussa on rivinvaihtomerkki '\n' (loppumerkkiä voidaan tarvittaessa säädellä funktion open parametreilla). Myös iteroitaessa tiedoston rivien yli for-lauseella on jokaisen rivin lopussa rivinvaihtomerkki.

Helppo tapa päästä eroon rivinvaihdosta, mikäli sillä ei ole käyttöä, on kutsua metodia str.rstrip().

Jos luettu merkkijono on tyhjä, tuloksena on siis rivi '\n', mikä puolestaan on eri kuin tyhjä merkkijono ''.

Jatkossa kuitenkin käytämme suoraviivaisempaa iterointia tiedon rivien ylitse.

Kokeillaan tehdä jotain tiedostolle bok.txt, jonka sisältö on seuraava:

"If you think education is expensive, try ignorance"

— Derek Bok, former President of Harvard University

Voisimme vaikkapa laskea rivien sekä merkkien määrän tiedostossa:

>>> path = 'bok.txt'
>>> file = open(path)
>>> lineCount = 0
>>> characterCount = 0
>>> for line in file:
...     lineCount += 1
...     characterCount += len(line)
...
>>> print("File {} has {} characters in {} lines".format(path, characterCount, lineCount))
File bok.txt has 109 characters in 3 lines

Hienoa!

Pari tärkeää asiaa on kuitenkin päässyt unohtumaan. Ensiksi, mitä tapahtuu, jos tiedostoa ei ole?

>>> path = 'otherfile.txt'
>>> file = open(path)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'otherfile.txt'

Funktio open heittää siis poikkeuksen FileNotFoundError mikäli tiedostoa ei ole. The Python Standard Libraryn luvussa 2. Built-in Functions on kuvattu tarkemmin, mitä open tekee eri tilanteissa:

open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None)
   Open file and return a corresponding file object. If the file cannot be opened, an OSError is raised.

Lienee siis syytä siepata luokan FileNotFoundError yläluokka OSError eikä pelkkää FileNotFoundErroria.

Tämä on muuttunut Pythonin versiossa 3.3

Aiemmissa versioissa poikkeuksena oli IOError.

Muutetaan siis koodiamme niin, että mahdollinen OSError napataan:

path = 'bok.txt'
try:
    file = open(path)
except OSError:
    print("Could not open {}".format(path))
else:
    lineCount = 0
    characterCount = 0
    for line in file:
        lineCount += 1
        characterCount += len(line)
    print("File {} has {} characters in {} lines".format(path, characterCount, lineCount))

Tässä käytimme try-except-rakenteen else-osiota, joka siis suoritetaan vain jos try-osio ei heitä poikkeusta.

Yksi pieni tekninen ongelma koodissa vielä on. Kun open avaa tiedoston, varaa se sitä varten käyttöjärjestelmältä resurssin, niin sanotun tiedostokahvan (engl. file handle). Vaikka olemme lukeneet kaikki rivit tiedostosta, ei Python tiedä että emme enää tarvitse tiedostoa mihinkään eikä osaa vapauttaa tiedostokahvaa. Jos lukisimme monia tiedostoja näin, pitäisi Python turhaan varattuna tiedostokahvoja ja ne saattaisivat loppua jossain vaiheessa kesken.

On siis parasta vapauttaa kahva ja se tapahtuu metodille file.close(). Voisimme laittaa kutsun file.close() else-osion loppuun. Tässä esimerkissä tämä tuskin olisi vaarallista, mutta yleensä emme voi olla varmoja, että else-osion koodi ei heitä uusia poikkeuksia, joten on paras laittaa closen kutsu finally-osioon. Siellä tulee kuitenkin tarkistaa, että avaaminen on onnistunut. Tämä selviää tarkistamalla onko muuttuja file sidottu.

Kääräistään lopuksi koko koodi funktion sisälle.

import sys

def print_character_and_line_counts(path):
    input_file = None
    try:
        input_file = open(path)
    except OSError:
        print("Could not open {}".format(path), file=sys.stderr)
    else:
        lineCount = 0
        characterCount = 0
        for line in input_file:
            lineCount += 1
            characterCount += len(line)
        print("File {} has {} characters in {} lines".format(path, characterCount, lineCount))
    finally:
        if input_file:
            input_file.close()
Parametrilla file=sys.stderr saadaan virheilmoitus menemään ns. standard error -tulostusvirtaan. Syöttö- ja tulostusvirroista lisää kohta.

Kokeillaan tätä tiedostoilla 'bok.txt' ja 'otherfile.txt':

>>> print_character_and_line_counts('bok.txt')
File bok.txt has 109 characters in 3 lines
>>> print_character_and_line_counts('otherfile.txt')
Could not open otherfile.txt

Sama kakku siististi valmiissa kääreessä: with-rakenne

Pythonissa on with-rakenne, jonka avulla voidaan hallistusti käyttää olioita, jotka edellyttävät siivoustoimenpiteitä käytön lopussa. Tiedoston avaaminen lukemista varten ja sulkeminen lopuksi hoituu siististi with-rakenteella:

def print_character_and_line_counts(path):
    with open(path) as input_file:
        lineCount = 0
        characterCount = 0
        for line in input_file:
            lineCount += 1
            characterCount += len(line)
        print("File {} has {} characters in {} lines".format(path, characterCount, lineCount))

Tämä rakenne sulkee mahdollisesti avoimen tiedoston tuli poikkeuksia tai ei, mutta poikkeukset pääsevät kuitenkin within ulkopuolelle, joten niihin on tarvittaessa varauduttava, tyyliin:

def print_character_and_line_counts3(path):
    try:
        with open(path) as input_file:
            lineCount = 0
            characterCount = 0
            for line in input_file:
                lineCount += 1
                characterCount += len(line)
            print("File {} has {} characters in {} lines".format(path, characterCount, lineCount))
    except OSError:
        print("Could not open {}".format(path), file=sys.stderr)

Tiedoston kaikkien rivien lukeminen kerralla

Joskus haluamme lukea kaikki tiedoston rivit kerralla ja tehdä sitten näille jotain. Tätä varten on tarjolla metodi str.readlines(), joka palauttaa tiedoston rivit listana merkkijonoja.

Esimerkiksi jos haluamme lajitella tiedoston sisältämät rivit aakkosjärjestykseen, luemme kaikki rivit listaksi merkkijonoja ja kutsumme listalle määritettyä metodia list.sort(). Lopuksi vielä muistamme poistaa turhan rivinvaihtomerkin kunkin rivin lopusta ennen tulostusta:

def print_sorted_file(path):
    try:
        with open(path) as input_file:
            lines = input_file.readlines()
            lines.sort()
            for line in lines:
                print(line.rstrip())
    except OSError:
        print("Could not open {}".format(path), file=sys.stderr)

Tätä voi kokeilla vaikkapa oheiseen tiedostoon exceptions.txt, jossa on Pythonin valmiiden poikkeusluokkien nimet.

Tämä voi vaatia paljon muistitilaa!

Metodia file.readlines() ei kannata käyttää, mikäli emme tarvitse kaikkia rivejä käsittelyyn samalla kertaa, kuten yllä olevassa lajitteluesimerkissä. Muuten varaamme tarpeettomasti muistitilaa, mikäli tiedosto on kovin iso.

Esimerkkitehtävä

Virta eli stream

Tähän asti olemme lukeneet tiedostoa käyttäen funktion open tuottamaa oliota miettimättä sen enempää, mikä kyseinen olio oikein on. Katsotaan, mitä Python toteaa oliosta:

>>> file=open('bok.txt')
>>> file
<_io.TextIOWrapper name='bok.txt' mode='r' encoding='UTF-8'>
>>> type(file)
<class '_io.TextIOWrapper'>

File on siis luokan io.TextIOWrapper ilmentymä. TextIOWrapper on tekstitiedostojen käsittelyyn tarkoitettu luokka ja sen dokumentaatio on the python standard libraryn luvussa 16.2. io — Core tools for working with streams, mistä löytyy tarkempi kuvaus tarjolla olevista luokista ja metodeista.

Tällaisia olioita kutsutaan tekstivirroiksi (engl. text stream). Tekstivirtaa voidaan ajatella peräkkäisenä jonona merkkejä c0, c1, ..., cn-1 ja positiona i joka on välillä 0...n.

Positio i kertoo, mistä kohtaa seuraavaksi luetaan. Kun funktio open palauttaa avatun virran, on positio aluksi 0. Lukeminen siirtää positiota eteenpäin; esimerkiksi kun luemme tiedostosta 'bok.txt' ensimmäisen rivin, on positio 53, kun luemme toisen rivin 54 ja kolmannen rivin 109.

>>> file=open('bok.txt')
>>> file.tell()
0
>>> file.readline()
'"If you think education is expensive, try ignorance"\n'
>>> file.tell()
53
>>> file.readline()
'\n'
>>> file.tell()
54
>>> file.readline()
' --- Derek Bok, former President of Harvard University\n'
>>> file.tell()
109
>>> file.readline()
''
>>> file.tell()
109
>>> file.close()

Tämänhetkinen positio selviää metodilla file.tell(). Positiota voi myös siirtää metodilla file.seek().

>>> file=open('bok.txt')
>>> file.seek(10)
10
>>> file.readline()
'ink education is expensive, try ignorance"\n'
>>> file.close()

Tekstitiedoston lukeminen pienemmissä paloissa

Yllä luimme tekstivirtaa riveittäin tai kaikki rivit yhdellä kertaa. Toisinaan haluamme kuitenkin lukea tekstivirtaa pienemmissä paloissa, jopa vain yksi merkki kerrallaan. Tähän on tarjolla metodi file.read(). Metodille voidaan antaa parametrina lukumäärä, jonka verran merkkejä halutaan lukea kerrallaan. Lukeminen alkaa aina tekstivirran tämänhetkisestä positiosta. Jos merkkejä on jäljellä vähemmän kuin mitä pyysimme, saamme vain jäljellä olevat merkit.

>>> file=open('bok.txt')
>>> file
<_io.TextIOWrapper name='bok.txt' mode='r' encoding='UTF-8'>
>>> file.tell()
0
>>> x=file.read(13)
>>> x
'"If you think'
>>> len(x)
13
>>> file.tell()
13
>>> x=file.read(50)
>>> x
' education is expensive, try ignorance"\n\n --- Dere'
>>> len(x)
50
>>> file.tell()
63
>>> x=file.read(1000)
>>> x
'k Bok, former President of Harvard University\n'
>>> len(x)
46
>>> file.tell()
109
>>> file.close()

Samaten, jos emme anna lainkaan luettavien merkkien lukumäärää tai annamme arvon None tai -1, luetaan kaikki jäljellä olevat merkit.

>>> file=open('bok.txt')
>>> file.read()
'"If you think education is expensive, try ignorance"\n\n --- Derek Bok, former President of Harvard University\n'
>>> file.close()

Lukeminen merkkijonosta

Toisinaan haluamme käsitellä merkkijonoa ikään kuin se olisi tiedosto. Esimerkiksi syötettä lukevaa ohjelmaa testattaessa voi olla näppärää pitää syötteet merkkijonoissa tiedostojen sijaan.

Tätä varten on tarjolla valmis luokka StringIO, ja sitä käytetään seuraavasti:

>>> import io
>>>
>>> f = io.StringIO("""Mielivaltaista tekstiä
... joka voi jatkuva vaikka eri riveille""")
>>>
>>> f.readline()
'Mielivaltaista tekstiä\n'

Formatoitujen tiedostojen lukeminen

Yksi keskeinen ominaisuus lähes joka ohjelmassa on mahdollisuus tallentaa ohjelman toimintaan liittyvää tietoa tiedostoihin sekä ladata ohjelman käyttöön. Tallennettavalla tiedolla on lähes aina jokin selkeä rakenne. Tiedon esitystapaa tiedostoissa kutsutaan tiedostoformaatiksi.

Luvun 4.2 kotitehtävässä opettelemme lukemaan lohkopohjaista tiedostoformaattia. Tällaista käytetään usein esimerkiksi ääntä tai kuvaa käsittelevien ohjelmien syötteessä. Luvun 4.3 kotitehtävässä vastaavasti opimme lukemaan vapaamuotoisempaa tiedostoformaattia, jota ihmisen on helpompi lukea ja kirjoittaa, mutta jota konekin ymmärtää.

Lisämateriaalia: Monimutkaisempaa tekstisyötteen käsittelyä

Seuraavassa tarkastelemme muutamaa monimutkaisempaa tapaa käsitellä tekstisyötettä. Esitetyt tekniikat ovat jossain määrin monimutkaisempia kuin edellä esitetyt, eikä näitä tarvita esimerkiksi kotitehtävien tekemiseen. Nämä esitetään tässä lähinnä mielenkiinnon herättämiseksi ohjelmointi- ja muiden kielten koneelliseen käsittelyyn. Eli ei hätää, vaikka tämä vaikuttaisikin hämärältä.

Tekstisyötteen tokenisointi

Toisinaan haluamme paloitella luetun tekstin merkityksellisiin sanatasoisiin yksiköihin ennen kuin näille tehdään jotain toimenpiteitä. Esimerkiksi Python-tulkki pilkkoo annetun syötetiedoston Python-kielen kannalta merkityksellisiin yksiköihin, kuten avainsanoihin (if, def, class, try jne.), literaaleihin kuten numerot ja lainausmerkeissä oleviin merkkijonoihin, operaattoreihin kuten +, -, > sekä erottimiin kuten (, ), : ja =. Tällaista toimintaa kutsutaan leksikaaliseksi analyysiksi tai tokenisoinniksi. Valitettavasti hyvää suomenkielistä termiä ei ole. Seuraavassa tutustumme tähän tarkemmin.

Esimerkki: Funktiokutsujen tokenisointi

Tarkastellaan funktiokutsuja, jotka ovat muotoa:

f(x, g(y), 10)

missä f ja g ovat funktion nimiä ja x sekä y puolestaan muuttujan nimiä.

Miten tehdään ohjelma, joka palauttaa meille syötteessä olevat tokenit, eli muuttujien ja funktioiden nimet, luvut ja ja erottimet? Haluaisimme tulokseksi olioita, joista selviää tokenin tyyppi, mahdollinen arvo sekä muuta mahdollisesti hyödyllistä tietoa. Esimerkiksi yllä esitetystä lausekkeesta haluaisimme saada seuraavan listan tokeneita:

Name(position=0, value=f)
Separator(position=1, value=()
Name(position=2, value=x)
Separator(position=3, value=,)
Name(position=5, value=g)
Separator(position=6, value=()
Name(position=7, value=y)
Separator(position=8, value=))
Separator(position=9, value=,)
Number(position=11, value=10)
Separator(position=13, value=))

Määritetään tätä varten abstrakti luokka Token ja sille alaluokat Number, Name ja Separator. Kaikilla tokeneilla on attribuutti position, joka sisältää sen tiedostoposition, josta token löytyi sekä attribuutti value, joka sisältää konkreettisen merkkijonon, joka erottaa samantyyppiset tokenit toisistaan.

class Token:
    def __init__(self, position, value):
        self.position = position
        self.value = value

    def __repr__(self):
        return "{}(position={}, value={})".format(type(self).__name__, self.position, self.value)

class Number(Token):
    pass

class Name(Token):
    pass

class Separator(Token):
    pass

Määritimme Tokenille uudelleen metodin __repr__, joka palauttaa oliota kuvaavan merkkijonon. Käytämme tässä olion tyyppiä, jonka saamme Pythonin sisäänrakennetulla funktiolla type ja kaikille luokille määritettyä attribuuttia __name__, joka sisältää luokan nimen. Koska kaikilla token-luokilla on samanlaiset attribuutit, ei meidän tarvitse määrittää uudestaan niille uudestaan luontimetodiakaan.

Määritetään tämän jälkeen Tokenizer-luokka:

class Tokenizer:
    def __init__(self, path):
        self.path = path
        self.file = open(path) # Jos tiedoston avaaminen ei onnistu, heittää OSErrorin
        self.buffer = []

    def close(self):
        self.file.close()

    def maybeReadCharToBuffer(self):
        """ Jos buffer on tyhjä, luetaan seuraava merkki filestä bufferiin """
        if self.buffer == []:
            nextChar = self.file.read(1)
            if nextChar == '':
                raise EOFError('End of file {}'.format(self.path))
            self.buffer.append(nextChar)

    def peekChar(self):
        """ Palautetaan seuraava merkki, mutta ei siirrytä eteenpäin syötteessä """
        self.maybeReadCharToBuffer()
        return self.buffer[0]

    def getChar(self):
        """ Palautetaan seuraava merkki, ja siirrytään eteenpäin syötteessä """
        self.maybeReadCharToBuffer()
        return self.buffer.pop()

    def unGetChar(self, char):
        """ Laittaa merkin takaisin bufferiin myöhempää lukemista varten """
        self.buffer.insert(0, char)

    def skipWhiteSpace(self):
        """ Ohitetaan välilyönnit ja muut tyhjät merkit """
        while self.peekChar().isspace():
            self.getChar()
        return self.peekChar()

    def getNextToken(self):
        """ Palautetaan seuraava token tai None jos syöte on loppu """
        try:
            nextChar = self.skipWhiteSpace()
            startPosition = self.file.tell()-1 # Tiedostopositio on yhden edellä
            if nextChar.isdigit():
                chars = [ self.getChar() ]
                while self.peekChar().isdigit():
                    chars.append(self.getChar())
                return Number(startPosition, int("".join(chars)))
            elif nextChar.isidentifier():
                chars = [ self.getChar() ]
                while self.peekChar().isidentifier():
                    chars.append(self.getChar())
                return Name(startPosition, "".join(chars))
            elif nextChar in ['(', ')', ',']:
                return Separator(startPosition, self.getChar())
            else:
                raise SyntaxError('{}:{}: Unexpected character {}'
                                  .format(self.path, startPosition, nextChar))
        except EOFError:
            return None

    def getTokens(self):
        """ Luetaan file ja muunnetaan se listaksi tokeneita """
        tokens = []
        nextToken = self.getNextToken()
        while nextToken:
            tokens.append(nextToken)
            nextToken = self.getNextToken()
        self.close()
        return tokens

Keskeisiä metodeja syötteen lukemisessa ovat maybeReadCharToBuffer, peekChar sekä getChar. Eräänlaisena välivarastona merkkien lukemisen ja käytön välissä käytetään listaa nimeltä buffer. Mikäli buffer on tyhjä lukee metodi maybeReadCharToBuffer sinne uuden merkin. Metodi peekChar palauttaa seuraavan merkin bufferista, mutta ei varsinaisesti etene syötteessä. Metodi getChar toimii muuten samoin, mutta se myös etenee syötteessä sillä se poistaa bufferista ensimmäisen merkin. Tästä seuraa, että buffer tyhjenee ja maybeReadCharToBuffer joutuu myöhemmin lukemaan sinne uuden merkin.

Metodi skipWhitespace ohittaa syötteessä kaikki seuraavat välilyönnit, rivinvaihdot ja muut niin sanotut tyhjät merkit. Tässä se käyttää apuna metodeja peekChar ja getChar. Näistä edellisen avulla tarkistetaan, pitääkö seuraava merkki ohittaa ja jos pitää niin jälkimmäiselle se ohitetaan.

Metodi getNextToken lukee ja palauttaa seuraavan tokenin syötteestä. Se ohittaa ensin tyhjät merkit ja laittaa sitten talteen alkuposition (tämä on yhden pienempi kuin mitä file.tell() antaa, sillä tiedostosta on jo luettu yksi merkki enemmän metodia peekChar varten). Sitten metodi tarkistaa, onko seuraava merkki numero (str.isdigit()), nimeen kuuluva kirjain (str.isidentifier()) tai joku erotinmerkeistä (, ) tai ,. Numero ja nimi luetaan hyvin samaan tapaan kuin miten tyhjät merkit ohitettiin: metodilla peakChar tarkistetaan seuraava merkki ja metodilla getChar siirrytään eteenpäin. Jos seuraava merkki ei ole mikään odotetuista, heitetään poikkeus.

Metodi getTokens lukee tiedoston ja palauttaa listan tokeneita.

Metodia unGetChar ei tässä esimerkissä tarvita, mutta usein tokenisoinnissa siitä on hyötyä. Jos toisin kuin tässä esimerkissä emme yhden merkin kurkistuksella eteenpäin tiedä, mikä on oikea valinta, on hyvä tarjota mahdollisuus peruuttaa valinta ja tämän voi tehdä metodilla unGetChar.

Kokeillaan ohjelmaa tokenize.py syötetiedostoon call.

>>> from tokenizer import Tokenizer
>>> for token in Tokenizer('call').getTokens():
...   print(token)
...
Name(position=0, value=f)
Separator(position=1, value=()
Name(position=2, value=x)
Separator(position=3, value=,)
Name(position=5, value=g)
Separator(position=6, value=()
Name(position=7, value=y)
Separator(position=8, value=))
Separator(position=9, value=,)
Number(position=11, value=10)
Separator(position=13, value=))

Parsiminen eli jäsennys

Edellä opimme pilkkomaan syötettä rakenteellisesti merkitseviin sanatason kokonaisuuksiin. Yleensä haluamme myös tunnistaa korkeamman tason rakenteita kuten lauseita. Ohjelmointikielten kääntämisessä ja luonnollisten kielten käsittelyssä jäsennys (engl. parsing) on keskeinen osatehtävä. Jäsennyksessä ajatellaan yleensä, että syöte on valmiiksi tokenisoitu, eli voimme käsitellä sanatason osasia, kuten numeroita, varattuja sanoja jne.

Jäsennykseen on tarjolla monia tekniikoita ja niitä voi opiskella esimerkiksi kurssilla T-106.4200 Johdatus kääntäjätekniikkaan.

Tutustutaan jäsennykseen yllä käyttämämme esimerkkikielen kautta.

Esimerkki: yksinkertainen jäsentäminen

Käyttämämme rakenteet ovat siis muotoa f(x, g(y), 10). Voimme esittää tällaisen muodon tarkemmin käyttäen niin sanottua EBNF (Extended Backus-Naur Form) -muotoa, jolla usein esitetään ohjelmointikielten kielioppi. Esimerkiksi Pythonin kielioppi löytyy The Python Language Referencen luvusta 10. Full Grammar specification.

Käsittelemämme rakenteet voidaan esittää seuraavilla EBNF-säännöillä.

expr       ::= Number | call
call       ::= Name [ '(' [ exprList ] ')' ]
exprList   ::= expr ( ',' expr )*

Merkintä ::= jakaa säännön vasempaan ja oikeaan puoleen. Vasemmalla on aina yksi sana, joka nimeää kieliopillisen rakenteen. Oikealla puolella on nolla tai useampi vaihtoehtoinen muoto tälle rakenteelle. Vaihtoehdot erotetaan toisistaan pystyviivalla.

Yllä olevat säännöt luetaan seuraavasti: jokainen lauseke (expr) on joko numero (Number) tai kutsu (call). Kutsu on nimi (Name), jonka perässä mahdollisesti on kaarisulkujen ( ja ) välissä lausekelista (exprList). Hakasulkumerkit [ ja ] tarkoittavat, että niiden välissä oleva sisältö voi esiintyä tai olla esiintymättä. Lausekelistassa pilkuilla ',' toisistaan erotettuja lausekkeita. Merkintä ( )* tarkoittaa, että sulkujen välissä oleva sisältö voi esiintyä mielivaltaisen monta kertaa (myös nolla kertaa). Kirjoitimme tässä pienillä kirjaimilla ne nimet, jotka määrittävät jonkun kielioppisäännön. Isolla kirjoitimme nimet, jotka kuvaavat yllä määrittämiämme tokeneita Name ja Number lainausmerkkeihin laitamme erotinmerkit (, , ja ).

Huomaa, että sääntö call käsittelee myös muuttujan nimet, ei pelkkiä funktiokutsuja. Edellisissä ei ole sulkuja ja niiden välissä parametreja.

Miten tämä määritys muutetaan ohjelmakoodiksi? Käytetään luvussa 2.1 oppimaamme top-down menetelmää.

Muodostetaan metodi kullekin yllä kuvatulle kielioppisäännölle. Tehdään metodin nimet lisäämällä jäsentämistä tarkoittava sana 'parse' nimen alkuun:

def parseExpr(self):
    pass

def parseCall(self):
    pass

def parseExprList(self):
    pass

Metodien rungot muodostetaan käymällä läpi kielioppisäännön oikeaa puolta ja kirjoittamalla koodi, joka tarkistaa vastaako syöte sääntöä. Jos säännössä vastaan tulee joku token (Name, Number tai erotin (, ,, tai )), pitää tarkistaa, että syötteessä on vastaava token. Jos vastaan tulee jokun säännön vasemmalla puolella esiintyvä nimi (expr, call, exprList), lisäämme kutsun vastaavaan metodiin. Toistuvat rakenteet (esim. säännössä exprList) korvaamme sopivalla silmukalla ja ehdolliset rakenteet (esim. [ exprList ]) korvaamme sopivalla ehdolla.

Tämä ei kaikkinensa ole aivan mekaanista työtä, vaan vaatii jonkin verran soveltamista. Käytämme tässä kahta apumetodia peekToken ja getToken, jotka toimivat samaan tapaan kuin Tokenizerin peekChar ja getChar.

Saamme seuraavat metodit

def parseExpr(self):
     if isinstance(self.peekToken(), Number):
         return self.getToken().value
     elif isinstance(self.peekToken(), Name):
         return self.parseCall()
     else:
         # Virhe

 def parseCall(self):
     name = self.getToken().value # Nimi talteen
     # Jos edessä on '(' kyseessä on oikea funktiokutsu
     if isinstance(self.peekToken(), Separator) and self.peekToken().value == '(':
         # Ohitetaan '('
         self.getToken()
         # Jos edessä on ')', on parametrilista tyhjä
         if isinstance(self.peekToken(), Separator) and self.peekToken().value == ')':
             parameters = list()
         else: # Muuten jäsennetään parametrit
             parameters = self.parseExprList()
             # Nyt pitäisi edessä olla ')'
             if isinstance(self.peekToken(), Separator) and self.peekToken().value == ')':
                 self.getToken()
             else:
                 # Virhe
         return ... # Palautetaan olio, joka kuvaa funktiokutsua
     else:
         return name # Palautetaan name, tämä ei ollutkaan varsinainen kutsu vaan muuttujan nimi

 def parseExprList(self):
     exprList = [ self.parseExpr() ] # Luetaan ensimmäinen expr
     # Niin kauan kun edessä on ',', luetaan seuraava expr
     while isinstance(self.peekToken(), Separator) and self.peekToken().value == ',':
         self.getToken()
         exprList.append(self.parseExpr())
     return exprList

Tulostus tekstitiedostoon

Katsotaan seuraavaksi, miten Pythonissa tulostetaan tekstitiedostoon.

Merkkijonojen muotoilu tulostusta varten

Tulostuksen yhteydessä tarvitaan usein muotoiltuja merkkijonoja ja Python tarjoaa useita tapoja niiden tekemiseen.

Suoraviivaisin tapa on liimailla haluttu lopputulos palasista käyttäen + -operaattoria. Jos käytetään muita kuin merkkijonoja, pitää ne konvertoida esim. str -funktiolla merkkijonoiksi. Esimerkiksi näin:

>>> nimi = "Maija Metso"
>>> ika = 37
>>> asema = "Johtaja"
>>>
>>> print(nimi + " " + str(ika) + ", " + asema)
Maija Metso 37, Johtaja

Tämä on toki täysin kelvollinen tapa muotoilla merkkijonoja, mutta +-merkkejä ja str -kutsuja tulee helposti runsaasti. Muotoilulausekkeista on tällöin vaikea ilman ohjelman suorittamista nähdä, millaisia merkkijonoja ollaan tekemässä.

Parempi on kuitenkin käyttää valmista metodia str.format, jolla saa hienostuneita ulkoasuja pienellä vaivalla. Käsittelemme sitä tarkemmin luvussa Merkkijonojen muotoilu metodilla str.format.

«  4. Kierros 4 (14.2.2016 kello 23:59)   ::   Etusivulle   ::   4.2. Merkkijonojen muodostaminen metodilla str.format  »