Часть 1. Введение в Scala. Case классы и
pattern matching

Страничка курса: https://maxcom.github.io/scala-course-2022/

План

  1. Почему Scala? Обзор курса.
  2. Структура проекта sbt.
  3. Обзор синтаксиса.
  4. Case-классы.
  5. Алгебраические типы.
  6. Pattern matching.

Максим Валянский

окончил ВМиК МГУ в 2001 году;

занимаюсь разработкой ПО сколько себя помню;

веду курс по Scala уже 3-й раз;

работаю архитектором в компании
«Ростелеком-Солар».

Материалы курса

Слайды, примеры программ и другие файлы доступны в репозитарии https://github.com/maxcom/scala-course-2022. Содержимое будет пополняться по ходу курса.

Почему Scala?

До Scala в продукте были:
Scheme (Lisp), OCaml, Java, Python, C++ и др.

Хотелось:

  • Качественную платформу, средства разработки и библиотеки.
  • Функциональное программирование*
    * хотя каждый разработчик по своему понимает что такое ФП
  • "Крутые" библиотеки/framework'и
    В момент перехода для нас это были Akka и Play Framework

Мы верим что функциональной программирование сделает наш софт качественнее, а разработку – более быстрой и предсказуемой.

Первый Scala компонент у нас появился осенью 2013 года в качестве эксперимента.

Это была распределенная система хранения бинарных данных - "файловое хранилище".

По результатам вывода в production эксперимент посчитали удачным.

Сейчас довольно большая часть продукта написана на Scala, и у нас много планов по развитию.

Scala — мультипарадигмальный язык программирования, спроектированный кратким и типобезопасным для простого и быстрого создания компонентного программного обеспечения, сочетающий возможности функционального и объектно-ориентированного программирования.

Работающий на JVM и хорошо интегрирующийся с существующим Java кодом

обо всём языке говорить долго

рассмотрим базовые возможности языка
и перейдем к практике

Состав курса

  1. Введение в программирование на Scala
  2. Многопоточность и асинхронное программирование.
  3. Разработка и использование веб-сервисов

Средства разработки

Есть много вариантов, мы будем использовать
Intellij IDEA Community Edition + Scala plugin

Видео: Ставим JDK и Intellij IDEA

Структура проекта (sbt):

build.sbt       -- настройки сборки
project         -- еще настройки сборки
project/target  -- кеш компилятора и вспомогательные файлы
src             -- исходные файлы и ресурсы
src/main        -- основной код
src/main/scala  -- основной код на Scala
src/test        -- исходные файлы и ресурсы тестов
src/test/scala  -- код тестов на Scala
target          -- результат компиляции
					

Синтаксис Scala: смесь Си + ML.

"Better Java", C, Python, ...

справочник по языку

или google + stackoverflow

Hello, World!


object HelloWorld extends App {
  println("Hello, world!")
}
					

// класс-синглтон для JVM
// наследование от App
object HelloWorld extends App {
  // код инициализации класса
  println("Hello, world!")
}
					

Блоки вместо выражений


object HelloWorld extends App {
  println({
    "Hello, world!"
  })
}
					

object HelloWorld extends App {
  println({
    var str = "Hello, " // переменная
    val add = "world!" // константа

    // val короче чем "final int" или "const int"

    str += add

    str // "возвращается" последнее значение в блоке
  })
}
					

Иммутательность "по-умолчанию":

  • var только если будем менять
  • изменяемые коллекции и др. только когда нужно

объявляем функцию


object HelloWorld extends App {
  println({
    // функцию можно объявить где угодно
    def square(x: Int) = x * x

    square(10)
  })
}
					
объявление функции:
def square(x: Int) = x * x
функция - это значение:
val f: (Int => Int) = square
(её тип - Function1[Int, Int])

if тоже является выражением:


def abs(x: Int) = {
  if (x >= 0) {
    x
  } else {
    -x
  } // и никакого "return"!
}
					

return не используем

Типы выводятся в:

  • val и var
  • возвращаемых значениях функций и методов

(кроме рекурсивных)

Рекомендуется указывать типы:

  • в публичных API
  • в var - без него могут быть неожиданности

while


var a: Int = 0

while (a<10) {
  a += 1
  println(a)
}  // бывает еще do { ... } while
					

break/continue нет, но если очень хочется то есть
util.control.Breaks

for - не цикл, но "прикидывается" им:


for (i <- 0 to 10) {
  println(i)
}
					

подробнее о for на третьем занятии

// про коллекции будет отдельная лекция
for (i <- Vector(1, 2, 3)) {
  println(i)
}
					

for .. yield - "List comprehension" из Python


println(for (v <- 1 until 10) yield v * v)

// аналог на Python:
// print([i * i for i in range(1, 10)])
					

с условием


for { // фигурные скобки дают более сложный синтаксис
  v <- 1 until 10 if v%2 == 0
} yield {
  v * v
}

// Python:
// [i*i for i in range(1, 10) if i%2==0]
					

писать на Scala - не сложно

Пара (tuple)

Комбинация из двух значений:

val pair: (Int, Int) = (1, 2)
val pair2 = "key" -> "value"
// Tuple2[Int, Int]
					

возврат нескольких значений из функций

преобразование коллекций

Деконструкция

плохой способ:


val first = pair._1
val second = pair._2
					
хороший:

val (first, second) = pair
// ввели константы "first" и "second"
					

бывают еще "тройки" и более - до 22

Конец первой части

Классы в ООП

ООП похоже на Java

  • Объект с изменяемым состоянием
  • Состояние приватное
  • Создание через new и identity
    сравнение через identity по умолчанию

Case-классы

Свой тип для данных.


case class Address(`type`: String, value: String) {
  def toStringAddress = s"${`type`}:$value"
}
					

Это не ООП! Данные не изменяемые, обычно не содержат бизнес-логики.

Собственный тип лучше, чем просто значения:


case class Address(`type`: String, value: String)
val address = Address("email", "someone@gmail.com")
// type - ключевое слово, по этому в апострофах

против пары


val address: (String, String) = ("email", "someone@gmail.com")

Собственный тип можно заводить и для простых значений

// где тут user, а где group?
userService.addUserToGroup(uuid1, uuid2)

case class UserId(uuid: UUID)
case class GroupId(uuid: UUID)

типы позволяют не путать значения между собой
(но это не всегда эффективно)

Создание без new

у обычных классов identity,
тут только содержимое


 // объект компаньон класса Address

object Address {  
  // apply генерируется автоматически, можно переопределить
  // например отдавать значения из кеша
  def apply(`type`: String, value: String) = 
    new Address(`type`, value)
}
					
* объект-компаньон заменяет static декларации Java

Что есть в case class?

  • toString
  • сравнение по содержимому
    (реализация equals, ==)
  • hashcode
    (поддержка для множеств и хеш-таблиц)
Геттеры

val address = Address("email", "abuse@sportloto.ru")
val value = address.value
					

Экстрактор


// похоже на деконструкцию пары
// ввели новую константу - "email"
val Address(_, email) = address
// "дырка" позволяет пропустить ненужное объявление
					

можно переопределить - функция unapply

Алгебраические типы

Case классы можно объединить в иерархию

// пример - калькулятор
sealed trait Expr

case class Number(value: Int) extends Expr
case class Plus(lhs: Expr, rhs: Expr) extends Expr
case class Minus(lhs: Expr, rhs: Expr) extends Expr
					
* дальше будет более правильная версия этого примера

Pattern matching

def value(expression: Expr): Int = expression match {
  case Number(value)   => value
  case Plus(lhs, rhs)  => value(lhs) + value(rhs)
  case Minus(lhs, rhs) => value(lhs) - value(rhs)
}				

val result = value(Plus(Number(2), Number(2)))
					

match перебирает экстракторы
(экстрактор может не подойти)

Pattern matching - альтернатива полиморфизму на методах:

trait Expr {
  def eval: Int
}
case class Number(value: Int) extends Expr {
  override val eval = value
}
case class Plus(lhs: Expr, rhs: Expr) extends Expr {
  override def eval = lhs.eval + rhs.eval
}
case class Minus(lhs: Expr, rhs: Expr) extends Expr {
  override def eval = lhs.eval - rhs.eval
}

Plus(Number(2), Number(2)).eval

Две модели:

  1. Фиксированная "схема" данных, произвольные операции – PM.
    легко добавлять операции, сложно расширять типы
  2. Фиксированные операции, большое разнообразие объектов – ООП
    легко добавлять типы, сложно добавлять операции

Сделаем более правильное определение ADT

Проблема 1:

val number: Expr = Number(3)
val expr = Plus(Number(2), Number(2))
val buffer = ArrayBuffer(Number(1), expr)

// не компилируется
buffer += number
					
потому что тип buffer вот такой:

ArrayBuffer[Product with Serializable with Expr]
					

а изменяемые коллекции - инвариантные

Более правильная версия примера


sealed trait Expr extends Product with Serializable

final case class Number(value: Int) extends Expr
final case class Plus(lhs: Expr, rhs: Expr) extends Expr
final case class Minus(lhs: Expr, rhs: Expr) extends Expr

// дополнительно запретили наследование
// sealed запрещает только прямых потомков
					

Option[T]

Тип с двумя вариантами:
  • Some[T] - контейнер для одного значения
  • None - значение отсутствует

более безопасная замена null


val v = Vector(1, 2, 3, 4, 5)

val r: Option[Int] = v.find(x => x > 2)
// r = Some(3)				

val rr: Option[Int] = v.get(0)
// rr = Some(1)
					
плохой вариант работы с Option:

if (r.isDefined) {
  println(r.get) // бросает исключение если значения нет
}					

// компилятор не верифицирует r.get
несколько правильных вариантов:

// 1
r match {
  case Some(k) => println(k)
  case None    => println("None")  
}

// 
// 2
println(r.getOrElse("None"))
					
еще варианты рассмотрим на 3-м занятии

Option заменяет null,
но null в JVM нельзя избежать


val opt1: Option[String] = null // упс
val opt2: Option[String] = Some(null) // упс

val opt3: Option[String] = Option(null) // правильно
					

Try[T]

Обработка исключений в Scala похожа на Java:

try {
  1 / 0
} catch {
  // это pattern matching по типу
  case ex: ArithmeticException ⇒
    println(ex.getMessage)
    1
}
					

try - тоже выражение, возвращает последнее значение

Иногда мы не хотим обрабатывать ошибки прямо сейчас:
  • хотим положить в список и потом обработать
  • передать между потоками
  • ...

Тип Try - ADT

  • Success - содержит значение
  • Failure - содержит исключение

получим его так:

val result: Try[Int] = Try {
  1 / 0
}					
import scala.util.{Random, Try}

val vector = Vector.fill(10) {
  Try {
    1 / Random.nextInt(5)
  }
}

// число успешных значений
vector.count(x => x.isSuccess)				

// fill - функция с двумя блоками параметров
// fill[A](n: Int)(elem: => A)
// "elem: => A" будет на лекции про ленивые вычисления
					

Могут ли функции возвращать Try?

да, но это делает все сложнее.
функция может и упасть, и вернуть Failure

Either[A, B]

Выбор из двух значений:
  • Right(x) - "правильное" значение
  • Left(y) - "левое" значение

Left может быть не только исключением

Left может быть конкретным исключением

Напоминаю: