Einführung in Scala

1. Einleitung

In diesem Tutorial werden wir uns Scala ansehen - eine der Hauptsprachen, die auf der Java Virtual Machine ausgeführt werden.

Wir beginnen mit den wichtigsten Sprachfunktionen wie Werten, Variablen, Methoden und Kontrollstrukturen. Anschließend werden einige erweiterte Funktionen wie Funktionen höherer Ordnung, Currying, Klassen, Objekte und Mustervergleich untersucht.

Um einen Überblick über die JVM-Sprachen zu erhalten, lesen Sie unsere Kurzanleitung zu den JVM-Sprachen

2. Projekteinrichtung

In diesem Tutorial verwenden wir die Standard-Scala-Installation von //www.scala-lang.org/download/.

Fügen wir zunächst die Abhängigkeit von der Scala-Bibliothek zu unserer pom.xml hinzu. Dieses Artefakt stellt die Standardbibliothek für die Sprache bereit:

 org.scala-lang scala-library 2.12.7 

Zweitens fügen wir das Scala-Maven-Plugin zum Kompilieren, Testen, Ausführen und Dokumentieren des Codes hinzu:

 net.alchim31.maven scala-maven-plugin 3.3.2    compile testCompile    

Maven hat die neuesten Artefakte für Scala-Lang und Scala-Maven-Plugin.

Schließlich werden wir JUnit für Unit-Tests verwenden.

3. Grundfunktionen

In diesem Abschnitt werden die grundlegenden Sprachfunktionen anhand von Beispielen untersucht. Zu diesem Zweck verwenden wir den Scala-Interpreter.

3.1. Dolmetscher

Der Interpreter ist eine interaktive Shell zum Schreiben von Programmen und Ausdrücken.

Lassen Sie uns "Hallo Welt" damit drucken:

C:\>scala Welcome to Scala 2.12.6 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_92). Type in expressions for evaluation. Or try :help. scala> print("Hello World!") Hello World! scala>

Oben starten wir den Interpreter, indem wir in der Befehlszeile 'scala' eingeben. Der Interpreter startet und zeigt eine Begrüßungsnachricht an, gefolgt von einer Eingabeaufforderung.

Dann geben wir unseren Ausdruck an dieser Eingabeaufforderung ein. Der Interpreter liest den Ausdruck, wertet ihn aus und druckt das Ergebnis. Dann wird eine Schleife ausgeführt und die Eingabeaufforderung erneut angezeigt.

Da es sofortiges Feedback gibt, ist der Dolmetscher der einfachste Weg, um mit der Sprache zu beginnen. Lassen Sie uns daher die grundlegenden Sprachfunktionen untersuchen: Ausdrücke und verschiedene Definitionen.

3.2. Ausdrücke

Jede berechenbare Anweisung ist ein Ausdruck .

Lassen Sie uns einige Ausdrücke schreiben und ihre Ergebnisse sehen:

scala> 123 + 321 res0: Int = 444 scala> 7 * 6 res1: Int = 42 scala> "Hello, " + "World" res2: String = Hello, World scala> "zipZAP" * 3 res3: String = zipZAPzipZAPzipZAP scala> if (11 % 2 == 0) "even" else "odd" res4: String = odd

Wie wir oben sehen können, hat jeder Ausdruck einen Wert und einen Typ .

Wenn ein Ausdruck nichts zurückzugeben hat, gibt er einen Wert vom Typ Unit zurück . Dieser Typ hat nur einen Wert: () . Es ähnelt dem Schlüsselwort void in Java.

3.3. Wertedefinition

Das Schlüsselwort val wird verwendet, um Werte zu deklarieren.

Wir verwenden es, um das Ergebnis eines Ausdrucks zu benennen:

scala> val pi:Double = 3.14 pi: Double = 3.14 scala> print(pi) 3.14 

Auf diese Weise können wir das Ergebnis mehrmals wiederverwenden.

Werte sind unveränderlich . Daher können wir sie nicht neu zuweisen:

scala> pi = 3.1415 :12: error: reassignment to val pi = 3.1415 ^

3.4. Variablendefinition

Wenn wir einen Wert neu zuweisen müssen, deklarieren wir ihn stattdessen als Variable.

Das Schlüsselwort var wird verwendet, um Variablen zu deklarieren:

scala> var radius:Int=3 radius: Int = 3

3.5. Methodendefinition

Wir definieren Methoden mit dem Schlüsselwort def . Nach dem Schlüsselwort geben wir den Methodennamen, die Parameterdeklarationen, ein Trennzeichen (Doppelpunkt) und den Rückgabetyp an. Danach geben wir ein Trennzeichen (=) gefolgt vom Methodenkörper an.

Im Gegensatz zu Java, verwenden wir nicht die Rückkehr Schlüsselwort das Ergebnis zurück. Eine Methode gibt den Wert des zuletzt ausgewerteten Ausdrucks zurück.

Schreiben wir eine durchschnittliche Methode , um den Durchschnitt zweier Zahlen zu berechnen:

scala> def avg(x:Double, y:Double):Double = { (x + y) / 2 } avg: (x: Double, y: Double)Double

Rufen wir dann diese Methode auf:

scala> avg(10,20) res0: Double = 12.5 

Wenn eine Methode keine Parameter akzeptiert, können die Klammern während der Definition und des Aufrufs weggelassen werden. Außerdem können wir die Klammern weglassen, wenn der Körper nur einen Ausdruck hat.

Schreiben wir eine parameterlose Methode coinToss, die zufällig "Head" oder "Tail" zurückgibt:

scala> def coinToss = if (Math.random > 0.5) "Head" else "Tail" coinToss: String

Als nächstes rufen wir diese Methode auf:

scala> println(coinToss) Tail scala> println(coinToss) Head

4. Kontrollstrukturen

Kontrollstrukturen ermöglichen es uns, den Kontrollfluss in einem Programm zu ändern. Wir haben folgende Kontrollstrukturen:

  • If-else-Ausdruck
  • While-Schleife und do while-Schleife
  • Zum Ausdruck
  • Versuchen Sie es mit Ausdruck
  • Übereinstimmungsausdruck

Im Gegensatz zu Java haben wir keine Schlüsselwörter zum Fortfahren oder Unterbrechen . Wir haben das Schlüsselwort return . Wir sollten es jedoch vermeiden.

Instead of the switch statement, we have Pattern Matching via match expression. Additionally, we can define our own control abstractions.

4.1. if-else

The if-else expression is similar to Java. The else part is optional. We can nest multiple if-else expressions.

Since it is an expression, it returns a value. Therefore, we use it similar to the ternary operator (?:) in Java. In fact, the language does not have have the ternary operator.

Using if-else, let's write a method to compute the greatest common divisor:

def gcd(x: Int, y: Int): Int = { if (y == 0) x else gcd(y, x % y) }

Then, let's write a unit test for this method:

@Test def whenGcdCalledWith15and27_then3 = { assertEquals(3, gcd(15, 27)) }

4.2. While Loop

The while loop has a condition and a body. It repeatedly evaluates the body in a loop while the condition is true – the condition is evaluated at the beginning of each iteration.

Since it has nothing useful to return, it returns Unit.

Let's use the while loop to write a method to compute the greatest common divisor:

def gcdIter(x: Int, y: Int): Int = { var a = x var b = y while (b > 0) { a = a % b val t = a a = b b = t } a }

Then, let's verify the result:

assertEquals(3, gcdIter(15, 27))

4.3. Do While Loop

The do while loop is similar to the while loop except that the loop condition is evaluated at the end of the loop.

Using the do-while loop, let's write a method to compute factorial:

def factorial(a: Int): Int = { var result = 1 var i = 1 do { result *= i i = i + 1 } while (i <= a) result }

Next, let's verify the result:

assertEquals(720, factorial(6))

4.4. For Expression

The for expression is much more versatile than the for loop in Java.

It can iterate over single or multiple collections. Moreover, it can filter out elements as well as produce new collections.

Using the for expression, let's write a method to sum a range of integers:

def rangeSum(a: Int, b: Int) = { var sum = 0 for (i <- a to b) { sum += i } sum }

Here, a to b is a generator expression. It generates a series of values from a to b.

i <- a to b is a generator. It defines i as val and assigns it the series of values produced by the generator expression.

The body is executed for each value in the series.

Next, let's verify the result:

assertEquals(55, rangeSum(1, 10))

5. Functions

Scala is a functional language. Functions are first-class values here – we can use them like any other value type.

In this section, we'll look into some advanced concepts related to functions – local functions, higher-order functions, anonymous functions, and currying.

5.1. Local Functions

We can define functions inside functions. They are referred to as nested functions or local functions. Similar to the local variables, they are visible only within the function they are defined in.

Now, let's write a method to compute power using a nested function:

def power(x: Int, y:Int): Int = { def powNested(i: Int, accumulator: Int): Int = { if (i <= 0) accumulator else powNested(i - 1, x * accumulator) } powNested(y, 1) }

Next, let's verify the result:

assertEquals(8, power(2, 3))

5.2. Higher-Order Functions

Since functions are values, we can pass them as parameters to another function. We can also have a function return another function.

We refer to functions which operate on functions as higher-order functions. They enable us to work at a more abstract level. Using them, we can reduce code duplication by writing generalized algorithms.

Now, let's write a higher-order function to perform a map and reduce operation over a range of integers:

def mapReduce(r: (Int, Int) => Int, i: Int, m: Int => Int, a: Int, b: Int) = { def iter(a: Int, result: Int): Int = { if (a > b) { result } else { iter(a + 1, r(m(a), result)) } } iter(a, i) }

Here, r and m are parameters of Function type. By passing different functions, we can solve a range of problems, such as the sum of squares or cubes, and the factorial.

Next, let's use this function to write another function sumSquares that sums the squares of integers:

@Test def whenCalledWithSumAndSquare_thenCorrectValue = { def square(x: Int) = x * x def sum(x: Int, y: Int) = x + y def sumSquares(a: Int, b: Int) = mapReduce(sum, 0, square, a, b) assertEquals(385, sumSquares(1, 10)) }

Above, we can see that higher-order functions tend to create many small single-use functions. We can avoid naming them by using anonymous functions.

5.3. Anonymous Functions

An anonymous function is an expression that evaluates to a function. It is similar to the lambda expression in Java.

Let's rewrite the previous example using anonymous functions:

@Test def whenCalledWithAnonymousFunctions_thenCorrectValue = { def sumSquares(a: Int, b: Int) = mapReduce((x, y) => x + y, 0, x => x * x, a, b) assertEquals(385, sumSquares(1, 10)) }

In this example, mapReduce receives two anonymous functions: (x, y) => x + y and x => x * x.

Scala can deduce the parameter types from context. Therefore, we are omitting the type of parameters in these functions.

This results in a more concise code compared to the previous example.

5.4. Currying Functions

A curried function takes multiple argument lists, such as def f(x: Int) (y: Int). It is applied by passing multiple argument lists, as in f(5)(6).

It is evaluated as an invocation of a chain of functions. These intermediate functions take a single argument and return a function.

We can also partially specify argument lists, such as f(5).

Now, let's understand this with an example:

@Test def whenSumModCalledWith6And10_then10 = { // a curried function def sum(f : Int => Int)(a : Int, b : Int) : Int = if (a > b) 0 else f(a) + sum(f)(a + 1, b) // another curried function def mod(n : Int)(x : Int) = x % n // application of a curried function assertEquals(1, mod(5)(6)) // partial application of curried function // trailing underscore is required to // make function type explicit val sumMod5 = sum(mod(5)) _ assertEquals(10, sumMod5(6, 10)) }

Above, sum and mod each take two argument lists.

We pass the two arguments lists like mod(5)(6). This is evaluated as two function calls. First, mod(5) is evaluated, which returns a function. This is, in turn, invoked with argument 6. We get 1 as the result.

It is possible to partially apply the parameters as in mod(5). We get a function as a result.

Similarly, in the expression sum(mod(5)) _, we are passing only the first argument to sum function. Therefore, sumMod5 is a function.

The underscore is used as a placeholder for unapplied arguments. Since the compiler cannot infer that a function type is expected, we are using the trailing underscore to make the function return type explicit.

5.5. By-Name Parameters

A function can apply parameters in two different ways – by value and by name – it evaluates by-value arguments only once at the time of invocation. In contrast, it evaluates by-name arguments whenever they are referred. If the by-name argument is not used, it is not evaluated.

Scala uses by-value parameters by default. If the parameter type is preceded by arrow ( =>), it switches to by-name parameter.

Now, let's use it to implement the while loop:

def whileLoop(condition: => Boolean)(body: => Unit): Unit = if (condition) { body whileLoop(condition)(body) }

For the above function to work correctly, both parameters condition and body should be evaluated every time they are referred. Therefore, we are defining them as by-name parameters.

6. Class Definition

We define a class with the class keyword followed by the name of the class.

After the name, we can specify primary constructor parameters. Doing so automatically adds members with the same name to the class.

In the class body, we define the members – values, variables, methods, etc. They are public by default unless modified by the private or protected access modifiers.

We have to use the override keyword to override a method from the superclass.

Let's define a class Employee:

class Employee(val name : String, var salary : Int, annualIncrement : Int = 20) { def incrementSalary() : Unit = { salary += annualIncrement } override def toString = s"Employee(name=$name, salary=$salary)" }

Here, we are specifying three constructor parameters – name, salary, and annualIncrement.

Since we are declaring name and salary with val and var keywords, the corresponding members are public. On the other hand, we are not using val or var keyword for the annualIncrement parameter. Therefore, the corresponding member is private. As we are specifying a default value for this parameter, we can omit it while calling the constructor.

In addition to the fields, we are defining the method incrementSalary. This method is public.

Next, let's write a unit test for this class:

@Test def whenSalaryIncremented_thenCorrectSalary = { val employee = new Employee("John Doe", 1000) employee.incrementSalary() assertEquals(1020, employee.salary) }

6.1. Abstract Class

We use the keyword abstract to make a class abstract. It is similar to that in Java. It can have all the members that a regular class can have.

Furthermore, it can contain abstract members. These are members with just declaration and no definition, with their definition is provided in the subclass.

Similarly to Java, we cannot create an instance of an abstract class.

Now, let's illustrate the abstract class with an example.

First, let's create an abstract class IntSet to represent the set of integers:

abstract class IntSet { // add an element to the set def incl(x: Int): IntSet // whether an element belongs to the set def contains(x: Int): Boolean }

Next, let's create a concrete subclass EmptyIntSet to represent the empty set:

class EmptyIntSet extends IntSet { def contains(x : Int) = false def incl(x : Int) = new NonEmptyIntSet(x, this) }

Then, another subclass NonEmptyIntSet represent the non-empty sets:

class NonEmptyIntSet(val head : Int, val tail : IntSet) extends IntSet { def contains(x : Int) = head == x || (tail contains x) def incl(x : Int) = if (this contains x) { this } else { new NonEmptyIntSet(x, this) } }

Finally, let's write a unit test for NonEmptySet:

@Test def givenSetOf1To10_whenContains11Called_thenFalse = { // Set up a set containing integers 1 to 10. val set1To10 = Range(1, 10) .foldLeft(new EmptyIntSet() : IntSet) { (x, y) => x incl y } assertFalse(set1To10 contains 11) }

6.2. Traits

Traits correspond to Java interfaces with the following differences:

  • able to extend from a class
  • can access superclass members
  • can have initializer statements

We define them as we define classes but using the trait keyword. Besides, they can have the same members as abstract classes except for constructor parameters. Furthermore, they are meant to be added to some other class as a mixin.

Now, let's illustrate traits using an example.

First, let's define a trait UpperCasePrinter to ensure the toString method returns a value in the upper case:

trait UpperCasePrinter { override def toString = super.toString toUpperCase }

Then, let's test this trait by adding it to an Employee class:

@Test def givenEmployeeWithTrait_whenToStringCalled_thenUpper = { val employee = new Employee("John Doe", 10) with UpperCasePrinter assertEquals("EMPLOYEE(NAME=JOHN DOE, SALARY=10)", employee.toString) }

Classes, objects, and traits can inherit at most one class but any number of traits.

7. Object Definition

Objects are instances of a class. As we have seen in previous examples, we create objects from a class using the new keyword.

However, if a class can have only one instance, we need to prevent the creation of multiple instances. In Java, we use the Singleton pattern to achieve this.

For such cases, we have a concise syntax called object definition – similar to the class definition with one difference. Instead of using the class keyword, we use the object keyword. Doing so defines a class and lazily creates its sole instance.

We use object definitions to implement utility methods and singletons.

Let's define a Utils object:

object Utils { def average(x: Double, y: Double) = (x + y) / 2 }

Here, we are defining the class Utils and also creating its only instance.

We refer to this sole instance using its nameUtils. This instance is created the first time it is accessed.

We cannot create another instance of Utils using the new keyword.

Now, let's write a unit test for the Utils object:

assertEquals(15.0, Utils.average(10, 20), 1e-5)

7.1. Companion Object and Companion Class

If a class and an object definition have the same name, we call them as companion class and companion object respectively. We need to define both in the same file. Companion objects can access private members from their companion class and vice versa.

Unlike Java, we do not have static members. Instead, we use companion objects to implement static members.

8. Pattern Matching

Pattern matching matches an expression to a sequence of alternatives. Each of these begins with the keyword case. This is followed by a pattern, separator arrow (=>) and a number of expressions. The expression is evaluated if the pattern matches.

We can build patterns from:

  • case class constructors
  • variable pattern
  • the wildcard pattern _
  • literals
  • constant identifiers

Case classes make it easy to do pattern matching on objects. We add case keyword while defining a class to make it a case class.

Daher ist der Mustervergleich viel leistungsfähiger als die switch-Anweisung in Java. Aus diesem Grund ist es eine weit verbreitete Sprachfunktion.

Schreiben wir nun die Fibonacci-Methode mit Pattern Matching:

def fibonacci(n:Int) : Int = n match  1 => 1 case x if x > 1 => fibonacci (x-1) + fibonacci(x-2) 

Als nächstes schreiben wir einen Unit-Test für diese Methode:

assertEquals(13, fibonacci(6))

9. Fazit

In diesem Tutorial haben wir die Scala-Sprache und einige ihrer Hauptfunktionen vorgestellt. Wie wir gesehen haben, bietet es eine hervorragende Unterstützung für die imperative, funktionale und objektorientierte Programmierung.

Wie immer finden Sie den vollständigen Quellcode auf GitHub.