A Scala programozási nyelv

Traits

A Scala trait

A trait egy olyan eszköz, mintha kombinálnánk a Java interface-eit és a Ruby mixin-jeit. A trait egyébként angolul jelleget, jellemző vonást jelent, azonban a magyar nyelvű szakirodalomban még nem igazán jelent meg a magyar megfelelője.

Scalában a trait definícióját egy osztály definícióhoz lehetne hasonlítani azzal a különbséggel, hogy nem a class, hanem a trait kulcsszóval vezetjük be.

trait Book { def title:String def title_=(n:String):Unit def computePrice = title.length * 10 }

A fenti példában szereplő trait nem definiál őst, amelyből származik, így az osztályokhoz hasonlóan ennek is az alapértelmezett AnyRef lesz az ősosztálya. Egy trait rendelkezhet absztrakt és konkrét metódusokkal, valamint adattagokkal is. Egy osztályt és egy vagy több traitet felhasználva előállíthatunk ún. mixineket, amely lehetővé teszi, hogy tetszőleges mértékben bővítsük egy adott osztály funkcionalitását. Ezt Scalában az extends vagy a with kulcsszavakkal tehetjük meg:

class HelloWorld extends ExampleTrait { // implementation... }

Ha az extends kulcsszóval származtatunk egy traitből, akkor implicit módon az osztály is a trait őséből fog származni, amely ős jelen esetben az AnyRef. A fenti osztály példányain most már meghívhatóak a traitből örökölt metódusok. A trait emellett típust is definiál, így ha egy változó deklarációban a típus egy trait, akkor a változó bármely olyan osztály példányával inicializálható, amelyből mixint állítottunk elő a traitet felhasználva.

Egy gyakoribb felhasználása a traiteknek az, amikor az osztály egy ősből származik és emellett egy vagy több traittel együtt alkot egy mixint. Ebben az esetben az adott osztály neve után fel kell tüntetnünk az ősosztályt (ami akár egy trait is lehet) az extends kulcsszó után, majd tetszőleges számú traitet a with kulcsszóval bevezetve

class ClassA extends TraitX with TraitY with TraitZ { // implementation... }

Egy Scala trait nagyon hasonlóan viselkedik egy osztályhoz, két különbségtől eltekintve. Egy osztálynak lehetnek paraméterei, amelyek az elsődleges konstruktornak adódnának át, ugyanakkor a traitek nem rendelkezhetnek ilyen paraméterekkel. A második különbség, hogy amíg az osztályok esetében a super hívások statikusak, addig a traitek esetében ez dinamikus.

Traitek használati esetei

A traitek egyik előnye abban rejlik, hogy egy viszonylag egyszerű, kevés funkcionalitással rendelkező interfészt könnyen kiegészíthetünk extra funkcionalitással, előállítva így egy eszközökben gazdagabb felületet. Erre egy jó példa a rendezés definiálása objektumok között.

Ha két objektum között értelmezett a rendezés, akkor el kell tudnunk dönteni igaz-e az, hogy az egyik objektum kisebb, kisebb vagy egyenlő, egyenlő, nagyobb vagy egyenlő, avagy nagyobb, mint a másik objektum. Ehhez definiálnunk kell az adott osztályhoz a <, <=, ==, >=, > operátorokat. Itt döntenünk kell, hogy melyik utat választjuk az alábbi kettő közül.

Mivel a < és az == operátorok segítségével kifejezhető az előbbi műveletek mindegyike, ezért megtehetjük, hogy csak ezt a két operátort definiáljuk, létrehozva ezzel egy ún. vékony interfészt. Ennek hátránya, hogy a klienseknek kell majd a többi operátort előállítaniuk, például az a > b || a == b szerkezetet alkalmazva az a >= b hiányában.

A másik választási lehetőségünk, hogy minden operátort definiálunk, viszont ez a gazdagabb interfész extra munkát és ismétlődő, boilerplate kódot is eredményez minden egyes osztályban, ahol a rendezés értelmezett.

Erre a problémára kínál megoldást Scalában az Ordered[T] trait, amely definiálja a <, <=, >=, > operátorokat, cserébe nekünk csak egyetlen metódust kell implementálnunk az adott osztályban. Ez a compare egyetlen paraméteres metódus, ami egy olyan int értékkel kell visszatérjen, amely negatív, nulla vagy pozitív, annak megfelelően, hogy az aktuális objektum kisebb, egyenlő vagy nagyobb, mint a paraméterként kapott objektum. Ha implementáltuk a compare metódust, akkor már csak egy mixint kell létrehoznunk az osztályból és az Ordered[T] traitből. Ezzel a megoldással nemcsak a boilerplate kódtól szabadulunk meg az egyes osztályokban, hanem a kliensek számára is operátorokban gazdag interfészt biztosítunk.

Az interfészek egyszerű kibővítése mellett a traitek rugalmas és erőteljes eszközt biztosítanak különböző funkcionalitások egymásra építésében is. Amint az már említésre került, egy traitben a super hívások dinamikusan kötnek, és ez lehetővé teszi, hogy akár egy absztrakt, még nem implementált metódus viselkedését változtassuk meg. Egy ilyen super hívást tartalmazó trait természetesen csak egy olyan osztállyal együtt alkothat mixint, amely implementálja az adott absztrakt metódust. Mivel egy osztály több traittel együtt is alkothat egy mixint, így az egyes traitek által definiált módosítások tetszőleges kombinációban alkalmazhatóak az osztály adott metódusára.

Vegyünk példának egy olyan absztrakt osztályt, ami egy egész számokból álló verem működését írja le, és mindössze az ehhez minimálisan szükséges push(x: Int) és pop() metódusokkal rendelkezik. Nevezzük ezt az absztrakt osztályt Stack-nek, az őt implementáló konkrét osztályt pedig StackImpl-nek. Ezek után definiáljunk két traitet, melyeket nevezzünk Decrementing-nek és Doubling-nak. Ezek a traitek felüldefiniálják egy push(x: Int) metódus viselkedését úgy, hogy azok rendre az x-1 és a 2*x értékeket rakják a verembe. Ekkor ezek a viselkedések tetszőleges kombinációban egymásra építhetőek:

class StackImpl extends Stack with Doubling with Decrementing { // implementation... }

A fenti osztály példányai olyan speciális verem objektumok, amelyek a verembe helyezett elemeken egy transzformációt végeznek, így az értékek dekrementáltjának dupláját helyezik a verembe. Ennek megfelelően a

push(1) push(-11) push(0)

utasítások eredményeként a verembe rendre a

0 24 -2

értékek kerülnek. A traitek sorrendje fontos, ugyanis ha fordított sorrendben írjuk fel őket, akkor az értékek duplájának dekrementáltját helyezzük a verembe. Ez az előző metódus lánc esetén az

1 -23 -1

értékeket jelenti. Ennek oka, hogy a super hívások dinamikus kötésének feloldása a legjobboldalibb traittől kezdődik. Ennek megfelelően az első esetben először a Decrementing trait módosítja az x paramétert x-1-re, majd ezzel hívja meg a super-t, ami a Doubling push(x: Int) metódusát jelenti. Ez a metódus is módosítja a paramétert 2*x-re, majd super-t hív, ami a StackImpl osztály konkrét metódusára vonatkozik.

Scala trait vs. Java interface

A Scala és a Java közötti nagyfokú kompatibilitás miatt a Scala traitet sok helyen olyan kibővített Java interface-ként definiálják, amely tartalmazhat adattagokat és konkrét metódusokat is. Viszont ez így elég pontatlan, még akkor is, ha a Java 8-ban megjelent default metódusok miatt már "csak" az adattagokban különbözik a két eszköz.

Az adattagok lényeges különbséget jelentenek, hiszen az adattagok miatt egy traitnek lehet állapota, ugyanakkor egy interface egyáltalán nem rendelkezik állapottal és ez nem is célja. Az egyetlen hasonlóság a két eszköz között, hogy mindkettő a többszörös öröklődéshez hasonló viselkedést valósít meg a diamond probléma elkerülése mellett.

Javában az osztályok között csak egyszeres öröklődés engedélyezett, de annak érdekében, hogy a típusrendszer ne legyen túl merev, interface-eket is használhatunk, amelyek között már engedélyezett a többszörös öröklődés. Scalában is csak egyszeres öröklődés engedélyezett az osztályok között, viszont rendelkezésünkre állnak a traitek, amelyek kb. fél úton helyezkednek el a Java interface-ei és a többszörös öröklődés között.

Többszörös öröklődés, linearizáció

A többszörös öröklődésről a legtöbb programozónak a diamond vagy rombusz probléma jut eszébe és az, hogy ettől óvakodni kell. A probléma röviden összefoglalva az, hogy bizonyos osztályhierarchia esetén a hierarchia alján található osztályban nem dönthető el egyértelműen, hogy melyik osztálytól is öröklünk egy adott viselkedést. Az egyes nyelvek különböző módszereket választanak a probléma feloldására. A C++ ezt a programozóra bízza, akinek explicit módon meg kell mondania, hogy melyik őstől származó implementációt szeretné használni az adott alosztályban.

Scalában is tiltott az osztályok közötti többszörös öröklődés, de a mixinek segítségével előállítható egy nagyon hasonló, viszont hatékonyabb viselkedés. A lényeg a super hívások kezelésében rejlik. Többszörös öröklődés esetén, ha egy super hívást látunk, azonnal meghatározható, hogy mely osztály metódusa hívódik meg az adott helyen. A traitek esetében viszont a super hívás célpontja az ún. linearizáció segítségével határozódik meg, így ez függ azon osztályoktól és traitektől, amelyek együtt alkotják a mixint.

Amikor egy osztályt példányosítunk, a Scala egy lineáris sorrendet épít fel az adott osztály, az esetleges szülő osztálya és minden a definícióban szereplő traitből.

class Foo extends Bar with A with B with C { // implementation... }

A linearizáció eredménye a fenti osztály esetében a következő:

Foo -> C -> B -> A -> Bar -> AnyRef -> Any

A linearizációs sorrendben szereplő osztályok és traitek bármelyikében egy super hívás esetén a tőle jobbra található osztály/trait megfelelő metódusa hívódik meg. Anélkül, hogy ismertetnénk a linearizációs folyamat minden részletét, láthatjuk, hogy a super hívások iránya mindig az osztálydefinícióban a legjobboldalibb traittől halad balra. Ez a folyamat implicit módon történik, így megelőzve a diamond probléma kialakulását.