Часть 3. Персистентные структуры данных. Ленивые вычисления. Монады.

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

План

  1. Практическое задание: пишем классификатор
  2. Разбор домашних заданий
  3. Персистентные структуры: Vector и HashMap
  4. "call by value" и "call by name"; lazy
  5. Stream: ленивый список.
  6. Монады и for.
  7. Пример: Eval из библиотеки cats.

Орг. вопросы

  • Выложил видео первых 2-х лекций
  • Новый план занятий
  • Akka - будет!

Практическое задание: пишем классификатор

Классификатор - алгоритм, относящий входные данные к одному из предопределенных классов.

Разработаем классификатор, определяющий является ли короткий текст позитивным, негативным или нейтральным.

На старте программы обучим классификатор на готовых текстах с оценками.

После на основе статистики будем оценивать произвольный текст.

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

  • Один из наиболее часто используемых
  • Прост в реализации и отладке
  • Я нашел хорошее описание с примером на Scala

Денис Баженов: Наивный байесовский классификатор

В статье есть:

  • Описание в применении к текстам
  • Описание как запрограммировать
  • Пример расчета - подойдет для тестов
  • Пример реализации на Scala

Для обучения классификатора используем готовый корпус:

Корпус коротких текстов для настройки классификатора

При использовании корпуса, просьба ссылаться на следующую работу: Автоматическое построение и анализ корпуса коротких текстов (постов микроблогов) для задачи разработки и тренировки тонового классификатора

Что делаем:

  • Классификатор с тестами
  • Разбиение текста на слова с зачисткой
  • Чтение корпуса твитов из CSV
  • Программу, классифицирующую введенный текст

На реализацию есть две недели:
8-го марта занятий не будет.

Работу над кодом ведем на gitlab.com!

приватный github.com - тоже можно, у кого есть

Разбор домашних заданий

1. Судоку - много кода.

Нужно было поискать короткое красивое решение.

Вместо этого - много решений методом "грубой силы".

Старайтесь писать простой код - его проще отлаживать, поддерживать и развивать.

2. Использование return

  • Императивный стиль
  • Плохо совместим с ленивыми и асинхронными вычислениями

В Scala почти всё является выражением,
return не нужен.

3. Доступ к спискам по индексу без необходимости


var set: Set[Int] = Set()
for (i <- list.indices) {
  set += list(i)
}
					

4. Комментарии вместо объявления функций

Задача программиста - разбить сложную задачу на набор простых.

Коментарий - не средство декомпозиции

def checkSudoku(game: List[List[Int]]): Boolean = {
  // rows
  ...
  много кода с объявлениями переменных
  ...
  //column
  ...
  много кода с объявлениями переменных
  ...
  // 3x3
  ...
  много кода с объявлениями переменных
  ...
} 

Такой код

  • Нельзя переиспользовать
  • Легко вырастить в "макаронного монстра"

Заводим функции, в Scala их можно создавать в любом блоке

5. Использование == в тестах


  "rotate function" should {
    "rotates left" in {
      val l = List('a', 'b', 'c', 'd', 'e')
      Rotator.rotate(2, l) == List('c', 'd', 'e', 'a', 'b')
    }
  }
					

== оператор сравнения языка Scala, его нельзя переопределить

=== оператор из тестовой библиотеки

=== лучше, он:

  • дает читаемые сообщения об ошибках
  • работает в любом месте теста, не только в конце

6. Опасное использование "for"

раскажу позже сегодня

Зачем мы говорили о List?

  • Классика ф. п. - его идеи используются в многих других стуктурах
  • Часто спрашивают на собеседованиях

"Я видел такое, во что вы, люди, просто не поверите. Штурмовые корабли в огне на подступах к Ориону. Я смотрел, как Си-лучи мерцают во тьме близ врат Тангейзера.

Рой Батти не умеет работать с List :-(

Vector

Напомним проблемы List:

  • Занимает в два раза больше массива
  • Много элементов - нагрузка на сборщик мусора
  • Время выполнения многих операций пропорционально длине
  • Вставка в конец - только с полным копированием

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

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

До 32 элементов

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

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

Добавление элемента - два уровня

Добавление элемента - три уровня

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

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

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

Почему effectively constant?

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

Vector - не List:

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

HashMap

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


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)
					

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

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

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

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

Реализация - префиксное дерево с основанием 32

Похоже на вектор, только индексом выступает hashCode

Структура создается по мере необходимости, на каждом уровне могут быть и данные, и ссылки на подуровни.

(картинка с слайда про Vector)

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

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

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

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

Параметры функции могут:

  • Вычисляться до вызова функции - "call by value"
  • Вычисляются внутри функции при обращении - "call by name"

Пример: Option.getOrElse


def getOrElse(opt: Option[Int], default: ⇒ Int): Int = {
  opt match {
    case None    ⇒ default // вычисляется прямо тут
    case Some(v) ⇒ v  
  }
}
					

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


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

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


List.fill(10)(Random.nextInt)
					

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

lazy val

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


import java.time.{Duration, Instant}

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

Thread.sleep(1000)

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

В worksheet не работает - "эффект наблюдателя"

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

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


def repeat(n: Int, v : ⇒ Int) {
  lazy val cached = v // вычисляется 0 или 1 раз
  
  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: ленивый список

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


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

Два вида ячеек:

  • Stream.Cons[+A](hd: A, tl: => Stream[A])
  • Stream.Empty

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

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


var n: Int = 0
val s: Stream[Int] = Stream.fill(100000) {
  n += 1
  Random.nextInt
}

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)
  }
}
					

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

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

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


import scala.math.BigInt
lazy val fibs: Stream[BigInt] = BigInt(0) #::
                                BigInt(1) #::
                                fibs.zip(fibs.tail).map { n => 
				  n._1 + n._2 
				}

fibs.take(5).toVector
					

Применение:

  • Числа Фибоначчи :-)
  • Алгоритмы поиска "в ширину"
  • наверное другие

Основное практическое применение:

  • Оптимизация серий трансформаций коллекций
  • Остановка вычислений когда получен результат

Минусы:

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

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

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

Для нас монада - шаблон проектирования.

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

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

Операции:

  • создания (pure; unit; return) - помещает значение в контекст
  • map - применяет к значению функцию, возвращающую новое значение
  • flatMap (bind) - применяет к значению функцию, возвращающую новые значение и контекст

Рассмотрим на примере 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 и collect, но сейчас это не важно)

for без yield использует forearch вместо последнего 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)))
					
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
}

Еще про for - пример из домашнего задания


def forall(list: List[Int], f: Int ⇒ Boolean): Boolean = {
  var result = true
  for (elem <- list if result)
    result = f(elem)
  result
}					
					

Компилятор это преобразует в


def forall(list: List[Int], f: Int ⇒ Boolean): Boolean = {
  var result = true
  list.withFilter(_ => result).foreach(elem => result = f(elem))
  result
}
					

withFilter - это тот же фильтр,
только оптимизированный для for

Данный код работает только потому,
что withFilter - ленивый.

Причем ленивый именно так, как хотел автор кода.

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

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

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

"Left Identity"

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

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

"Right Identity"

m.flatMap(pure) == m

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

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

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

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

Try - тоже монада; вычисляется пока не возникнет исключение

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

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

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

Операция flatMap позволяет избежать цепочек callback'ов.

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

Free - монада, свободная о какой-то реализации логики.

Собирает pipeline в структуру, которую потом можно передать в интерпретатор.

Разделяет бизнес-логику и её реализацию.

Рассмотрим Eval из Cats - монаду, выполняющую ленивые вычисления.

import cats.Eval

case class User(id: Int, info: String)
def loadUserById(id: Int): User = ???

// строим pipeline
val result = for {
  v <- Eval.now(10)
  user <- Eval.later(loadUserById(v))
} yield {
  user.info
}

// вычисление происходит тут
result.value

Напоминаю: