Helsingin yliopisto / tietojenkäsittelytieteen laitos / Ohjelmointitekniikka (Scala) / © Arto Wikla 2016

10 Periytyminen

Muutettu viimeksi 29.3.2016 / Sivu luotu 9.4.2010 / [oppikirjan esimerkit] / [Scala]

Sivun sisältöä:

Olio-ohjelmoinnissa luokkia rakennellaan perimällä muita luokkia (inheritance), asettamalla muiden luokkien ilmentymiä luokan jäseniksi (composition) ja ohjelmoimalla luokkiin omia ominaisuuksia, jotka täydentävät tai muuten muuttavat perittyjä ominaisuuksia.

Scalan olio-ohjelmointitekniikat muistuttavat kovasti Javan vastaavia; tärkein lisäys ovat piirretyypit, "traitit", jotka tavallaan ovat rajoitettua moniperintää.
Oikeastaan jo Javan rajapintaluokat (interface) ovat moniperintää vieläkin rajoitetumpana: Vain tyyppi peritään, ei edes osatoteutusta.

Piirretyypit esitellään omassa luvussaan. Tässä luvussa käydään tiiviisti läpi Scalan "javamaiset" periytymistekniikat.

Tuttu juttu

Kun jo opitut Scala-syntaksin esitystyylit otetaan huomioon, Javasta tuttu tekniikka on helppo siirtää Scalaan:
class Yli {
  private var a = 10
  def setA(a: Int) {this.a = a;}
  def getA = a
  override def toString = "Yli-olion a on " + a
}

class Ali extends Yli {
  private var b = 100
  def setB(b: Int) {this.b = b;}
  def getB = b
  override def toString = "Ali-olion b on " + b
}

val x = new Yli
println(x); // Yli-olion a on 10
x.setA(123)
println(x)  // Yli-olion a on 123

val y = new Ali()
println(y)  // Ali-olion b on 100
y.setB(321)
println(y)  // Ali-olion b on 321

println("Alista näkyy Ylin a: " + y.getA) // Ali-oliossa on siis myös a: 10
y.setA(999)                              // ja sitä voi myös muuttaa a: 999
println("Ja sitä voi muuttaa a = " + y.getA)

Abstrakti luokka ja aliluokka

Peruskursseilta tunnettuun tapaan abstrakti luokka on sellainen, joka ei toteuta kaikkia metodejaan, antaa vain niiden otsikot. Aliluokan tehtävä on sitten korvata nämä perityt metodit konkreettisesti toteutettuina.

Abstraktin luokan tavanomainen tehtävä on määritellä tyyppi, johon kuuluvia eri aliluokkien ilmentymiä voidaan sitten käsitellä tuon abstraktin luokan tyyppisissä muuttujissa, parametreissa, .. (Jos tämä ei kuulosta tutulta, on paras pikimmiten kerrata peruskurssien asiat! ;-)

Huom: Scalassa tähän tehtävään on tyypillisempää ja tyylikkäämpää kuitenkin käyttää piirretyyppiä (trait). Siitä lisää seuraavassa luvussa.

Kuten Javassa Scalan abstrakti luokka ilmaistaan määreellä abstract. Scalassa abstrakteille metodeille tuota määrettä ei kuitenkaan anneta:

Laaditaan 1960-luvun F1-maailmaan liittyvä esimerkki vanhalta Java-kurssilta muokattuna:

abstract class Moottori {
  def annaKierrosluku(): Double   // vain metodin otsikko!
  def asetaPolttoaineensyottomaara(maara: Double): Unit
  // ... Täällä voisi vallan mainiosti olla myos ei-abstrakteja metodeita!
  // ... Jopa ja nimenomaan myös sellaisia, jotka kutsuvat abstrakteja metodeita!
}
Tässä siis vain esitetään vaatimus, että jokaisen Moottori-tyyppisen olion pitää omata nuo metodit. Ja erityisesti Moottori siis kelpaa muuttujan, parametrin, funktion, ..., tyypiksi.

Ohjelmoidaan sitten luokka, joka sisältää abstraktin Moottori-tyyppisen kentän. Idea (kuten pitäisi olla tuttua!) on siis siinä, että voidaan ohjelmoida välineitä, jotka eivät ole riippuvaisia minkään erityisen moottorin ominaisuuksista. Yleiskäyttöisyyttä, koodin uudelleenkäyttöä, varmistettua riippumattomuutta...

class Auto(m: Moottori) {  // Moottori on abstrakti luokka!
  // rakennellaan loputkin autosta ...
  def mitenLujaaMennaan() = {
    // nopeus riippuu tavalla tai toisella kierrosluvusta:
    m.annaKierrosluku()/30
  }
  def kaasuta(bensaa: Double) {
    m.asetaPolttoaineensyottomaara(bensaa)

    // ...
  }
  // ties mitä muita metodeita ...
}
Abstraktista luokasta ei tunnetusti voi luoda ilmentymiä! Tarvitaan siis jokin abstraktin Moottori-luokan ei-abstrakti aliluokka, jotta Auto-ilmentymiä voitaisiin luoda. Ohjelmoidaanpa siis Moottori-luokalle vaikkapa aliluokka Cosworth, joka toteuttaa perimänsä abstraktit metodit:
class Cosworth extends Moottori {
  private var kierrosluku = 0.0
  // muut tietorakenteet ...

  def annaKierrosluku() = kierrosluku // getteri

  def asetaPolttoaineensyottomaara(maara: Double) {
    kierrosluku =
     if (maara < 0) 0.0
    else if (maara > 100)
      9300.0
    else
      (maara/100)*9300  // tms...
  }
  // muita aksessoreita ...
}
Ja johan alkaa syntyä kilpa-autoja:
val brrrmmm = new Cosworth()     // "oikea moottori", joka
val lotus49 = new Auto(brrrmmm)   // kelpaa auton luontiin

lotus49.kaasuta(96)
println(lotus49.mitenLujaaMennaan()) // 297.6

lotus49.kaasuta(-23);
println(lotus49.mitenLujaaMennaan()) // 0.0

lotus49.kaasuta(1100)
println(lotus49.mitenLujaaMennaan()) // 310.0

// myös tyypillinen tekniikka:
val brabham = new Auto(new Cosworth())

brabham.kaasuta(99)
println(brabham.mitenLujaaMennaan()) // 306.9
Vastaavalla tavalla voitaisiin luoda ja käyttää Auto-olioita milloin milläkin moottorilla... Auton moottoriksi kelpaa mikä tahansa olio, jonka luokka on Moottori-luokan ei-abstrakti aliluokka!

Terminologinen huomautus: Ohjelmointikielistä puhuttaessa esitteleminen (declare) ja määritteleminen (define) tarkoittavat erityisiä asioita: Nimet eli tunnukset esitellään ja nimien eli tunnuksien merkitys määritellään. Yllä luokka Moottori vain esitteli tunnuksen annaKierrosluku. Luokka Cosworth sitten myöskin esittelyn lisäksi määritteli tuon tunnuksen omalla tavallaan.

Parametrittomat aksessorit

Scalan tyyliä ja tekniikkaa: Parametriton aksessori voidaan kirjoittaa kahteen tapaan:

Edellä kirjoitettiin tyhjät sulkeet näkyviin:

  def annaKierrosluku(): Double       // abstraktina
  ...
  m.annaKierrosluku()                 // käytössä
  ...
  def annaKierrosluku() = kierrosluku // toteutuksessa
Sulkeet voi myös jättää pois
  def annaKierrosluku: Double       // abstraktina
  ...
  m.annaKierrosluku                 // käytössä
  ...
  def annaKierrosluku = kierrosluku // toteutuksessa

Kumpikin tapa kelpaa kääntäjälle, mutta kaikki sekoitukset eivät. [Englanniksi (vai Scala-englanniksi?) sanapari on "parameterless method" ja "empty-paren method".]

"Tapana on", "tyyliin kuuluu", kirjoittaa tyhjä parametrilista näkyviin silloin, kun metodi muuttaa olion kenttiä, kun metodi siis on "setteri" tai sillä on muita sivuvaikutuksia. Tyhjä parametrilista on tapana kirjoittaa myös silloin, kun kyseessä on "getteri", jonka arvo riippuu muuttuvista kentistä.

Mutta sivuvaikutukseton "getteri" immutaabeleista kentistä on tyylin mukaista ohjelmoida ilman tyhjiä sulkeita.

Tässä tyylittelyssä ei ole todellakaan kyse pelkästä "ohjelmointiestetiikasta"! Kun parametriton metodi kirjoitetaan ilman tyhjiä sulkeita, tarvittaessa metodi voidaan korvata kentällä tyyliin;

  // metodit height ja width
  abstract class Element {
    def contents: Array[String]
    def height: Int = contents.length
    def width: Int = if (height == 0) 0 else contents(0).length

  }

  // kentät height ja width
  abstract class Element {
    def contents: Array[String]
    val height = contents.length
    val width = if (height == 0) 0 else contents(0).length
  }
Tämän luokan käyttäjä (aliluokan ohjelmoija tai aliluokan ilmentymiä käyttävä ohjelmoija) viittaa nimiin height ja width ihan samalla tavalla riippumatta siitä, ovatko ne metodeita vai kenttiä!

Yksi tärkeä ero metodi- ja kenttäratkaisussa on se, että kenttä vie tilaa ja metodin kutsu puolestaan laskenta-aikaa. Valinta on luokan toteuttajan. Ja oleellista on, että tämä valinta on kapseloitu tuohon luokkaan! Sovelluksen kannalta toiminnallisuus on sama.

"Eikä tässä vielä kaikki": Vaikka abstrakti luokka määrittelisi parametrittoman metodin, toteutusluokka voi toteuttaa sen kenttänä!

abstract class Moottori {
  def annaKierrosluku: Double
  def asetaPolttoaineensyottomaara(maara: Double): Unit
}

class Auto(m: Moottori) {  // Moottori on abstrakti luokka!
  // rakennellaan loputkin autosta ...
  def mitenLujaaMennaan() = {
    // nopeus riippuu tavalla tai toisella kierrosluvusta:
    m.annaKierrosluku/30
  }
  def kaasuta(bensaa: Double) {
    m.asetaPolttoaineensyottomaara(bensaa)
  }
}

class TasaKone extends Moottori {
  val annaKierrosluku = 1000.0 // vakiogetteri
  def asetaPolttoaineensyottomaara(maara: Double) { }
}

val surrur = new TasaKone()     // "oikea moottori", joka
val trabi = new Auto(surrur)    // kelpaa auton luontiin

trabi.kaasuta(96)
println(trabi.mitenLujaaMennaan()) // 33.333333333333336
trabi.kaasuta(-23);
println(trabi.mitenLujaaMennaan()) // 33.333333333333336
trabi.kaasuta(1100)
println(trabi.mitenLujaaMennaan()) // 33.333333333333336
Huom: Perittyä kenttää ei voi korvata metodilla.

Metodien ja kenttien korvaaminen (override)

Perittyjen ominaisuuksien korvaaminen (override) on hyvin samanlaista kuin Javassa; perityn kentän kanssa saman niminen kenttä korvaa, peittää ja syrjäyttää perityn kentän, perityn metodin kanssa samanotsikkoinen metodi korvaa, peittää ja syrjäyttää perityn metodin.

Kuten nähtiin, Scalassa kuitenkin myös kentät voivat korvata metodeja. Tästä seuraa tärkeä ero Javaan: kenttien ja metodien nimet kuuluvat samaan nimiavaruuteen, joten niiltä vaaditaan yksikäsitteisyyttä.

Ja todellakin, kenttä voi korvata perityn metodin! Mutta ei toisin päin...

Javassa samannimisyys sallitaan kentille, metodeille, luokille (eli tyypeille), pakkauksille ja osoitteille (nimetyille lauseille break- ja contunue-lauseita varten). Scalassa tällaisia "nimiavaruuksia" on vain kaksi: arvot (kentät, metodit, pakkaukset, ainokaiset) ja tyypit (luokkien ja piirretyyppien (trait) nimet).

Parametroidut kentät – luokkaparametrit

Kuten jo aiemmin on opittu, konstruktorin muodollisista parametreista eli luokkaparametreista tulee olioiden (olio-)privaatteja kenttiä. Määre val tuottaa julkisen vakiokentän. Ja var tekee kentästä julkisen muuttujan.

Myös määreet private, protected, override ja final ovat käytettävissä. Javasta poiketen Scalan protected tuottaa vain aliluokkanäkyvyyden, ei pakkausnäkyvyyttä! Ja hyvä niin. Määre final Javan tapaan estää kentän (tai metodin) korvaamisen aliluokassa.

Perityn kentän voi korvata myös konstruktorin parametrilistassa eli luokkaparametreissa.

  class Cat {
    val dangerous = false
  }

  class Tiger( override val dangerous: Boolean,
               private var age: Int
             ) extends Cat
Tämä (lähes) vastaa sitä, että kirjoitettaisiin:
 class Tiger(param1: Boolean, param2: Int) extends Cat {
    override val dangerous = param1
    private var age = param2
 }
Tässä tosin tilaa varataan myös privaattikentille param1 ja param2.

Yliluokan konstruktorin kutsu ja super-viite

Ohjelmoidaan tason piste luokkana Piste, jonka ainoa setteri on xy-siirtymä annetuin parametrein:
class Piste(private var x: Int, private var y: Int) { 
  def this() = this(0, 0)
  def siirry(dx: Int, dy: Int) {x += dx; y += dy;}
  override def toString = "("+x+","+y+")"
}

val a = new Piste()
val b = new Piste(7, 14)
println(a)               // (0,0) 
println(b)               // (7,14)
a.siirry(2, 3) 
b.siirry(4, 5)
println(a)               // (2,3)
println(b)               // (11,19)

Laaditaan sitten tälle luokalle aliluokkana värillinen piste:

class VariPiste( x: Int, y: Int, private var vari: Int
               )  extends Piste(x, y) {
                          // HUOM: yliluokan konstruktorille annetaan parametrit!
  def this() = this(0, 0, 0)
  def this(vari: Int) = this(0, 0, vari)

  //-- lisämetodi:
  def uusiVari(vari: Int) {this.vari = vari}

  override def toString = super.toString + " väri: " + vari
                           // HUOM: super-viittaus korvattuun perittyyn metodiin!
}

val c = new VariPiste()
val d = new VariPiste(7)
val e = new VariPiste(4,5,9)
println(c)               // (0,0) väri: 0
println(d)               // (0,0) väri: 7
println(e)               // (4,5) väri: 9
e.siirry(1, 1) // yliluokan operaation käyttö!
println(e)               // (5,6) väri: 9
e.uusiVari(14) // oman operaation käyttö
println(e)               // (5,6) väri: 14

  • Yliluokan konstruktoria kutsutaan siis parametreineen extends-ilmauksen yhteydessä!.
  • Korvattuun perittyyn metodiin viitataan tutulla tavalla ilmauksella super.

    override-määre

    Ensin vaikuttaa siltä, että override-määreen käyttösäännöt ovat selkeät ja helposti ymmärrettävissä ja perusteltavissa:

    Esimerkki:

    abstract class Yli {
      val a = 1
      private val b = 2
    
      def c = 3    // metodeita
      def d: Int   //  abstrakti
    }
    
    class Ali extends Yli {
      override val a = 10  // override pakollinen
      private val b = 20   // override kielletty
    
      override def c = 30  // override pakollinen
      def d = 40           // override sallittu muttei pakollinen
    
      val e = 50           // override kielletty
    } 
    
    Sitten paljastuu, ettei asia kuitenkaan ole edes noin "suoraviivainen":
    abstract class Yli {
      def a = 1
      val b = 2
      var c = 3
      val d = 4
    }
    
    class Ali extends Yli {
    
       override def a = 10 + super.a  // korvattu metodi, viittaus korvattuun metodiin
    
       override val b = 20     // ok
    // val xx = 10 +  super.b  // error: super may be not be used on value b
    
    // override var c = 30     // variable c cannot override a mutable variable
    // val yy = 10 +  super.c  // error: super may be not be used on variable c
    
    // override var d = 40    // error overriding value d in class Yli of type Int;
                              // variable d needs to be a stable, immutable value
    }
    
    val x = new Ali
    println(x.a)  // 11
    println(x.b)  // 20
    

    Scalan override ei vaikuta ihan yhtä tyylikkäältä kuin kielen monet muut ominaisuudet...

    Polymorfismi

    Polymorfismin idean pitäisi olla jo tuttu. Pieni kertaus:

    "Arton polymorfismisääntöjä":

    Olkoon TyyppiB luokka, joka on luokan TyyppiA välitön tai välillinen aliluokka. Ja olkoon muuttuja x tuon yliluokan tyyppiä: x: TyyppiA

    Tällaisen muuttujan arvoksi voi sijoittaa viitteen mihin tahansa olioon, joka on tyyppiä TyyppiA. Koska "jokainen kissa on eläin" eli aliluokan jokainen ilmentymä on myös yliluokan tyyppiä, on luvallista sijoittaa

      x = new TyyppiB()
    
    "Aksessoidaan muuttujaa" tuttuun tyyliin: x.met() Pikku esimerkki:
    val a: VariPiste = new VariPiste(7)
    val b: Piste = a
    val c: Any = a
    
    println(a)  // (0,0) väri: 7
    println(b)  // (0,0) väri: 7
    println(c)  // (0,0) väri: 7
    
    Siis olion tyyppi – ei muuttujan tyyppi – määrää, mikä versio toString-metodista valitaan.

    final

    final-määreellä voidaan estää aliluokkaa korvaamasta yliluokan jäsentä. Myös itse luokan käyttö yliluokkana voidaan kieltää samalla määreellä.