Часть 3. Vector. Ленивые вычисления. LazyList и View. Монады.

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

План

  1. Критика List
  2. Vector и HashMap
  3. View.
  4. "call by value" и "call by name"; lazy
  5. LazyList: ленивый список.
  6. Монады и for.

Критика List

Реализация Seq по-умолчанию - List

  • Вставка в конец - только с полным копированием,
    но все про это забывают когда пишут бизнес-логику
  • Время выполнения многих операций пропорционально длине
    Там где есть O(N), легко получается O(N^2)
  • Занимает в два раза больше массива
  • Много элементов - нагрузка на сборщик мусора
  • Средства диагностики heap dump плохо работают
java.lang.OutOfMemoryError:
GC overhead limit exceeded

Посмотрите на исследование производительности коллекций Scala: Benchmarking Scala Collections.

(исследование старое,
Vector сейчас лучше чем там измерено)

Vector

Vector - современная персистентная коллекция,
без этих недостатков.

Используется и в Scala, и в Clojure

Реализация до Scala 2.13.2 на базе аналогичной структуры Clojure

В 2.13.2 и далее - новая реализация

Расскажу о "классической" реализации
так как она проще

До 32 элементов - массив

До 1024 элементов (32*32)

Очередной уровень

Добавление элемента - два уровня
линии показывают переиспользование элементов

Добавление элемента - три уровня
(на картинке заполнены все листы исходного дерева)

Добавление в начало - аналогично;
Vector хранит смещение первого элемента

Добавление в хвост и в голову одинаково эффективно

Стоимость операций - effectively constant:

  • получение элемента по индексу
  • добавление в конец
  • добавление в начало

Почему effectively constant?

Максимум 6 уровней, это достаточно

Vector - не List:

  • Декомпозиция на хвост/голову дорогая операция
    для обхода используем итератор или встроенные функции
  • Сборка не добавлением, а через VectorBuilder
    ListBuffer это полноценная коллекция, а VectorBuilder - только builder
  • Используем готовые функции - они уже оптимизированы

HashMap

реализация - префиксное дерево с 32 ветвями,
похоже на "разряженный" Vector

для пользователя:


Map("one" -> "first", "two" -> "second", "three" -> "third")

m.get("one") // Some("first")

val m1 = m + ("five" -> "fifth") // добавление

val m2 = m - "one" // удаление
					

map/flatMap/filter/fold
аналогично Seq[(K,V)]


m.map(p => p._1.toUpperCase -> p._2)
					

операции не меняют тип исходной коллекции

если результат не пара то результат будет Seq

Map - еще и частично определенная функция:


val m = Map("one" -> 1, "two" -> 2)

// определена там где есть данные по ключу
List("one", "two", "three").collect(m)

// эквивалентно
List("one", "two", "three").flatMap(m.get)

// List(1, 2)
					

Ключ - неизменяемый объект любого типа

Метод hashCode возвращает Int для любого объекта

  • У равных (equals) объектов они одинаковые
  • У неравных - различные, насколько это возможно
  • У case class, строк и пар создается автоматически

Добавление и удаление -
effectively constant, как у Vector.

Поиск - effectively constant,
если хеш-функция хорошая.
Значения с одинаковым
хеш-кодом хранятся в списке.

View

Временное представление коллекции для цепочек преобразований.

В Scala 2.12 и ранее были проблемы.


case class Person(id: Int, name: String, student: Boolean)

def makeIndex(persons: Vector[Person]): Map[Int, Person] = {
  // каждая операция - новая структура
  persons.filter(_.student).map(p => p.id -> p).toMap
}
					

case class Person(id: Int, name: String, student: Boolean)

def makeIndex(persons: Vector[Person]): Map[Int, Person] = {
  // похожи на "stream" из Java
  persons.view.filter(_.student).map(p => p.id -> p).toMap
}
					

вычисление происходит в "toMap"

Два вида View для коллекций:

  • SeqView - последовательный доступ
    на базе Iterator
  • IndexedSeqView - доступ по индексу

SeqView

  • map/filter/... выполняются при обращении
  • take не вычисляет то что не нужно
  • drop вычисляет до начала, потом по необходимости
  • append/prepend/concat

IndexedSeqView

  • Доступ по индексу
  • slice/splitAt/take/drop... делают подколлекции без копирования
  • head/tail, в том числе декомпозиция

MapView для Map

View - не коллекция

Ленивые вычисления

Откладываем вычисления до момента,
когда нужен результат

(View - это тоже ленивые вычисления)

Параметры функции могут вычисляться:

  • до вызова функции - "call by value"
  • внутри функции при обращении - "call by name"

Пример: Option.getOrElse


// реализация из Scala 2.13.1
@inline final def getOrElse[B >: A](default: => B): B =
    if (isEmpty) default else this.get

// пример 
Option(v).getOrElse(throw new RuntimeException("Oops!"))
					

Значение вычисляется заново каждый раз


// метод List[A]
  def fill[A](n: Int)(elem: => A): List[A]
					

создает новый список с разными элементами


List.fill(10)(Random.nextInt)
					

Такие вызовы похожи на передачу функции без аргументов

Библиотеки могут таким образом
расширять "синтаксис".

Например, Try вместо try, или свой цикл (List.fill) и т.п.

В Java часто нужны аннотации + фреймворки
(например @Transactional)

lazy val

"Ленивые" значения - вычисляются один раз, результат сохраняется (memoization)


import java.time.{Duration, Instant}

lazy val lazyCurrent = Instant.now
val current = Instant.now

Thread.sleep(1000)

Duration.between(lazyCurrent, current) 
// разница больше секунды
					

при отладке помним об "эффекте наблюдателя".

lazy работает и в классах, и внутри функций

(но помним про overhead,
особенно у глобальных lazy val)

Превращаем call by name в lazy:


def repeat(n: Int, v: => Int) {
// вычисляется 0 раз если n = 0, 1 раз в остальных случаях
  lazy val cached = v  
  List.fill(n)(cached)
}
					

Еще пример - регистронезависимый id,
хотим сохранить исходное написание

final case class UserId(id: String) {
  private lazy val loId: String = id.toLowerCase()

  override def equals(obj: Any) = {
    obj match {
      case other: UserId ⇒
        other.loId == loId
      case _ ⇒
        false
    }
  }

  override def hashCode() = loId.hashCode
}

демо-код, с некоторыми "локалями" будут проблемы

Stream/LazyList: ленивый список

Stream - старая реализация из 2.12 и ранее
LazyList - 2.13+, исправлены некоторые недостатки

Структура похожа на List


val s: LazyList[Int] = 3 #:: 2 #:: 1 #:: LazyList.empty
					

У Stream два вида ячеек:

  • Stream.Cons[+A](hd: A, tl: => Stream[A])
    обращения к tail происходят через lazy val
  • Stream.Empty - аналог Nil

У LazyList аналогично, но реализация скрыта
(еще отличия будут дальше).

Cons ячейка вычисляет "хвост" при обращении
и сохраняет его. Только до следующего звена.

Функции тоже работают лениво, например map:


var n: Int = 0
val s: LazyList[Int] = LazyList.fill(100000) {
  // побочны эффекты и ленивые вычисления лучше не совмещать
  // это код только для демонстрации работы
  n += 1
  Random.nextInt
}

println(n) // 0

s.map(_ * 2).take(1).toVector

println(n) // 1
					

Пример реализации map:


def map(s: Stream[Int], f: Int ⇒ Int): Stream[Int] = {
  if (s.isEmpty) {
    s
  } else {
    f(s.head) #:: map(s.tail, f)
  }
}
					

для LazyList чуть сложнее

Функции, обходящие весь список, "форсируют" его.
Например, length или foldLeft.

Stream может быть бесконечным

Фибоначчи: каждое последующее число равно сумме двух предыдущих чисел


import scala.math.BigInt

val fibs: LazyList[BigInt] =
  BigInt(0) #::
    BigInt(1) #:: // zip из двух списков делаем список пар
      fibs.zip(fibs.tail).map { n =>
        n._1 + n._2
      } // список объявлен "рекурсивно"


fibs.take(5).toVector
					

Отличие Stream и LazyList:

Stream - неудачное название

Stream: первый элемент всегда вычислен,
LazyList - полностью ленивый.

Пример: решение Судоку.

Пример: поиск кратчайшего решения "лабиринта".

Минусы:

  • Плохо сочетаются с исключениями и побочными эффектами
  • Задержки - иногда тоже побочный эффект
  • Бесконечные последовательности легко случайно форсировать
  • Overhead - память и блокировки

«Монада — всего лишь моноид из категории эндофункторов, что может быть проще?»

(c) A Brief, Incomplete, and Mostly Wrong History of Programming Languages

Монада это:

  • Шаблон проектирования - об этом сегодня
  • Элемент теории категорий - об этом потом

Много типов из разных областей являются монадами.

Монада - значение, помещенное в контекст.

Операции:

  • создания (обычно "pure") - помещает значение в контекст
  • flatMap - применяет к значению функцию, возвращающую новое значение и контекст ("монадическая функция")
Альтернативное определение:
  • создание (pure)
  • map - применяет к значению функцию, возвращающую новое значение
  • flatten - раскрывает вложенный контекст

Рассмотрим на примере Option


def findUserId(name: String): Option[Int] = ???
def loadUserById(id: Int): Option[User] = ???

val opt = Option("username") // создание

opt.flatMap(findUserId).flatMap(loadUserById)
					

Последовательное вычисление,
пока не встретится None

for в Scala – не цикл

for { ... } yield { ... }

Комбинирует flatMap и map
(и еще filter, но это не для монад)

for без yield использует
foreach вместо последнего map


val jobTitle: Option[String] = for {
  name <- opt // первая операция определяет тип
  id   <- findUserId(name)
  user <- loadUserById(id)
} yield {
  user.jobTitle
}
					

Посмотрите "desugar for" в IDEA


opt.flatMap(name => 
  findUserId(name).flatMap(id => 
    loadUserById(id).map(user => 
      user.jobTitle)))
					
без flatMap
opt match {
  case Some(name) ⇒
    findUserId(name) match {
      case Some(id) ⇒
        loadUserById(id) match {
          case Some(user) ⇒ user.jobTitle
          case None       ⇒ None  
        }
      case None ⇒
        None
    }
  case None ⇒
    None
}

Монада - абстракция цепочки связанных вычислений.

Монада контролирует выполнение этой цепочки.

Законы, которые должны выполнять монады

"Left Identity"

Для любой монадической функции f


pure(x).flatMap(f) == f(x)
					

применение функции к значению в монаде эквивалентно применению функции к значению

"Right Identity"


m.flatMap(pure) == m
					

применение функции создания не меняет монаду

ассоциативность


m.flatMap(f).flatMap(g) == m.flatMap(x => f(x).flatMap(g))
					

уравнивает разные способы комбинации функций

Законы не всегда выполняются даже у реализаций из Scala-библиотеки

Try - тоже монада

Вычисляется пока не возникнет исключение

Either - монада.
Вычисляется правая сторона, левая сторона - остановка вычисления.

Вычисления не обязательно должны происходить прямо сейчас и в текущем потоке.

Future - монада, выполняющая вычисление в другом потоке.

Рассмотрим её устройство на 5-й лекции.

Напоминаю: