Osa 12

Funktionaalista ohjelmointia

Funktionaalisella ohjelmoinnilla tarkoitetaan ohjelmointiparadigmaa, jossa vältetään tilan muutoksia mahdollisimman pitkälle. Muuttujien sijasta ohjelman suoritus perustuu funktionaalisessa ohjelmoinnissa mahdollisimman pitkälti funktioiden keskinäisiin kutsuihin.

Aikaisemmin esitetyt lambda-lausekkeet ja listakoosteet ovat esimerkkejä funktionaalisesta ohjelmointityylistä, koska niitä käyttämällä voidaan välttää ohjelman tilan muutokset - esimerkiksi lambda-lausekkeella voimme luoda funktion ilman että viittausta siihen tallennetaan mihinkään.

Funktionaalinen ohjelmointi on esimerkki ohjelmointiparadigmasta eli ohjelmointityylistä. Muita tyypillisiä ja kurssilla jo aiemmin käsiteltyjä paradigmoja ovat esimerkiksi

  • imperatiivinen paradigma, joka perustuu peräkkäisiin komentoihin ja niiden suorittamiseen järjestyksessä
  • proseduraalinen paradigma, jossa ohjelma jaetaan pienempiin aliohjelmiin. Imperatiivinen ja proseduraalinen paradigma tarkoittavat joidenkin määrittelyjen mukaan samaa asiaa.
  • olio-ohjelmointi, jossa ohjelma ja sen tila mallinnetaan luokista muodostettujen olioiden avulla.

Pythonin monipuolisuus tulee hyvin esille siinä, että voimme hyödyntää siinä useita eri paradigmoja - jopa samoissa ohjelmissa. Näin voimme hyödyntää tehokkainta ja selkeintä tapaa ongelmien ratkaisemiseksi.

Tarkastellaan vielä muutamaa funktionaalisen ohjelmoinnin työkalua Pythonissa.

map

Funktio map suorittaa annetun operaation kaikille annetun iteroitavan sarjan alkioille. Niinpä map muistuttaa koostetta monessa mielessä, syntaksi tosin näyttää erilaiselta.

Tarkastellaan esimerkkinä funktiokutsua, joka muuttaa merkkijonot kokonaisluvuiksi:

mjonolista = ["123","-10", "23", "98", "0", "-110"]

luvut = map(lambda x : int(x), mjonolista)

print(luvut)

for luku in luvut:
    print(luku)
Esimerkkitulostus

<map object at 0x0000021A4BFA9A90> 123 -10 23 98 0 -110

Funktion map yleinen syntaksi on siis

map(<funktio, jota alkioille kutsutaan>, <sarja, jonka alkioille funktiota kutsutaan>)

Funktio palauttaa map-tyyppisen objektin, jonka voi joko iteroida läpi for-lauseella tai esimerkiksi muuttaa listaksi list-funktiolla:

def alkukirjain_isoksi(mjono: str):
    alku = mjono[0]
    alku = alku.upper()
    return alku + mjono[1:]

testilista = ["eka", "toka", "kolmas", "neljäs"]

valmiit = map(alkukirjain_isoksi, testilista)

valmiit_lista = list(valmiit)
print(valmiit_lista)
Esimerkkitulostus

['Eka', 'Toka', 'Kolmas', 'Neljäs']

Kuten esimerkistä huomataan, map-funktiossa voi tietysti käyttää lambda-lausekkeella luodun funktion lisäksi myös def-avainsanalla aiemmin määriteltyä nimettyä funktiota.

Edellinen esimerkki voitaisiin toteuttaa myös vaikkapa listakoosteen avulla, esimerkiksi:

def alkukirjain_isoksi(mjono: str):
    alku = mjono[0]
    alku = alku.upper()
    return alku + mjono[1:]

testilista = ["eka", "toka", "kolmas", "neljäs"]


valmiit_lista = [alkukirjain_isoksi(alkio) for alkio in testilista]
print(valmiit_lista)

...tai esimerkiksi iteroimalla lista läpi for-lauseella ja tallentamalla käsitellyt alkiot uuteen listaan append-metodilla. Onkin tyypillistä, että saman asian voi toteuttaa usealla eri tavalla. Eri vaihtoehtojen tunteminen auttaa valitsemaan niistä ohjelmaan (ja omaan makuun) parhaiten sopivan.

Kannattaa huomata, että map-funktion palauttama lopputulos ei ole lista, vaan iteraattori-olio ja vaikka se käyttäytyykin listan tapaan monissa tilanteissa, niin näin ei ole aina.

Tarkastellaan seuraavaa esimerkkiä:

def alkukirjain_isoksi(mjono: str):
    alku = mjono[0]
    alku = alku.upper()
    return alku + mjono[1:]

testilista = ["eka", "toka", "kolmas", "neljäs"]

# talletetaan map-funktion tulos
valmiit = map(alkukirjain_isoksi, testilista)

for sana in valmiit:
  print(sana)

print("sama uusiksi:")
for sana in valmiit:
  print(sana)

Tulostus on seuraava:

Esimerkkitulostus

Eka Toka Kolmas Neljäs sama uusiksi:

Eli kun map-funktion tuloksena olevat nimet yritetään tulostaa toiseen kertaan, ei tulostu mitään. Syynä tälle on se, läpikäynti for-lauseella käy iteraattorin oliot jo läpi, ja kun samaa yritetään toistamiseen, ei ole enää mitään läpikäytävää!

Jos ohjelma haluaa tarkastella map-funktion tulosta useampaan kertaan, tulee tulos esimerkiksi muuttaa listaksi antamalla se parametriksi list-konstruktorille:

testilista = ["eka", "toka", "kolmas", "neljäs"]

# muutetaan map-funktion palauttama iteraattori listaksi
valmiit = list(map(alkukirjain_isoksi, testilista))

for sana in valmiit:
  print(sana)

print("sama uusiksi:")
for sana in valmiit:
  print(sana)
Esimerkkitulostus

Eka Toka Kolmas Neljäs sama uusiksi: Eka Toka Kolmas Neljäs

map ja oliot

Funktiolla map voidaan toki käsitellä myös omien luokkien olioita. Asiaan ei liity mitään tavanomaisesta poikkeavaa. Tarkastellaan seuraavaa esimerkkiä

class Pankkitili:
    def __init__(self, numero: str, nimi: str, saldo: float):
        self.__numero = numero
        self.nimi = nimi
        self.__saldo = saldo

    def lisaa_rahaa(self, rahasumma: float):
        if rahasumma > 0:
            self.__saldo += rahasumma

    def hae_saldo(self):
        return self.__saldo

t1 = Pankkitili("123456", "Reijo Rahakas", 5000)
t2 = Pankkitili("12321", "Keijo Köyhä ", 1)
t3 = Pankkitili("223344", "Maija Miljonääri ", 1000000)

tilit = [t1, t2, t3]

asiakkaat = map(lambda t: t.nimi, tilit)
for nimi in asiakkaat:
  print(nimi)

saldot = map(lambda t: t.hae_saldo(), tilit)
for saldo in saldot:
  print(saldo)
Esimerkkitulostus

Reijo Rahakas Keijo Köyhä Maija Miljonääri 5000 1 1000000

Koodissa selvitetään ensin funktion map avulla tilien omistajat. Huomaa miten lambda-funktiolla haetaan attribuuttina oleva nimi pankkitiliolioista:

asiakkaat = map(lambda t: t.nimi, tilit)

Tämän jälkeen haetaan samalla tyylillä jokaisen pankkitilin saldo. Lambda-funktio on nyt hieman erilainen, sillä saldo saadaan selville kutsumalla pankkitiliolion metodia:

saldot = map(lambda t: t.hae_saldo(), tilit)
Loading

filter

Funktio filter muistuttaa funktiota map, mutta nimensä mukaisesti se ei poimi kaikkia alkioita lähteestä, vaan ainoastaan ne, joille annettu funktio palauttaa arvon True.

Tarkastellaan taas ensin esimerkkiä funktion käytöstä:

luvut = [1, 2, 3, 5, 6, 4, 9, 10, 14, 15]

parilliset = filter(lambda luku: luku % 2 == 0, luvut)

for luku in parilliset:
    print(luku)
Esimerkkitulostus

2 6 4 10 14

Sama esimerkki voitaisiin kirjoittaa ilman lambda-lauseketta määrittelemällä funktio def-avainsanalla:

def onko_parillinen(luku: int):
    if luku % 2 == 0:
        return True
    return False

luvut = [1, 2, 3, 5, 6, 4, 9, 10, 14, 15]

parilliset = filter(onko_parillinen, luvut)

for luku in parilliset:
    print(luku)

Toiminnallisuuden kannalta ohjelmat ovat täysin yhtäläiset. Onkin mielipidekysymys kumpaa pitää selkeämpänä.

Tarkastellaan vielä toista esimerkkiä suodattamisesta. Ohjelmassa poimitaan kalalistasta ainoastaan ne kalat, jotka ovat vähintään 1000 gramman painoisia:

class Kala:
    """ Luokka mallintaa tietynpainoista kalaa """
    def __init__(self, laji: str, paino: int):
        self.laji = laji
        self.paino = paino

    def __repr__(self):
        return f"{self.laji} ({self.paino} g.)"

if __name__ == "__main__":
    k1 = Kala("Hauki", 1870)
    k2 = Kala("Ahven", 763)
    k3 = Kala("Hauki", 3410)
    k4 = Kala("Turska", 2449)
    k5 = Kala("Särki", 210)

    kalat = [k1, k2, k3, k4, k5]

    ylikiloiset = filter(lambda kala : kala.paino >= 1000, kalat)

    for kala in ylikiloiset:
        print(kala)
Esimerkkitulostus

Hauki (1870 g.) Hauki (3410 g.) Turska (2449 g.)

Taas kerran sama voitaisiin toteuttaa listakoosteena:

ylikiloiset = [kala for kala in kalat if kala.paino >= 1000]

filter palauttaa iteraattorin

Funktion map tapaan, myös funktio filter palauttaa listan sijaan iteraattorin ja on tilanteita joissa on syytä olla varuillaan sillä iteraattorin voi käydä läpi vain kerran. Eli seuraava yritys tulostaa suuret kalat kahteen kertaan ei onnistu:

k1 = Kala("Hauki", 1870)
k2 = Kala("Ahven", 763)
k3 = Kala("Hauki", 3410)
k4 = Kala("Turska", 2449)
k5 = Kala("Särki", 210)

kalat = [k1, k2, k3, k4, k5]

ylikiloiset = filter(lambda kala : kala.paino >= 1000, kalat)

for kala in ylikiloiset:
    print(kala)

print("sama uudelleen")

for kala in ylikiloiset:
    print(kala)

Tulostuu

Esimerkkitulostus

Hauki (1870 g.) Hauki (3410 g.) Turska (2449 g.) sama uudelleen

Jos funktion filter tulosta on tarve käsitellä useaan kertaan, tulee se muuttaa esimerkiksi listaksi:

kalat = [k1, k2, k3, k4, k5]

# muutetaan tulos listaksi kutsumalla list-konstruktorioa
ylikiloiset = list(filter(lambda kala : kala.paino >= 1000, kalat))
Loading

reduce

Viimeinen tarkastelemamme funktio on reduce. Kuten funktion nimi vihjaa, sen tarkoituksena on vähentää sarjan alkioiden määrä. Itse asiassa alkioiden sijasta reduce palauttaa yksittäisen arvon.

Reduce toimii sitten, että se pitää mukanaan koko ajan arvoa, jota se muuttaa yksi kerrallaan käydessään läpi listan alkioita.

Seuraavassa on esimerkki, joka summaa reduce-funktion avulla listan luvut yhteen. Huomaa, että Pythonin versiosta 3 alkaen funktio reduce pitää erikseen ottaa käyttöön moduulista functools.

from functools import reduce

lista = [2, 3, 1, 5]

lukujen_summa = reduce(lambda summa, alkio: summa + alkio, lista, 0)

print(lukujen_summa)
Esimerkkitulostus

11

Tarkastellaan esimerkkiä hieman tarkemmin. Funktio reduce saa kolme parametria. Parametreista toisena on läpikäytävä lista, ja kolmantena on laskennan alkuarvo. Koska laskemme listan alkioiden summaa, on sopiva alkuarvo nolla.

Ensimmäisenä parametrina on funktio, joka suorittaa toimenpiteen yksi kerrallaan kullekin listan alkiolle. Tällä kertaa funktio on seuraava:

lambda summa, alkio: summa + alkio

Funktiolla on kaksi parametria. Näistä ensimmäinen on laskennan sen hetkinen tulos ja toinen parametri on käsittelyvuorossa oleva listan alkio. Funktio laskee uuden arvon parametriensa perusteella. Tässä tapauksessa uusi arvio on vanha summa plus kyseisen alkion arvo.

Funktion reduce toiminta hahmottuu kenties selkeämmin, jos käytetään lambdan sijaan normaalia funktiota apuna ja tehdään funktiosta aputulostuksia:

from functools import reduce

lista = [2, 3, 1, 5]

# reducen apufunktio joka huolehtii yhden alkion arvon lisäämisestä summaan
def summaaja(summa, alkio):
  print(f"summa nyt {summa}, vuorossa alkio {alkio}")
  # uusi summa on vanha summa + alkion arvo
  return summa + alkio

lukujen_summa = reduce(summaaja, lista, 0)

print(lukujen_summa)

Ohjelma tulostaa:

Esimerkkitulostus

summa nyt 0, vuorossa alkio 2 summa nyt 2, vuorossa alkio 3 summa nyt 5, vuorossa alkio 1 summa nyt 6, vuorossa alkio 5 11

Ensimmäisenä siis käsitellään listan alkio, jonka arvo on 2. Tässä vaiheessa summa on 0, eli sillä on reducelle annettu alkuarvo. Funktio laskee ja palauttaa näiden summan eli 0 + 2.

Tämä arvo on parametrin summa arvona kun funktiota kutsutaan seuraavalle listan alkiolle eli luvulle 3. Funktio laskee ja palauttaa 2 + 3, joka taas toimii parametrina seuraavalle funktiokutsulle.

Toinen esimerkkimme laskee kaikkien listassa olevien kokonaislukujen tulon.

from functools import reduce

lista = [2, 2, 4, 3, 5, 2]

tulo = reduce(lambda tulo, alkio: tulo * alkio, lista, 1)

print(tulo)
Esimerkkitulostus

480

Koska on kyse tulosta, ei alkuarvo voi olla nyt 0 (miten käy jos se olisi nolla?), vaan sopiva arvo sille on 1.

Aivan kuten filter ja map, myös reduce voi käsitellä minkä tahansa tyyppisiä olioita.

Tarkastellaan esimerkkinä pankin tilien yhteenlasketun saldon selvittämistä reducella:

class Pankkitili:
    def __init__(self, numero: str, nimi: str, saldo: float):
        self.__numero = numero
        self.nimi = nimi
        self.__saldo = saldo

    def lisaa_rahaa(self, rahasumma: float):
        if rahasumma > 0:
            self.__saldo += rahasumma

    def hae_saldo(self):
        return self.__saldo

t1 = Pankkitili("123456", "Reijo Rahakas", 5000)
t2 = Pankkitili("12321", "Keijo Köyhä ", 1)
t3 = Pankkitili("223344", "Maija Miljonääri ", 1000000)

tilit = [t1, t2, t3]

from functools import reduce

def saldojen_summaaja(yht_saldo, tili):
  return yht_saldo + tili.hae_saldo()

saldot_yhteensa = reduce(saldojen_summaaja, tilit, 0)

print("pankissa rahaa yhteensä")
print(saldot_yhteensa)

Ohjelma tulostaa:

Esimerkkitulostus

pankissa rahaa yhteensä 1005001

Huomaa miten funktio saldojen_summaaja "kaivaa" saldon jokaisen tiliolion sisältä kutsumalla tilille saldon palauttavaa metodia:

def saldojen_summaaja(yht_saldo, tili):
  return yht_saldo + tili.hae_saldo()
Loading