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

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

Luku 7.2: Piirreluokkia

Tästä sivusta:

Pääkysymyksiä: Miten kuvaan ohjelmassa 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. Luokan sulkeminen.

Mitä tehdään? Luetaan, ja on siellä ohjelmointitehtäväkin.

Suuntaa antava työläysarvio:? Reilu tunti ilman vapaaehtoisia tehtäviä.

Pistearvo: B35.

Oheisprojektit: Subtypes (uusi).

../_images/person04.png

Johdanto: tasokuvioita

Oletetaan, että olemme laatimassa ohjelmaa, 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 #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

}
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

}

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:

object ShapeTest extends App {
  val shapes = Buffer[?????]()
  shapes += new Circle(10)
  shapes += new Rectangle(10, 100)
  shapes += new Circle(5)

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

}
Mitä ?????-merkinnän kohdalle pitäisi kirjoittaa? Mikä voidaan mainita shapes-puskurin alkioiden tyypiksi?
Ja edelliseen liittyen: 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:

object ShapeTest extends App {
  val shapes = Buffer(new Circle(10), new Rectangle(10, 100), new Circle(5))

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

}
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 seuraavassa luvussa 7.3.)
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, ja siksi on mainiota, että kääntäjä osaa kyseenalaistaa metodikutsun current.area. Sellaisen puskurin alkiolle, joka sisältää "mitä vaan olioita" ei tosiaan pidä mennä noin vain kutsumaan area-metodia.

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 yllä oleva 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ä spesifisemmästä 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."

On myös olemassa tapoja esittää tällaisia ajatuksia tietokoneohjelmassa. Yhtä sellaista — piirreluokkia — käsitellään tässä luvussa. Toista — yliluokkia — käsitellään luvussa 7.3.

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    

}
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ä tavallisia luokkiakin.
Määritellään: kaikilla kuvioilla on tällainen area-metodi pinta-alan laskemiseen. Mutta: tässä onkin määritelty vain metodin nimi, parametrit (joita ei tässä tapauksessa ole) ja palautusarvon tyyppi (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).

Toisin kuin nähdynlaisiin tavallisiin luokkiin, piirreluokkiin voi määritellä abstrakteja, toteutuksettomia metodeita. Esimerkiksi Shape-piirreluokassa ilmoitetaan, että minkä tahansa kuvion pinta-ala on mahdollista 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: "Luokkaan Circle on liitetty (mixed in) piirre Shape." Tai: "Kaikki Circle-tyyppiset oliot ovat paitsi Circlejä myös Shape-tyyppisiä, ja niillä 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.

Huom. Ympyröillä ja suorakaiteilla on extends Shape-määrittelyn johdosta myös isBiggerThan-metodi, vaikka sitä ei ole 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ä new Shape? Ja mitä silloin syntyy? Kokeillaan:

new Shape<console>:12: error: trait Shape is abstract; cannot be instantiated

Ei ole olemassa Shape-olioita, jotka olisivat "vaan kuvioita", eikä sellaista voi luoda new-operaattorilla. 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 = new 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
Tällä kurssilla yleensä käytetyistä metodeista poiketen isInstanceOf-metodille annetaan tyyppiparametri hakasuluissa.
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]<console>:13: warning: fruitless type test: a value of type o1.shapes.Circle cannot also be a o1.shapes.Rectangle
              ympyra.isInstanceOf[Rectangle]
                                 ^
res2: Boolean = false

Piirreluokka alkioiden tyyppinä

Vector(new Circle(1), new Circle(2))res3: Vector[o1.shapes.Circle] = Vector(o1.shapes.Circle@e17571, o1.shapes.Circle@1e56bea)
Vector(new Circle(1), new 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 mahdollisesti tulevien 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

}

Kaksi area-metodikutsua isBiggerThan-metodissa ovat mahdollisia siksi, että pari riviä alempana oleva 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 niihin liitetyn piirreluokan Shape 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:

object ShapeTest extends App {
  val shapes = Buffer(new Circle(10), new Rectangle(10, 100), new Circle(5))

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

}
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 kaikilla Shape-olioilla olevan toteutus area-metodille.

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 = new Rectangle(10, 10)
println(test.area)
test = new Rectangle(10, 20)
println(test.area)
var test = new Shape(10, 20)
println(test.area)
var test = new Rectangle(5, 10)
println(test.area)
test = new Circle(10)
println(test.area)
var test: Shape = new Circle(10)
println(test.area)
test = new 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ä on syytä tarkentaa asiaa hieman.

Staattisesti tyypitetyissä kielissä kuten Scala 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. Esimerkiksi metodikutsusta jokuOlio.jokuMetodi() seuraa käännösaikainen virheilmoitus, mikäli muuttujan jokuOlio staattinen tyyppi ei 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 staattinenkin tyyppi.

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

var test: Shape = new Rectangle(10, 20)
println(test.area)
test = new Circle(10)
println(test.area)
val selected = readLine("Haluatko ympyrän? Sano 'joo', jos haluat, muuten tulee neliö. ")
if (selected == "joo") {
  test = new Circle(readLine("Säde: ").toInt)
} else {
  val sivu = readLine("Sivu: ").toInt
  test = new Rectangle(sivu, sivu)
}
println(test.area)
Muuttuja test määritellään tässä Shape-tyyppiseksi, jolloin siihen voi tallentaa viittauksen mihin tahansa olioon, jonka tyyppiin on liitetty Shape-piirre.
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 = new Rectangle(5, 10)test1: o1.shapes.Rectangle = o1.shapes.Rectangle@38c8ed

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

test1 = new Circle(10)<console>:11: error: type mismatch;
 found   : o1.shapes.Circle
 required: o1.shapes.Rectangle
       test1 = new Circle(10)
             ^

Kuitenkin jos muuttujalle erikseen määrätään tyypiksi Shape, niin staattinen tyyppi on "laajempi" kuin arvon dynaaminen tyyppi:

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

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

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

Pikkutehtävä: piirreluokat ja tietotyypit

Katso uudestaan tätä ShapeTest-ohjelmaa:

object ShapeTest extends App {
  val shapes = Buffer(new Circle(10), new Rectangle(10, 100), new Circle(5))

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

}
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ä, voidaanko koodi suorittaa ilman virheilmoitusta):

var test = new Circle(10)
println(test.radius)
var test: Shape = new Rectangle(10, 20)
println(test.radius)
var test: Shape = new Rectangle(10, 20)
println(test.area)
test = new Circle(10)
println(test.radius)
var test: Shape = new 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

}
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 vaikkapa viittaus Circle-olioon tallennettuna Shape-tyyppiseen muuttujaan ja halutaan kutsua sen radius-metodia? Tai mitä jos halutaan tehdä valinta ohjelman ajon aikana sen perusteella, viittaako Shape-tyyppinen muuttuja tietyllä hetkellä ympyrään vai johonkin muuhun kuvioon?

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

var someShape: Shape = new 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.3).

Tiedoksi kiinnostuneille: huonompi tapa

Toinen mutta yleensä huonompi mahdollisuus on käyttää asInstanceOf-metodia, joka on kaikilla Scala-olioilla. Huomaa tyyppiparametri ja palautusarvon staattinen tyyppi:

someShape.asInstanceOf[Circle]res5: Circle = Circle@1de2de2
someShape.radius<console>:14: error: value radius is not a member of Shape
            someShape.radius
                      ^
someShape.asInstanceOf[Circle].radiusres6: Double = 10.0

Varoitus! Tämä aiheuttaa ajonaikaisen virhetilanteen, mikäli 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.

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. Liitä luokkaan 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 = new RightTriangle(3.0, 4.0)triangle: o1.shapes.RightTriangle = o1.shapes.RightTriangle@18bcb2d
triangle.hypotenuseres7: Double = 5.0
triangle.areares8: Double = 6.0
new Circle(3).isBiggerThan(triangle)res9: Boolean = true
triangle.isBiggerThan(new Rectangle(7, 5))res10: Boolean = false

Huom. isBiggerThan-metodia ei tässä tehtävässä saa eikä kannata kirjoittaa RightTriangle-luokkaan. Kolmiotkin saavat sen käyttöönsä Shape-piirreluokasta.

Osa 2/2: piirinlaskemismetodi kaikille kuvioille

  1. Lisää 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 ja tallennettuasi Shape.scalan huomaat Eclipsen 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. (Projektissa on mukana myös luokka nimeltä Square, johon palaamme myöhemmin.) Käyttöesimerkkejä:
triangle.perimeterres11: Double = 12.0
new Circle(5).perimeterres12: Double = 31.41592653589793
new Rectangle(2, 5).perimeterres13: Double = 14.0

Palauttaminen

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 osien yleiskäyttöisyys paranee. 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 helppo ylläpitää. Lisäksi uusien alakäsitteiden lisääminen on helppoa. Kun esimerkiksi 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ä yllä olevat luokat on määritelty ja niiden lisäksi voi olla määriteltyinä muitakin luokkia, joilla on piirre T1.

Useita yläkäsitteitä

Yläkäsitteitä eri tasoilla

../_images/inheritance_person.png

On mahdollista liittää piirreluokka toiseen piirreluokkaan. 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.
}
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.
}

Nyt kun määritellään luokka TeachingAssistant siten, että sillä on piirre Employee, 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.

}
../_images/inheritance_multiple.png

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

Äskeisessä esimerkissä 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 with Student {
  // Nyt assarit ovat kaikkia seuraavista tyypeistä: TeachingAssistant, Student,
  // PersonAtAalto, Employee. (Assareilla on PersonAtAalto-piirreluokan ominaisuudet
  // vain kertaalleen, vaikka tuohon yläkäsitteeseen viekin kaksi eri "polkua".)
}

Jos liitettäviä piirreluokkia on vielä enemmän, ne voi luetella näin:

class X extends MyTrait1 with MyTrait2 with MyTrait3 with MyTrait4 with Etc

Liitettävillä piirreluokilla ei ole pakko olla yhteistä yläkäsitettä (kuten PersonAtAalto on Employeelle ja Studentille oheisessa esimerkissä), vaan luokkaan voi liittää toisiinsa muuten liittymättömiäkin piirreluokkia.

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 with T2 {
  def m2 = 2
  def m3 = 3.0
}

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

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 olemmekin 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.5 vapaaehtoisessa tehtävässä nopeutta kuvaavaan Velocity, aloita lukemalla siitä.

Mieti, miten voisit lisätä FlappyBug-projektin 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.

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 löytyvät kaikilta alakäsitteiden ilmentymiltä:

val myObject: Supertype = new SubtypemyObject: Supertype = Subtype@714aadf7
myObject.magicNumberres14: Int = 42
myObject.textres15: String = value of 'text' for all Subtype instances

Piirreluokat vs. Java-kielen rajapintaluokat

Sanalla rajapinta (interface) on muun muassa seuraavat (toisiinsa liittyvät) merkitykset:

  1. ohjelman osan eli esimerkiksi luokan "julkisivu", jonka kautta tuota osaa voi käyttää (luku 3.1);

  2. eräissä kielissä (erityisesti: Javassa) esiintyvä rakenne, josta voidaan käyttää myös nimeä rajapintaluokka ja joka muistuttaa Scalan piirreluokkia:

    // Tämä on Javaa, ei Scalaa.
    
    interface Shape { /* jne. */ }
    
    class Circle implements Shape { /* jne. */ }
    

Javan rajapintaluokista Scalan piirreluokat eroavat ensisijaisesti siten, että piirreluokissa voi olla myös ilmentymäkohtaisia muuttujia, kun taas rajapintaluokissa ei voi.

Etukäteen määriteltyjä ilmentymiä

Piirreluokat ja yksittäisoliot

Yksittäisolioon voi liittää piirreluokan extends-sanalla aivan kuin luokkaankin.

Itse asiassa olet jo tehnytkin niin. Scalan luokka App on nimittäin piirreluokka, joka kuvaa sovellusohjelman käsitettä ja tarjoaa erinäisiä sovelluksen käynnistämiseen liittyviä palveluita. Kun olet kirjoittanut ohjelman käynnistysolioon extends App olet siis määritellyt, että kyseiseen yksittäisolioon liittyy App-piirre. Tai toisin sanoen: kyseinen yksittäisolio on erikoistapaus yläkäsitteestä App.

Myös seuraavassa esimerkkiohjelmassa liitetään piirteitä yksittäisolioihin. Samalla tehtävä toimii esimerkkinä tilanteesta, jossa kaikki tietyn tyypin ilmentymät ovat etukäteen tiedossa, eikä ole tarkoituskaan että kukaan voisi niitä määritellä lisää.

Verityyppitehtävän paluu

Luvun 4.4 mallinnettiin verityyppejä. Käytimme ABO-luokittelun ja Rhesus-luokittelun yhdistelmää, ja kuvasimme kunkin verityypin merkkijonon ja totuusarvon yhdistelmänä tähän tapaan:

val myBlood = new BloodType("AB", true)myBlood: o1.blood.BloodType = AB+
val yourBlood = new BloodType("A", true)yourBlood: o1.blood.BloodType = A+
myBlood.canDonateTo(yourBlood)res16: Boolean = false

Aiemmassa versiossamme:

Oli vain yksi BloodType-käsite, joka tarkoitti aina yhdistelmää ABO-verityypistä ja Rhesus-verityypistä.

Kuitenkaan näitä kahta ei aina välttämättä käytetä yhdessä, ja verityyppiluokitteluja on olemassa muitakin. Lisäksi:

Luokan käyttäjä määräsi varsinaisen verityypin parametreilla. Luotimme siihen, että käyttäjä antoi mielekkäästi muotoiltuja merkkijonoja parametreiksi.

Tehdään ohjelmastamme joustavampi kuvaamalla ABO- ja Rhesus-luokittelutavat erikseen. Samalla otamme mallinnuksen välineeksi piirreluokat, joilla kuvaamme näitä eri luokitteluja.

Tarkastellaan aluksi Rhesus-luokittelua. Kuvataan sen mukaisia verityyppejä piirreluokalla Rhesus:

trait Rhesus {
  val isPositive: Boolean
  def isNegative = !this.isPositive
  def canDonateTo(recipient: Rhesus) = this.isNegative || this == recipient
  def canReceiveFrom(donor: Rhesus)  = donor.canDonateTo(this)
}
Luokassa on abstrakti muuttuja isPositive. Millä tahansa oliolla, joka kuvaa Rhesus-luokittelun mukaista verityyppiä, tulee olla tämä muuttuja ja sillä jokin arvo.
Rhesus-tyyppi määrittelee metodit, jotka ovat Rhesus-luokittelun mukaisilla verityypeillä (käytännössä siis molemmilla kahdesta tyypistä).

Koska verityyppejä on pieni määrä ja ne ovat etukäteen tiedossa, voimme luontevasti kuvata kunkin yksittäisen verityypin yksittäisolioilla. Rhesus-luokittelussa ryhmiä on kaksi: positiivista verityyppiä kuvatkoon yksittäisolio RhPlus ja negatiivista RhMinus:.

object RhPlus extends Rhesus {
  val isPositive = true
  override def toString = "+"
}

object RhMinus extends Rhesus {
  val isPositive = false
  override def toString = "-"
}
Kumpaankin yksittäisolioista on liitetty Rhesus-piirre, eli ne ovat yksittäistapauksia Rhesus-yläkäsitteestä. Niillä on kaikki piirreluokan metodit.
Oliot toteuttavat piirreluokan abstraktiksi jättämän muuttujan eri totuusarvoilla.

Nyt verityyppien Rhesus-luokittelun mukaisia yhteensopivuuksia voi tutkia näitä olioita käyttäen:

RhPlus.canDonateTo(RhMinus)res17: Boolean = false
RhMinus.canDonateTo(RhPlus)res18: Boolean = true
RhMinus.canDonateTo(RhMinus)res19: Boolean = true
RhMinus.isPositiveres20: Boolean = false

Yleisemmin sanoen: tähän tapaan voimme mallintaa käsitteen (piirreluokan), josta on tarjolla rajattu joukko nimettyjä, etukäteen tunnettuja ja kaikki tapaukset kattavia ilmentymiä (yksittäisoliot).

Vastaava voisi olla tarjolla myös ABO-luokittelulle: piirreluokka ABO ja yksittäisoliot A, B, AB ja O. Ja muillekin veriryhmäluokitteluille, jos niitä haluaisimme mallintaa.

Seuraavissa vapaaehtoisissa osioissa toteutat ABO-luokittelun samalla periaatteella ja voit tutustua siihen, miten näitä luokitteluja voi yhdistellä.

ABO-verityypeille oma piirreluokka

Projektissa Subtypes on pakkaus o1.blood. Sen tiedostossa BloodType.scala on äskeinen Rhesus-koodi. Lisää samaan tiedostoon myös piirreluokka ABO sekä yksittäisoliot A, B, AB ja O; nuo neljä ovat ainoat oliot, joilla on tuo piirre.

ABO-piirreluokassa on oltava:

  • Abstrakti muuttuja antigens, johon on tarkoitus tallentaa merkkijonona kyseisen verityypin sisältämät antigeenit (esim. "A" tai "AB").
  • Metodit canDonateTo ja canReceiveFrom, jotka toimivat vastaavasti kuin luvun 4.4 BloodType-luokan ja äskeisen Rhesus-piirreluokan samannimiset metodit. Nämä metodit tosin tarkastelevat tyyppien yhteensopivuutta ainoastaan ABO-antigeenien perusteella eivätkä huomioi Rhesus-tekijää.

Yksittäisolioilla A, B, AB ja O on oltava:

  • Konkreettinen arvo antigens-muuttujalle siten, että se luettelee mitkä antigeenit kyseiseen veriryhmään liittyvät: "A", "B", "AB" tai "". (Viimeinen on siis tyhjä merkkijono.)
  • toString-metodi, joka palauttaa veriryhmän nimen. Nimi on sama kuin antigens, paitsi että O-veriryhmän nimi on "O".

Jos et muista, miten veriryhmien yhteensopivuus määrittyy, kertaa luvusta 4.4 ja joko omasta ratkaisustasi aiempaan tehtävään tai esimerkkiratkaisusta.

Tiedostosta BloodTest.scala löytyy testiohjelma, jolla voit kokeilla koodiasi. Testikoodia on tosin kommentoitu ulos, jotta et saisi turhia virheilmoituksia; poista kommentit, kun olet valmis testaamaan.

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

Entä luokittelujen yhdisteleminen?

Äsken erottelimme aiemmin yhdessä käytetyt veriluokittelut. Tarkoitus olisi, että niitä voisi käyttää joustavasti erikseen tai yhdistellä keskenään tai muihin luokitteluihin. Miten tuo yhteiskäyttö nyt sitten voisi käydä?

Yksi tapa on toteutettu luokaksi ABORh, joka löytyy (ulos kommentoituna) BloodType.scalasta sekä tästä:

class ABORh(val abo: ABO, val rhesus: Rhesus) {
  def canDonateTo(recipient: ABORh) = this.abo.canDonateTo(recipient.abo) && this.rhesus.canDonateTo(recipient.rhesus)
  def canReceiveFrom(donor: ABORh) = donor.canDonateTo(this)
  override def toString = this.abo.toString + this.rhesus.toString
}
Luokka vastaa toiminnallisuudeltaan aiempaa BloodType-luokkaamme. Se on rakennettu ABO- ja Rhesus-luokittelut yhdistämällä.
Tämän luokkamme ei tarvitse turvautua siihen, että sen käyttäjä antaa sille kelvollisia merkkijonoja. Luokalle voi antaa vain ABO- ja Rhesus-tyyppisiä arvoja eli käytännössä niitä yksittäisolioita, joita olemme määritelleet.

Luokan sulkeminen

Äskeinen ohjelmamme perustui ajatukseen, että Rhesus-piirreluokan toteuttaa vain pari tiettyä oliota, jotka olemme etukäteen koodiin kirjanneet, ja ABO-piirreluokan vastaavasti neljä. Nuo samaan tiedostoon kirjaamamme yksittäisoliot kattavat kaikki mahdolliset tapaukset; Rhesus- ja ABO-tietotyyppien käyttäjä voi luottaa siihen, että kunhan hän huomioi nuo arvot, niin kaikki on kunnossa. Piirreluokat Rhesus ja ABO on ikään kuin suljettu umpeen: niiden käyttäjä ei voi keksiä niille uusia ilmentymiä eikä ole tarkoituskaan.

Voimme kirjata asian Scalana:

sealed trait Rhesus {
  // ...
}
sealed piirreluokan määrittelyn alussa kertoo, että tuota piirrettä ei voi liittää mihinkään muihin luokkiin tai olioihin kuin niihin, jotka on määritelty samassa tiedostossa.

Voit lisätä sanan sealed piirreluokkien Rhesus ja ABO määrittelyihin.

(Tuossa suljimme piirreluokan. Myös tavallisen luokan voi sulkea, mistä puhutaan luvussa 7.3.)

Luetelmatyypeistä

Kuten näit, suljetun yläkäsitteen ja yksittäisolioiden yhdistelmällä voi kuvata tyyppejä, joilla on rajattu, tunnettu, kattava joukko mahdollisia arvoja. Samankaltaiseen tavoitteeseen käytetään monissa ohjelmointikielissä luetelmatyyppejä (enumerated type). Myös Scalalla voi määritellä luetelmatyyppejä, mutta tässä luvussa esitetty vaihtoehto on usein kätevämpi.

Syvemmälle Scalan tyyppijärjestelmään

(Tämä lisätehtävä on äskeistä verityyppitehtävää haastavampi ja edellyttää omatoimista opiskelua kurssimateriaalin ulkopuolelta. Se sopii tässä kohden kurssia lähinnä ennestään ohjelmoinnin parissa harrastuneille. Aloittelijoiden kannattaa ohittaa tämä ja muutkin voivat.)

Saimme jo kuvattua Rhesus-verityypit, ABO-verityypit ja niiden yhdistelmän ABORh. Noille piirreluokillemme oli yhteistä canDonateTo-metodi, ja canReceiveFrom-metodi (jolla oli jopa identtinen toteutus) sekä ajatus siitä, että kyseessä on eräs verityyppiluokittelu.

Määritellään yläkäsite BloodType, joka kuvaa noiden kolmen piirreluokan yhteistä yläkäsitettä. Rhesus, ABO ja ABORh ovat kaikki BloodTypejä.

Tässä ensimmäinen hahmotelma:

trait BloodType {
  def canDonateTo(recipient: BloodType): Boolean
}

sealed trait Rhesus extends BloodType { /* etc. */  }
sealed trait ABO    extends BloodType { /* etc. */  }
sealed trait ABORh  extends BloodType { /* etc. */  }
Tämä määrittely aiheuttaa ongelman, joka ilmenee alatyypeissä Rhesus, ABO ja ABORh.

Tutki, mikä virhetilanne syntyy. Pohdi, miksi niin käy.

Tarkastele sitten tätä versiota:

trait BloodType[ThisSystem] {
  def canDonateTo(recipient: ThisSystem): Boolean
}
Koodiin on lisätty tyyppiparametri.

Lue tyyppiparametreista Scalan dokumentaatiosta ja muualta. Mitä alatyyppeihin Rhesus, ABO ja ABORh pitää nyt lisätä, jotta ne toimisivat yhteen tyyppiparametrillisen BloodType-yläkäsitteen kanssa? Tee muutokset koodiin. (Tälle tehtävälle ei ole automaattista tarkistinta. Arvioi itse oman ratkaisusi toimivuus.)

Äskeisestä puuttui vielä toinen metodi. Tarkoitus olisi poistaa identtinen canReceiveFrom alatyypeistä ja toteuttaa se kertaalleen BloodType-piirreluokassa. Näin:

trait BloodType[ThisSystem] {
  def canDonateTo(recipient: ThisSystem): Boolean
  def canReceiveFrom(donor: ThisSystem) = donor.canDonateTo(this)
}

Tuo versiopa ei enää toimikaan. Tutki, mikä virhetilanne syntyy. Pohdi, miksi niin käy.

Tässä versio, jossa molemmat metodit on kelvollisesti määritelty:

trait BloodType[ThisSystem <: BloodType[ThisSystem]] {
  this: ThisSystem =>
  def canDonateTo(recipient: ThisSystem): Boolean
  def canReceiveFrom(donor: ThisSystem) = donor.canDonateTo(this)
}
Mikä tuo on? Lyhyesti sanottuna se on tyypin yläraja (upper bound) ja tarkoittaa, että tyyppiparametrin ThisSystem tulee olla jokin tyypin BloodType[ThisSystem] alatyyppi.
Mikä tuo on? Lyhyesti sanottuna se on ns. self type, jota on tässä käytetty kirjaamaan, että this viittaa tässä piirreluokassa ThisSystem-tyyppiseen olioon.

Selvitä, miten koodi ratkaisee edellisen version ongelman. Etsi mainituista käsitteistä lisätietoja netistä tarpeen mukaan.

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 tai yksittäisoliolle. Piirreluokkien avulla voi näin kuvata yläkäsitteitä.
  • Luokkaan tai yksittäisolioon voi liittää piirreluokan (tai useita), jolloin sen oma määrittely täydentyy piirreluokan määrittelyllä.
  • Piirreluokan metodit voivat olla abstrakteja. Tämä tarkoittaa sitä, että metodille ei määritellä yleistä toteutusta piirreluokassa vaan alakäsitteille erikseen. Kuitenkin metodin olemassaolo taataan kaikissa olioissa, joilla on kyseinen piirre. Myös muuttuja voi olla abstrakti vastaavasti.
  • Jos yläkäsitteellä on ennalta tunnettu joukko ilmentymiä, voi ne kuvata yksittäisolioina, joilla on sama yläkäsite.
  • Lukuun liittyviä termejä sanastosivulla: piirreluokka eli piirre, abstrakti metodi, abstrakti muuttuja; staattinen tyyppi, dynaaminen tyyppi; DRY; abstraktio; suljettu luokka.

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!

Kierrokset 1–13 ja niihin liittyvät tehtävät ja viikkokoosteet on laatinut Juha Sorva.

Kierrokset 14–20 on laatinut Otto Seppälä. Ne eivät ole julki syksyllä, mutta julkaistaan ennen kuin määräajat lähestyvät.

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 Riku Autio, Jaakko Kantojärvi, Teemu Lehtinen, Timi Seppälä, Teemu Sirkiä ja Aleksi Vartiainen.

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

Yksityiskohtaiset animaatiot Scala-ohjelmien suorituksen vaiheista ovat suunnitelleet Juha Sorva ja Teemu Sirkiä. Niiden teknisen toteutuksen ovat tehneet Teemu Sirkiä ja Riku Autio käyttäen Teemun toteuttamia Jsvee- ja Kelmu-työkaluja.

Muut diagrammit ja materiaaliin upotetut vuorovaikutteiset esitykset on laatinut Juha Sorva.

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

Opetustapa, jossa 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+ on luotu Aallon LeTech-tutkimusryhmässä pitkälti opiskelijavoimin. Pääkehittäjänä toimii tällä hetkellä Jaakko Kantojärvi, jonka lisäksi järjestelmää kehittävät useat tietotekniikan ja informaatioverkostojen opiskelijat.

Kurssin tämänhetkinen henkilökunta on kerrottu luvussa 1.1.

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

../_images/imho7.png
Palautusta lähetetään...