Scala — pierwsze kroki cz.5

Język Scala oprócz tego, że jest językiem o funkcyjnym paradygmacie programowania, to również jest w pełni obiektowa. Istnienie metod i klas statycznych w Javie powoduje, że puryści językowi twierdzą, że nie jest ona w prawdziwym obiektowym językiem programowania. Scala rozwiązała ten problem wprowadzając obiekty, które są czymś zbliżonym do wzorca singletonu. Każda klasa może posiadać odpowiadający jej obiekt o identycznej nazwie co klasa, posiadający tylko jedną wersję powstającą przy uruchamianiu programu. Innym problemem, który udało się rozwiązać jest dziedziczenie wielobazowe, dostępne w Javie tylko w szczątkowej formie interfejsów. W Scali mamy Traits czyli instancje podobne do klas, ale różniące się tym od nich, że nie posiadają konstruktora z parametrem. Klasa może dziedziczyć po jednej zwykłej klasie i równocześnie po wielu Traits. Definiowanie klas ma również pewne drobne ułatwienia jak chociażby fakt, że ciało klasy jest równocześnie jego konstruktorem.

Klasy

Przykładowa definicja klasy:


 class Licznik {
      println("Konstruktor")
      private var suma = 0
      def +=(x: Int) = suma += x 
      def -=(x: Int) = suma -= x
      def ? = suma.toString
   }

Kod napisany w nawiasach klamrowych, jeśli nie jest definicją metody, należy do konstruktora, co możemy sprawdzić tworząc instancję klasy:

val l = new Licznik

Jak widać konstrukcja instancji wymaga słowa kluczowego new, a nawiasy po nazwie klasy są opcjonalne. Słowo Konstruktor zostanie wydrukowane na standardowe wyjście zaraz po utworzeniu instancji klasy Licznik.
Teraz możemy wywoływać metody klasy:


 l.+=(234)
l.?
l -= 12
I ?

Jak widać możemy wywoływać metody klasycznie z kropkami i nawiasami lub bez nich oddzielając obiekt, nazwę metody i parametr spacją. (Dla metod z kilkoma parametrami muszą być nawiasy). W przypadku metody oznaczonej znakiem zapytania nie można wywołać jej z nawiasami, ponieważ nie zostały one użyte w czasie jej definicji. Definicje klas zostały tutaj napisane w skróconej formie, której używać możemy tylko gdy definicja metody ma tylko jedną linijkę. W przeciwnym przypadku niezbędne są jeszcze nawiasy klamrowe. Podobnie jak w Javie mamy słowa kluczowe private i protected, zaś wszystko co nie jest oznaczone jednym z tych słów uznane jest za publiczne. Aby dodać dodatkowy konstruktor piszemy definicję nowej metody klasy o nazwie this.

Dziedziczenie

class LicznikNazwany(nazwa:String) extends Licznik {
      override def ? = nazwa + ": " + super.?.toString
     }

Tak jak w Javie możemy dziedziczyć tylko po jednej klasie. Robimy to używając słowa kluczowego extends. Tym razem nasz konstruktor przyjmuje parametr, więc musimy utworzyć instancję tej klasy dodając jej nazwę:

val ln = new LicznikNazwany("Licznik1")

Próbujemy jak działa klasa:

ln += 34
ln ?

Po przetestowaniu kodu zobaczymy, że różnica w porównaniu do klasy bazowej polega na dodawaniu do każdego wydruku nazwy licznika. Zauważmy, że w klasie dziecka przy nadpisywaniu metody ? użyto słowa kluczowego override. Jest ono obowiązkowe w takiej sytuacji. Słowo super pozwala upewnić się, że dana metoda jest wywoływana w klasie rodzica.

Obiekty

Obiekt jest czymś podobnym do singletonu. Ma identyczną nazwę z klasą i musi być zdefiniowany w tym samym pliku co dana klasa. Ma on dostęp do wszystkich składowych pól prywatnych klasy. Sam jest dostępny tak jak w Javie obiekt statyczny.

Utwórzmy obiekt towarzyszący naszej klasie:

object LicznikNazwany {
var licznik = 0;
def apply() = {
  licznik += 1
  new LicznikNazwany("Licznik: " + licznik.toString) 
}
}

Możemy dopisać na początku deklaracji klasy LicznikNazwany słowo private, przez co nie da się utworzyć samodzielnie instancji tej klasy. Instancje tej klasy będziemy otrzymywać z naszego stowarzyszonego obiektu, dzięki metodzie apply. Metoda ta jest sposobem na przeładowanie operatora (). Dzięki temu możemy utworzyć instancje klasy LicznikNazwany jako:

val licz = LicznikNazwany() 

Nie jest to wywołanie konstruktora, tylko wywołanie na obiekcie LicznikNazwany metody apply. W taki sposób, jak niektórzy zapewne już zauważyli, utworzyliśmy prostą wersję wzorca projektowego fabryka.

Traits

Są one podobne do klas. Jednak nie mogą posiadać konstruktora. Możemy dziedziczyć po wielu traits bez ograniczeń (używając wiele razy słowa with). Przykładowe wykorzystanie:

trait Filozof {
        def filozofia() { println("Używam RAM, więc jestem") }
 }
abstract class Zwierze {
      def glos() 
 }
class Pies extends Zwierze with Filozof {
     def glos() {println("Hau hau!")}
}
val pies = new Pies
pies.glos
pies.filozofia

Traits dają nieco większe możliwości niż interfejsy w Javie, ponieważ mogą dostarczać już gotowy kod, tak jak w tym przypadku Filozof dostarcza metodę filozofia. Interfejsy Javy są w Scali traktowane jako traits z abstrakcyjnymi definicjami metod.
W definicji metody glos() klasy Pies nie musimy używać słowa override, ponieważ definicja tej metody w klasie rodzica jest abstrakcyjna. Abstrakcyjna klasa może mieć również nieabstrakcyjne definicje metod.

Możliwe jest dodawanie traits nie w definicji klasy, tylko przy tworzeniu instancji klasy:

class Kot extends Zwierze {
     def glos() {println("Miau!")}
 }
val kot = new Kot
kot.glos
val kotFilozof = new Kot with Filozof
kotFilozof.filozofia

Zatem możemy wytworzyć obiekty o różnym zachowaniu. Daje to dużo możliwości, ale warto jednak stosować umiar.

Przykład

Kod do tej pory przedstawiony jest bardzo prosty i nie oddaje w jaki sposób należy korzystać z obiektowości w języku funkcyjnym. Znacznie lepiej oddaje to przykład kodu jaki możemy znaleźć w "Programming in Scala", której współautorem jest twórca Scali Odersky:

class Ulamek(licz: Int, mian: Int) {
  require( mian != 0)
  private val dziel = NWD(licz.abs, mian.abs)
  val li = licz / dziel
  val mi = mian / dziel
  
  def this(licz: Int) = this(licz, 1)
  
  def +(ulam: Ulamek): Ulamek = 
    new Ulamek(
        li * ulam.mi + ulam.li * li,
        mi * ulam.mi
    )
    
  def +(i: Int): Ulamek = 
    new Ulamek(licz + i * mi, mi)
    
  def -(ulam: Ulamek): Ulamek = 
    new Ulamek(
        li * ulam.mi - ulam.li * li,
        mi * ulam.mi
    )
    
   def -(i: Int): Ulamek = 
    new Ulamek(licz - i * mi, mi)
  
   def *(ulam: Ulamek): Ulamek = 
    new Ulamek(li * ulam.li, mi * ulam.mi)
    
   def *(i: Int): Ulamek = 
    new Ulamek(li * i, mi)
    
   def /(ulam: Ulamek): Ulamek = 
    new Ulamek(li * ulam.mi, mi * ulam.li)
    
   def /(i: Int): Ulamek = 
    new Ulamek(li, mi * i)  
   
   override def toString = li +"/"+ mi
   
   def NWD(a: Int, b: Int): Int = 
    if(b == 0) a else NWD(b, a % b)
    
}

Kod ten realizuje ułamek zwykły (może być niewłaściwy) o całkowitym liczniku i mianowniku. Typowym zachowaniem dla stylu funkcyjnego jest brak metod w klasie, które mutowałyby samą siebie. Pola klasy są niezmienne (oznaczone jako val). Zdefiniowane są jako publiczne, ale dzięki niemutowalności nie ma potrzeby pisania geterów i seterów. Każda operacja na ułamkach powoduje powstanie nowej wartości. Taki kod dobrze się również sprawdza w przypadku programowania wielowątkowego, nie trzeba się martwić o synchronizację, przekazujemy między wątkami gotową instancję klasy.
Polecenie require sprawdza warunek rzucając wyjątkiem w razie zerowego mianownika. Innym ciekawym udogodnieniem z jakim mamy do czynienia jest tail recursion (rekurancja ogonkowa) i zdolność kompilatora do zamiany jej na iterację. Metoda NWD obliczająca największy wspólny dzielnik jest napisana jako rekurencja, ale tak, że funkcja po zakończeniu nie musi wracać do poprzedniego wywołania w celu obliczenia wartości. Taką rekurencję kompilator Scali automatycznie zamienia w kodzie bajtowym na zwykłą iterację, dzięki czemu jest ona wydajna i nie zajmuje dużo miejsca w pamięci.
Korzystanie z klasy Ulamek może wyglądać następująco:

val x = new Ulamek(2, 3)
val y = x * x + 1
val z = y / 4

Możemy dodać do niego liczbę typu Int, jednak problemem może być fakt, że nie możemy wykonać np. operacji:

 val u = 2 + x 

Problem ten można rozwiązać używając implicit conversion:

implicit def intNaUlam(x: Int) = new Ulamek(x)

Jeśli spróbujemy teraz dodać do liczby typu Int ułamek, to kompilator zauważy, że nie ma takiej metody w Int, która brałaby jako argument typ Ulamek. Poszuka więc czy w zasięgu jest zdefiniowana metoda implicit, zamieniająca typ Int na Ulamek. Jeśli tak to wykona tę operację. Jest to bardzo wygodne narzędzie do tworzenia wszelkich DSLi, jednak trzeba uważać, żeby nie nadużywać go, ponieważ osobie korzystającej z naszego kodu może nieźle namieszać.

Podumowanie

Tak jak wspominałem jest to ostatni z zaplanowanych przeze mnie odcinków kursu. Oczywiście nie wyczerpuje to w żadnym stopniu tematu, a jedynie jest lekkim muśnięciem, pokazującym możliwości języka i jego cechy. Mam nadzieję, że przybliżył nieco tematykę osobom, które nie miały jeszcze z nim styczności, a może nawet zachęci niektórych z was do dalszego pogłębiania wiedzy. (Do czego jest mnóstwo książek w tym darmowa pierwsza edycja Programming in Scala. Są dwie po polsku. Można też znaleźć również mnóstwo kursów i tutoriali.) Nie jest to prosta droga bo faktycznie składnia łącząca programowanie funkcyjne i obiektowe wymaga nieco więcej wysiłku niż większość innych języków programowania. Fakt ten został już zauważony przez twórców Scali, wiele dużych firm używających Scali również zwraca na to uwagę. Dlatego jednym z celów zespołu pracującego nad następnymi wersjami ma być właśnie przyjrzenie się składni języka i odrzucenie rzeczy zbędnych, ale w taki sposób, aby nie stracić na ekspresyjności. Zmiany te jednak pojawią się dopiero za jakiś czas, ponieważ w tej chwili trwają prace nad wykorzystaniem nowych cech funkcyjnych JVM w wersji 8, pozwalających uprościć kompilację kodu i przyspieszyć niektóre z bibliotek Scali.
Wydaje mi się, że język ten nie zdobędzie nigdy takiej popularności jak inne języki głównego nurtu. Barierą jest brak wystarczającej ilości programistów, którzy opanowali język i jego narzędzia na odpowiednim poziomie. Można jednak zauważyć, że są dziedziny, gdzie zdobywa on silną pozycję. Jest to głównie Big Data i podobne dziedziny, gdzie przetwarza się dużo danych w wielu wątkach, wykorzystując aktorów. Nie oznacza to, że tylko do tego się nadaje, Osoba (czego jestem sam przykładem), która zainwestuje swój czas w poznanie Scali, doceni szybkość jaką daje pisanie w tym języku aplikacji webowych po stronie serwera w takich frameworkach jak Lift, Spray.io czy Play.