Podstawy składni Scali

Podstawowa składnia Scali jest analogiczna do innych języków obiektowych. Ten krótki wstęp prezentuje najważniejsze elementy, które będą niezbędne do dalszej nauki. W trakcie warsztatów mogą przydać się poniższe ściągi.

Scalacheat - ściąga z najważniejszymi elementami składni Scali.

Java/Scala cheat sheet - ściąga pokazująca, jak przepisać Javę na Scalę.

Definiowanie stałych, zmiennych, klas oraz metod

Jednymi z podstawowych elementów Scali są:

Za pomocą słówka kluczowego class definiujemy klasy. W Scali klasa nie ma opisanego konstruktora w postaci osobnej metody. Zamiast tego sygnaturę konstruktora definiujemy zaraz po nazwie klasy, a implementacją konstruktora jest całe ciało klasy.

class Foo(arg: Int) {
  val a: Int = arg
  lazy val b: Int = 2
  var c: Int = 3
  def d: Int = 4
  
  println("Print from constructor")
}

Inferencja typów i type ascription

Nie ma potrzeby definiowania typów zmiennych i stałych czy też typów zwracanych przez metody - może to zostać wyinferowane przez kompilator. Możliwe jest również podanie explicite, jakiego typu jest dane wyrażenie (type ascription), pod warunkiem, że typy są zgodne.

class Foo {
  val a = 1
  val b: Long = 1
  val c = 1: Long
}

Klasy

class Foo(var x: Int, val y: String, z: Double) extends Bar(x, y) {
  assert(x > 0, "positive please")
  var a: String = y * x
  val readonly: Int = 5
  private var secret = 1
  
  def this(x: Int, y: String, zStr: String) = this(x, y, zStr.toDouble)
}

Powyższy przykład przedstawia klasę Foo, która:

Podstawowe specyfikatory dostępu w Scali to: public (domyślny), protected i private. Dodatkowo w przypadku specyfikatorówprotected oraz private możliwe jest podanie dodatkowej informacji, w jakim zasięgu (np. do którego pakietu) ograniczony jest dostęp do danej składowej (protected[x]/private[x]).

Blok kodu

Blok kodu to kilka wyrażeń otoczonych nawiasami klamrowymi {}. Wartością bloku jest wartość ostatniego wyrażenia (nie powinno się używać słowa kluczowego return).

Ćwiczenie

Uzupełnij poniższy kod tak, by asercje były prawidłowe.

Uwaga 1: ??? może zastąpić dowolne wyrażenie w kodzie Scali - pozwala na skompilowanie kodu, ale w momencie wykonania rzuca wyjątek. W tym ćwiczeniu należy zastąpić ??? odpowiednimi wartościami.

Uwaga 2: Metoda assertEq to zdefiniowana przez nas metoda przyjmująca dwa argumenty tego samego typu, sprawdzająca, czy są one takie same i wyświetlająca odpowiednią informację.

Uwaga 3: ArrayBuffer to jedna z kolekcji dostępnych w Scali, o których będziemy mówić później. Na potrzeby tego ćwiczenia można postrzegać go jako mutowalną kolekcję, do której są dodawane kolejne elementy w trakcie działania kodu za pomocą operatora +=. Aby porównać wartości tych tablic wpisz w miejsce ??? kolejne wartości, np.: ArrayBuffer(1,2,3,4,5)

Podpowiedź 1: >Zwróć uwagę na to, które kawałki kodu wykonają się od razu po utworzeniu instacji klasy.<

Podpowiedź 2: >Różne fragmenty kodu mogą wykonywać się tylko raz lub przy każdym odwołaniu.<

Rozwiązanie: https://scalafiddle.io/sf/2aUnjvX/0

Hierarchia typów w Scali

Hierarchia typów w Scali - obrazek z oficjalnej dokumentacji.

Wszystkie standardowo definiowane przez programistę klasy znajdą się w hierarchii typów pod AnyRef. Typ Unit ma tylko jedną wartość () - jest przydatny do definiowania metod, które nie mają zwracać żadnej wartości (analogicznie do void w Javie). W metodzie zwracającej Unit nie trzeba dopisywać () na końcu, kompilator robi to automatycznie.

Kontrola przepływu

Uwaga: większość konstrukcji Scali to wyrażenia, które zwracają konkretną wartość, a nie tylko są imperatywną częścią języka.

Pętle while/do-while

W przypadku pętli zaprezentowanych powyżej typem wyrażenia while czy do-while jest Unit.

var x = 0

val t: Unit = ()
val u: Unit = while (x < 5) { x += 1 }

Wyrażenia if/else

Podobnie jak w powyższym przypadku, Scalowy if/else może być traktowany jako konstrukcja czysto imperatywna. Jednak taka struktura jest również wyrażeniem, którego wartością będzie wartość ostatniego wyrażenia w tym bloku, który faktycznie się wykona (można tu zauważyć analogię do Javowego operatora b ? x : y).

Jeśli dany if nie jest zakończony konstrukcją else, to jest ona uzupełniana przez kompilator jako else (). Typ całego wyrażenia if jest inferowany jako najbardziej wyspecjalizowany typ wspólny dla wartości, jakie mogą zostać zwrócone, zatem w takim przypadku będzie to typ Any lub AnyVal (lub Unit).

val a = 5

val any: Any = if (a < 5) "asd" //else ()
val anyVal: AnyVal = if (a < 5) 0 //else ()

“Pętle” for

W Scali nie ma standardowej pętli for. Istnieje natomiast bardziej ogólna konstrukcja zwana for comprehension, która m.in. może spełniać podobną funkcję jak Javowa pętla foreach. Mechanizm ten ma jednak dużo większe możliwości, o których powiemy później.

Słowo kluczowe object

Słowo kluczowe object pozwala w Scali na tworzenie singletonów - jedynych instancji ich własnych klas, do których można odwoływać się po nazwie.

Szczególnym przypadkiem jest object o nazwie takiej samej jak klasa. Mówimy wówczas o nich companion object i companion class. Mają one wzajemnie dostęp do swoich składowych prywatnych. Companion object można traktować jako analogię do statycznych składowych klas w Javie, a zatem wykorzystywać do definiowania metod pomocniczych (w tym tzw. metod wytwórczych - ang. factory method) czy stałych niezwiązanych z konkretnymi instancjami klasy.

class Foo(a: Int, b: Int) {
  import Foo._
  private val c: Int = someComplexMathLogic(a)
  private val d: Int = someComplexMathLogic(b)
}

object Foo {
  private def someComplexMathLogic(x: Int): Int = ???

  def apply(a: Int): Foo = new Foo(a, a)
}

Predef

Podobnie jak w Javie domyślnie importowane jest wszystko z pakietu java.lang (import java.lang.*;), w Scali domyślnie zaimportowana jest zawartość obiektu scala.Predef (import scala.Predef._). Dzięki temu zapewnione są niektóre podstawowe możliwości języka (np. operatory, “globalne” metody typu println czy bezpieczne konwersje między typami liczbowymi).

Interpolacja i multi-line stringi

Scala domyślnie wspiera trzy rodzaje interpolacji stringów:

Mechanizm ten jest rozszerzalny - możliwe jest stworzenie własnej interpolacji.

Dodatkowo Scala pozwala na proste i czytelne tworzenie stringów wielolinijkowych.

Tuple

Scala oferuje tzw. tuple, czyli pewnego rodzaju rekordy, “pojemniki” na kilka wartości potencjalnie różnych typów. Są to klasy typu TupleN[T1, T2, ..., TN], gdzie N oznacza ilość wartości, które chcemy wpakować do jednego pudełka (od 1 do 22), a T1, T2, …, TN to typy tych kolejnych wartości. Taki typ można również zapisywać jako (T1, T2, ..., TN), co jest częściej spotykane. Istnieje kilka różnych sposobów tworzenia tupli, z których jeden jest najczęściej używany (patrz niżej). Do poszczególnych wartości możemy się odwoływać za pomocą takich metod jak ._N, gdzie N to numer pola, które nas interesuje, uwaga, indeksowany od 1.

Funkcje

Scala jako język funkcyjny wspiera istnienie funkcji jako „obywateli pierwszej kategorii” (ang. first class objects), co oznacza, że funkcję można przypisać do zmiennej, przekazać jako argument itd.

Typy funkcyjne zdefiniowane są jako FunctionN[T1, T2, ..., TN, R], gdzie N oznacza ilość argumentów przyjmowanych przez funkcję (od 0 do 22), T1, T2, …, TN to typy kolejnych argumentów, a R to typ zwracany. Inaczej taki typ można zapisać jako (T1, T2, ..., TN) => R.

Funkcje bezargumentowe definiowane są jako () => { //ciało funkcji }, w szczególności w przypadku ciała składającego się z pojedynczego wyrażenia, nawiasy klamrowe można pominąć.

Scala daje sporo składniowych możliwości definiowania funkcji. Pierwszym z nich jest:

val fun = (x: Int) => x.toString

W przypadku, gdy typ funkcji jest explicite określony, nie ma potrzeby określania typu argumentu bezpośrednio przy nim.

val fun: Int => String = x => x.toString

W przypadku tego typu prostych funkcji (jednym z wymagań jest jednokrotne użycie argumentu) można również posłużyć się skróconą składnią.

val fun: Int => String = _.toString

Korzystając ze składni z _, również można bezpośrednio podać typ argumentu.

val fun = (_: Int).toString

Analogicznie tworzone są funkcje o większej ilości argumentów (w składni z _ istotna jest kolejność parametrów):

val fun = (s: String, i: Int) => s.toUpperCase * i
val fun2: (String, Int) => String = (s, i) => s.toUpperCase * i
val fun3: (String, Int) => String = _.toUpperCase * _
val fun4 = (_: String).toUpperCase * (_: Int)

Funkcje mogą być wywoływane bezpośrednio poprzez fun(args). Jest to równoznaczne z wywołaniem metody apply tj. fun.apply(args).

Symbol => jest prawostronnie łączny, a zatem np. A => B => C oznacza funkcję, która otrzymuje argument typu A i zwraca funkcję z B w C, czyli (A => (B => C)).

Rekurencja ogonowa i metody wewnętrzne

Alt Text

Rekurencja występuje, gdy metoda jako część swojej logiki woła samą siebie. Każde nowe wywołanie metody powoduje odłożenie odpowiednich informacji na stosie wywołań. Ponieważ pamięć komputera jest skończona, to możemy odłożyć jedynie ograniczoną liczbę ramek na stos.

Istnieje jednak pewien szczególny typ funkcji rekursywnych zwanych ogonowymi. Charakteryzuje się on tym, że do obliczenia wyniku funkcji potrzebujemy tylko wartości zwróconej przez następne wywołanie (dokładnie jedno) rekurencyjne. W takim przypadku kompilator może zoptymalizować rekurencję do prostej pętli.

Język Java nie wspiera takiej optymalizacji, więc pisanie funkcji rekurencyjnych w czystej Javie zawsze niesie za sobą ryzyko przepełnienia stosu. Ponieważ Scala jest językiem starającym się wspierać zarówno programowanie funkcyjne jak i obiektowe, to jej kompilator zawiera zaimplementowaną optymalizację ogonowej rekurencji.

Dodatkowo istnieje adnotacja @scala.annotation.tailrec - gdy jakaś funkcja posiada takową adnotację, to kompilator wygeneruje błąd, jeżeli nie uda mu się zastąpić rekurencyjnego wywołania pętlą. Dzięki temu mamy 100% pewność, że w czasie działania programu stos nie zostanie przepełniony przez wywoływanie danej funkcji.

Istnieje jednak ograniczenie tego mechanizmu w obecnej wersji Scali - optymalizowana jest jedynie rekurencja ogonowa w obrębie jednej funkcji. W przykładzie funkcje ping oraz pong można wyrazić za pomocą pętli while, niestety obecnie kompilator nie jest na tyle mądry, żeby wykryć rekurencję ogonową w takim przypadku.

Ponieważ pod spodem ScalaFiddle korzysta ze Scala.js, zachowanie kodu w poniższym przykładzie zależy od przeglądarki. Gdy poniższy kod zostanie uruchomiony na JVMce, będzie widać, że w przypadku funkcji, które nie są rekurencyjnie ogonowe, głębokość stosu będzie się zwiększała z każdym wywołaniem. Silnik JavaScriptu w Firefoxie zachowuje się podobnie jak JVMka, ale już z naszych obserwacji wynika, że V8 jest na tyle sprytny, że również optymalizuje wywołania factorial do pętli.

Scala pozwala również na zagnieżdżanie metod.

def max(a:Int, b:Int, c:Int) = {
  def max(x:Int, y:Int) =
    if (x > y) x else y
    
  max(a, max(b, c))
}

Metoda wewnętrzna ma dostęp do wszystkich argumentów przekazanych do jej rodzica, a także do zmiennych lokalnych zdefiniowanych do miejsca jej definicji, więc nie ma potrzeby przekazywania ich ponownie.

Metody wewnętrzne często wykorzystuje się w sytuacji, gdy chcemy wyekstrahować jakiś kawałek logiki w ograniczonym zakresie. W Javie w takich przypadkach musimy stworzyć prywatną metodę, do której dostęp mają wszystkie inne metody w danej klasie. Kończy się to często tym, że klasa ma wiele prywatnych pomocniczych metod używanych tylko przez jedną inną metodę. Użycie w takim przypadku funkcji wewnętrznej zwiększa czytelność kodu ponieważ od razu wiemy, gdzie będzie używana dana metoda (ograniczamy jej widoczność).

Ćwiczenie 1

Uzupełnij implementację metod compose, uncurry i curry tak, by wykonywały one operacje odpowiednio złożenia, zwijania i rozwijania funkcji.

Podpowiedź 1: >Zacznij od stworzenia pustej (bez implementacji działania) funkcji, która będzie pasowała do oczekiwanego typu zwracanego. Zastosuj dowolną składnię z rozdziału o funkcjach np. (i: Int) => ???<

Podpowiedź 2: >Pamiętaj, że zapis Int => Double => String można postrzegać jako Int => (Double => String), czyli jako funkcję przyjmującą jeden argument typu Int i zwracającą nową funkcję.<

Podpowiedź 3: >Jeśli masz już pustą funkcję, teraz spróbuj wywołać funkcję otrzymaną jako argument. Przekaż do niej argumenty, które otrzymała nowa funkcja.<

Podpowiedź 4: >Początki implementacji mogą wyglądać np. następująco: i => dla compose; (i, d) => dla uncurry; i => d => dla curry.<

Rozwiązanie: https://scalafiddle.io/sf/qrBIXAn/0

Ćwiczenie 2

Napisz funkcję rekurencyjną, która oblicza element z danego wiersza oraz danej kolumny w trójkącie Pascala. Funkcja nie musi korzystać z rekursji ogonowej.

Pascal1

Żródło: https://en.wikipedia.org/wiki/Pascal%27s_triangle

Pascal2

Żródło: https://www.mathsisfun.com/pascals-triangle.html

Pascal3

Żródło: https://www.mathsisfun.com/pascals-triangle.html

Podpowiedź 1: >Element (r,c) w trójkącie Pascala jest równy sumie elementów (r-1,c-1) i (r-1,c).<

Podpowiedź 2: >Pamiętaj o zdefiniowaniu warunków brzegowych, aby zakończyć rekurencję. Zauważ, że wartości na krawędziach trójkąta są równe 1.<

Rozwiązanie: https://scalafiddle.io/sf/aNnsrkg/0

Ćwiczenie 3

Napisz funkcję, która wylicza n-ty wyraz ciągu Fibonacciego za pomocą rekursji ogonowej.

Podpowiedź 1: >Standardową metodą obliczania zadanego wyrazu ciągu Fibonacciego jest pętla, która za pomocą dwóch zmiennych zapamiętuje dwa poprzednie wyrazy oraz co iterację oblicza następny, zapisując go w jednej ze zmiennych.<

Podpowiedź 2: >Spróbuj wyrazić pętlę opisaną wyżej za pomocą funkcji wewnętrznej, która zamiast zapisywać wyniki do zmiennych, będzie przekazywać wartości do kolejnego wywołania rekurencyjnego i zakończy działanie, gdy wartość iteratora będzie równa n. Wyraz pierwszy i drugi możesz obsłużyć warunkiem w funkcji głównej i zwrócić 1.<

Podpowiedź 3: >Sygnatura funkcji wewnętrznej może wyglądać tak: def inFib(it: Int, a: Int, b: Int): Int = ???. Spróbuj ją zaimplementować oraz wywołać.<

Rozwiązanie: https://scalafiddle.io/sf/wy9ay51/1

Materiały