Objektumorientáltság Scalában

Kategória: Scala.

A Scala funkcionális és objektumorientált nyelv egyszerre, így a szokásos objektumorientált struktúrák is jelen vannak a nyelvben. Számos egyéb nyelvi elemhez hasonlóan az objektumorientáltság is jelentős ráncfelvarráson esett át.

Osztályok

Az objektumorientáltság alapja az osztály, melyet a legtöbb objektumorientált nyelvben a class kulcsszóval lehet létrehozni; ez a Scala-ban is megmarad. Egy osztálynak lehetnek attribútumai és metódusai. Lássunk egy példát!

class Point {
  var x: Int = 0
  var y: Int = 0

  def move(dx: Int, dy: Int) = {
    x += dx
    y += dy
  }

  def format() = "[" + x + ", " + y + "]"
}

A Point osztály tartalmaz két attribútumot: x és y (koordináták), valamint két metódust: move(dx, dy) és print. Példányosítani a new kulcsszóval tudunk:

val p = new Point
p.x = 1
p.y = 4
p.move(2, 3)
p.move(1, -1)
p.format()

Konstruktor

A fenti példa működik, de nem szép. Konstruktornak hívjuk egy osztályon belül azt a metódust, amely létrehozza magát az osztályt, beállítja az attribútumokat. Módosítsuk a kódot úgy, hogy a pont alapértelmezett értékét már létrehozáskor be tudjuk állítani, ne kelljen utólag.

A Scala ebben az esetben is tömörít. A Java-ban az osztálynév megadása után osztályon belül soroljuk fel az attribútumokat, majd jönnek külön eljárásokként a konstruktorok. A Scala-ban már az osztálynév megadásakor zárójelben megadhatunk változókat, amelyek egyben attribútumok is lesznek, valamint az alapértelmezett konstruktor paraméterei. További konstruktorokat hozhatunk létre a this kulcsszóval, pl.:

class Point(var x: Int, var y: Int) {
  def this() = this(0, 0)

  def move(dx: Int, dy: Int) = {
    x += dx
    y += dy
  }

  def format() = "[" + x + ", " + y + "]"
}

A kliens oldal maradhat olyan mint fent, de az alábbi kompaktabb:

val p = new Point(1, 4)
p.move(2, 3)
p.move(1, -1)
p.format()

Öröklődés

Az osztályokat ki tudjuk terjeszteni az extends kulcsszóval.

class Circle(x: Int, y: Int, var r: Int) extends Point(x, y) {
  override def format() = "(" + super.format() + ", " + r + ")"
}

Vegyük észre az override kulcsszót: ezzel jelezzük azt, hogy az ősosztály metódusát definiáljuk felül, jelen esetben a format függvényt. A super kulcsszót használjuk az ősosztály eljárásának meghívásához. Az imént létrehozott származtatott osztály használata:

val c = new Circle(2, 6, 3)
c.move(2, 4)
c.format

Látható tehát, hogy a move eljárást a leszármaztatott osztály megörökölte. Ha az ősosztályban nem szeretnénk felüldefiniálhatóvá tenni egy eljárást, akkor a final kulcsszóval tudjuk ezt jelezni, pl.:

final def move(dx: Int, dy: Int) = {
  x += dx
  y += dy
}

Hasonlóan magát az osztályt is meg tudjuk jelölni; ez esetben nem lehet az osztályból származtatni. Próbáljuk a fenti példában a Point mögé írni a final kulcsszót (final class Point), és vegyük észre a fordítási hibát a származtatásnál.

A Scala-ban létezik még egy köztes öröklődési korlátozás is: a sealed, ami azt jelenti, hogy csak abban a forrásfájlban megengedett a kiterjesztés, ahol a kiterjesztendő osztály definiálva van.

Örökölni - a Java-hoz hasonlóan - csak egy osztályból lehet.

Absztrakt osztályok

A többi programozási nyelvhez hasonlóan a Scala is támogatja az absztrakt osztályokat. Egy absztrakt osztály nem feltétlenül valósít meg minden metódust, ennélfogva nem is lehet példányosítani. Egy változó statikus típusa lehet absztrakt osztály, a tényleges típusa viszont nem.

Példaként vegyük a bevezetőben ismertetett osztályt. Ebből készítünk egy absztrakt osztályt, ami a move függvényt deklarálja, de nem definiálja. Ebből származik egy konkrét osztály, ami az absztraktból származik és megvalósítja annak absztrakt metódusát. A kliens absztrakt statikus, de konkrét dinamikus példányt hoz létre.

abstract class AbstractPoint {
  var x: Int = 0
  var y: Int = 0

  def move(dx: Int, dy: Int) : Unit

  def format() = "[" + x + ", " + y + "]"
}

class ConcretePoint extends AbstractPoint {
  def move(dx: Int, dy: Int) : Unit = {
    x += dx
    y += dy
  }
}

val p: AbstractPoint = new ConcretePoint
p.x = 1
p.y = 4
p.move(2, 3)
p.move(1, -1)
p.format()

Csomagok

Már a közepes méretű szoftverek esetén is olyan sok osztály van, hogy azokat célszerű logikai egységekbe szervezni. A C++-ban ezeket a logikai egységeket névtereknek, a Java-ban csomagoknak (package) hívjuk. A Scala ez utóbbit örökölte. A csomagok hierarchikusan egymás ágyazhatók. A Java-ban kötelező úgy létrehozni a könyvtárstruktúrát, hogy az megegyezzen a csomagszerkezettel. Ez a megkötés a Scala-ban megszűnt, bár jó gyakorlatként (best practice) célszerű itt is alkalmazni.

A csomagot a package kulcsszóval tudjuk jelölni. Ha egy másik csomagban található osztályt (vagy bármi mást) szeretnénk használni, akkor a teljes csomag elérési útvonalat meg kell adnunk, tehát pl. package1.package2.MyClass. Ezt egyszerűsíthetjük is úgy, hogy az elején megadunk egy import utasítást, ahol jelezzük a fordítónak a pontos útvonalat (tehát pl. import package1.package2.MyClass); ez esetben elég az osztálynevet megadnunk (MyClass). Ha egy csomagból az összes osztályt meg szeretnénk jelölni, akkor az aláhúzást (_) használhatjuk: import package1.package2._. Ha két ugyanolyan nevű osztályt szeretnénk használni két csomagból, akkor célszerű mindkét esetben kiírni a használat helyén a teljes elérési útvonalat. Az ilyet egyébként, ha csak lehet, érdemes elkerülni.

Lássunk egy példát! Hozzunk létre egy könyvtárat package1 néven, azon belül pedig készítsünk egy másikat package2 néven. Ez utóbbiban készítsünk egy forrásfájlt Package2Class.scala néven az alábbi tartalommal:

package package1.package2

class Package2Class {}

Láthatjuk, hogy eső utasításként megadjuk a csomagnevet. Most hozzuk létre a package1 könyvtárban a következőt, Package1Class.scala néven:

package package1

import package1.package2.Package2Class

class Package1Class {
  val p2c = new Package2Class
}

Használjuk a Package2Class osztályt, amit előtte importálni kellett.

Hozzáférések

Szabályozhatjuk az attribútumok és metódusok hozzáférését. Emlékeztetőül: az objektumorientált világ három leggyakoribb hozzáférési szintje a publikus (public; minden osztály látja), védett (protected; a leszármazott osztályok látják) és a privát (private; osztályon kívül senki sem látja). Programozási nyelvtől függően lehet még egyéb hozzáférési szint is, de ezek a legfontosabbak. A Scalaban is ezek a hozzáférési szintek vannak (némi bonyolítással persze, amit hamarosan látunk), a tömörítés jegyben viszont alapból minden publikus. Így nincs is public kulcsszó. Egészen pontosan: az attribútum mindig privát, és a fordító generál egy publikus settert és gettert. Eléréskor ezeket a settereket és gettereket használja.

Főszabályként az attribútumokat érdemes priváttá tenni, a metódusok közül pedig csak az legyen publikus, amit az osztály interfészének tekintünk. Ilyen értelemben a bevezető példa nem volt jó, mert valójában nem szeretnénk, hogy az x és y koordinátákat közvetlenül elérje a kliens. Módosítsunk rajta!

class Point(private var x: Int, private var y: Int) {...}

Ezzel megakadályozzuk a mezők közvetlen elérését, tehát a p.x = 1 utasítás fordítási hibát eredményez.

A Scala lehetővé teszi a hozzáférést csomag (ld. később) vagy osztály szinten: megadhatjuk, hogy az osztály- vagy csomagstruktúra mely szintjétől legyen látható; szögletes zárójelben lehet ezt jelezni a hozzáférést módosító után. Lássunk egy példát!

import p1.p2.p3.C3

package p1 {
  package p2 {
    package p3 {

      class C3 {
        private[p2] val i = 2
      }

    }

    class C2 {
      def f() = {
        val c3 = new C3
        c3.i
      }
    }

  }

  class C1 {
    def f() = {
      val c3 = new C3
      c3.i // error
    }

  }
}

A három szintű csomagstruktúra legbelső csomagjában definiáltunk egy osztályt, azon belül egy attribútumot, és ez csak a középső csomagtól érhető el. Tehát a p1.p2.C2 osztályból elérhető, a p1.C1-ből nem.

Case osztályok

Az osztályok egy igen gyakori speciális esete az, amikor csak adatot tárolunk benne. Ez esetben a Java-ban létrehozzuk az osztályt, megadjuk az attribútumokat, mindegyikre megalkotjuk a getter és setter metódusokat, tipikusan két konstruktort készítünk: az egyik az összes paramétert tartalmazza, a másik üres, megírjuk az equals() metódust, akkor már a hashCode()-ot is, végül a toString()-et. Tehát két-három attribútum esetén annyi kód jön létre, hogy ki se fér egy képernyőre. Arról nem is beszélve, hogy mindezeket külön forrásfájlokba kell írni. Ezt a problémát kezeli a Scala az ún. case osztályok (case class) segítségével: a case kulcsszót kell a class elé írni. Leginkább a C nyevben megalkotott, majd a C++-ban megörökölt struct szerkezetre hasonlít. (Ez egyébként olyan osztályt generál, melyben a mezők nem megváltoztathatóak, így a fenti eset nem teljesen igaz: hiányoznak a setterek.)

Példányosításkor nincs szükség a new kulcsszóra sem:

case class Person(name: String, age: Int)
val person = Person("John", 35)

Az == a case osztályok esetén is érték szerint hasonlít. A case osztályok használhatóak mintaillesztésnél. A case osztályokhoz is adhatunk metódusokat.

Felmerülhet a kérdés, hogy mikor használjunk case osztályt, és mikor hagyományosat. Közöttük a határ nem éles. Néhány szabály:

  • A case osztályra struktúraként tekintsünk, melyben nem megváltoztatható adatokat tárolunk. Ha az osztályt mintaillesztésre is szeretnénk használni, akkor ezt kell választanunk.
  • Azokban az esetekben, ahol az osztálynak van megváltoztatható belső állapota, akkor a hagyományos osztályt kell választanunk.

Egykék

Az egyke (singleton) az egyik leggyakoribb programtervezési minta: azt biztosítja, hogy az osztálynak pontosan egy példánya legyen. Általános megvalósítása: létrehozzuk az osztályt, a konstruktort priváttá tesszük, létrehozunk egy példányt, amit elmentünk egy statikus változóban, és vagy közvetlenül elérhetőévé tesszük (ami nem szép, mert ehhez publikus elérésű attribútumot kell létrehoznunk), vagy egy publikus statikus metóduson keresztül adjuk vissza (melynek neve tipikusan getInstance()). Tehát rengeteg felesleges kód keletkezik. Ráadásul a kohézió elve is sérül, ugyanis egy ilyen osztály valójában legalább két dolgot csinál: egyrészt megvalósítja az egyke tervezési mintát, másrészt tartalmazza az üzleti logikát.

A Scala ezt is tömörítette: ha a class kulcsszó helyett ezt írjuk: object, akkor automatikusan létrehoz egy példányt, és az osztály (ill. objektum) nevén keresztül tudjuk elérni az attribútumokat és metódusokat. Ez tehát olyan, mintha a Java-ban egy osztály minden eleme statikus lenne. (Valójában ez utóbbi is tekinthető egykének, még ha nem is túl elegáns megoldás.) A Scala egyébként nem ismeri a static módosítót.

Egykére már láttunk példát, ugyanis a main eljárást tartalmazó osztály egyke. De lássunk egy másik példát:

object MySingleton {
  def salute() = println("Hello, world!")
}

MySingleton.salute()

Trait-ek

A trait Scala specifikus struktúra, ilyen a Java-ban nincs. Leginkább az interfészekhez vagy absztrakt osztályokhoz hasonlít. Több mint interfész, mert a metódosoknak lehetnek megvalósításaik (emlékeztetőül: a Java modern verzióiban is lehetnek, de kezdetben ez ott nem volt lehetséges), és tartalmazhatnak attribútumokat is.

Valójában nem is úgy hívjuk, hogy az osztály megvalósítja a trait-et (lehet, hogy nincs is rajta mit megvalósítani), gyakoribb kifejezés az, hogy belekeveri, belemixeli (mix in) az osztályba a trait-et. Tehát egy korábbi állítást újrafogalmazva: egy osztály tetszőleges számú trait-et keverhet magába. A trait-eket a with kulcsszóval lehet mixelni, de ja nincs öröklődés, akkor az elsőnek ebben az esetben is extends-nek kell lennie. Ez viszont az úgy gyémánt (diamond) problémához vezet, ami a következő: ha van egy A "őstrait" (trait A), amit bekever a B (B extends A) és a C (C extends A), a D osztály pedig bekeveri a B-t és a C-t (class D extends C with B), akkor az A-ban definiált, majd a B-ben és C-ben is felülírt elem esetén nem nyilvánvaló, hogy az melyikre vonatkozik. Ennek megoldása a Scala esetén a linearizáció (linearisation): először a közvetlenül kiterjesztett osztály teljes osztályhierarchiáját írjuk le (jelen esetben ez az B → A → AnyRef → Any), majd elé írjuk az első with bekeverést, egészen addig, a pontig, ami már benne van a hierarchiában, azt nem kell (ez a példában a C lesz, azaz nem kell a C → A, mert az A már benne van, így a részeredmény erre nőtt: C → B → A → AnyRef → Any), menjünk sorban a bekeveréseken (a példában nincs több ilyen), végül magát a fő traitet írjuk az egész mögé (így a végeredmény a következő lesz: D → C → B → A → AnyRef → Any). Ami az így keletkezett listában az első találat, az lesz a megoldás (tehát ha az A traitben van egy f() függvény, amit a B és a C is felüldefiniál, példányosítjuk D-t, ami nem definiálja felül, és azon meghívjuk az f() függvényt, akkor ténylegesen a C trait függvénye lesz meghívva).

Lássunk egy példát, amelyben két trait található:

trait Introduce {
  def introduce()
}

trait Expand {
  def duplicate()
  def multipleByFour() = {
    duplicate()
    duplicate()
  }
}

class Circle(var r: Int) extends Introduce with Expand {
  def introduce() = println("Hello, I am a circle with radius " + r + ".")
  def duplicate() = r *= 2
}

val c = new Circle(5)
c.multipleByFour()
c.introduce()

Az Expand trait két metódust definiál, az egyiknek olyan megvalósítása van, amely használja a másik absztrakt eljárást. Ha a Circle a Point-ból öröklődne, akkor a deklarálás (a részleteket mellőzve) így nézne ki: class Circle extends Point with Introduce with Expand.

Típuskonverzió

Típuskonverzió lehetséges a Scala-ban, bár viszonylag ritka. Az isInstanceOf[T] segítségével tudjuk lekérdezni, hogy az objektum adott típusú-e, az asInstanceOf[T] pedig végrehajtja a típuskonverziót. Lássunk erre is egy példát!

class BaseCircle(var r: Int) {
  def introduce() = println("Hello, I am a base circle with radius " + r + ".")
}

class FancyCircle(r: Int, var color: String) extends BaseCircle(r) {
  def introduceFancy() = println("Hello, I am a fancy circle with radius " + r + " and color " + color + ".")
}

val c: BaseCircle = new FancyCircle(5, "red")
c.isInstanceOf[FancyCircle] // true
// c.introduceFancy() // error
c.asInstanceOf[FancyCircle].introduceFancy()

Generikus típusok

A modern programozási nyelvek jelentős részében lehetőség van generikus osztályok, függvények létrehozására. Ez azt jelenti, hogy nem adunk meg minden típust, hanem egy vagy több általános típus lesz, és ezt példányosításkor, tehát kliens oldalon határozzuk csak meg. A kód ugyanaz minden típus esetén. A C++-ban ezeket sablonoknak (template) hívjuk, a Java-ban generikus típusoknak. A Scala ez utóbbit vette át.

A Scala-ban a generikus típust szögletes zárójelben adjuk meg. A neve elvileg bármi lehet, konvencionálisan az ábécé első nagybetűit szoktuk használni. Igazi jelentősége a gyűjtemények esetén van. Most lássunk egy kissé erőltetett, de egyszerű példát:

class GenericClass[A] {
  def salute(x: A) = println("Hello, " + x + "!")
}

new GenericClass[String].salute("Csaba")
new GenericClass[Int].salute(42)

Megadhatjuk a generikus típusok korlátait. Felső és alsó korlátot tudunk adni a >: és <: operátorok segítségével. Lássunk egy absztrakt példát, ahol alsó és felső korlátot is szabunk:

class Thing
class Vehicle extends Thing
class Bicycle extends Vehicle
class BMX extends Bicycle

class GenericClass[A >: Bicycle <: Vehicle]

new GenericClass[Thing] // wrong
new GenericClass[Vehicle] // OK
new GenericClass[Bicycle] // OK
new GenericClass[BMX] // wrong

Fontos még megismerünk a generikus típusok varianciáit: ez azt jelenti, hogy a létre jövő típus merre kompatibilis. Háromféle lehetőség van:

  • Invariáns: pontos megfelelés kell, tehát pl. egy GenericClass[Bicycle] nem kaphat értékül sem GenericClass[Vehicle]-t, sem GenericClass[BMX]-et. Ez az alapértelmezett, és fent erről láttunk példát.
  • Kovariáns: leszármazottat kaphat értékül (pl. a GenericClass[Bicycle] típusú változó értékül kaphat egy GenericClass[BMX]-et), de őst nem. Ezt + jellel jelöljük a generikus típus előtt.
  • Kontravariáns: őst kaphat értékül (pl. a GenericClass[Bicycle] típusú változó értékül kaphat egy GenericClass[Vehicle]-t), de leszármazottat nem. Ezt - jellel jelöljük a generikus típus előtt.

Lássunk egy példát minden lehetőségre:

class Thing
class Vehicle extends Thing
class Bicycle extends Vehicle
class BMX extends Bicycle

class InvariantClass[A]
class CovariantClass[+A]
class ContravariantClass[-A]

val ic: InvariantClass[Bicycle] = new InvariantClass[Vehicle] // wrong
val ic: InvariantClass[Bicycle] = new InvariantClass[Bicycle] // OK
val ic: InvariantClass[Bicycle] = new InvariantClass[BMX] // wrong
val cvc: CovariantClass[Bicycle] = new CovariantClass[Vehicle] // wrong
val cvc: CovariantClass[Bicycle] = new CovariantClass[Bicycle] // OK
val cvc: CovariantClass[Bicycle] = new CovariantClass[BMX] // OK
val cnc: ContravariantClass[Bicycle] = new ContravariantClass[Vehicle] // OK
val cnc: ContravariantClass[Bicycle] = new ContravariantClass[Bicycle] // OK
val cnc: ContravariantClass[Bicycle] = new ContravariantClass[BMX] // wrong

Összefoglalva: a generikus típusokat nemcsak újragondolták, hanem - elnézést a kemény kifejezésért - eléggé túltolták. Azt gondolom, hogy ha egy programban túl sok a generikus típus, akár több generikus paraméterrel, azok öröklődésével, ráadásul alsó és/vagy felső határral, invarianciával, kovarianciával, kontravarianciával egyaránt, az valósággal olvashatatlanná teszi a kódot. Az a személyes véleményem, hogy a generikus típusokat hagyjuk meg kizárólag a gyűjtemény osztályoknak, és ott is - bár nincs ráhatásunk - célszerű csak az alapot használni. Soha ne feledjük: a program, amit megírunk, jó eséllyel másnak kell majd karbantartania, és ha olyan kódot írunk, amit nehéz megérteni, akkor az nagyon sokba fog kerülni hosszú távon.

A Scala osztályhierarchia

A Scala osztályhierarchia első ránézésre meglepő elemeket tartalmaz:

scala-classhierarchy.png
  • Minden típus őse az Any. Ez definiálja a mindenhol értelmezhető műveleteket: equals(), hashCode(), toString().
  • Az Any-nek két közvetlen leszármazottja van: az AnyVal, ami az érték típusok őse (érték típusok azok, amelyek más programozási nyelvekben tipikusan primitív típusok, pl. Int vagy Float; a Scala-ban nincsenek primitív típusok), az AnyRef pedig azoké, amelyek referenciák. Ez utóbbi többé-kevésbé megfelel a Java-ban az Object-nek.
  • Az érték típusok között a Unit nagyjűból a Java-ban a void-nak felel meg.
  • Mindegyik referencia típusból származik a Null, ami a többi programozási nyelvben szereplő null értékez hasonlítható.
  • A Scala-ban nemcsak ősosztály van, hanem olyan is, amely minden típusból származik, legyen az érték vagy referencia típusú, ez a Nothing.

Amit mi magunk készítünk osztály az szinte biztos, hogy közvetlenül az AnyRef osztályból származik, és közvetlenül a miénkből fog származni a Null.

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License