Luku 2.1: Suunnitelmasta toteutukseen
Tästä sivusta:
Pääkysymyksiä: Kuinka suunnitellaan suurempia ohjelmia. Kuinka suunnitelmasta saa toimivan ohjelman?
Mitä käsitellään? Katsotaan kahta keskenään vastakkaista suunnittelumenetelmää, Top-down ja Bottom-up lähestymistapaa, sekä kuinka (ja milloin) tuotetuista suunnitelmista siirrytään toteutukseen. Pohditaan kuinka ohjelma voidaan toteuttaa osissa siten että kokonaisuutta pääsee testaamaan jo ennen kuin kaikki osaset ovat olemassa.
Mitä tehdään? Ensisijaisesti luetaan - Viime kierroksella tehtyä suunnitelmaa voi vilkuilla halutessaan uudelleen
Suuntaa antava vaativuusarvio: Helppo, Ei tehtäviä, mutta toisaalta paljon uusia termejä joiden ymmärtäminen vaatisi oman suunnitelman tekoa.
Suuntaa antava työläysarvio:? 1-2 tuntia.
Pistearvo: 0
- “Planning without action is futile, action without planning is fatal.”
- – Unknown
Suurempien ohjelmien ja API:en rakenteesta
Yksinkertainen pieni ohjelma saattaa koostua muutamasta luokasta, jotka käyttävät toistensa palveluja. Kun luokkia tulee enemmän, ohjelman monimutkaisuus kasvaa ja on vaikeampaa pitää kirjaa siitä kuinka ohjelma kokonaisuutena toimii. Kun luokat vielä riippuvat toisistaan, muutokset yhdessä paikassa saattavat vaikuttaa toiseen. Jotta vähänkin suurempia ohjelmia on mahdollista toteuttaa, täytyy kerrallaan käsiteltävän olevan tiedon määrää jollain tavoin hallita.
Termi abstraktio on tullut vastaan jo monesti aiemmin kurssimateriaalissa. Abstraktiolla tarkoitetaan yleisesti ottaen sitä, että jotakin ongelman tai ratkaisun osaa voidaan tarkastella sekä käyttää keskittyen vain sen olennaisiin piirteisiin ja jättäen sen tarkka sisäinen toteutus tietoisesti huomiotta. Abstraktioiden muodostamisesta käytetään termiä abstrahointi. Läheinen termi tälle on tiedon kätkeminen, jossa tarkoituksellisesti piilotamme näkyvistä yksityiskohtia.
Termi, modularisointi tarkoittaa prosessia, jossa jokin kokonaisuus (ohjelma, ohjelman osa, algoritmi, tms.) jaetaan selkeisiin osakokonaisuuksiin jotka voidaan toteuttaa itsenäisesti toisistaan. Näistä osakokonaisuuksista käytetään nimeä moduli. Kullakin modulilla on lisäksi jokin selkeä rajapinta, jonka kautta sen kanssa voi toimia, ja jonka kautta se toimii muiden modulien kanssa. Modulilla on myös yksityiskohtainen ohjelmakoodista koostuva toteutus.
Termillä kapselointi tarkoitetaan datan ja sen käsittelyyn tarkoitettujen funktioiden tai metodien kokoamista yhtenäiseksi kokonaisuudeksi.
Vaikka yllä olevat termit muistuttavat toisiaan, eivät ne tarkkaan ottaen tarkoita samaa. Esimerkiksi tiedon kätkeminen ja kapselointi vaikuttavat samankaltaisilta, mutta tiedon kätkeminen nähdään suunnitteluun liittyvänä periaatteena, kun taas kapselointi on ohjelmointikielen mekanismi.
Ohjelmointikielet tukevat abstraktioiden rakentamista, kapselointia ja modularisointia eri tavoin. Mm. Python tarjoaa useita työkaluja tiedon ryhmittelyyn, modularisointiin:
- Modulit: Pythonin keskeinen tapa organisoida ohjelmakoodia. Moduli on tiedosto, joka sisältää joukon funktio- ym. määrittelyjä. Python-modulien rajapinnan voi ajatella koostuvan näistä määrittelyistä.
- Import-lause: Etsii nimen perusteella modulin ja ottaa sen sisältämiä määrittelyjä käyttöön.
- Pakkaukset: Pythonissa moduleja voi organisoida hierarkisesti nimettyihin pakkauksiin. Pakkaukset ovat myös moduleita.
- Myös luokat ja metodit voivat kaikki sisältää luokkamäärittelyjä. Myös tätä rakennetta voidaan haluttaessa käyttää samalla tavoin luokkien ryhmittelyyn.
Suunnitelmasta toteutukseen
Mutta mistä tällaisen suuren ohjelman toteutus pitäisi aloittaa? Yksi suurimmista ongelmista ohjelmaa suunnitellessa ja toteuttaessa on, että ohjelmassa on suuri määrä toiminnallisuutta hajautettuna luokkiin, joiden välillä on joukko riippuvuuksia. Vähänkään isompaa ohjelmaa ei kuitenkaan voi toteuttaa kehittämällä sitä yhtenä kokonaisuutena, luottaen että kaikki osaset lopulta loksahtavat yhteen.
Katsotaan siis kahta keskenään vastakkaista suunnittelumenetelmää, Top-down ja Bottom-up lähestymistapoja ja kuinka tuotetuista suunnitelmista siirrytään toteutukseen.
Top-down suunnittelu
Top-down suunnittelussa työ aloitetaan koko järjestelmän tasolta ja pohditaan, mitä järjestelmä korkeimmalla abstraktiotasolla tekee. Tästä pyritään löytämään ja nimeämään seuraavan tason alijärjestelmät ilman, että päätetään mitään niiden toteuttamisesta. Alijärjestelmä on tässä vaiheessa jonkinlainen “musta laatikko”, joka tarjoaa kaikki tarvittavat palvelut, mutta sen toteutus on meille tuntematon.
Esimerkiksi Timelinerin toiminnallisuus voitaisiin jakaa kolmeen alijärjestelmään: käyttöliittymään, toimintalogiikkaan sekä ilmoitusjärjestelmään. Käyttöliittymän erottaminen toimintalogiikasta on aina hyvä idea.
Yllämainittu menetelmä toistetaan kunkin alijärjestelmän, mustan laatikon, tasolla. Alijärjestelmä kuvataan käyttäen apuna pienempiä osasia. Nämä voivat olla taas joitakin alijärjestelmiä, useamman vielä tuntemattoman luokan kokoelmia, mutta niiden vastuut ja riippuvuudet muista luokan osasista voidaan jälleen kerran asettaa vaikka näitäkään osia ei vielä ole. Vastaavasti kukin näin löydetty järjestelmä voidaan osittaa samalla tavalla jne.
Esimerkiksi Timelinerin toimintalogiikan voimme jakaa ihmisiin liittyvään toiminnallisuuteen, kalentereihin liittyvään toiminnalisuuteen ja töihin (tentit, projektit jne.) liittyvään toiminnallisuuteen.
Herää kysymys, missä vaiheessa tämä osittaminen loppuu? Voisimme ajatella etenevämme yksittäisen koodirivin tasolle, mutta tämä ei yleensä ole mielekästä. Yleensä jossain vaiheessa havaitsemme, että tarvittavaa alijärjestelmää varten on olemassa valmis toteutus joko käyttämällä jotain valmista kirjastoa tai uudelleenkäyttämällä itse tekemäämme koodia tai sitten törmätään luokkiin ja metodeihin, jotka meidän on toteutettava itse.
Suunnitellessa käytännössä määritellään järjestelmien ulkoiset rajapinnat ennen niiden toteutusta. Alijärjestelmän rajapintoja määritettäessä mietitään, miten samalla abstraktiotasolla olevat alijärjestelmät voivat käyttää toisiaan. Kun rajapinnat on määritetty kunnolla, voimme toteuttaa ja testata eri alijärjestelmiä/luokkia toisistaan riippumatta. Alijärjestelmän A ei tarvitse tietää alijärjestelmän B toteutusta; riittää että se tuntee B:n rajapinnan.
Toistoja - No pain no gain
Ensimmäinen yritys saattaa olla pettymys. Samalla on kuitenkin myös opittu uutta, joten jos prosessin toistaa, lopputulos lähes varmasti paranee. Todennäköisesti suunnitelmaa pitää tarkentaa useaan kertaan ennenkuin se on toteutettavissa. Kun toteutetusta tekee, oppii tarvittavista tehtävistä ja komponenttien vastuista usein uutta, joka olisi pitänyt tietää jo aiemmin. Prosessin voi hyvin toistaa ylhäältä alas, ja suorittaa samalla korjauksia. Muista että epäonnistuneesta suunnitelmasta kannattaa osata päästää irti.
- “Plans are nothing; planning is everything.”
- – Dwight D. Eisenhower
Top-down toteutus
Myös toteutusta voidaan tehdä Top-Down järjestyksessä. Mutta kuinka kirjoitetaan luokka joka käyttää mustia laatikoita, luokkia, joiden toteutusta ei vielä ole?
Top-down suunnittelun tuloksena meillä on luokkien rajapinnat ja käsitys mihin paketteihin ne kuuluvat, mutta ei juurikaan toteutuksia luokille (poislukien uudelleenkäyttämämme koodi, esim. kirjastoluokat). Jotta yksittäisen luokan toteutusta voidaan alkaa kirjoittaa, korvataan kaikki sen tarvitsemat muut luokat luokilla, joilla on haluttu rajapinta, mutta ei toiminnallisuutta.
Note: Scala-spesifistä
Tämä mahdollistaa sen, että toteutettava luokka on mahdollista kääntää ilman tarpeettomia virheilmoituksia. Kuten myöhemmin luvussa 2.2 huomaamme, toteutettavan luokan toimintaa voidaan kääntämisen lisäksi myös testata, vaikka tarvittujen luokkien toteutukset puuttuvatkin.
Tyhjiä luokkia ja muitakin korvikkeita
Oikean toteutuksen tilalla käytetään eri syistä eri tyyppisiä “sijaisolioita”. Sijaisolion tehtävä voi olla koodin kääntämisen mahdollistaminen, mutta jo varsin aikaisessa vaiheessa on tärkeää, että koodin toiminnallisuutta pääsee testaamaan. Sijaisolion toteuttavaa luokka kutsutaan “sijaisluokaksi”. Testaukseen ja sen apuvälineisiin tutustutaan tarkemmin luvuissa 2.2 ja 2.3.
Dummy
Dummy-oliot/luokat ovat sijaisluokista yksinkertaisimpia. Kyse on käytännössä luokasta jossa on mahdollisimman yksinkertainen ohjelmallisen rajapinnan täyttävä toteutus. Dummy-olioita ei varsinaisesti ole tarkoitus kutsua vaan niitä käytetään varsinaisen testattavan luokan metodien parametreina ja paluuarvoina. Dummyjä tarvitaan erityisesti staattisesti tyypitetyissä ohjelmointikielissä, joissa tyyppitarkastus tapahtuu käännösaikana ja täten edellä mainituilla parametreilla ja paluuarvoilla on oltava määritettynä kelvollinen rajapinta. Koska Python on dynaamisesti tyypitetty kieli, ei dummyille tarvitse määrittää rajapintaa.
Kaikkein yksinkertaisin Dummy-olio syntyy seuraavasti:
class Dummy:
pass
dummy = Dummy()
d.x = 10
d.y = 20
Seuraava määritys mahdollistaa parametrit konstruktorille:
class Dummy(object):
def __init__(self, **kwds): self.__dict__ = kwds
x = Dummy2(pituus=10, leveys=20)
Rajapinnan omaavia Dummy-olioita voi tehdä esim. seuraavasti:
#
# Dummy-luokka, jonka kaikki metodit on määritelty ilman toteutusta.
# Metodeja voi kutsua ja kenttiä voi asetella ilman virheitä
#
class Dummy:
def method1(self):
pass
def method2(self, n):
pass
print("Create dummy")
d = Dummy()
print(d)
d.method1()
d.method2(20)
print("Bye")
Null object design pattern on yleinen dummy-olio, jolle voi tehdä melkein mitä vaan. Koska Python ei edellytä parametreilta ja paluuarvoilta tiettyä tyyppiä, mitään metodeja ei tarvitse olla määritettynä. Toisaalta Null-olioon voi kohdistaa minkä tahansa operaation.
Stub
Tynkä eli Stub-olio on jo enemmän testaukseen tarkoitettu sijaisluokka ja sitä voidaan käyttää uutta koodia kehittäessä ohjelmoidessa tehtävän jatkuvan pikku testauksen osana. Tyngät, kuten Dummy:tkin toteuttavat halutun rajapinnan, mutta palauttavat “purkitettuja” vastauksia niitä kutsuvalle koodille. Tyngät voivat myös rakentaa niin että ne tallentavat saamansa metodikutsut, konstruktoriparametrit, jne. Tätä tietoa voidaan usein käyttää avuksi testauksessa kun halutaan tietää toimiiko tynkää käyttävä luokka oikein.
class DataContainerStub:
def store(self, key, value):
assert key == "Antti"
assert value == 10
def retrieve(self, key):
assert key == "Antti"
return 10
Näitä DataConteinerStub olioita voi käyttää testissä, joka tallettaa tietokantaan ainoastaan yhdelle avaimelle “Antti” arvon 10 ja hakee talletetun arvon sieltä. Kaikki muut kutsut tuottavat virheen.
Fake
Fake-olio on testeissä käytettävä olio, jolla on oikea mutta niin yksinkertainen toteutus, ettei sitä voi lopullisessa ohjelmassa käyttää. Esim. yksinkertainen keskusmuistitietokanta oikean tietokantapalvelun tilalta.
class FakeDB:
def __init__(self):
self.contents = {}
def store(self, key, value):
self.contents[key] = value
def retrieve(self, key):
return self.contents[key]
Mock
Mock-oliot ovat toiminnaltaan ennalta ohjelmoitavia testausversioita olioille/metodeille, joille joko ei ole toteutusta tai jonka toteutuksen ei haluta vaikuttavan testitilanteeseen. Tyypillisesti niiden luomiseen käytetään jotakin apukirjastoa. Mock eroaa tyngästä olennaisesti siinä, että se määritellään sen oletetun käyttäytymisen kautta. Mitään toteutusta ei laadita. Pythonin versiossa 3.3 on määritetty moduli unittest.mock. Dokumentaatioon kannattaa tutustua, jos aikoo tehdä mockeja.
unittest.mock ei sisälly Pythonin aiempiin versioihin.
On olemassa muita mock-kirjastoja, joita voi käyttää Pythonin kanssa. Näistä lisää Python testing tools taxonomy-sivulla.
Sijaisolioiden luokittelu
Gerard Meszaros luokittelee sijaisoliot seuraavasti:
- Test double: mikä tahansa sijaisolio (nimi tulee elokuvatermistä “stunt double”)
- Dummy: olio, jonka metodeja ei kutsuta, vaan jota ainoastaan välitetään parametrina tai paluuarvona.
- Fake: olio, jolla on oikea mutta niin yksinkertainen toteutus, ettei sitä voi lopullisessa ohjelmassa käyttää. Esim. yksinkertainen keskusmuistitietokanta oikean tietokantapalvelun tilalta.
- Stub: olio, jonka metodit tarjoavat vastauksia vain testissä oletetuilla parametreilla.
- Mock: olio, jolle erikseen määritetään oletetut kutsut sekä vastaukset niihin.
Bottom-up suunnittelu
Bottom-up suunnittelustrategia etenee päinvastaiseen suuntaan kuin Top-Down, lähtien liikkeelle yksittäisistä osasista ja yhdistämällä niitä kerros kerrallaan suuremmiksi kokonaisuuksiksi. Yksittäisten luokkien ohjelmointi ja testaus voitaisiin periaattessa aloittaa heti. Osasten yhdistely myöhemmin voi kuitenkin hyvin hankalaa koska ne on suunniteltu itsenäisesti. Lisäksi lopputulos voi olla rakenteeltaan sekava, hankala muokata ja ylläpitää.
Bottom-up suunnittelu tukee koodin uudelleenkäyttöä, sillä vanhat osat otetaan sellaisinaan suunnittelun lähtökohdiksi ja saadaan mahdollisesti pienillä muokkauksilla tai sellaisenaan käyttöön. Tässä Bottom-up suunnittelu eroaa Top-down suunnittelusta, jossa syntyvät rajapinnat ja yksittäiselle komponentille tulevat vaatimukset eivät välttämättä sovi sellaisenaan vanhalle koodille.
Todellisuus
Todellisuudessa eri lähestymistapoja kannattaa yhdistellä parhaan lopputuloksen saavuttamiseksi.
Käytännössä toimiva ratkaisu suunnitteluun on yhdistää ja vuorotella Top-Down- ja Bottom-up -suunnittelua siten, että ohjelmistolle syntyy selkeä ja ylläpidettävä rakenne, mutta samalla myös alimman tason komponentteja voidaan käyttää uudelleen ja niiden rajapinnat ovat selkeitä ja käyttökelpoisia. Käytännössä suunnittelua täytyy iteroida joidenkin modulien kohdalla useitakin kertoja kunnes hyvä ratkaisu löytyy. Tätä voisi kutsua vaikkapa Meet-in-the- middle suunnitteluksi.
“When to use iterative development? You should use iterative development only on projects that you want to succeed.” –Martin Fowler, in “UML Distilled
Top-down suunnittelulla vältetään moduulien yhteensopivuusongelmia sekä turhia tai monimutkaisia riippuvuuksia ja saadaan kokonaiskuva projektista, ja Bottom-up lähestymistapa mahdollistaa mm. koodin uudelleenkäytön ja mahdollistaa yksittäisten luokkien suunnittelun ilman ylhäältä saneltua rakennetta.
Vinkki: TODO
Kun projektissa on tyhjiä luokkia, sijaiskoodia ja yleensä ottaen jotain keskeneräistä, on hyvä idea merkitä tämä
koodi kommentein tulevaisuudessa tapahtuvien sekaannusten välttämiseksi. Yleisesti käytettyjä merkkisanoja ovat
TODO
ja FIXME
, jotka osa IDE:istä tunnistavat automaattisesti, näyttäen ne vaikkapa keskeneräisten
työtehtävien listalla
Suunnitellun rakenteen laadusta
Syntyneen paketti- ja luokkarakenteen laatuun liittyvät keskeisesti termit koheesio (cohesion) ja kytkentä (coupling).
High cohesion
Ohjelman eri osaset ovat vastuissa erilaisista tehtävistä ohjelmassa. Jos luokalla on joukko erilaisia tehtäviä, jotka riippuvat toisistaan vain löyhästi, on todennäköisyys että tällaiselle koodille löytyy uusiokäyttöä aika pieni. Mitä selkeämmin yksittäinen luokka hoitaa yksittäisen, selkeästi määritellyn, tehtävän, sitä helpompaa sitä on uusiokäyttää. Sama sääntö pätee myös metodeille ja suuremmille moduleille. Mitä pienemmästä ja selvemmin määritellystä joukosta tehtäviä koodin yksiköt vastaavat, sitä suurempi on koheesio.
Voit pohtia vaikkapa Timeliner-projektimme työtehtäviä kuvaavaa luokkaa. (älä huoli - tehtävään ei tarvitse koskea jos olet palauttanut sen) Onko luokalla joukko tehtäviä, joista jonkin voisi ulkoistaa luokalle, joka hoitaisi juuri tuon kyseisen tehtävän hyvin?
Loose Coupling
Vilkaise UML-kaaviota, jonka rakensit viime kierroksen tehtävää varten. Joillakin kaavion luokista on lukuisia riippuvuuksia toisiin luokkiin, ja on selvää, että ne myös käyttävät suurta joukkoa toistensa metodeja välittäessään toisilleen tietoa. Jos toiseen luokista täytyy tehdä muutos, muuttuu herkästi myös toinen luokka, siitä riippuvat luokat, jne.
Kytkentä, coupling, tarkoittaa sitä, kuinka monimutkainen ja laaja kahden luokan keskenään käyttämä rajapinta on. Jos rajapinnassa on muutama selkeä metodi ja luokkien tarvitsee tietää mahdollisimman vähän toistensa toteutuksesta, on luokkien välillä löyhä kytkentä.
Kannattaa havaita, että korkean koheesion ja löyhän kytkennän aikaansaaminen voi vaatia alkuun enemmän työtä ja tuottaa suuremman määrän koodia, mutta edut seuraavat myöhemmin koodin elinkaaren aikana.
Muita huomioita
Tarpeettoman monimutkaiset riippuvuudet luokkien välillä eivät siis ole haluttuja. UML-kaaviosta voi havaita myös muita potentiaalisia ongelmia. Tilanne, jossa samaan tietoon pääsee käsiksi kovin montaa reittiä ja erityisesti silmukat kaaviossa voivat olla merkki siitä, että asioita tehdään liian hankalasti. Pohdi tarvitaanko kaikkia viittauksia, vai onko tieto saavutettavissa myös muuta kautta.
Yleensä on hyvä, että riippuvuus on yksisuuntainen. Esimerkiksi Käyttöliittymän ja ohjelmalogiikan välinen suhde pyritään aina rakentamaan sellaiseksi, että käyttöiittymästä on riippuvuuksia logiikkaan, mutta ei päinvastoin. Tällöin käyttöliittymää voidaan muokata, suuriakin ominaisuuksia lisätä, jne. ilman että logiikkakoodiin täytyisi tehdä muutoksia.
On syytä myös korostaa että suunnitteluongelmiin ei ole olemassa yhtä parasta ratkaisua. Kannattaa myös huomata että suunnittelutavoitteet voivat olla (ja usein ovat) ristiriitaisia, joten suunnittelijan täytyy joskus tehdä kompromisseja.
Modularisoinnin etuja
- ylläpidettävyys
- uudelleenkäyttö
- Työnjako projektissa
Pastaohjelmointia
Toisinaan ohjelman rakennetta on verrattu erilaisiin pastalaatuihin.
- Spagettikoodi: kuten nimi sanoo, ohjelmakoodin rakenne on monimutkainen ja kietoutunut kuin lautasellinen spagettia. Jokainen koodirivi voi viitata minne tahansa ja ohjelman ylläpito on mahdotonta.
- Lasagnekoodi: ohjelman rakenne on kerroksittainen kuin lasagnessa. Joskus kerroksia voi olla liikaa, esimerkiksi silloin kun luokkarakenne koostuu syvästä aliluokkahierarkiasta, jossa kukin luokka tekee hyvin vähän.
- Raviolikoodi: paljon pieniä, löyhästi kytkettyjä luokkia, mikä muistuttaa ravioleja (täytettyjä pastanyyttejä). Vaikka lähestymistapa tuottaakin kaunista kapselointia ja olla toivottavaa kytkennän ja koheesion kannalta, voi tällaisen koodin ylläpito olla vaikeaa, jos raviolit (luokat) ovat liian pieniä.
Yhteenvetoa
- Suunnittelussa ohjelma kannattaa jakaa jonkinlaisiin alijärjestelmiin ja tarpeettomia riippuvuuksia näiden välillä on mahdollisuuksien mukaan vältettävä
- Top-down suunnittelu toimii jakamalla ohjelman vastuita moduleille, jatkaen alaspäin kunnes päästään yksittäisiin luokkiin.
- Bottom-up menetelmä taas suunnittelee aihealueeseen sopivia luokkia ja lähtee yhdistelemään niistä alijärjestelmiä.
- Molemmilla lähestymistavoilla on heikkoutensa. Ei ole yhtä menetelmää, jolla päästään varmasti hyvään suunnittelutulokseen, käytännössä menetelmiä yhdistellään ja jonkin toisen järjestelmän tuottaman osasen voi vaikkapa uudelleensuunnitella toisella menetelmällä. Suunnittelun toistaminen on hyvä idea - ongelma-alueesta tulee usein opittua uutta.
- Ei ole yleispätevää tapaa selvittää, onko jokin ratkaisu ”hyvä” tai ”huono”. Samaan ongelmaan on erilaisia ratkaisuja ja vaikkapa kaksi kurssilaista tuskin tuottaisi samanlaisen ratkaisun vähänkään isompaan ongelmaan.
- Toteuttaessa ja testatessa ohjelmaa, luokkia korvataan sijaisluokilla. Eri tyyppiset sijaisluokat vastaavat eri tarpeisiin.
Palaute
Vastaa palautekyselyyn A+:ssa.