JAVA exPress > Archive > Issue 7 (2010-03-30) > Pierwsze kroki w Scali

Pierwsze kroki w Scali

If I were to choose language other than Java it would be Scala

James Gosling
Creator of Java

 I can honestly say if someone had shown me the Programming in Scala book by Martin Odersky, Lex Spoon & Bill Venners back in 2003 I'd probably have never created Groovy.

James Strachan
Creator of Groovy

Historia

Scala to nowy bardzo ciekawy język programowania. Łączy ze sobą dwa "światy" programowania – świat programowania obiektowego i świat funkcyjny. Historia Scali zaczyna się trochę wcześniej niż w 2001 na Politechnice w Lozannie w Szwajcarii (EPFL), gdzie Martin Odersky wraz z grupą studentów zakłada projekt nowego języka. Wszystko zaczęło się od eksperymentalnego języka Pizza, który został utworzony przez Odersky’iego i Philip’a Wadler’a w 1998 roku. Wtedy obaj panowie pracowali w Sun’ie nad rozwojem Javy i zastanawiali się jak wprowadzic do języka Generics (JSR-014). Wpadli wtedy na pomysł, że zrobią nowy język, który posłuży im jako pole doświadczalne. Napisanie nowego języka to dużo pracy, zysk był jednak spory, oswobodzeni z Javy mogli próbować nowych rzeczy, ograniczał ich tylko JVM. Pizza ma Generics, domknięcia i pattern matching. Wiosną 1998 do Odersky’iego i Wadler’a dołącza David Stoutamire i Gilad Bracha bazując na doświadczeniach Pizzy tworzą kolejny język – GJ (Generics Java). Implementacja GJ została wcielona do Javy pod postacią generics w 1.5 (praktycznie niezmieniona). Martin Odersky napisał kompilator do GJ, który stał się podstawą javac, to było już w wersji 1.3 (2000), mimo to Generics były niedostępne w języku, aż do wersji 1.5 (2004). Orginalna wersja Odersky’iego została rozszerzona o "wildcards" czyli "? extends T". W 2001 Odersky przenosi się do Szwajcarii na uniwersytet w Lozannie, gdzie zajmuje stanowisko profesora metod programowania i rozpoczyna pracę nad nowym językiem.

Instalacja

Do rozpoczęcia pracy wystarczy ściągnąć dystrubucję ze strony domowej – http://www.scala-lang.org/downloads i zainstalować. Do dyspozycji są dwie wersje – pod konkretną platformę bądź uniwersalny instalator. Jeżeli nie korzystamy z instalatora po rozpakowaniu należy ustawić zmienną środowiskową SCALA_HOME na katalog ze Scalą i do zmiennej PATH dodać katalog bin dystrybucji. Po tych zabiegach wpisując scala w linii poleceń uruchamia się interaktywny interpreter. Do małych zadań, skryptowania, zabawy interpreter jest doskonałym narzędziem, przy tworzeniu większego projektu do dyspozycji są trzy zintegrowane środowiska programistyczne: Netbeans, IntelliJ IDEA oraz Eclipse. W chwili pisania artykułu pluginy IDE są piętą achillesową Scali, da się pracować, trzeba jednak przygotować się na to że nie wszystko będzie działało tak samo dobrze jak dla Javy.

Scala jest językiem w pełni obiektowym to znaczy wszystko jest obiektem, jest też językiem funkcyjnym, pozwala na tworzenie metod wyższych rzędów, domknięć i preferuje obiekty niemutowalne. Kompiluje się do bytecode’u Javy oraz do CLI .NET, pokazuje to że twórcom zależy na przenośności języka i nie wiążą się ściśle z JVM’em.

Pierwszy kod

Wewnątrz interpretera, nie musimy deklarować zmiennych. Zatem wpisując:

    scala> 2+2

Otrzymamy:

    res0: Int = 4

Interpreter przypisuje to co wpisujemy do kolejnych zmiennych o nazwie res[n]. W Scali nie ma też operatorów w tradycyjnym sensie. To znaczy operatory są zaimplementowane w bibliotece, nie jest to element języka. Dlatego powyższy zapis jest skrótem od takiego:

    scala> (2).+(2)

Oznacza to nic innego jak wywołanie metody + na obiekcie typu Int z argumentem Int w notacji tzw. operatorowej. Jeżeli metoda przyjmuje tylko jeden argument, można ją wywoływać bez kropki i nawiasów. Jest to jeden z elementów który pozwala na szybkie i łatwe tworzenie DSL’i (Domain Specific Language) w Scali. Poniższy zapis przypisuje cztery do zmiennej x (właściwie wartości, ale o tym za chwilę).

    scala> val x = 2+2

Póki co kod wygląda jak w języku z dynamicznym typowaniem. Nic bardziej mylnego. Scala jest statyczna, dzięki inferencji (wnioskowania) typów nie musimy ich jawnie podawać, kompilator zrobi to za nas. Oczywiście nie zawsze otrzymany typ jest taki jakbyśmy chcieli dlatego możemy go podać jawnie:

    scala> val x: Int = 2+2
    scala> val y: Double = 2+2

Póki co kod wygląda jak w języku
z dynamicznym typowaniem.
Nic bardziej mylnego. Scala jest statyczna

Tam gdzie to możliwe Scala wykorzystuje obiekty Javy. Powyższe wyrażenia zostaną przez kompilator przetłumaczone najprawdopodniej na Javowe int i double. Znamiennym przykładem jest String:

    scala> val hello = „Hello, world !”
    hello: java.lang.String = Hello, world !

Dla rozbudzenia apetytu, możesz spróbować wpisać w interpreterze:

    scala> „Hello, world”.foreach(println(_))

Powyższy zapis korzysta z kilku funkcjonalności języka: pełnej obiektowości, implicit conversions (domyślnych konwersji), funkcji wyższych rzędów oraz czegoś co nazywa się placeholder syntax (składnia zastępnikowa ?). W tej części opiszę m.in. implicit conversions.

Definicja funkcji:

    scala> def double(x: Int): Int = x*2

Powyższy zapis definuje funkcję o nazwie double z jednym parametrem Int, zwracającą typ Int. W funkcjach ostatnia zapis jest zwracany dzięki temu nie ma obowiązku pisania słówka kluczowego returns, jest to opcjonalne. Co instotne Inferencer jest w stanie wypełnić za nas typ zwracany przez funkcję:

    scala> def double(x: Int) = x*2
    double: (Int)Int

Jeżeli funkcja, metoda jest zbyt długa aby zmieścić się w jednej linii możemy użyć nawiasów klamrowych:

    def double(x: Int) = {
            x*2
    }

Istotny jest znak = bez niego funkcja będzie zwracała Unit, który jest podobny do void w Javie.

W Scali prawie wszystkie wyrażenia coś zwracają np. if:

    def even(x: Int) = if((x%2) == 0) true else false

Tutaj if-else jest wykorzystane jak operator trójargumentowy w Javie "?". Istotny jest też sposób w jaki Scala interpretuje operator porównania ==. Działa on odwrotnie niż w Javie, nie porównuje referencji obiektów, działa jak equals w Javie. Jest to bardziej intuicyjne i pozwala zapobiec błędom w rodzaju:

Java:

    Public boolean isEqual(Integer x,Integer y) {
                    return x == y;
            }

    boolean b1 = isEqual(2, 2);
    boolean b2 = isEqual(new Integer(2), new Integer(2));
    System.out.println(b1+" "+b2);
    true false

Wracając do funkcji even, lepiej by wyglądała tak:

    def even(x: Int) = x%2==0

Myślę, że jest to odpowiednia chwila na odpalenie zintegrowanego środowiska programistycznego (jeżeli jeszcze tego nie zrobiłeś). Ja korzystam z eclipse. Tworzymy nowy projekt, nowy pakiet nazwa dowolna i nowy obiekt (Scala Object). Nie będę zgłębiał czym jest obiekt w Scali, po krótce można go potraktować jak puszkę na elementy statyczne klas. Nam jest potrzebny do napisania pierwszej aplikacji w Scali.

    package example

    object Main extends Application {
            println(2+2)
    }

wszystkie metody z predef
są automatycznie importowane
i dostępne w aktualnym zasięgu

Kilka istotnych szczegółów. W Scali istnieje obiekt o nazwie Predef, wszystkie metody z predef są automatycznie importowane i dostępne w aktualnym zasięgu (scope). Stąd mamy dostępne println(), czyż nie wygląda to znacznie lepiej niż: System.out.println();. Średniki są opcjonalne tam gdzie nie są konieczne można je pominąć. Application jest specjalnym rodzajem Trait (cecha), cały kod wewnątrz obiektu, który rozszrza Application trafia do main Javy. Jeżeli nasza aplikacja miałaby czytać parametry wejściowe wtedy powinniśmy zdefiniować metodę main:

    package example

    object Main2 {
      def main(args : Array[String]):Unit = {
    println(2+2)
            // Wypisuje argumenty na konsoli
    args.foreach(println(_))
            // W javie wygląda to mniej więcej tak
            for(arg <- args) {
                    println(arg);
            }
         }
    }

Przeciążanie operatorów, implicit conversions

Zdefiniujmy klasę ułamka, która jest dobrym przykładem na pokazanie kilku cech Scali:

    class Rational(num: Int, denom: Int)

I to wszystko? Tak! Aczkolwiek taka definicja ułamka nie na wiele się zda. Powyższy zapis definiuje nową klasę która przyjmuje dwa parametry. Za kulisami jest tworzony konstruktor, nazywany głównym konstruktorem (primary). W Javie klasa Rational wygląda tak:

    public class Rational {
            public Rational(int num, int denom) {}
    }

Stwórzmy pola do przechowywania licznika i mianownika, dalej w Javie:

    public class Rational {
            private int num;
            private int denom;
            public Rational(int num, int denom) {
                    this.num = num;
                    this.denom = denom;
            }
    }

Jest to codzienność w Javie, tworzymy konstruktor bierzemy argumenty i przypisujemy do pól. Eclipse może nas w tym wyręczyć, lecz mi osobiście nie chce się klikać szukać w menu kontekstowym refactor itd. Zajmuje to podobny czas co napisanie tego z tzw. "palca".

Spójrzmy na ten sam kod w Scali:

    class Rational(val nom: Int, val denom: Int)

I to wszystko? Tak! Nawet więcej. Taka definicja tworzy główny konstruktor oraz dwa pola, dzięki słowu kluczowemu val przed argumentami. Dodatkowo podczas tworzenia instancji klasy Rational argumenty są automatycznie przypisywane do pól.

    val half = new Rational(1,2)
    println(half.num+"/"+half.denom)

Powyższy kod wypisze na konsoli:

    1/2

No tak, ale teraz odwołuję się bezpośrednio do pól, jak będę chciał zmienić implementację pobierania licznika, mianownika to będę miał problem. W Scali dostęp jest jednorodny, to znaczy że wywołania metod i odwołania do pól są traktowane identycznie, w dowolnym momencie możemy podstawić zamiast pola metodę, która oblicza jego wartość.

wywołania metod i odwołania do pól
są traktowane identycznie

Klasa Rational pozwala już na przechowywanie licznika i mianownika, a także na dostęp do nich. Niestety nie jest poprawna z punktu widzenia matematycznego, gdyż możemy zdefiniować ułamek o mianowniku 0! Pamiętasz obiekt Predef? Jest w nim bardzo przydatna metoda require, jest bardzo podobna do assert w Javie, tylko na szczęście nie jest domyślnie wyłączona.

    class Rational(val nom: Int, val denom: Int) {
            require(denom != 0)
    }

Jeżeli teraz spróbujemy utworzyć nową instancję Rational z mianownikiem 0 zostanie wyrzucony IllegalArgumentException:

    scala> new Rational(1,0)
    java.lang.IllegalArgumentException: requirement failed
            at scala.Predef$.require(Predef.scala:107)
            at Rational.<init>(<console>:5)
            at .<init>(<console>:6)
            at .<clinit>(<console>)
            at RequestResult$.<init>(<console>:3)
            at RequestResult$.<clinit>(<console>)
            at RequestResult$result(<console>)
            at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
            at sun.reflect.NativeMetho...

Kolejna rzecz o którą, aż się prosi żeby zaimplementować to metoda toString. Działanie tej metody jest identyczne jak w Javie.

    class Rational(val nom: Int, val denom: Int) {
            require(denom != 0)
    override def toString(): String = (num+"/"+denom)
    }

Teraz po przekazaniu obiektu typu Rational do println bądź sklejeniu go ze String’iem zostanie wywołana metoda toString. Rzecz warta uwagi to słówko kluczowe override. Jest ono konieczne, gdy nadpisujemy metodę z klasy nadrzędnej, jeżeli nie nadpisujemy żadnej metody, to kompilator zgłosi błąd. Jest to rozmiękczenie problemu znanego jako problem ułomnej klasy bazowej. Sprowadza się on do klas rozszerzających inne klasy, w szczególności jest on dotkliwy jeżeli nie mamy dostępu do kodu klasy bazowej. Jeżeli w Javie nadpiszemy metodę z klasy bazowej i w kolejnej wersji zostanie ona z niej usunięta (klasy bazowej) to nie mamy żadnej szansy się o tym dowiedzieć jeżeli nie śledzimy zmian w kodzie. Nasza metoda zostanie potraktowana jako definicja nowej metody, niekoniecznie będzie to efekt pożądany. W Scali taki kod nie skompiluje się. W Javie 6 zostało to lekko poprawione poprzez anotację @Overrides, niestety anotacja nie jest obowiązkowa.

Na tym etapie możemy utworzyć klasę w interpreterze i ujrzymy wywołanie metody toString:

    scala> new Rational(1,2)
    res10: Rational = 1/2

Jest nieźle, ale liczby o mianowniku 1 to są tak naprawdę liczby całkowite. Pisanie jawnie mianownika nie jest wygodne. Dodajmy dodatkowy konstruktor:

    class Rational(val num: Int, val denom: Int) {
            require(denom != 0)
            override def toString(): String = (num+"/"+denom)
            def this(x: Int) = this(x,1);
    }
    scala> new Rational(3);
    res11: Rational = 3/1

W Scali operatory to zwyczajne metody

Ostatnia funkcjonalność której nie może zabraknąć to działania na ułamkach. Żeby ograniczyć miejsce, zdefiniuję tylko dodawanie. W Javie dodałbym w tym celu metodę add w klasie Rational, która brałaby argument Rational i zwracałaby sumę obu ułamków. W Scali operatory to zwyczajne metody, zatem mogę zdefiniować nowy operator dla typu Rational:

    class Rational(val num: Int, val denom: Int) {
            require(denom != 0)
            override def toString(): String = (num+"/"+denom)
            def this(x: Int) = this(x,1);
            def +(that: Rational) = new Rational(
              num*that.denom + that.num*denom, denom*that.denom)
    }
    scala> val half = new Rational(1,2)
    half: Rational = 1/2

    scala> half + half
    res0: Rational = 4/4

Świetnie, dodawanie ułamków działa i mogę korzystać z notacji operatorowej pozwoli to na tworzenie bardziej intuicyjnego i zwięzłego kodu. Spróbuj przepisać to samo w Javie, kod będzie znacznie rozleglejszy a co za tym idzie trudniejszy w utrzymaniu. Klasa ma już wszystko co trzeba (pomijając brakujące operacje) jednak próba dodania liczby całkowitej do ułamka, nie powiedzie się, mimo że jest to całkowicie poprawne:

    scala> 1 + half
    <console>:7: error: overloaded method value + with alternatives (Double)Double <and> (Float)Float <and> (Long)Long <and>
     (Int)Int <and> (Char)Int <and> (Short)Int <and> (Byte)Int <and> (java.lang.String)java.lang.String cannot be applied to
     (Rational)  1 + half
                       ^

W typie Int nie ma metody + biorącej Rational jako argument i raczej nieprędko się pojawi. Kompilator jednak próbował ją odnaleźć w kilku typach: Double, Float, Long, Char, Short, Byte i String aż w końcu się poddał. Owo wyszukiwanie to imlicit conversions, jeżeli kompilator nie może odnaleźć metody w danym obiekcie to szuka czy nie ma dostępnej konwersji do typu, który tą metodę posiada. Niemalże dynamiczne typowanie! Tak naprawdę na codzień spotykamy się z obcym kodem i nic nie możemy z nim zrobić, możemy z tym żyć i pamiętać aby nie dodawać Int’ów do ułamków, ewentualnie pozostaje nam wzorzec Adaptera, zamiast Int’a musiałbym korzystać z klas adaptujących, jeżeli klasa adaptowana ma duży interfejs utrzymywanie go będzie problematyczne. Definicja imiplicit conversion jest bardzo prosta:

    object Main extends Application {
      implicit def intToRational(x: Int): Rational = new Rational(x);
      val half = new Rational(1,2);
      println(half)
      println(1+half)
    }

Powyższy kod wypisze na konsoli:

    1/2
    3/2

Istotne jest to aby definicja implicit była w zasięgu. Jeżeli zdefiniuje ją w klasie Rational, nie zostanie odnaleziona. Kompilator nie importuje konwersji automatycznie, wyobraź sobie całą masę konwersji dziejących się dookoła bez wiedzy programisty. Jest to potężne narzędzie, ale nadużyte powoduje ciężkie dni z debugerem, gdy niewiadomo dlaczego kod wykonuje "magiczne" operacje. Konwersje są aplikowane także, jeżeli spodziewany typ jest inny niż przekazany. W Scali istnieją konwersje pomiędzy typami prostymi w górę, to znaczy rozszerzające. Dzięki konwersji ze String można pisać np. taki kod:

    for(char <- "Hello world !") {
        println(char)
    }

Podsumowanie

Ostatnią rzeczą jaką chciałem opisać to integracja Javy ze Scalą. Na nasze szczęście jest ona praktycznie niezauważalna. W istocie String w Scali to nic innego jak java.lang.String. Klasy Javy z których będziemy korzystać w naszym kodzie należy najpierw zaimportować (o ile są w innym pakiecie).

Scala to bardzo elegancki język,
który pozwala programować na wysokim poziomie.

Mechanizm importowania jest nieco rozszerzony w stosunku do Javy. Importowane mogą być całe pakiety w ten sposób importowanie jest hierarchiczne. Jeżeli mamy pakiety o strukturze first->second, to:

    import first._;
    import second._;

Spowoduje zaimportowanie wszystkich elementów pakietu first (_ jest traktowane jak * w Javie) oraz wszystkich elementów pakietu first.second.

Podsumowując Scala to bardzo elegancki język, który pozwala programować na wysokim poziomie. Dzięki pełnej kompilacji do bytecodu Javy działa tak samo szybko. Jest statycznie typowany, dlatego kompilator wychwyci za nas proste błędy, a środowisko podpowie nam dokumentację dla danego obiektu. Jednocześnie cechy takie jak wnioskowanie typów, implicit conversions i duck typing pozwala na pisanie kodu, który wygląda prawie jak język dynamiczny. Elementy funkcyjne języka o których nie pisałem wcale idealnie nadają się do pisania bardzo zwięzłego kodu. Pełna integracja z Javą umożliwia pisanie projektów hybrydowych Scala-Java i uruchamianie kodu Javy w Scali jak i na odwrót. Zachęcam do spróbowania Scali nawet jeżeli nie zamierzasz pisać w niej to przynajmniej zdobędziesz wizję tego co w Javie można poprawić i przygotujesz się lepiej na domknięcia w JDK 7.

Źródła:

  • Java Language Specification (3rd edition)
  • Programming In Scala – Odersky, Spoon, Venners
  • http://scala-lang.org/

Nobody has commented it yet.

Only logged in users can write comments

Developers World