Tämä kurssi on jo päättynyt.

Kurssin viimeisimmän version löydät täältä: O1: 2024

Luku 7.3: Käsitteitä sukupuussa: piirreluokat

Tästä sivusta:

Pääkysymyksiä: Miten kuvaan ohjelmassani ylä- ja alakäsitteitä? Miten teen luokista muokattavampia ja yleiskäyttöisempiä?

Mitä käsitellään? Ylä- ja alakäsitteiden kuvaaminen piirreluokkien avulla. Abstraktit metodit ja muuttujat. Staattinen vs. dynaaminen tietotyyppi. Tyyppihierarkiat.

Mitä tehdään? Luetaan ja ohjelmoidaan.

Suuntaa antava työläysarvio:? Kolme tai neljä tuntia.

Pistearvo: B100.

Oheismoduulit: Traits (uusi). Lisätehtävässä AuctionHouse2 (uusi).

../_images/person04.png

Johdanto: tasokuvioita

Oletetaan, että olemme laatimassa sovellusta, jonka on tarpeen käsitellä tasokuvioita kuten ympyröitä ja suorakaiteita. Ohjelman täytyy muun muassa pystyä laskemaan ympyröiden ja suorakaiteiden pinta-aloja.

Kyse ei siis ole Pic-tyyppisistä ympyröiden ja suorakaiteiden kuvista, vaan nyt mallinnamme geometrisia käsitteitä.

Kuvataan ympyröitä tällaisella luokalla Circle:

import scala.math.Pi

class Circle(val radius: Double):
  def area = Pi * this.radius * this.radius
  // jne. muita ympyröiden metodeita

Kuvataan suorakaiteita puolestaan luokalla Rectangle:

class Rectangle(val sideLength: Double, val anotherSideLength: Double):
  def area = this.sideLength * this.anotherSideLength
  // jne. muita suorakaiteiden metodeita

Esitetyissä Circle- ja Rectangle-luokissa ei yksittäisinä luokkina tarkasteltuina ole vikaa. Ne voisivat kyllä toimia toisistaan riippumattomasti jonkin ohjelman osina, mutta:

Mutta #1

Mitä jos haluamme lisäksi vertailla kuvioita pinta-alan perusteella toisiin kuvioihin? Yksi tapa olisi kirjoittaa luokkiin metodeita näin:

import scala.math.Pi

class Circle(val radius: Double):

  def area = Pi * this.radius * this.radius

  def isBiggerThan(another: Circle): Boolean = this.area > another.area

  def isBiggerThan(rectangle: Rectangle): Boolean = this.area > rectangle.area

end Circle
class Rectangle(val sideLength: Double, val anotherSideLength: Double):

  def area = this.sideLength * this.anotherSideLength

  def isBiggerThan(another: Rectangle): Boolean = this.area > another.area

  def isBiggerThan(circle: Circle): Boolean = this.area > circle.area

end Rectangle

Pitääkö tosiaan kumpaankin luokkaan kirjoittaa tällaiset kaksi aivan samankaltaista vertailumetodia? Ei kovin DRY ratkaisu. Ja mitä jos kuviotyyppejä on enemmän kuin kaksi?

Eikä siinä vielä kaikki.

Mutta #2

Mitä jos haluamme tallentaa samaan kokoelmaan viittauksia erimuotoisiin kuvioihin: ympyröihin, suorakaiteisiin ja ehkä muihinkin kuvioihin? Vaikkapa näin:

@main def shapeTest() =

  val shapes = Buffer[?????]()
  shapes += Circle(10)
  shapes += Rectangle(10, 100)
  shapes += Circle(5)

  var sumOfAreas = 0.0
  for current <- shapes do
    sumOfAreas += current.area
  println("Pinta-alojen summa on: " + sumOfAreas)

end shapeTest

Mitä ?????-merkinnän kohdalle pitäisi kirjoittaa? Mikä voidaan mainita shapes-puskurin alkioiden tyypiksi?

Ja edelliseen liittyvästi: Mikä on current-muuttujan tyyppi? "Joku sellainen, jolle voi kutsua area-metodia"?

Ei kai sentään ole pakko tehdä erillisiä listoja ympyröistä, suorakaiteista jne.? Se ei olisi kovin käytännöllistä.

Saamme lisävaloa ongelmaan, kun alustamme puskurin sisällön samalla kun luomme puskurin. Siis näin:

@main def shapeTest() =

  val shapes = Buffer(Circle(10), Rectangle(10, 100), Circle(5))

  var sumOfAreas = 0.0
  for current <- shapes do
    sumOfAreas += current.area
  println("Pinta-alojen summa on: " + sumOfAreas)

end shapeTest

Tämä on sinänsä täysin mahdollinen käsky. Scala-kieleen kuuluu tyyppipäättely (luku 1.8), ja koska alkioiksi on merkitty ympyröitä ja suorakaide, niin Scala-työkalut päättelevät, että tässä ei luoda "puskuria, jossa on ympyröitä" eikä "puskuria, jossa on suorakaiteita" vaan "puskuri, jossa on sekalaisia olioita". (Tarkemmin sanoen tyyppi on Buffer[AnyRef]. Siihen, mikä AnyRef on, palaamme luvussa 7.5.)

Jos puskurin alkioina on mielivaltaisia olioita, voi muuttuja current viitata millaiseen vain olioon. Kutsu current.area tuottaa nyt käännösaikaisen virheilmoituksen, joka kertoo suunnilleen: "Ei tuollaista metodia ole millä tahansa oliolla."

On totta, että millä tahansa olioilla ei ole area-nimistä metodia. Siksi on mainiota, että kääntäjä osaa kyseenalaistaa metodikutsun current.area. Kun puskurimme sisältää "mitä vaan olioita", ei area-metodia tosiaan pidäkään mennä noin vain kutsumaan puskurin alkioille.

Toisaalta tässä tapauksessa tiedämme, että olemme laittaneet puskuriin nimenomaan Rectangle- ja Circle-olioita, joita yhdistää (ainakin) se, että niillä tällainen metodi on. Olisi luonnollista ja kätevää, jos äskeinen shapeTest-koodi toimisi.

Mikä neuvoksi?

Ylä- ja alakäsitteistä

Tämä on ympyrä. →
← Tämä on myös kuvio.

Ihminen mieltää, että ympyrät ja suorakaiteet ovat kuvioita ja että pinta-ala on tällaisten tasokuvioiden yleinen ominaisuus. Meille on luonnollista, että tietty asia voi olla ilmentymä sekä tarkemmasta käsitteestä (kuten ympyrä) että sen yläkäsitteestä (kuten kuvio). Voimme myös ajatella, että:

  • "Tämä ohjelma laskee kuvioiden kokonaispinta-alan." tai

  • "Metodi area on kaikilla kuvioilla." tai

  • "Metodi isBiggerThan ottaa parametrikseen minkä tahansa toisen kuvion."

Voimme mieltää ja piirtää luokkien väliset suhteet tähän tapaan:

../_images/inheritance_shape.png

Ala- ja yläkäsitteen välillä on niin sanottu is a -suhde: "Every circle is a shape."

Voimme myös esittää tämän ajatuksen tietokoneohjelmassa, kuten kohta näkyy.

Piirreluokka yläkäsitteenä

Määritellään kuvion käsitettä vastaava tietotyyppi Shape.

trait Shape:

  def isBiggerThan(another: Shape) = this.area > another.area

  def area: Double    

end Shape

Tässä määritellään piirreluokka (lyhyemmin vain piirre; trait) nimeltä Shape. Tämän piirreluokan ajatuksena on määritellä millaisia kuviot yleisesti ottaen ovat: "kaikilla olioilla, joita voi tässä ohjelmassa sanoa kuvioiksi — olivat ne sitten muuten minkälaisia tahansa — on seuraavanlaiset metodit".

Piirreluokan määrittelyssä käytetään avainsanaa trait sanan class sijaan, mutta muuten se muistuttaa kovasti tutunlaisia luokkamäärittelyjä.

Määritellään: kaikilla kuvioilla on tällainen isBiggerThan-metodi, jolla voi verrata kuvioiden pinta-aloja keskenään.

Parametrin tyyppi on Shape, eli tälle metodille annetaan parametriksi juuri sellainen kuvio-olio, jollaisia tämä piirreluokka kuvaa. Huomaat: piirreluokkaa voi käyttää tietotyyppinä siinä missä tavallisiakin luokkia.

Määritellään: kaikilla kuvioilla on tällainen area-metodi pinta-alan laskemiseen. Mutta: määrittelemmekin vain metodin nimen, parametrit (joita ei tässä tapauksessa ole) ja paluuarvon tyypin (Double kaksoispisteen perässä).

Sen sijaan varsinaista toteutusta eli pinta-alan laskemistapaa tälle metodille ei ole määritelty lainkaan! Sanomme: kyseessä on abstrakti metodi (abstract method).

Loppumerkki on tässäkin vapaaehtoinen. Se on tapana kirjoittaa ainakin silloin, jos piirreluokan koodissa on tyhjiä rivejä.

Toisin kuin nähdynlaisiin tavallisiin luokkiin, piirreluokkiin voi määritellä abstrakteja, toteutuksettomia metodeita. Esimerkiksi Shape-piirreluokassa ilmoitetaan, että minkä tahansa kuvion pinta-alan voi laskea area-nimisellä parametrittomalla metodilla ja tuloksena saadaan Double-arvo. Kuitenkin metodin toteutus on jätetty auki; se on tarkoitus määritellä kullekin kuviotyypille erikseen.

Alakäsitteen määrittely

Yläkäsitteen Shape-määrittely on nyt kunnossa, mutta on määrittelemättä, että ympyrät ja suorakaiteet ovat kuvioita. Tehdään se näin:

import scala.math.Pi

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

Yläkäsitettä kuvaavalle piirreluokalle määritellään alakäsite avainsanalla extends.

Määrittelyn voi tässä lukea vaikkapa näin: "Luokka Circle laajentaa piirreluokan Shape kuvaamaa tietotyyppiä." Tai: "Luokka Circle perii (inherits) Shape-piirteen." Ajatus on joka tapaussa se, että kaikki Circle-tyyppiset oliot ovat paitsi Circlejä myös Shape-tyyppisiä; niillä siis on myös Shape-piirreluokan kuvaamat ominaisuudet.

Luokassa voidaan tarjota toteutukset piirreluokan abstrakteille metodeille. Esimerkiksi tässä määritellään, että ympyrä on sellainen kuvio, jonka pinta-ala lasketaan π * r2, ja suorakaide on sellainen kuvio, jonka pinta-ala lasketaan sivujen kertolaskulla.

Huomaa: Ympyröillä ja suorakaiteilla on extends Shape-määrittelyn vuoksi myös isBiggerThan-metodi, vaikka emme sitä näiden luokkien määrittelyyn kirjoitettukaan.

Piirreluokan tyyppiset oliot

Edellisen perusteella voimme todeta:

  • Piirreluokat kuvaavat tietotyyppejä kuten tavalliset luokatkin.

  • Piirreluokan nimeä voi käyttää esimerkiksi muuttujan tyyppinä kuten luokankin nimeä.

  • On olemassa Shape-tyyppisiä olioita.

Voiko siis Shape-olion luoda käskyllä Shape()? Ja mitä silloin syntyy? Kokeillaan:

Shape()-- Error
...

Ei ole olemassa Shape-olioita, jotka olisivat "vaan kuvioita", eikä sellaista voi luoda. Ja hyvä niin, koska ei missään ole määritelty moisille olioille area-metodin toteutustakaan.

Piirreluokasta luodaan ilmentymiä epäsuorasti sen alakäsitteitä kuvaavien luokkien kautta, kuten seuraava REPL-kokeilu kertoo. Aloitetaan luomalla ympyrä:

val ympyra = Circle(1)ympyra: o1.shapes.Circle = o1.shapes.Circle@1a1a02e

Käytetään nyt entuudestaan tuntematonta metodia isInstanceOf, joka on käytettävissä kaikilla Scala-olioilla. Tämän metodin avulla voi selvittää, onko olio tiettyä tyyppiä.

ympyra.isInstanceOf[Circle]res0: Boolean = true

Toisin kuin tällä kurssilla yleensä käytetyille metodeille isInstanceOf-metodille annetaan tyyppiparametri hakasulkeissa.

Kysytään ympyra-muuttujan osoittamalta oliolta, onko se Circle-tyyppinen. Onhan se.

Tutkitaan nyt, onko kyseinen olio Shape-tyyppinen:

ympyra.isInstanceOf[Shape]res1: Boolean = true

Saatiin taas true. Näemme, että oliolla voi Scalassakin olla yhtä aikaa monta tyyppiä niin kuin halusimmekin.

Oliomme ei suinkaan ole kaikkia tyyppejä. Se ei esimerkiksi ole Rectangle:

ympyra.isInstanceOf[Rectangle]-- Warning:
  |ympyra.isInstanceOf[Rectangle]
  |^^^^^^
  |this will always yield false since type o1.shapes.Circle and class Rectangle are unrelated
res2: Boolean = false

Piirreluokka alkioiden tyyppinä

Vector(Circle(1), Circle(2))res3: Vector[o1.shapes.Circle] = Vector(o1.shapes.Circle@e17571, o1.shapes.Circle@1e56bea)
Vector(Circle(1), Rectangle(2, 3))res4: Vector[o1.shapes.Shape] = Vector(o1.shapes.Circle@876228, o1.shapes.Rectangle@3d619a)

Kun kokoelmassa on vain ympyröitä, päätellään taulukon alkioiden tyypiksi Circle.

Kun kokoelmassa on myös suorakaiteita, päätellään taulukon alkioiden tyypiksi näiden yhteinen yläkäsite eli piirreluokka Shape.

Ratkaisuja johdanto-ongelmiin

Piirreluokan avulla johdannossa esitetyt ongelmat katoavat:

isBiggerThan-metodi kaikille kuvioille

Edellä luonnostelimme useita isBiggerThan-metodeita Circle- ja Rectangle-luokkiin. Nyt kuitenkin käytössämme on yläkäsite Shape. Siihen määritelty isBiggerThan-metodi (toistettuna alla) kelpaa sekä ympyröiden että suorakaiteiden — ja kaikkien mahdollisten lisäkuviotyyppien! — pinta-alavertailuun. Vertailu onnistuu joustavasti niin yhdenmuotoisten kuvioiden kesken kuin ristiin erimuotoisten välilläkin.

trait Shape:

  def isBiggerThan(another: Shape) = this.area > another.area

  def area: Double

end Shape

Nuo kaksi area-metodikutsua isBiggerThan-metodissa ovat mahdollisia siksi, että kaikilla Shapeilla on area-määrittely. Abstraktikin määrittely takaa, että olivatpa isBiggerThan-metodikutsun vastaanottanut this-olio ja parametriksi annettu another-olio millaisia vain kuvioita, niin niiltä väistämättä löytyy jollain tavalla toteutettu area-metodi.

Toteutuspakko!

Jotta luokista Circle ja Rectangle voi luoda ilmentymiä, on näille ilmentymille pakko toteuttaa niiden piirreluokalta Shape perimä abstrakti metodi area.

Jos yllä kuvatuista Circle- ja Rectangle-luokista jättäisi area-metodin toteutuksen pois, syntyisi käännösaikaisia virheilmoituksia, joissa valitetaan, ettei kyseistä metodia ole toteutettu. Scala-työkalut siis pitävät huolen siitä, että konkreettisilta olioilta löytyy toteutukset piirreluokkien abstrakteille metodeille. Voimme luottaa siihen, että tietyntyyppiselta oliolta löytyvät tietyt metodit.

shapeTestkin toimii

Myös shapeTest-esimerkkiohjelma toimii nyt alkuperäisessä muodossaan:

@main def shapeTest() =

  val shapes = Buffer(Circle(10), Rectangle(10, 100), Circle(5))

  var sumOfAreas = 0.0
  for current <- shapes do
    sumOfAreas += current.area
  println("Pinta-alojen summa on: " + sumOfAreas)

end shapeTest

Puskurin alkioiden tyypiksi päätellään Shape. Tyypinhän saisi myös kirjata erikseen itsekin muodossa Buffer[Shape](…), jos siltä tuntuisi.

Tämäkin area-kutsu on nyt mahdollinen, koska current on tyyppiä Shape ja piirreluokka takaa, että kaikilla Shape-olioilla on toteutus area-metodille.

Piirreluokat ja yksittäisoliot

Yksittäisolio voi periä piirteen extends-sanalla aivan kuin luokkakin voi. Tällainen olio on yksittäinen erikoistapaus piirreluokan kuvaamasta käsitteestä. Jos piirreluokassa on abstrakteja metodeja, olion toteutettava ne.

object SingularShape extends Shape:
  def area = 51                      // toteuttaa abstraktin metodin
  val description = "non-Euclidian"  // tämän yksittäisolion lisämuuttuja

Itse asiassa olet jo tehnytkin vastaavasti. Scalan App on nimittäin piirreluokka, joka kuvaa sovellusohjelman käsitettä. Kun olet kirjoittanut ohjelman käynnistysolioon extends App olet siis määritellyt, että kyseinen yksittäisolio perii App-piirteen. Tai toisin sanoen: kyseinen yksittäisolio on erikoistapaus yläkäsitteestä App.

Pohdintatehtävä tähän väliin

Mitkä seuraavista koodinpätkistä käyvät järkeen? Mitä arvelet toimivien koodinpätkien tekevän? Miksi toimimattomat eivät toimi? Oletetaan, että piirreluokka Shape on määritelty kuten yllä ja Circle ja Rectangle sen alatyypeiksi.

Vastaukset ovat varsin intuitiivisia. Jos et tiedä, yritä arvata. Tehtävän esiin nostamista teemoista kerrotaan lisää alempana.

var test = Rectangle(10, 10)
println(test.area)
test = Rectangle(10, 20)
println(test.area)
var test = Shape(10, 20)
println(test.area)
var test = Rectangle(5, 10)
println(test.area)
test = Circle(10)
println(test.area)
var test: Shape = Circle(10)
println(test.area)
test = Rectangle(10, 20)
println(test.area)

Staattinen vs. dynaaminen tyyppi

Kerrataan luvusta 1.2 pari termiä:

  • Sanalla staattinen viitataan ohjelmoinnissa usein siihen "olomuotoon", joka ohjelmalla on (myös) silloin, kun sitä ei ajeta. Usein tällä sanalla viitataan siis sellaisiin seikkoihin, jotka näkyvät suoraan ohjelmatekstistä tai ovat pääteltävissä sen perusteella.

  • Sanalla dynaaminen viitataan ohjelmoinnissa usein siihen "olomuotoon", joka ohjelmalla on, kun sitä ajetaan: ohjelmakoodin ajamisprosessiin ja ohjelma-ajon aikana tapahtuviin asioihin. Ohjelman dynaamisia ominaisuuksia ei voi kaikilta osin päätellä ohjelmakoodista, vaan niihin voivat vaikuttaa esimerkiksi ohjelman käyttäjän antamat syötteet.

Luvussa 1.3 todettiin, että lausekkeilla on arvot ja näillä arvoilla tyypit. Luvussa 1.4 taas, että muuttujilla on tyyppi. Luku 1.4 myös kertoi, että kun muuttujaan sijoitetaan jonkin lausekkeen arvo, on tuon arvon tyypin oltava muuttujan tyypin kanssa yhteensopiva. Tähän asti kurssilla on pitkälti voinut ajatella, että "yhteensopivuus" tarkoittaa "lausekkeen arvolla on oltava sama tyyppi kuin muuttujalla". Nyt piirreluokkien myötä asiaan on syytä hieman tarkentaa.

Staattisesti tyypitetyissä kielissä kuten Scalassa voidaan tehdä ero staattisen ja dynaamisen tietotyypin välillä. Niistä seuraavaksi.

Staattinen tyyppi

Staattinen tyyppi (static type) voidaan selvittää ohjelmakoodia tutkimalla, ohjelmaa ajamatta, eikä siihen vaikuta esimerkiksi ohjelmalle annettu syöte. Tässä esimerkkejä:

  • Metodien parametreille kirjataan kaksoispisteen perään parametrimuuttujien staattiset tyypit.

  • Sijoituksesta val teksti = "laama" voidaan suoraan päätellä muuttujan staattisen tyypin olevan String.

  • Summalausekkeen 1 + 1 voidaan päätellä olevan staattiselta tyypiltään Int sillä perusteella, että myös molempien osalausekkeina olevien literaalien staattinen tyyppi on Int.

Staattiset tyypit on muuttujilla ja lausekkeilla. Siis: ei lausekkeiden arvoilla vaan koodissa olevilla ilmaisuilla itsellään!

Staattinen tyyppi määrää, mitkä operaatiot ja metodikutsut sallitaan ja mitä ei. Metodikutsusta jokuOlio.jokuMetodi() seuraa käännösaikainen virheilmoitus, ellei jokuOlion staattinen tyyppi ole sellainen, jolle on määritelty parametriton metodi jokuMetodi.

Dynaaminen tyyppi

Muuttujiin tallennetuilla arvoilla ja lausekkeiden arvoilla on dynaamiset tyypit (dynamic type). Näihin tyyppeihin vaikuttaa se, mitä ohjelma-ajon aikana tapahtuu.

Usein arvojen dynaamiset tyypit ovat samat kuin vastaavien muuttujien ja lausekkeiden tyypit. Esimerkiksi kokonaislukujen summalauseke 1 + 1 evaluoituu kakkosta kuvaavaksi arvoksi, jonka dynaaminen tyyppi on Int kuten lausekkeen staattinen tyyppikin.

Ero staattisen ja dynaamisen tyypin väliltä löytyy esimerkiksi seuraavasta ohjelmanpätkästä:

var test: Shape = Rectangle(10, 20)
println(test.area)
test = Circle(10)
println(test.area)
val selected = readLine("Haluatko ympyrän? Sano 'joo', jos haluat, muuten tulee neliö. ")
if selected == "joo" then
  test = Circle(readLine("Säde: ").toInt)
else
  val sivu = readLine("Sivu: ").toInt
  test = Rectangle(sivu, sivu)
println(test.area)

Muuttuja test määritellään tässä Shape-tyyppiseksi, jolloin siihen voi tallentaa viittauksen mihin tahansa olioon, jonka tyyppi perii Shapen.

Kunkin lausekkeen test staattinen tyyppi on siis Shape. Kaikki esimerkin metodikutsut test.area ovat sallittuja, koska Shape-tyypille on määritelty area-metodi.

Kuitenkin test-lausekkeen arvon dynaaminen tyyppi eroaa staattisesta tyypistä. Tätä esimerkkikoodia ajettaessa muuttuja viittaa Rectangle-tyyppiseen olioon, sitten Circleen. Viimeisellä rivillä test-lausekkeen arvon dynaaminen tyyppi riippuu käyttäjän aiemmin antamasta syötteestä.

Kullakin area-metodin kutsukerralla se, mikä metoditoteutus suoritetaan, riippuu arvon dynaamisesta tyypistä. Sanotaan: metodien nimet on dynaamisesti sidottu (dynamically bound) metoditoteutuksiin. Tai epävirallisemmin: tapa, jolla lähetettyyn viestiin reagoidaan, riippuu vastaanottajaolion tyypistä.

Arvon dynaamisen tyypin ei siis tarvitse olla identtinen vaan tyyppiyhteensopiva (type compatible) staattisen tyypin kanssa. Viittaus Circle-olioon voi olla Circle- tai Shape-tyyppisessä muuttujassa muttei String- tai Obstacle-tyyppisessä.

Tyypit REPLissä

Tässä vielä muutama aiheeseen liittyvä käsky REPLissä. Kiinnitä huomiosi siihen, miten REPLin tulosteissa näkyvät toisaalta muuttujien staattiset tyypit yhtäsuuruusmerkin vasemmalla puolella ja toisaalta arvojen dynaamiset tyypit oikealla osana olioiden kuvauksia.

var test1 = Rectangle(5, 10)test1: o1.shapes.Rectangle = o1.shapes.Rectangle@38c8ed

test1-muuttujan (staattinen) tyyppi tulee päätellyksi siihen sijoitettavan lausekkeen tyypin perusteella. Se on siis Rectangle, eikä muuttujaan voi sijoittaa viittausta ympyräolioon. Virheilmoitus on suhteellisen selväsanainen:

test1 = Circle(10)-- Error:
  |test1 = Circle(10)
  |        ^^^^^^^^^^
  |        Found:    o1.shapes.Circle
  |        Required: o1.shapes.Rectangle

Mutta jos määräämme muuttujalle erikseen tyypiksi Shape, niin staattinen tyyppi on "laajempi" kuin arvon dynaaminen tyyppi:

var test2: Shape = Rectangle(5, 10)test2: o1.shapes.Shape = o1.shapes.Rectangle@bdee1c

Tällaiseen muuttujaan voi sijoittaa myös ympyräviittauksen:

test2 = Circle(10)test2: o1.shapes.Shape = o1.shapes.Circle@1071884

Pikkutehtävä: piirreluokat ja tietotyypit

Katso uudestaan tätä shapeTest-ohjelmaa:

@main def shapeTest() =

  val shapes = Buffer(Circle(10), Rectangle(10, 100), Circle(5))

  var sumOfAreas = 0.0
  for current <- shapes do
    sumOfAreas += current.area
  println("Pinta-alojen summa on: " + sumOfAreas)

end shapeTest

Mikä on current-muuttujan staattinen tyyppi?

Luettele allekkain ja järjestyksessä kaikkien niiden arvojen dynaamiset tyypit, jotka current-muuttuja saa ohjelman suorituksen aikana. (Vain luokkien nimet, ei pakkauksia.)

Arvioi tässä ja seuraavissa kohdissa taas annetun koodinpätkän toimivuutta (mikä tarkoittaa tässä sitä, voiko koodin suorittaa ilman virheilmoitusta):

var test = Circle(10)
println(test.radius)
var test: Shape = Rectangle(10, 20)
println(test.radius)
var test: Shape = Rectangle(10, 20)
println(test.area)
test = Circle(10)
println(test.radius)
var test: Shape = Circle(10)
println(test.radius)

Mitä hyötyä tuosta rajoituksesta on?

Tässä kappaleessa kerrotaan tarkemmin, miksi käytettävissä olevien toimintojen rajoittaminen nimenomaan staattisen tyypin perusteella on järkevää. Siis: miksi on määritelty niin, että esimerkiksi äskeisen tehtävän lopussa radius-arvoa ei voinut käyttää? Jos asia jo tuntuu aivan perustellulta, voit ohittaa kappaleen ja siirtyä eteenpäin.

Asialla on käytännön merkitystä muun muassa kaikissa sellaisissa metodeissa, joilla on Shape tai muu yläkäsite parametrin tyyppinä eli parametrimuuttujan staattisena tyyppinä. Tälläinen metodihan on esimerkiksi Shape-piirreluokan isBiggerThan:

trait Shape:

  def isBiggerThan(another: Shape) = this.area > another.area

  def area: Double

end Shape

Tässä another-muuttuja osoittaa erääseen kuvio-olioon, ja muuttujan staattinen tyyppi on Shape. Kutsu another.area on sallittu juuri siksi, että area on staattisen tyypin mukainen metodi.

Sen sijaan tässä metodissa ei voisi ryhtyä vertailemaan kuvioita käyttäen vaikkapa lauseketta another.radius, koska sädettä ei ole kaikilla mahdollisilla olioilla, jotka voi välittää parametriksi isBiggerThan-metodille. Jos moinen kutsu sallittaisiin, ei metodin suorittaminen onnistuisikaan, mikäli parametriksi annettaisiin vaikkapa suorakaide. Ongelma ilmenisi vasta ohjelma-ajon aikana.

Sama pätee muuten anotherin lisäksi this-muuttujalle, jonka staattinen tyyppi on Shape siksi, että koodi on kirjoitetettu Shape-piirreluokkaan.

Yleisemmin sanoen: kun parametrin tyyppinä on yläkäsite, voi metodi kohdistaa siihen vain yläkäsitteen määräämiä toimintoja. Näin taataan, että metodi toimii kaikille eri alakäsitteiden ilmentymille.

Staattisen tyypin avulla työkalut (eritoten kääntäjä) voivat paikantaa virheitä paremmin ja jo ennen ohjelma-ajoa. Ellei staattinen tyyppi rajaisi toimintoja, ei tietotyyppien kirjaamisesta koodiin olisi läheskään niin paljon iloa. (Vrt. teksti staattisesta ja dynaamisesta tyypityksestä luvun 1.8 lopussa.)

Mainitun rajoituksen kiertäminen

Mitä jos nyt kuitenkin on Shape-tyyppiseen muuttujaan tallennettuna vaikkapa viittaus Circle-olioon, ja haluamme kutsua olion radius-metodia? Tai mitä jos haluamme tehdä valinnan ohjelma-ajon aikana sen perusteella, viittaako Shape-tyyppinen muuttuja tietyllä hetkellä ympyrään vai johonkin muuhun kuvioon?

match-käsky (luku 4.3) tulee avuksi. Se tekee valinnan nimenomaan dynaamisen tyypin perusteella. Näin:

var someShape: Shape = Circle(10)someShape: Shape = Circle@1de2de2
someShape match
  case someCircle: Circle =>
    println("It's a circle and its radius is " + someCircle.radius)
  case _ =>
    println("It's not a circle.")It's a circle and its radius is 10.0

Tapauksessa, jossa someShapessa on Circle-tyyppinen arvo, tuo arvo tulee tallennetuksi someCircle-nimiseen muuttujaan. someCirclen staattinen tyyppi on Circle.

Ilmaisun case _ voi lukea "missä tahansa muussa tapauksessa" (luku 4.4).

Tiedoksi kiinnostuneille: huonompi tapa

Toinen mutta lähes aina huonompi mahdollisuus on asInstanceOf-metodi, joka on kaikilla Scala-olioilla. Huomaa tyyppiparametri ja paluuarvon staattinen tyyppi:

someShape.asInstanceOf[Circle]res5: Circle = Circle@1de2de2
someShape.radius-- Error: ... value radius is not a member of o1.shapes.Shape

Varoitus! Tämä aiheuttaa ajonaikaisen virhetilanteen, jos kuviomuuttuja ei viittaa ympyrään. Kääntäjä ei pysty tarkastamaan asiaa luotettavasti. Käyttämällä asInstanceOf-metodia ohjelmoija ohittaa kielen vahvan tyypityksen ja heikentää ohjelman tyyppiturvallisuutta. Siirtyy täysin ohjelmoijan harteille varmistaa, että kyseisen olion dynaaminen tyyppi todella vastaa sitä, mitä koodiin on asInstanceOf-sanan perään hakasulkeisiin kirjoitettu. Tätä menetelmää kannattaa käyttää vain harkitusti ja harvoin — O1’llä ei koskaan.

asInstanceOf-metodi vastaa monesta muusta kielestä löytyviä tyyppimuunnoksia (type cast tai vain cast).

Ohjelmointitehtävä: käytä ja muokkaa piirrettä

Osa 1/2: uusi kuviotyyppi

Tee kuvioille uusi alakäsite, suorakulmainen kolmio, jota kuvaa luokka RightTriangle. Luokan tulee periä Shape-piirre. Suorakulmaisilla kolmioilla pitäisi siis olla kaikki kuvioiden yleiset ominaisuudet, minkä lisäksi niille tulee hypotenuse-metodi. Haluttua toiminnallisuutta valottaa tämä REPL-esimerkki:

val triangle = RightTriangle(3.0, 4.0)triangle: o1.shapes.RightTriangle = o1.shapes.RightTriangle@18bcb2d
triangle.hypotenuseres6: Double = 5.0
triangle.areares7: Double = 6.0
Circle(3).isBiggerThan(triangle)res8: Boolean = true
triangle.isBiggerThan(Rectangle(7, 5))res9: Boolean = false

isBiggerThan-metodia ei tässä tehtävässä saa eikä kannata kirjoittaa RightTriangle-luokkaan. Kolmioillakin se on, koska ne perivät sen Shape-piirreluokasta.

Osa 2/2: piirinlaskemismetodi kaikille kuvioille

  1. Lisää Traits-moduulin Shape-piirreluokkaan uusi abstrakti, parametriton metodi perimeter, joka laskee ja palauttaa kuvion piirin (eli reunaviivan kokonaispituuden) Double-tyyppisenä lukuna.

  2. Lisättyäsi abstraktin metodin Shape.scalaan huomaat IntelliJ’n paheksuvan: alakäsitteiden määrittelyt eivät enää kelpaa, koska niistä puuttuu toteutus perimeter-metodille.

  3. Kirjoita perimeter-metodille toteutus paitsi uuteen luokkaasi RightTriangle myös luokkiin Circle ja Rectangle. (Moduulissa on myös luokka nimeltä Square, johon palaamme myöhemmin.) Käyttöesimerkkejä:

triangle.perimeterres10: Double = 12.0
Circle(5).perimeterres11: Double = 31.41592653589793
Rectangle(2, 5).perimeterres12: Double = 14.0

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

Vapaaehtoisia lisäyksiä

Voit lisätä shapeTest-ohjelman kuvioita sisältävään puskuriin kolmion tai useita. Sen pitäisi onnistua helposti, jos olet laatinut RightTriangle-luokan oikein.

Voit myös tehdä RightTriangle-luokkaan ja muihin kuvioluokkiin toString-metodit, jotta kuvioita on kätevämpi käsitellä REPLissä. Tai Shape-luokkaan yleisluontoisen toString-metodin kaikille kuvioille.

Pohdintaa piirreluokkien eduista

Piirreluokat ovat käteviä, ja niillä voi usein parantaa ohjelman laatua merkittävästikin. Esimerkiksi:

  • Ohjelman osat ovat yleiskäyttöisempiä. Samalle metodille voi antaa dynaamiselta tyypiltään toisistaan poikkeavia parametreja, ja metodi toimii niille kaikille. Yksi esimerkki on isBiggerThan yllä.

  • Muokattavuus paranee. Koodia, joka on vähemmän toisteista, on helpompi ylläpitää. Lisäksi uusien alakäsitteiden lisääminen on helppoa. Esimerkiksi kun lisäsit uudeksi kuviotyypiksi kolmiot, niin riitti kirjoittaa maininta extends Shape sekä area-metodin toteutus. Tällöin uudentyyppisille olioillekin voi kutsua isBiggerThan-metodia, ja niitä voi käyttää missä vain yhteydessä, jossa Shape-olioita muutenkin käytetään.

Kokoava pikkutehtävä

Tutki seuraavia esimerkkiluokkia.

trait T1:
  def m1 = 1
  def m2: Int
class A(val x: Int, val y: Int) extends T1:
  def m2 = this.x + 1
  def m3 = this.y + 1
class B(val x: Double, val y: Int) extends T1:
  def m2 = this.x.toInt + 1
  def m3 = this.y + 2

Mitkä kaikki seuraavista väitteistä pitävät paikkansa? Oletetaan, että nuo luokat on määritelty ja niiden lisäksi voi olla määriteltyinä muitakin luokkia, jotka perivät T1-piirteen.

Useita yläkäsitteitä

Yläkäsitteitä eri tasoilla

../_images/inheritance_person.png

Yksi piirreluokka voi periä toisen. Tällä tavoin voit määritellä piirreluokkienkin välille ylä- ja alakäsitesuhteita.

Esimerkiksi näin:

trait PersonAtAalto:
  // Tänne metodeita ja/tai ilmentymämuuttujia, jotka ovat yhteisiä
  // Aallossa työskenteleville/opiskeleville/vieraileville henkilöille.
end PersonAtAalto
trait Employee extends PersonAtAalto:
  // Tänne metodeita ja/tai muuttujia työntekijäolioille.
  // Kaikilla työntekijäolioilla on ne PersonAtAalto-piirreluokassa
  // määriteltyjen lisäksi.
end PersonAtAalto

Nyt kun määritellään luokka TeachingAssistant, joka perii Employeen, niin assarit ovat sekä työntekijöitä että henkilöitä:

class TeachingAssistant extends Employee:

  // Assariolioilla on kaikki ne ilmentymämuuttujat ja metodit,
  // jotka on määritelty Employee-piirreluokassa ja lisäksi
  // kaikki ne, jotka on määritelty PersonAtAalto-piirreluokassa.

  // Lisäksi täällä voi määritellä muita muuttujia ja metodeita
  // nimenomaan assariolioille.

end TeachingAssistant

Tietotyyppien "sukupuita" kutsutaan tyyppihierarkioiksi (type hierarchy).

../_images/inheritance_animals-fi.png

Tyyppihierarkia voi olla monihaarainen- ja tasoinen.

Useita välittömiä yläkäsitteitä

../_images/inheritance_multiple.png

Esimerkissämme TeachingAssistant-luokalla oli välittömänä yläkäsitteenä piirreluokka ja epäsuorasti myös tuon piirreluokan yläkäsite. On myös mahdollista kirjata yhden luokan määrittelyyn useita piirreluokkia välittömiksi yläkäsitteiksi, kuten seuraava esimerkki osoittaa.

Oletetaan, että on olemassa yllä määriteltyjen luokkien lisäksi piirreluokka Student, joka kuvaa opiskelijoita. Halutaan määritellä, että kurssiassarit ovat myös opiskelijoita sen lisäksi, että he ovat työntekijöitä. Tämä onnistuu helposti:

class TeachingAssistant extends Employee, Student:
  // Nyt assarit ovat kaikkia seuraavista: TeachingAssistant,
  // Student, PersonAtAalto, Employee. (Assareilla on
  // PersonAtAalto-piirteen ominaisuudet vain kertaalleen,
  // vaikka tuohon yläkäsitteeseen viekin kaksi eri "polkua".)
end TeachingAssistant

Jos piirreluokkia peritään vielä enemmän, ne voi luetella näin:

class X extends MyTrait1, MyTrait2, MyTrait3, MyTrait4

Noin luetelluilla piirreluokilla ei ole pakko olla yhteistä yläkäsitettä (vaikka esimerkissämme PersonAtAalto sellainen Employeelle ja Studentille olikin), vaan luokka voi periä toisiinsa muuten liittymättömiäkin piirreluokkia.

Pilkkujen sijaan voi käyttää with-sanaa. Tee kummin vain, mutta molemmat tyylit on hyvä tunnistaa:

class TeachingAssistant extends Employee with Student

class X extends MyTrait1 with MyTrait2 with MyTrait3 with MyTrait4

Tässä samat luokat kuin aiemmassa tehtävässä:

trait T1:
  def m1 = 1
  def m2: Int
class A(val x: Int, val y: Int) extends T1:
  def m2 = this.x + 1
  def m3 = this.y + 1
class B(val x: Double, val y: Int) extends T1:
  def m2 = this.x.toInt + 1
  def m3 = this.y + 2

Lisätään vielä nämä:

trait T2:
  def m4 = 4
class C extends T1, T2:
  def m2 = 2
  def m3 = 3.0

Mikä tai mitkä seuraavista väitteistä pitävät paikkansa?

Yläkäsitteet ja metodien korvaaminen

Kakkoskierrokselta alkaen olemme käyttäneet sanaa override metoditoteutusten korvaamiseen uusilla. Erityisen usein olemme käyttäneet sitä:

  • toString-metodeissa (luku 2.5): laatimamme toString-toteutukset korvaavat oletusarvoisen toteutuksen (joka tuottaa kuvauksia kuten o1.shapes.Square@ecfb83).

  • View-olioiden tapahtumankäsittelijöissä (kuten onClick; luku 3.1): View-luokan tarjoamat oletustoteutukset reagoivat tapahtumiin olemalla jouten, mutta voimme korvata ne sovellukseen sopivilla reaktioilla.

Metodeita voi korvata tyyppihierarkioissa yleisemminkin. Tehdään kokeeksi muutama miniluokka:

../_images/inheritance_traitx.png
trait PiirreX:
  def tervehdys = "Terveisiä lähettää PiirreX"
class Luokka1 extends PiirreX
class Luokka2 extends PiirreX:
  override def tervehdys = "Terveisiä lähettää Luokka2"

Kokeillaan REPLissä:

Luokka1().tervehdysres13: String = Terveisiä lähettää PiirreX
Luokka2().tervehdysres14: String = Terveisiä lähettää Luokka2

Luokka1 saa tervehdys-metodinsa PiirreX’ltä.

Luokka2 korvaa tuon metodin omalla versiollaan.

Scalassa sana override on kirjoitettava metodin määrittelyyn aina, kun korvaa yläkäsitteen metoditoteutuksen.

Miksi override-pakko? Koska se on suolainen.

Kun kirjoitat koodiin override, niin kuittaat tietäväsi, että "tässä korvaan yläkäsitteelle määritellyn toteutuksen toisella". Ellei override-sanaa vaadittaisi, saattaisit hyvinkin sattumalta ja huomaamattasi antaa metodille sellaisen nimen, joka on jo muussa käytössä jossakin yläkäsitteessä, mistä voisi seurata erikoisiakin bugeja.

Samoin kuin esimerkiksi staattinen tyypitys myös override-merkintä on käytäntö, joka pienentää virheiden riskiä. Tällaista ohjelmointikielen piirrettä, joka hankaloittaa huonon koodin kirjoittamista, sanotaan joskus "syntaktiseksi suolaksi" (vrt. yleisempi termi syntaktinen sokeri).

Lisäetu on, että korvaaminen tulee näin dokumentoitua myös koodin lukijalle.

../_images/inheritance_traitxy.png

Lisätään tyyppihierarkiaamme toinen piirreluokka ja pari luokkaa:

trait PiirreY extends PiirreX:
  override def tervehdys = "Terveisiä lähettää PiirreY"
class Luokka3 extends PiirreY
class Luokka4 extends PiirreY:
  override def tervehdys = "Terveisiä lähettää Luokka4"

Toinen piirreluokkamme siis perii ensimmäisen, ja uudet luokat edelleen perivät tämän toisen piirreluokan.

Kokeillaan luokkia:

Luokka3().tervehdysres15: String = Terveisiä lähettää PiirreY
Luokka4().tervehdysres16: String = Terveisiä lähettää Luokka4

Luokka3 ei määrittele omaa toteutusta tervehdysmetodille. Se perii tuon metodin välittömältä yläkäsitteeltään PiirreY (joka korvaa PiirreX’n ylempänä hierarkiassa määrittelemän toteutuksen).

Luokka4 määrittelee metoditoteutuksen, joka korvaa toteutukset kummastakin yläkäsitteestä PiirreY ja PiirreX.

Tutkitaan vielä vähän:

var olio: PiirreX = Luokka1()olio: PiirreX = Luokka1@6d293b41
olio.tervehdysres17: String = Terveisiä lähettää PiirreX
olio = Luokka4()olio: PiirreX = Luokka4@dc6c5ca
olio.tervehdysres18: String = Terveisiä lähettää Luokka4

Huomaa: Muuttujan staattinen tyyppi on PiirreX. Sen arvon dynaaminen tyyppi on Luokka4.

tervehdys-metodia voi kutsua mille tahansa lausekkeelle, jonka staattinen tyyppi on PiirreX (tai jokin PiirreX’n alatyyppi), so. mille tahansa oliolle, jolla taatusti on tämä metodi. Se, mitä kutsuttaessa tapahtuu, puolestaan riippuu siitä, mikä on viestin vastaanottavan olion dynaaminen tyyppi. Tässä siis suoritetaan nimenomaan Luokka4-tyyppisille oliolle määritelty korvaava testimetodi, vaikka muuttujan tyyppi on PiirreX.

The super keyword

Tehdään vielä yksi kokeiluluokka:

class Luokka5 extends PiirreY:
  override def tervehdys = super.tervehdys + ", ja niihin yhtyy Luokka5"

Avainsanalla super voi viitata yläkäsitteen osaksi tehtyyn määrittelyyn. Tässä kutsutaan yläkäsitteeltä perittyä versiota tervehdys-metodista. Luokka5-tyyppisen olion tervehdys-metodi siis ensin kutsuu yläkäsitteen PiirreY versiota metodista ja saamansa merkkijonon perään Luokka5’lle ominaisen tekstinpätkän. Esimerkki alla.

Luokka5().tervehdysres19: String = Terveisiä lähettää PiirreY, ja niihin yhtyy Luokka5

Ilmentymämuuttujat piirreluokassa

Piirreluokassa voi olla myös ilmentymämuuttujia.

trait Supertype:
  val magicNumber = 42
  val text: String// defined trait Supertype
class Subtype extends Supertype:
  val text = "value of 'text' for all Subtype instances"// defined class Subtype

Ilmentymämuuttuja voi olla abstrakti kuten metodikin.

Nuo muuttujat periytyvät alakäsitteille ja löytyvät niiden ilmentymiltä:

val myObject: Supertype = Subtype()myObject: Supertype = Subtype@714aadf7
myObject.magicNumberres20: Int = 42
myObject.textres21: String = value of 'text' for all Subtype instances

Luontiparametreja piirreluokassa

Esimerkki: terveysammattilaisia

../_images/inheritance_medical.png

Laaditaan kokeeksi piirreluokka kuvaamaan terveysalan ammattilaisia:

trait MedicalPro

Tuossa sivussa näkyy tyyppihierarkia, jonka pian määrittelemme.

Keskitytään aluksi vain yhteen MedicalPron alakäsitteistä. Ensihoitajasta (paramedic) mallinnetaan, onko hän töissä ambulanssissa (vai sairaalan päivystyksessä):

class Paramedic(val inAmbulance: Boolean) extends MedicalPro

Entä jos haluamme nyt kuvata, että kaikilla MedicalPro-olioilla on työnantaja, joka on tallessa String-tyyppisessä muuttujassa ja joka saa arvonsa luontiparametrista oliota luodessa?

Voimme lisätä piirreluokkaan luontiparametrin ja samalla muuttujan (val) ihan kuin tavalliseen luokkaankin.

trait MedicalPro(val employer: String)

MedicalPro-olioita ei luoda suoraan vaan alatyyppiensä kautta. Miten siis tuolle luontiparametrille välitetään arvo? Ja miten työnantajatieto pitäisi huomioida Paramedic-luokassa?

Sanotaan, että haluamme luoda Paramedic-olioita seuraavasti, antaen luontiparametriksi ensin työnantajan ja sitten ambulanssi-Booleanin.

val ensihoitaja = Paramedic("Helsingin kaupunki", true)ensihoitaja: Paramedic = Paramedic@61c98b6c

Pannaan Paramedic-luokkamme toimimaan noin. Tehdään samalla (esimerkin vuoksi) oletus, että ensihoitajien työnantaja on aina jokin kaupunki.

class Paramedic(city: String, val inAmbulance: Boolean) extends MedicalPro(city)

Lisäsimme luokalle luontiparametrin. Huomaa, että sen edessä ei ole val-sanaa sen merkiksi, että luokalla on city-niminen ilmentymämuuttuja. Tarkoitus on, että tämän city-luontiparametrin arvo päätyy employer-ilmentymämuuttujaan, joka on määritelty MedicalPro-piirreluokassa.

inAmbulance-luontiparametri ja sitä vastaava ilmentymämuuttuja meillä oli jo edellisessä Paramedic-luokan versiossa. Tässä ei ole mitään uutta.

Koodin kiinnostavin kohta on tämä: kun alakäsitteestä luodaan ilmentymä, tehdään myös yläkäsitteelle määritellyt alustustoimenpiteet. Alakäsitteen määrittelystä usein välitetään luontiparametreja yläkäsitteen ohjelmakoodin hoidettaviksi. Esimerkiksi tässä määritellään, että kun Paramedic-oliota luodaan, tehdään samat alustukset kuin mille vain MedicalPro-oliolle ja että MedicalProlle annetaan luontiparametriksi ensimmäinen uuden Paramedic-olion saamista kahdesta luontiparametrista. (Ks. animaatio alla.)

Esimerkki jatkuu

../_images/inheritance_medical.png

Syvennetään tyyppihierarkiaamme. Lisätään piirreluokka Doctor. Emme tässä pikkuesimerkissä mallinna lääkäreistä muita tietoja kuin MedicalPro-olioista yleensäkin, joten tämä riittää:

trait Doctor extends MedicalPro

Yksinkertainen mallimme jakaa lääkärit kahteen ryhmään, yleislääkäreihin ja erikoislääkäreihin, joista jälkimmäisiä on erilaisia. Piirreluokka Specialist ilmoittaa, että kustakin erikoislääkäristä kirjataan hänen erikoisalansa:

trait Specialist(val specialization: String) extends Doctor

Tarkoitus olisi, että yleislääkäreitä kuvaavaa luokkaa voisi käyttää näin:

val yleislaakari = GeneralPractitioner("InstaCare Hospital")yleislaakari: GeneralPractitioner = GeneralPractitioner@4df03572

Tässä ensimmäinen yritys toteutukseksi:

class GeneralPractitioner(employer: String) extends Doctor      // ei toimi

Perusidea tuossa on ihan oikein: yleislääkäri periytyy lääkäristä, ja lääkärihän on jo edellä periytetty MedicalProsta. Jotain silti puuttuu. Käännösaikainen virheilmoitus antaa vinkin:

class GeneralPractitioner(employer: String) extends Doctor-- Error:
  |class GeneralPractitioner(employer: String) extends Doctor
  |      ^
  |      parameterized trait MedicalPro is indirectly implemented,
  |      needs to be implemented directly so that arguments can be passed

Toisin sanoen: koska korkeammalla tyyppihierarkiassa oleva piirreluokka MedicalPro vaatii luontiparametrin, on meidän sellainen sille annettava. Vika korjaantuu mainitsemalla tuo yläkäsite erikseen. Tämä toimii:

class GeneralPractitioner(employer: String) extends MedicalPro(employer), Doctor

Ilmoitamme, että MedicalPro-yläkäsitteelle välitetään parametriksi juuri se työnantajamerkkijono, jonka luotavalle GeneralPractitioner-oliolle annettiin.

Huomaa tässäkin, että luontiparametrin edessä ei lue val, vaan employer-niminen ilmentymämuuttuja on jo määritelty MedicalPro-piirreluokassa. Tässä kyse on vain GeneralPractitionerin luontiparametrin nimestä, jonka voimme valita vapaasti; employer käy hyvin.

Tekemättä on vielä luokka Neurologist, jonka on tarkoitus kuvata hermostoon erikoistuneita lääkäreitä. Sen toivomme toimivan näin:

val laakari = Neurologist("Chicago Grace Hospital")laakari: Neurologist = Neurologist@4f13e602
laakari.specializationres22: String = neurology

Kerrataan vielä piirreluokat, jotka meillä jo on:

trait MedicalPro(val employer: String)

trait Doctor extends MedicalPro

trait Specialist(val specialization: String) extends Doctor

Näillä eväillä voimme kirjoittaa Neurologist-luokan:

class Neurologist(employer: String) extends MedicalPro(employer), Specialist("neurology")

Välitämme työnantajan luontiparametriksi MedicalProlle kuten edelläkin teimme.

Myös Specialist-piirreluokka vaatii merkkijonon luontiparametriksi. Välitämme sille tietyn tekstin, jonka haluamme tulevan erikoisalaksi kaikille Neurologist-olioille.

Doctor-piirreluokkakin kyllä on Neurologist-luokan yläkäsite. Sitä ei tuossa extends-rimpsussa tarvitse erikseen mainita, koska Doctor on Specialistin yläkäsite eikä Doctorille tarvitse välittää luontiparametreja.

Halutessasi löydät tämän esimerkin koodin Traits-moduulista.

Tehtävä: erilaisia viestejä

Traits-moduulin pakkauksessa o1.messages on muutama yksinkertainen luokka, jotka (leikisti) kuvaavat verkkopalvelun käyttäjien toisilleen lähettämiä viestejä. Viestejä on muutamaa eri tyyppiä:

../_images/inheritance_message.png
  • DirectMessage on tietylle vastaanottajalle lähetetty viesti.

  • Post on viesti, joka ei ole kohdennettu kenellekään yksittäiselle ihmiselle.

  • Comment on viesti, joka on lähetetty vastauksena johonkin alkuperäiseen Postiin.

  • Message on kaikkien viestien yläkäsite; se on piirreluokka. Kaikille viesteille on yhteistä se, että niillä on content-niminen ilmentymämuuttuja, jossa on viestin sisältö merkkijonona.

  • Ja onpa vielä määritelty myös käsite Reply, piirreluokka sekin. Reply kuvaa viestejä, jotka ovat vastauksia johonkin. Kuten oheisesta kaaviosta näkyy, Commentin on tarkoitus olla eräänlainen Reply, joskin tämä on vielä annetussa koodissa määrittelemättä.

Tehtäväsi on tutustua noiden pikkuluokkien koodiin ja täydentää sitä seuraavasti:

  1. Täydennä Comment-luokan extends-rimpsua niin, että luokka perii myös Replyn. Muista, että Reply vaatii luontiparametrin; voit välittää sille sen Post-olion, johon kommentti vastaa ja jonka Comment saa original-parametrinaan.

  2. Lisää kaikille Message-olioille tieto siitä, onko kyseinen viesti julkinen vai ei. Tee se lisäämällä Message-piirreluokan otsikkoriville toinen luontiparametri ja ilmentymämuuttuja: val isPublic: Boolean. Muokkaa sitten luokat DirectMessage, Post ja Comment yhteensopiviksi uusitun Messagen kanssa. Tarkemmin sanoen:

  3. Kohdennetut viestit eivät ole julkisia: välitä DirectMessagesta Message-piirreluokalle jälkimmäiseksi luontiparametriksi literaali false.

  4. Post voi näkyä julkisesti tai olla näkymättä. Lisää Post-luokalle toinen luontiparametri, joka on tyyppiä Boolean. Välitä tuon luontiparametrin arvo eteenpäin Message-luokalle.

    • Parametrin nimi voi olla isPublic tai jotain muuta. Huomaa, että eteen ei tule val, koska tarkoitus ei ole määritellä uutta ilmentymämuuttujaa.

  5. Comment voi myös olla julkinen tai ei-julkinen. Lisää sille (kolmas) luontiparametri vastaavasti.

Samassa tiedostossa on myös pieni testiohjelma. Kun olet tehnyt pyydetyt muutokset, voit poistaa kommenttimerkit testiohjelman ympäriltä ja tarkistaa tulosteen.

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

Kokoava koodinlukutehtävä

Seuraava vähän hölmö ohjelma kokoaa yhteen edellä esiteltyjä asioita. Voi käyttää sitä tietojesi tarkistukseen. Jos ymmärrät ohjelman toiminnan yksityiskohtaisesti(!), niin ymmärrät myös keskeisimmät piirreluokkiin ja tyyppihierarkioihin liittyvät ohjelmarakenteet. Tehtävä on vapaaehtoinen.

Autoilutarina

Lue seuraava koodi. Mieti perusteellisesti, mitkä tekstit se tulostaa ja missä järjestyksessä. Kirjoita mieluiten tuloste itsellesi muistiin!

@main def driveAbout() =
  val car = Car()
  car.receivePassenger(Schoolkid())
  car.receivePassenger(ChemicalEngineer("C. Chemist"))
  car.receivePassenger(MechanicalEngineer("M. Machine"))
  car.receivePassenger(ElectricalEngineer("E. Electra"))
  car.receivePassenger(ComputerScientist("C.S. Student"))
  car.start()
class Car:
  private val passengers = Buffer[Passenger]()

  def receivePassenger(passenger: Passenger) =
    passenger.sitDown()
    this.passengers += passenger

  def start() =
    println("(The car won't start.)")
    for passenger <- this.passengers do
      passenger.remark()
end Car
trait Passenger(val name: String):
  def sitDown() =
    println(this.name + " finds a seat.")

  def speak(sentence: String) =
    println(this.name + ": " + sentence)

  def diagnosis: String

  def remark() =
    this.speak(this.diagnosis)
end Passenger
trait Student extends Passenger:
  def diagnosis = "No clue what's wrong."
class Schoolkid extends Passenger("Anonymous pupil"), Student
trait TechStudent extends Student:
  override def remark() =
    super.remark()
    this.speak("Clear as day.")
class ChemicalEngineer(name: String) extends TechStudent, Passenger(name):
  override def diagnosis = "It's the wrong octane. Next time, I'll do the refueling."
class MechanicalEngineer(name: String) extends TechStudent, Passenger(name):
  override def diagnosis = "Nothing wrong with the gas. It must be the pistons."
  override def speak(sentence: String) =
    super.speak(sentence.replace(".", "!"))
class ElectricalEngineer(name: String) extends TechStudent, Passenger(name):
  override def sitDown() =
    println(this.name + " claims a front seat.")
  override def diagnosis = "Hogwash. The spark plugs are faulty."
class ComputerScientist(name: String) extends TechStudent, Passenger(name):
  override def remark() =
    this.speak(super.diagnosis)
    this.speak(this.diagnosis)
  override def diagnosis = "Let's all get out of the car, close the doors, reopen, and try again."

Kävithän koodin ajatuksella läpi? Kirjoititko odottamasi tulosteen muistiin?

Avaa nyt Traits-moduuli ja aja pakkauksesta o1.cruising löytyvä ohjelma (jonka koodi on yllä). Vastasiko tuloste täsmälleen sitä, mitä odotit? Jos ei, selvitä mistä erot johtuivat. Voit käyttää debuggeria apuna.

Ohjelmointiharjoitus: oikeushenkilöitä

Tehtävänanto

Tutustu pakkauksen o1.legal dokumentaatioon Traits-moduulissa. Se kuvaa useita luokkia, joilla voi mallintaa oikeusjuttuja sekä erilaisia oikeushenkilöitä (legal entity tai legal person), jotka toimivat oikeusjutuissa asianomistajina ja vastaajina.

Toteuta luokat moduulin sisältämiin Scala-tiedostoihin. (Paikat niille on merkitty koodiin kommentein.)

Luokkia on monta, mutta ne ovat yksinkertaisia. Tehtävän keskiössä ovat näiden käsitteiden väliset suhteet. Ne on esitetty kuvana alla.

../_images/module_legal.png

Suositellut vaiheet ja vinkkejä

Voit edetä esimerkiksi seuraavasti.

  1. Selaa aluksi ainakin tyyppien CourtCase, Entity, NaturalPerson ja JuridicalPerson dokumentaatio, niin saat kokonaiskuvan luokista.

  2. Laadi luokka CourtCase. Huomaa, että oikeusjuttuun liittyy kaksi Entity-piirreluokan tyyppistä muuttujaa: kyseiset oliot ovat jonkinlaisia oikeushenkilöitä, mutta CourtCase ei ota kantaa siihen, millaisia.

  3. Laadi piirreluokka Entity omaan tiedostoonsa.

  4. Laadi NaturalPerson samannimiseen tiedostoon.

    • Yläkäsite on ilmoitettu extends-sanalla dokumentaatiossa.

    • Dokumentaatio kertoo, mitkä metodit periytyvät yläkäsitteiltä ja mitkä ovat kyseiselle alakäsitteelle uusia. Kunkin Scaladoc-sivun alkupäässä on kohta, jonka avulla voi valita (Filter), mitkä metodit sivulla näytetään. Napsauttamalla tuon kohdan auki löydät Inherited-osion, jonka nappuloilla voit säädellä, näkyvätkö sivulla myös yläkäsitteiltä periytyvät metodit. Kokeile.

  5. Laadi luokka FullCapacityPerson (eli täysvaltainen luonnollinen henkilö).

    • Varsinkin, kun luokkamme ovat pieniä, voimme hyvin tehdä niin, että sijoitamme NaturalPersonin alatyypit samaan tiedostoon tuon yläkäsitteensä kanssa.

    • Huomaa dokumenttisivun alusta: tämä luokka merkitään alakäsitteeksi sekä Entitylle että NaturalPersonille. Vain jälkimmäinen riittäisi muuten, mutta Entityllekin on välitettävä luontiparametri. (Tarve on vastaava kuin MedicalPro- ja Message-ohjelmissa ylempänä.)

    • Huomaa myös, että ilmentymämuuttuja nimelle on jo määritelty Entity-piirreluokassa, joten sitä val-sanaa ei pidä toistaa täällä. (Tältäkin osin tilanne on sama kuin edeltävissä esimerkissä.)

  6. Ota esiin Restriction.scala, jota käytetään kohta apuna vajaavaltaisten henkilöiden kuvaamisessa. Piirreluokka Restriction on jo tehty, samoin sen erikoistapauksena yksittäisolio Illness. Lisää vastaava yksittäisolio Underage.

  7. Toteuta ReducedCapacityPerson.

    • Tälläkin kertaa luontiparametreja pitää välittää kahdelle eri yläkäsitteelle.

    • Jos olet toteuttanut aiemmat metodit oikein, niin tämän pitäisi toimia kind-metodin toteutuksena:

      override def kind = super.kind + " with " + this.restriction
      
  8. Toteuta JuridicalPerson. Yksikin rivi riittää (koska lisämetodeita ei tarvita).

  9. Toteuta HumanOrganization ja GeographicalFeature.

    • Yläkäsitteen abstraktiksi jättämän parametrittoman defin voi korvata myös muuttujamäärittelyllä. Määrittele HumanOrganizationiin contact-muuttuja ja GeographicalFeatureen kind-muuttuja.

    Lisää defin toteuttamisesta muuttujalla

    Parametrittoman abstraktin metodin voi tosiaan toteuttaa muuttujallakin, kuten esim. contact-muuttujalle tuossa ehdotettiin. Oleellista on, että lausekkeella someOrganization.contact on NaturalPerson-tyyppinen arvo. On luokan käyttäjän näkökulmasta merkityksetöntä, onko kyseessä val-muuttuja vai vaikutukseton, parametriton metodi, joka palauttaa aina saman arvon. (Lisää aiheesta Wikipedia-artikkelissa uniform access principle sekä Philip Schwartzin opetusmateriaalissa.)

  10. Sinun ei tarvitse vaivautua kirjoittamaan Nation-, Municipality- ja Corporation-luokkia, joissa ei ole mitään uutta asiaa. Voit yksinkertaisesti poistaa kommentit annettujen toteutusten ympäriltä. Jos olet laatinut ohjelman oikein, näiden luokkien pitäisi toimia sellaisinaan.

  11. Toteuta luokka Group.

    • Ryhmillä ei ole tässä ohjelmassa keskenään erilaisia nimiä. Välitä Entity-piirreluokalle luontiparametriksi dokumentaation mukainen merkkijonoliteraali.

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

Huutokaupat uusiksi piirreluokilla

Seuraava ohjelmointitehtävä on jatkoa aiemmille kauppapaikka-aiheisille tehtäville. Itse tehtävä on vapaaehtoinen, mutta suosittelen, että vähintäänkin tutustut siihen, mihin tehtävässä pyritään.

Luvussa 5.1 kehitit luokan FixedPriceSale, ehkä myös luokat DutchAuction ja EnglishAuction. Nämä luokat kuvaavat eri tavoin myytäviä esineitä. Luvun 5.5 esimerkissä puolestaan laadimme luokan AuctionHouse, joka edusti sellaisia kauppapaikkoja, joissa kaikki esineet ovat huudettavissa perinteiseen EnglishAuction-tyyliin.

Jos teit mainitut aiemmat tehtävät, voit käyttää omia ratkaisujasi pohjana seuraavalle tehtävälle. Jos et, voit käyttää esimerkkiratkaisuja (FixedPriceSale, DutchAuction, EnglishAuction).

Uudistus luokkarakenteeseen

Aiemmissa tehtävissä esiintyneet luokat suhtautuvat toisiinsa suurin piirtein näin:

../_images/module_auctionhouse1.png

Toisin sanoen: AuctionHousessa on englantilaistyyppisiä huutokauppoja. Luokat FixedPriceSale ja DutchAuction ovat täysin irrallisia.

Tässä tehtävässä refaktoroit aiemmin laadittua koodia. Refaktoroinnin tavoite on parempi laatu: muokkaat FixedPriceSale-, EnglishAuction- ja DutchAuction-luokkia niin, että niitä kaikkia voi käyttää yhdessä. Samalla vähennät turhaa toistoa koodissa. Refaktoroinnin työkaluna toimivat tässä tapauksessa piirreluokat.

Tarkoitus olisi rakentaa tällainen versio:

../_images/module_auctionhouse2.png

Keskeisin uudistus on, että piirreluokka ItemForSale on yläkäsite eri tavoin myytäville esineille. Kun näin on, voimme käyttää tätä yläkäsitettä, kun toteutamme yleishyödyllisemmän AuctionHouse-luokan. Lisäksi InstantPurchase-piirre kokoaa yhteen vakiohintaisten esineiden ja hollantilaishuutokauppojen yhteisiä ominaisuuksia.

Toteuta uudistus

Toteuta ItemForSale, EnglishAuction, InstantPurchase, FixedPriceSale, DutchAuction ja AuctionHouse vastaamaan AuctionHouse2-moduulin Scaladoc-dokumentaatiota.

Ohjeita ja vinkkejä:

  • Luokat kannattanee toteuttaa yllä luetellussa järjestyksessä.

  • Katso dokumentaatiosta tarkasti, mitkä metodit ovat abstrakteja. Katso myös, mitkä metodit periytyvät kullekin luokalle sen yläkäsitteiltä.

  • Älä nytkään toista val-sanaa alatyypin luontiparametreissa, jos kyseinen muuttuja on jo piirreluokassa määritelty. Älä siis käytä esimerkiksi description-muuttujan kohdalla val-sanaa muualla kuin piirreluokassa ItemForSale. Alakäsitteillekin sopii kyllä kirjoittaa tuon niminen luontiparametri.

  • Tässäkin kauppapaikka-aiheisessa tehtävässä voit käyttää annettua käyttöliittymää kokeillaksesi tärkeimpien metodien toimivuutta. Käynnistysolion o1.auctionhouse.gui.TestApp ohjelmakoodista tosin tulee aluksi virheilmoituksia, mutta ne kaikkoavat, kunhan saat oman toteutuksesi käynnistyskuntoon.

    ../_images/auctionhouse2_gui.png
  • Ainoa AuctionHouse-luokaan tarvittava muutos on, että korvaat EnglishAuctionin yleisemmällä käsitteellä.

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

Tehtävän tehtyäsi voit ihalla, miten tyyppihierarkia muutti vanhat irralliset ja toisteiset koodit kauniiksi käsitteelliseksi malliksi, jossa kustakin käsitteestä on määritelty vain juuri ne muutamat asiat, jotka erottavat sen sukulaiskäsitteistä.

Toinen valinnainen lisätehtävä

o1-pakkauksen mallinnustyökaluja (ja FlappyBug vielä kerran)

Kurssikirjastossa o1 on piirreluokka HasVelocity, joka kuvaa yleisellä tasolla sellaisia asioita, joilla on sijainti ja nopeus kaksiulotteisessa koordinaatistossa. Joitakin tuollaisia asioita olemme jo ohjelmissamme mallintaneet, joskin hyödyntämättä tätä piirreluokkaa. Tutkitaan, mitä välineitä se meille tarjoaa.

Tutustu HasVelocity piirreluokan dokumentaatioon. Huomaa, että sillä on yläkäsitteenä toinen piirreluokka HasPos; tutustu siihenkin. Jos et tutustunut luvun 3.6 vapaaehtoisessa tehtävässä nopeutta kuvaavaan Velocity, aloita lukemalla siitä.

Mieti, miten voisit lisätä FlappyBug-ohjelman luokille Bug ja Obstacle HasVelocity-piirteen ja hyödyntää sitä luokkien toteutuksessa. Tee tuo muutos luokkiin. Lue halutessasi vinkit alta.

Vinkkejä Obstacle-luokkaan:

  • Alkuun tarvitaan tietysti extends-lisäys.

  • Luokan on toteutettava HasVelocity-piirreluokan abstrakti metodi velocity. Lisää se. Esteen vauhti on x-suunnassa vakio ja y-suunnassa nolla.

  • Huomaa HasVelocityn tarjoama metodi nextPos. Hyödynnä sitä metodin approach toteutuksessa.

Vinkkejä Bug-luokkaan:

  • Tähänkin tarvitaan extends-määrittely ja velocity-metodi.

  • Tässäkin voit käyttää nextPosia ötökkää liikuttavan koodin yksinkertaistamiseen (fall-metodissa tai move-apumetodissa, jos teit sellaisen aiemmin, joskin sen voi ehkä nyt poistaa tarpeettomana).

    • Ehkä keksit myös tavan yksinkertaistaa esteluokan touches-metodia samalla?

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

Yhteenvetoa

  • Piirreluokka on tavallista luokkaa muistuttava rakenne, jolla voi mallintaa käsitteen ja joka määrittelee tietotyypin.

  • Piirreluokassa voi määritellä ilmentymämuuttujia ja metodeita, jotka ovat yhteisiä useammalle luokalle. Piirreluokkien avulla voi näin kuvata yläkäsitteitä.

    • Luokka voi periä piirreluokan (tai useita), jolloin luokan oma määrittely täydentyy piirreluokan määrittelyllä.

    • Piirreluokista ja tavallisista luokista voi muodostaa tietotyyppien "sukupuita", tyyppihierarkioita.

  • Piirreluokan metodin voi määritellä abstraktiksi. Tämä tarkoittaa, että metodille ei määritellä yleistä toteutusta piirreluokassa vaan alakäsitteille erikseen. Kuitenkin metodin olemassaolo taataan kaikissa olioissa, joilla on kyseinen piirre.

  • Alakäsite voi korvata yläkäsitteen metodin alakäsitteelle ominaisella toteutuksella.

  • Lukuun liittyviä termejä sanastosivulla: piirreluokka eli piirre, abstrakti metodi, abstrakti muuttuja; tyyppihierarkia; staattinen tyyppi, dynaaminen tyyppi; DRY; abstraktio; korvata (metodi).

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, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Mikael Lenander, Ilona Ma, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, 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 ja Juha Sorva. 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. Pääkehittäjänä on nyt Markku Riekkinen, jonka lisäksi A+:aa 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.

Lisäkiitokset tähän lukuun

Kiitos autoilutarinan pohjana olleen vitsin keksijälle, kuka sitten onkin.

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