Luku 7.5: Yli- ja aliluokat

../_images/person04.png

Johdanto

Edellisissä luvuissa on opittu, miten yläkäsitteitä voi mallintaa piirreluokilla. Tämä luku täydentää edellisiä: tutustumme siihen, miten myös tavallisen luokan voi merkitä toisten luokkien yläkäsitteeksi niin, että toiset luokat periytyvät siitä.

Lisää tasokuvioita

Luvussa 7.3 määrittelimme piirreluokan Shape ja sen perivän luokan Rectangle:

trait Shape:
  def isBiggerThan(another: Shape) = this.area > another.area
  def area: Double
class Rectangle(val sideLength: Double, val anotherSideLength: Double) extends Shape:
  def area = this.sideLength * this.anotherSideLength

Mitä jos haluamme lisätä ohjelman tyyppivalikoimaan neliöt: sellaiset suorakaiteet, joiden jokaisen sivun pituus on aina täsmälleen sama kuin muidenkin sivujen? Neliöolion voisi luoda käskyllä kuten Square(10).

Yksi tapa olisi tietysti luoda Square-luokka, joka perii Shape-piirteen näin:

class Square(val sideLength: Double) extends Shape:
  def area = this.sideLength * this.sideLength

Kalvamaan kuitenkin jää, että koodissa on nyt selvästi toistoa: neliön pinta-ala-algoritmi on aivan sama kuin suorakaiteenkin; sattuu vain olemaan niin, että sivut ovat saman mittaiset. Toteutus ei ilahduta käsitteellisen mallinnuksen kannaltakaan, sillä se asettaa neliöt suorakaiteiden rinnalle Shape-tyypin alakäsitteeksi. Ihmisinä miellämme, että neliöt ovat erikoistapaus suorakaiteista: kukin neliö on myös suorakaide (ja kuvio).

Ongelma ratkeaa helposti: voimme määritellä, että neliö on suorakaiteen alakäsite. Alla kuvana esitetyn käsitehierarkian voi muodostaa, vaikka Rectangle onkin tavallinen luokka eikä piirreluokka.

../_images/inheritance_shape_square.png

Ali- ja yliluokat

Tehdään pieni lisäys Rectangle-luokkaan:

open class Rectangle(val sideLength: Double, val anotherSideLength: Double) extends Shape:
  def area = this.sideLength * this.anotherSideLength

Vaikka kyseessä on tavallinen luokka (class), josta voi luoda ilmentymiä, niin tämä luokka on avoin. Kirjoittamalla avainsanan open ilmoitamme, että Rectangle saa vapaasti toimia yliluokkana (superclass) mille vain toisille luokille eli siitä sopii periyttää toisia luokkia.

Squaresta tehdään Rectanglen aliluokka (subclass):

class Square(size: Double) extends Rectangle(size, size)

Käytämme jälleen extends-sanaa. Tällä kertaa sen perässä on mainittu tavallinen luokka eikä piirreluokka. Aliluokka Square periytyy yliluokastaan; Square-oliot ovat nyt myös Rectangle-tyyppisiä.

Luokalla Square on vain yksi luontiparametri, joka kertoo kunkin sivun mitan.

Kun aliluokasta luodaan ilmentymä, tehdään myös yliluokassa määritellyt alustustoimenpiteet. Aliluokka voi välittää luontiparametreja yliluokalleen; idea on sama kuin piirreluokille luvussa 7.3. Tässä määritellään, että kun Square-oliota luodaan, tehdään samat alustustoimenpiteet kuin Rectangle-oliolle. Tämä tehdään niin, että kummaksikin suorakaiteen luontiparametriksi (eli kummaksikin sivunpituudeksi) tulee neliöolion saaman luontiparametrin arvo.

Kuinka välttämätön tuo open on?

On syynsä sille, miksi ei ole hyvä ajatus periyttää aliluokkia ihan mistä tahansa luokasta, joka ei ole moiseen tarkoitettu. Nuo syyt on havaittu todellisiksi isoissa ohjelmaprojekteissa eivätkä tule kunnolla esille tällaisella alkeiskurssilla. Pientä osviittaa niistä saa lisälukemistosta tämän luvun lopussa.

Scalassa siis luokka merkitään avoimeksi open-sanalla. Näin ohjelmoija ilmoittaa, että kyseinen luokka on suunniteltu käytettäväksi periytymisessä ja sille sopii määritellä aliluokkia vapaasti. Ellei luokkaa noin avaa, ei sille pidä määritellä aliluokkia muualla kuin samassa kooditiedostossa. open-sanan poisjättö ei teknisesti tee mahdottomaksi määritellä aliluokkia muuallakin, mutta Scala-kääntäjä varoittaa moisesta epäilyttävästä koodista, ja tuollaista periytymistä onkin parempi välttää. (Vrt. sulkeminen sealed-sanalla, joka täysin estää suorien alakäsitteiden määrittelemisen muissa tiedostoissa; luku 7.4.)

Joissakin poikkeustilanteissa on perusteltua laatia aliluokka sellaisellekin toisaalla määritellylle luokalle, joka ei ole avoin. Kääntäjän varoitusilmoituksen voi tällöin poistaa antamalla käskyn import scala.language.adhocExtensions. O1-kurssilla tälle ei ole tarvetta.

Periytyminen vs. piirreluokat

Yliluokasta periytyminen näyttää kovasti samanlaiselta kuin luvussa 7.3 nähty piirreluokasta periytyminen. Samankaltaisesta asiasta onkin kyse.

Erojakin on; vertaillaan. Tässä ensin piirreluokkien ominaisuuksia käyttäen esimerkkinä piirreluokkaa Shape, joka kuvaa Rectangle-luokan yläkäsitteen:

Kun kyseessä on piirreluokka (trait):

Esimerkki

Yläkäsitettä kuvaa piirreluokka.

trait Shape

Alakäsite perii tuon piirreluokan.

class Rectangle extends Shape

Piirreluokassa saa olla abstrakteja metodeita ja muuttujia.

def area: Double

Piirreluokasta ei voi luoda suoraan ilmentymää.

Pelkkä Shape() ei toimi.

Luokka voi periä useita piirreluokkia.

class X extends Piirre1, Piirre2, Piirre3 toimii.

Piirreluokka ei voi välittää luontiparametreja ylätyyp(e)illeen.

trait Piirre1 extends Piirre2(parametrit) ei toimi.

Ja tässä vastaavasti yliluokkien ominaisuuksia. Esimerkkinä on yliluokka Rectangle, joka kuvaa Square-luokan yläkäsitteen:

Kun kyseessä on yliluokka (class):

Esimerkki

Yläkäsitettä kuvaa tavallinen luokka (joka on hyvä määritellä avoimeksi, jos tarkoitettu periytymiseen).

open class Rectangle

Aliluokka periytyy yliluokasta.

class Square extends Rectangle

Tavallisessa luokassa ei saa olla abstrakteja metodeja tai muuttujia. (Paitsi, että... lisää aiheesta kohta.)

Rectanglen kaikilla metodeilla on toteutus.

Yliluokasta voi luoda ilmentymän suoraan.

Rectangle(...) toimii.

Luokalla saa (mm. Scalassa) olla vain yksi välitön yliluokka.

class X extends Yli1, Yli2 ei toimi. (Mutta
class X extends Yli1, Piirre1, Piirre2 on sallittu.)

Mikä vain tavallinen luokka (class) saa välittää luontiparametreja ylityyp(e)illeen.

class Square(size: Int) extends Rectangle(size, size) toimii.

On monia tilanteita, joissa kumpi tahansa noista vaihtoehdoista kelpaa.

Ohjelmointiharjoitus: esineitä esineiden sisällä

Johdanto

Oletetaan, että laaditaan ohjelmaa, jossa on tarkoitus kuvata erilaisia esineitä. Käytössämme on yksinkertainen luokka Item:

open class Item(val name: String) :
  override def toString = this.name

Otetaan tässä pienessä ohjelmointitehtävässä tavoitteeksi, että tällaisten "tavallisten esineiden" lisäksi ohjelmassamme olisi sellaisia esineitä, joiden sisällä voi olla toisia esineitä. Esimerkiksi laukun sisällä voisi olla kirja ja laatikko, joista laatikon sisällä olisi sormus.

Tehtävänanto

Laadi Itemille aliluokka Container, joka kuvaa säkkien ja laatikoiden kaltaisia esineitä, jotka voivat sisältää toisia esineitä:

  • Tällaisilla säiliöesineillä on nimi kuten muillakin esineillä.

  • Lisäksi niillä on addContent-metodi, jolla sisältöä lisätään.

  • Sekä contents-metodi, joka palauttaa lisätyt esineet vektorissa.

  • Container-esineiden toString-metodi poikkeaa tavallisten esineiden vastaavasta.

Laatimasi luokan tulisi toimia tähän tapaan:

val container1 = Container("box")container1: o1.items.Container = box containing 0 item(s)
container1.addContent(Item("ring"))
container1res0: o1.items.Container = box containing 1 item(s)
val container2 = Container("bag")container2: o1.items.Container = bag containing 0 item(s)
container2.addContent(Item("book"))
container2.addContent(container1)
container2res1: o1.items.Container = bag containing 2 item(s)
container1.contentsres2: Vector[o1.items.Item] = Vector(ring)
container2.contentsres3: Vector[o1.items.Item] = Vector(book, box containing 1 item(s))

toStringin paluuarvossa sisällettyjen esineiden lukumäärään otetaan mukaan vain välittömästi sisällä olevat. Esimerkissämme laukun sisällä on siis kaksi esinettä, vaikka laukun sisältämän laatikon sisällä onkin vielä sormus.

(Miten saataisiin kaikki "sisällön sisällötkin"? Palataan siihen luvussa 12.2.)

Ohjeita ja vinkkejä

  • Traits-moduulin pakkauksesta o1.items löytyy tuo Item-luokka, josta tosin puuttuu alusta open. Lisää se.

  • Samasta pakkauksesta löytyy myös alku Container-luokalle sinun täydennettäväksesi.

  • Älä unohda, että yliluokalle Item on välitettävä luontiparametri.

  • Älä määrittele ilmentymämuuttujaa name uudelleen val-sanaa käyttäen luokassa Container. Tuo muuttuja on jo määritelty yliluokassa. Container-luokan luontiparametrin nimi voi silti hyvin olla name.

  • Huomaa, että tässä tehtävässä korvaat (override) yliluokan Item toteutuksen toString-metodille etkä Scala-olioiden oletustoteutusta kuten aiemmissa ohjelmissa.

  • Osaatko toteuttaa aliluokan toString-metodin niin, että kutsut sen sisältä yliluokan toString-metodia sen sijaan, että katsoisit esineen nimen suoraan? (Tämä ei ole välttämätöntä.)

  • Älä lisää luokkaan muita julkisia jäseniä kuin pyydetyt. Yksityisiä jäseniä voit lisätä harkintasi mukaan.

  • Voit itse päättää, millaista kokoelmaa käytät Container-luokan sisäisessä toteutuksessa (vektori? puskuri?). Joka tapauksessa contents-metodin on palautettava sisältö vektorissa. Jos käytät puskuria, lisää alkuun import scala.collection.mutable.Buffer.

A+ esittää tässä kohdassa tehtävän palautuslomakkeen.

Vähän abstrakteista luokista

Tavallisten luokkien ja piirreluokkien lisäksi on mahdollista määritellä niin sanottuja abstrakteja luokkia (abstract class). Abstraktit luokat muistuttavat monin tavoin piirreluokkia, mutta tiettyjä erojakin on.

O1-kurssin kannalta abstraktit luokat eivät ole keskeisiä; suosimme ohjelmissamme piirreluokkia. Ohjelmointiopintojesi edetessä opit valitsemaan abstraktien luokkien ja piirreluokkien välillä itsenäisemmin. Nyt riittää tietää yleissivistävästi, että abstrakteja luokkia on olemassa; se on syytä tietää, koska ne eivät ole harvinaisuus Scalassa tai ohjelmointikielissä muutenkaan. Kaikissa ohjelmointikielissä ei ole abstraktin luokan ja piirreluokan käsitettä erikseen.

Katsotaan esimerkki. Luvun 7.3 ohjelmassa meillä oli piirreluokka Entity:

trait Entity(val name: String):
  def contact: NaturalPerson
  def kind: String
  override def toString = s"$name ($kind)"

Kaksi metodeista on abstrakteja. Tällaisia alatyyppien toteutettavaksi jätettyjä metodeita ei voi ihan tavallisessa luokassa olla, mutta seuraava vaihtoehtoinen määrittely on mahdollinen:

abstract class Entity(val name: String):
  def contact: NaturalPerson
  def kind: String
  override def toString = s"$name ($kind)"

Sana abstract alussa tekee luokasta abstraktin. Tällaiseen luokkaan saa kirjata abstrakteja metodeita kuten piirreluokkaankin. Ja samoin kuin piirreluokasta ei voi luoda suoraan ilmentymiä, ei voi abstraktista luokastakaan. Abstraktit luokat ovat avoimia ilman erillistä mainintaa, eli open-sanaa ei lisäksi tarvita.

Luokkaa, joka ei ole abstrakti eikä piirreluokka, voi vertailun vuoksi sanoa konkreettiseksi luokaksi (concrete class).

Abstrakti luokka muistuttaa eräin tavoin piirreluokkaa ja eräin tavoin konkreettista yliluokkaa. Verrataan kootusti:

Piirreluokka

Abstrakti
yliluokka
Konkreettinen
yliluokka

Voiko se sisältää abstrakteja metodeja?

Voi.

Voi.

Ei voi.

Voiko siitä luoda suoraan ilmentymiä?

Ei voi.

Ei voi.

Voi.

Voiko se välittää luontiparametreja yläkäsitteilleen?

Ei voi.

Voi.

Voi.

Voiko sellaisia periä useita (luetella extends-sanan perässä)?

Voi.

Ei voi.

Ei voi.

Käyttäisinkö piirreluokkaa vai abstraktia luokkaa, noin yleisemmin?

Nyrkkisääntö: Ellei ole erityistä syytä käyttää abstraktia luokkaa, käytä piirreluokkaa, koska niistä voi periytyä joustavammin.

Valinta piirreluokan ja abstraktin yliluokan välillä voi tuntua hankalalta. O1-kurssilla yleensä annetaan luokkatason spesifikaatiot valmiina, ja valinnat on tältä osin tehty puolestasi. Aihe on ajankohtaisempi kurssilla Ohjelmointistudio 2. Vähän lisää aiheesta löytyy myös Kirjoja ja linkkejä -sivulla mainituista kirjoissa Scala Cookbook ja Programming in Scala.

Luokkahierarkiat Scala APIssa

Kuten piirreluokkia, myös abstrakteja ja konkreettisia yliluokkia voi käyttää käsitehierarkioissa. Esimerkkejä hierarkioista löytyy Scala APIsta. Katsotaan muutama.

Käyttöliittymäelementtien hierarkia

Pakkaus scala.swing tarjoaa luokkia, jotka kuvaavat käyttöliittymien rakennuspalikoita eli GUI-elementtejä. Alla on osittain kuvattu se hierarkia, jonka nämä luokat muodostavat.

../_images/inheritance_swing.png

Osa näistä pakkauksen scala.swing luokista on piirreluokkia, osa tavallisia luokkia. Kukin alakäsite saa ominaisuuksia yläkäsitteittensä kautta. Ylin käsite UIElement määrittelee kaikille elementeille yhteisiä ominaisuuksia kuten taustaväri ja koko.

Swing-kirjastosta

Luvussa 12.4 on johdanto käyttöliittymiin Swing-kirjastolla. Tuo valinnainen luku on sijoitettu kurssin ja tämän kurssimateriaalin loppupäähän. Jos aihe kovasti kutkuttaa, voit lukea sen aiemminkin. Riittävät esitiedot siihen sinulla on nyt periytymisestä opittuasi.

Option-hierarkia (ja suljetut yliluokat)

../_images/inheritance_option.png

Pieni hierarkia liittyy tuttuun Option-luokkaankin.

Jo luvussa 4.3 näit, että Option-tyyppisiä olioita on kahdenlaisia. Jokainen Option-tyyppinen olio on joko Some-olio jonkinlaisella sisällöllä tai None. Tässäkin on kyse periytymisestä: Option-tyyppi on abstrakti luokka, josta periytyvät konkreettinen luokka Some ja yksittäisolio None.

Luvussa 7.4 mainittiin, että piirreluokan voi sulkea sealed-sanalla, jolloin sille ei voi määritellä muita välittömiä alakäsitteitä kuin ne, jotka on kirjattu samaan tiedostoon. Yliluokankin voi sulkea, ja Option on juuri tällainen suljettu yliluokka: kuten luvussa 4.3 todettiin, Option voi olla joko Some tai None (jotka on määritelty samassa tiedostossa) mutta ei koskaan mikään muu. Se onkin Optionin nimenomainen tarkoitus. Emme voi itse määritellä Optionille muita aliluokkia, ja hyvä niin.

Kaikkien luokkien äiti: Any

Tutkitaan Scala-olioita REPLissä:

val sekalaisia = Vector[Any](123, "laama", true, Vector(123, 456), Square(10), IArray(1, 2, 3))sekalaisia: Vector[Any] = Vector(123, laama, true, Vector(123, 456), o1.shapes.Square@114c3c7, Array(1, 2, 3))

Esimerkissä luodaan vektori, jossa on keskenään aivan erilaisia olioita: Int, String, Boolean, Vector[Int], Square ja IArray[Int]-tyyppinen alkiokokoelma.

Vektorin alkioiden tyyppi on Any. Kyseessä on "vektorillinen mitä tahansa olioita". Ilmeisesti siis myös kokonaisluvut, vektorit, neliöt jne. ovat Any-tyyppisiä?

Piirreluokkia lukuun ottamatta kaikki Scala-luokat ja yksittäisoliot — myös itse kirjoittamasi — periytyvät automaattisesti Any-nimisestä luokasta, vaikka tätä ei erikseen koodiin normaalisti kirjatakaan. Aivan kaikki Scala-ohjelmissa käytetyt oliot ovat siis Any-tyyppisiä muiden tyyppiensä lisäksi.

Tätä kaikkien luokkien kantaluokkaa voi käyttää myös vaikkapa muuttujan tyyppinä, kuten seuraavassa REPL-esimerkissä:

var jokuOlio: Any = "kumkvatti"jokuOlio: Any = kumkvatti
jokuOlio.isInstanceOf[Any]res4: Boolean = true
jokuOlio.isInstanceOf[String]res5: Boolean = true
jokuOlio.isInstanceOf[Square]res6: Boolean = false
jokuOlio = Square(10)jokuOlio: Any = o1.shapes.Square@ecfb83
jokuOlio.isInstanceOf[Any]res7: Boolean = true
jokuOlio.isInstanceOf[String]res8: Boolean = false
jokuOlio.area-- Error: ... value area is not a member of Any

Kuten tyypin nimikin antaa ymmärtää, tällaiseen muuttujaan voi sijoittaa arvoksi viittauksen millaiseen tahansa olioon, vaikkapa merkkijonoon tai neliöön, kuten tässä.

Muuttujan jokuOlio staattinen tyyppi on siis Any, ja kutsu jokuOlio.isInstanceOf on luvallinen koska (ja vain koska) kyseinen metodi isInstanceOf on määritelty luokassa Any ja on näin käytettävissä mille tahansa Scala-oliolle.

Sen sijaan kutsu jokuOlio.area epäonnistuu, vaikka muuttujaan sattuukin olemaan tallennettuna Square-tyyppinen olio, jolla area-metodi on. Muuttujan staattinen tyyppi rajoittaa sitä, millaiset metodikutsut ovat sallittuja.

Useimmiten Any-tyyppinen muuttuja ei ole sovelias valinta, koska se rajoittaa muuttujan arvon käyttämistä liiaksi. Kun staattisena tyyppinä on Any, voi arvolla tehdä vain sellaisia asioita, jotka on määritelty Any-luokassa. Näitä kaikille Scala-oliolle yhteisiä metodeita ovat vain isInstanceOf, toString, ==, != sekä kourallinen muita. Muuttujille kannattaa yleensä valita jokin spesifisempi tyyppi, kuten olet kurssilla tähänkin mennessä tehnyt. (Vähän lisää aiheesta jäljempänä.)

Melkein kaikkien luokkien äiti: AnyRef

Scalan piällystyyppi Any jakautuu kahteen "päähaaraan". Sillä on välittömät aliluokat AnyVal ja AnyRef:

../_images/inheritance_any.png

Jako AnyRefiin ja AnyValiin liittyy Scala-kielen toteutukseen eikä ole erityisen keskeinen aloittelevan Scala-ohjelmoijan tai yleisemmin ohjelmoinnin perusteiden kannalta. Näistä tyypeistä on silti hyvä tietää sen verran, että hahmotat, miksi niiden nimet esiintyvät joskus Scaladoc-dokumenteissa, REPL-tulosteissa ja virheilmoituksissa.

AnyVal on yliluokka eräille sellaisille valmiille luokille, jotka edustavat tietynlaisia suhteellisen yksinkertaisia perustietotyyppejä ja joiden käyttö on tietyillä tavoin tehokkaampaa kuin muiden tietotyyppien. AnyValista periytyvät tutut tietotyypit Int, Double, Boolean, Char, Unit, ja muutama muu. AnyVal-luokalle on suhteellisen harvoin järkevää itse tehdä aliluokkia, tällä kurssilla ei koskaan.

AnyRef puolestaan on yliluokka kaikille muille (ei-piirre-)luokille ja yksittäisolioille. Esimerkiksi luokat String ja Vector sekä yllä itse laadittu luokka Item periytyvät AnyRefistä.

AnyValia ja AnyRefiä voi kokeilla REPLissä näin:

val sekalaisia2 = Vector[AnyVal](123, true)sekalaisia2: Vector[AnyVal] = Vector(123, true)
val sekalaisia3 = Vector[AnyRef]("laama", Vector(123, 456), Square(10))sekalaisia3: Vector[AnyRef] = Vector(laama, Vector(123, 456), o1.shapes.Square@667113)

Int- ja Boolean-tyypit periytyvät molemmat AnyValista. Näitä arvoja voi laitta AnyValeja sisältävään kokoelmaan.

String, Vector[Int] ja Square periytyvät AnyRefistä.

Vielä yksi: Matchable

Mainittujen "ylätyyppien" lisäksi voit törmätä vielä yhteen: Matchable on piirreluokka, joka kattaa yläkäsitteenä kaikki sellaiset Scala-tyypit, joita on luvallista käyttää hahmontunnistuksessa — siis match-käskyssä. Sekä AnyRef- että AnyVal-piirteet perivät Matchable-piirteen, joten Matchable on käsitteenä lähes yhtä laaja kuin Any.

match-käskyä voi soveltaa melkein kaikkiin Scala-arvoihin, mutta on joitakin aivan poikkeuksellisia tyyppejä, joilla ei Matchable-piirrettä ole. Lisätietoja on saatavilla verkosta, mutta ne eivät ole kurssin kannalta merkityksellisiä.)

Lisää AnyRefistä ja AnyValista

Kiinnostuneille (JVM:stä/Javasta jotain tietäville) tiedoksi, että JVM-pohjaisessa Scala-toteutuksessa AnyVal-aliluokkia on toteutettu JVM:n alkeistyypeillä kuten int ja double, kun taas AnyRef-luokasta periytyvät luokat on toteutettu JVM-tasollakin luokilla.

Nimet AnyRef ja AnyVal heijastelevat tätä jakoa. Edellisen kategorian toteutuksessa käytetään viittauksia (reference) mutta jälkimmäisessä vain yksinkertaisia arvoja (value). AnyValien on oltava tilaltaan muuttumattomia ja täyttää muitakin tiukkoja ehtoja. Oikeissa paikoissa käytettyinä niillä voi parantaa suoritustehokkuutta.

Ilmentymien räätälöintiä yläkäsitteistä

Luvussa 2.4 opimme luomaan olion, jolle on ilmentymäkohtaisesti räätälöity metodi luokan määrittelemien metodien lisäksi:

object terasmies extends Henkilo("Clark"):
  def lenna = "WOOSH!"
end terasmies// defined object terasmies

Olemme sittemmin käyttäneet samaa tekniikkaa View-luokan kanssa määritellessämme yksittäisiä View-olioita, joille olemme räätälöineet omia metodeita.

Tässä vaiheessa kurssia voimme todeta, että itse asiassa tämä ilmentymäkohtainen räätälöinti on esimerkki periytymisestä. Esimerkiksi yllä REPLissä annettu käsky periyttää Henkilo-yliluokasta yksittäisen olion. Vastaavasti olemme toteuttaneet View-yläkäsitteen abstraktiksi jättämän makePic-metodin erilaisilla tavoilla.

Yksittäistä olioita määritellessä voi yliluokkia ja piirreluokkia yhdistellä joustavasti muillakin tavoin. Voit halutessasi lukea siitä alta lisää.

Alakäsitteen määritteleminen "lennosta"

Scala mahdollistaa olioiden luomisen niin, että olio perii piirreluokan "lennosta", mikä määritellään olionluomiskäskyn yhteydessä.

Määritellään pohjustuksena pari piirreluokkaa ja yksi tavallinen luokka. Nämä kolme ovat toisistaan täysin erilliset:

class Elain(val laji: String):
  override def toString = "eläin, tarkemmin sanoen " + this.laji
trait Sylkeva:
  def sylje = "pthyi"
trait Kyttyrallinen:
  def kyttyroidenMaara: Int// defined class Elain
// defined trait Sylkeva
// defined trait Kyttyrallinen

Kokeillaan uutta tapaa tehdä olio:

val lemmikki = new Elain("laama") with Sylkevalemmikki: Elain & Sylkeva = eläin, tarkemmin sanoen laama

Määrittelemme "lennosta" uuden, tarkemmin nimeämättömän alatyypin, joka on Elain-luokan aliluokka ja joka perii myös Sylkeva-piirteen. Luomme tuosta luokasta saman tien yhden ilmentymän.

Sana new on pakollinen tällaisissa käskyissä, joissa ei ainoastaan luoda uutta ilmentymää vaan samalla määritellään sille uusi tyyppi. Huomaa myös with.

Tällä Elain & Sylkeva -yhdistelmätyyppiä olevalla oliolla voi siis tehdä asioita, joita on määritelty joko Elain-luokassa tai Sylkeva-piirreluokassa:

lemmikki.lajires9: String = laama
lemmikki.syljeres10: String = pthyi

Toki saman lopputuloksen saa myös määrittelemällä erikseen nimetyn luokan seuraavaan tapaan ja luomalla sitten tuosta luokasta ilmentymän.

class Laama extends Elain("laama"), Sylkeva// defined class Laama

Metodien lisääminen "lennosta" määriteltyyn tyyppiin

Jatketaan esimerkkiä. Luodaan olio, joka on sellaista tyyppiä,

  • joka on Elain-luokan aliluokka,

  • joka perii piirteet Kyttyrallinen ja Sylkeva, ja

  • joka toteuttaa Kyttyrallinen-piirreluokan abstraktin kyttyroidenMaara-metodin tietyllä tavalla.

val seSeOn = new Elain("dromedaari") with Kyttyrallinen with Sylkeva:
  def kyttyroidenMaara = 1seSeOn: Elain & Kyttyrallinen & Sylkeva = eläin, tarkemmin sanoen dromedaari

Katsotaan lopuksi, miten samalla tekniikalla voi luoda olion, jolla on Sylkeva-piirre ja omanlaisensa toString-metodi:

val tunnettuKalamies = new Sylkeva:
  val nimi = "Eemeli"
  override def toString = this.nimitunnettuKalamies: Sylkeva = Eemeli

Tässä kirjoitettiin new-sanan perään piirreluokan eikä tavallisen luokan nimi. Käsky ei kuitenkaan luo ilmentymää suoraan piirreluokasta (mitä ei voikaan tehdä). Se määrittelee uuden nimettömän alatyypin, jolla on tuo piirre ja mainitut lisäominaisuudet, ja luo ilmentymän tästä uudesta tyypistä.

Hakusanoja: scala trait mixin, scala anonymous subclass.

Lisälukemisto

Staattisen tyypin valitsemisesta

Staattiset tyypit on useimmiten hyvä kirjata koodiin "laajoiksi" eli käyttää yliluokkaa tai piirreluokkaa muuttujien tyyppinä. Tämä koskee eritoten parametrimuuttujia. Näin metodeista ja luokista tulee yleiskäyttöisempiä ja helpommin muokattavia.

Vertaa:

def doSomething(circle: Circle) =
  // ...

vs.

def doSomething(shape: Shape) =
  // ...

Ensimmäinen määrittely on perusteltu, jos metodin todella on järkevää toimia ainoastaan ympyräolioille (esimerkiksi siksi, että se tarvitsee toimiakseen parametriksi saamansa ympyräolion sädettä, jollaista ei ole muilla kuvioilla). Muutoin jälkimmäinen määrittely on yleensä parempi, koska metodi toimii nyt erilaisille kuvioille, myös mahdollisille Shape-piirreluokan vielä luomattomille alatyypeille.

Aina ei voi yleistää; muutenhan kaiken tyypiksi tulisi Any. Mutta yleistä kun voit.

Julkisen ja yksityisen väliltä: protected

Aliluokan olioillakin on yliluokan private-muuttujat osana tietojaan. Yliluokalta perityt metodit käyttävät noita muuttujia. Mutta aliluokan koodista ei voi suoraan viitata yliluokan private-osiin. Vastaavasti myöskään piirreluokan perivät luokat eivät pääse suoraan käsiksi piirreluokan yksityisiin osiin.

Scalassa, kuten joissakin muissakin kielissä, on käytettävissä myös näkyvyysmääre protected. Se sallii juuri tuon äsken mainitun, jota private ei salli: protected-muuttujaa tai metodia voi käyttää luokan itsensä lisäksi alakäsitteiden koodissa (mutta ei vapaasti mistä tahansa muualta, kuten julkisia osia). Lisätietoja löydät netistä.

Moniperintä

Ylempänä mainittiin, että luokalla voi kirjata vain yhden yliluokan. Miksi ei saisi periä kuin yhdestä välittömästä yliluokasta? Selvitä netitse, mitä on moniperintä (multiple inheritance). Voit myös selvittää, mikä on moniperintään liittyvä "tuomion timantti" ("deadly diamond of death") ja miksi jotkut pitävät sitä ongelmana ja toiset eivät.

Hieman provokatiivista mutta kiinnostavaa lisäluettavaa (lähinnä Javaa ja sen rajapintaluokkia ennestään tunteville): 'Interface' Considered Harmful.

Jos luokka periytyy useasta piirreluokasta, mitä metoditoteutusta käytetään?

Tässä pieni esimerkki:

trait X:
  def metodi: String
trait A extends X:
  override def metodi = "a:n metodi"
trait B extends X:
  override def metodi = "b:n metodi"// defined trait X
// defined trait A
// defined trait B
class AB extends A, B
class BA extends B, A// defined class AB
// defined class BA
AB().metodires11: String = b:n metodi
BA().metodires12: String = a:n metodi

Lisätietoja hakusanoilla scala linearization tai Kirjoja ja linkkejä -sivun lähteistä.

Liskovin periaate

../_images/liskov-fi.png

"Neliö suorakaiteen aliluokkana" on klassinen esimerkki, kun tarkastellaan Liskovin periaatetta (Liskov substitution principle), josta on tässä annettu epätäsmällinen mukaelma. Tämän periaatteen noudattamista pidetään eräänä olio-ohjelman laadun kriteerinä.

Luokille, jotka on laadittu Liskovin periaatetta noudattaen, pätee:

Jos S on T:n aliluokka, niin on mahdollista ja mielekästä kohdistaa luokasta S luotuun ilmentymään mikä tahansa sellainen toimenpide, jonka voi kohdistaa luokasta T luotuun ilmentymään.

Toisin sanoen: T:n ilmentymän paikalle sopii yhtä hyvin myös S:n ilmentymä.

Palataan esimerkkiin. Aiemmin tässä luvussa määriteltiin tällaiset luokat:

open class Rectangle(val sideLength: Double, val anotherSideLength: Double) extends Shape:
  def area = this.sideLength * this.anotherSideLength
class Square(size: Double) extends Rectangle(size, size)

Mieti, onko tämä ohjelmakoodi Liskovin periaatteen mukainen vai ei. Entä jos muutettaisiin Rectangle-luokasta val- sanat vareiksi? Muista: yllä määriteltiin luokan Square laatimisen tavoitteeksi, että kaikkien Square-olioiden tulee edustaa sellaisia suorakaiteita, joiden sivut ovat keskenään samanmittaiset.

Lue lisää aiheesta vaikkapa Wikipediasta:

Käsitteiden välisistä riippuvuuksista

Piirreluokkaluvussa 7.3 oli jo esillä ajatus siitä, että ylä- ja alakäsitteen välillä on epäsymmetrinen suhde: alakäsite määritellään yläkäsitteen avulla muttei toisin päin. Sama pätee periytymiseenkin:

  • Aliluokan koodiin kirjataan, mistä yliluokasta aliluokka periytyy. Tällöin voidaan luottaa siihen, että aliluokan olioillakin on tietyt perityt metodit käytettävissä. Aliluokan koodissa voidaan kutsua this.yliluokastaPerittyMetodi. Aliluokasta voi myös super-sanalla viitata nimenomaisesti yliluokan osiin.

  • Periytymistä ei merkitä yliluokkaan, ja yliluokan koodi onkin sikäli aliluokista riippumaton. Yliluokan koodissa ei voi this-oliolle kutsua mahdollisten aliluokkien erityismetodeita vaan vain metodeita, jotka koskevat koko yläkäsitettä. super-sanalla ei ole vastinetta, joka viittaisi periytymishierarkiassa alaspäin.

Jos yliluokat olisivat riippuvaisia aliluokistaan, niin muutokset aliluokkiin usein aiheuttaisivat muutoksia yliluokan toimintaan. Tämä olisi monesti harmillista. Esimerkiksi: Ei ole harvinaista periä jokin ohjelmakirjastossa määritelty luokka (vaikkapa View). Kirjaston laatija ei voi etukäteen tai muutenkaan tuntea kaikkia aliluokkia eikä reagoida niihin tuleviin muutoksiin.

Toisaalta on toivottavaa, että yliluokkaan voi lisätä uutta toiminnallisuutta metodeina, ja nuo metodit tulevat käyttöön kaikkiin aliluokkiin. Käytännössä tämä usein onnistuukin, mutta asiaan liittyy myös eräs periytymisen heikkous:

Yliluokkien hauraudesta

Ilmaisu hauraan yliluokan ongelma (fragile base class problem) viittaa tilanteisiin, joissa yliluokkaa ei voi muuttaa tuntematta aliluokkien yksityiskohtia.

Yksi esimerkki ongelmasta on metodin lisääminen yliluokkaan niin, että se "rikkoo" aliluokan, jonne oli satuttu tekemään samanniminen metodi. Scalan tapauksessa tällöin syntyy käännösaikainen virhe, joka valittaa override-määrittelyn puuttumisesta aliluokassa. Sellaisissa toisissa kielissä, jotka eivät vaadi override-merkintää, seurauksena voi olla yllättävä virheellinen toiminta ohjelma-ajon aikana.

Aiheeseen palataan olio-ohjelman suunnittelun yhteydessä kevään puolella. Voit hakea tietoa esimerkiksi Wikipedian artikkelista; ks. myös Composition over inheritance -periaate.

Korvaamisen ja alakäsitteiden estäminen: final

Jos kirjoitat defin eteen sanan final, ei tuota metodia voi korvata aliluokassa. Metodi periytyy alatyyppien olioillekin sellaisenaan. Korvausyritys tuottaa käännösaikaisen virheilmoituksen.

Sama sana final luokkamäärittelyn alussa ennen class-sanaa estää kokonaan aliluokkien määrittelemisen tuolle luokalle. (Vrt. luokan sulkeminen sanalla sealed, joka estää muut välittömät aliluokat kuin samassa tiedostossa määritellyt.)

Sopivasti käytettynä final-määre voi parantaa ohjelman ymmärrettävyyttä tai ehkäistä luokkien epätarkoituksenmukaista käyttöä. Joissain tapauksissa myös ohjelman tehokkuus paranee, kun kääntäjän ei tarvitse huomioida korvaavien toteutusten mahdollisuutta.

Scalan peruskirjastossa on monia final-luokkia.

Yhdistetyypit (kuten Int|String)

Aiemmin totesimme, että jos staattisena tyyppinä on Any, voivat arvot olla mitä vain, kuten tässä:

val sekalaisia: Vector[Any] = Vector(-123, "laama")sekalaisia: Vector[Any] = Vector(-123, laama)

Vaan mitä jos tuosta jättää tyyppimäärittelyn Vector[Any] pois?

val sekalaisia = Vector(-123, "laama")sekalaisia: Vector[Int | String] = Vector(-123, laama)
val ekaAlkio = sekalaisia.headval ekaAlkio: Int | String = -123

Scala päättelee tyypiksi Vector[Int|String] eli "vektorillinen alkioita, joista kukin on joko kokonaisluku tai merkkijono".

Myös paljas Int|String on tyyppi. Tämän vektorin head-metodin palauttaa arvon, jonka staattinen tyyppi on "joko kokonaisluku tai merkkijono". Tällaista tyyppiä sanotaan yhdistelmätyypiksi (union type).

Mitä arvolla voi tehdä, jos sen staattinen tyyppi on Int|String? Vain sellaisia asioita, joita voi tehdä sekä kokonaisluvuilla että merkkijonoilla:

ekaAlkio.length-- [E008] Not Found Error:
  value length is not a member of Int | String
ekaAlkio.abs-- [E008] Not Found Error:
  value abs is not a member of Int | String

length on määritelty merkkijonoille ja abs kokonaisluvuille, ...

... mutta kumpaakaan metodeista ei ole sekä merkkijonoilla että kokonaisluvuilla, joten tällaiset käskyt tuottavat käännösaikaisen virheen.

Mutta jos haluat tehdä valinnan dynaamisen tyypin perusteella, match-käsky toimii hyvin yhdistetyyppienkin kanssa:

ekaAlkio match
  case luku: Int    => luku.abs
  case jono: String => jono.lengthres13: Int = 123

Mitään kolmatta casea ei tarvita sen varalta, että kyseessä olisi jokin muu arvo, koska Int|String-tyyppinen arvo on väistämättä joko Int tai String.

Yhdistetyypin voi myös kirjata koodiin. Esimerkiksi muuttujan tyypiksi voi merkitä yhdistetyypin, kuten alla on tehty.

def laske(lukuTaiJono: Int | String) =
  lukuTaiJono match
    case luku: Int    => luku.abs
    case jono: String => jono.lengthdef laske(lukuTaiJono: Int | String): String
Vector(-123, "laama").map(laske)res14: Vector[Int] = Vector(123, 5)

Yhteenvetoa

  • Piirreluokkien lisäksi myös tavalliselle luokalle voi määritellä alakäsitteitä: yläkäsitettä kuvaavan luokan eli yliluokan ominaisuudet periytyvät sen aliluokille.

  • Luokka voi olla abstrakti, jolloin siinä voi olla abstrakteja metodeita ja muuttujia kuten piirreluokassakin. Abstraktista luokasta ei voi luoda ilmentymiä suoraan vaan vain aliluokkiensa kautta.

  • Piirreluokilla ja abstrakteilla yliluokilla on paljon yhteistä mutta myös eroja. Erot riippuvat ohjelmointikielestä (ja kaikissa kielissä ei ole kumpaakin käsitettä erikseen).

    • O1-kurssilla laadimme näistä lähinnä piirreluokkia. Kurssin tarpeisiin abstrakteista luokista ei tarvitse tietää juuri muuta kuin että ne muistuttavat piirreluokkia.

  • Kaikki Scala-luokat kuuluvat hierarkiaan, jonka kantaluokkana on valmis luokka Any.

  • Lukuun liittyviä termejä sanastosivulla: periytyminen, aliluokka, yliluokka, tyyppihierarkia, Any; abstrakti luokka; staattinen tyyppi, dynaaminen tyyppi; avoin luokka, suljettu luokka; final.

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!

Materiaalin luvut tehtävineen ja viikkokoosteineen on laatinut Juha Sorva.

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, Kaisa Ek, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Onni Komulainen, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Mikael Lenander, Ilona Ma, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, Joel Toppinen, Anna Valldeoriola Cardó ja Aleksi Vartiainen.

Lukujen alkuja koristavat kuvat ja muut vastaavat kuvituskuvat on piirtänyt Christina Lassheikki.

Yksityiskohtaiset animaatiot Scala-ohjelmien suorituksen vaiheista suunnittelivat Juha Sorva ja Teemu Sirkiä. Teemu Sirkiä ja Riku Autio toteuttivat ne apunaan Teemun aiemmin rakentamat työkalut Jsvee ja Kelmu.

Muut diagrammit ja materiaaliin upotetut vuorovaikutteiset esitykset laati Juha Sorva.

O1Library-ohjelmakirjaston ovat kehittäneet Aleksi Lukkarinen, Juha Sorva ja Jaakko Nakaza. Useat sen keskeisistä osista tukeutuvat Aleksin SMCL-kirjastoon.

Tapa, jolla 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+ luotiin alun perin Aallon LeTech-tutkimusryhmässä pitkälti opiskelijavoimin. Nykyään tätä avoimen lähdekoodin projektia kehittää Tietotekniikan laitoksen opetusteknologiatiimi ja tarjoaa palveluna laitoksen IT-tuki; sitä ovat kehittäneet kymmenet Aallon opiskelijat ja muut.

A+ Courses -lisäosa, joka tukee A+:aa ja O1-kurssia IntelliJ-ohjelmointiympäristössä, on toinen avoin projekti. Sen suunnitteluun ja toteutukseen on osallistunut useita opiskelijoita yhteistyössä O1-kurssin opettajien kanssa.

Kurssin tämänhetkinen henkilökunta löytyy luvusta 1.1.

Joidenkin lukujen lopuissa on lukukohtaisia lisäyksiä tähän tekijäluetteloon.

a drop of ink
Palautusta lähetetään...