Часть 2. Иммутабельность и базовые структуры данных. Работа со списками.

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

План

  1. Орг. вопросы и результаты домашнего задания
  2. Обзор большого практического задания
  3. Функциональный подход и иммутабельность.
  4. Персистентные структуры данных на примере List.
  5. Работа со списками: "классика" и ListBuffer.
  6. filter, map и fold.
  7. Доп.: NonEmptyList, immutable queue, операции над Option

План поменяем, чтобы успеть к 20 апреля.

Критерии для получения зачета
(нужно выполнить все):

  • Посещение более 50% занятий
    (отмечаем посещения!)
  • Сделано более 50% домашних заданий
  • Сделано практическое задание - реализован базовый функционал
Критерии оценки:
  • 5 - Посещение более 75% занятий; 100% домашних заданий; практическое задание реализовано в полном объеме
  • 4 - Посещение более 75% занятий; 100% домашних заданий; практическое задание не стабильно или не в полном объеме
  • 3 - Посещение более 50% занятий; 50% домашних заданий; минимальный функционал практического задания

Практическое задание - разработка сервиса эмоциональной оценки текстов соц. сетей

  1. Классификатор текстов *
  2. Сервис для оценки произвольного текста *
  3. Оценка сообщений соц. сетей *
  4. Отслеживание новых записей и история оценок
  5. Хранение состояния на диске; отказоустойчивость
  6. Реактивное обновление интерфейса при изменениях

* - "минимальный" функционал

Реализацию начнем на следующем занятии.

Видео с лекций постараемся выложить.

Еще у нас будет стажерская программа.

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

Переполнение стека в рекурсивном merge


def merge(a1: Seq[Int], a2: Seq[Int]): Seq[Int] = (a1, a2) match {
  case (a1_head +: a1_tail, a2_head +: a2_tail) =>
    if (a1_head < a2_head) a1_head +: merge(a1_tail, a2)
    else a2_head +: merge(a1, a2_tail)
  case _ => a1 ++ a2
}
					

Падает в StackOverflow при более чем ~3500 элементов

Использование длины списка для проверки на пустоту


if (list.length > 0) {
  // ...
}
					

вместо


if (list.nonEmpty) {
  // ...
}					
					

Seq бывают разные - в некоторых реализациях length "дорогая" операция

Code Style

Имена переменных пишем в camelCase;
имена типов - MyClassName

Подробнее: Scala Style Guide

Функциональный подход и иммутабельность.

В функциональном программировании предпочитают

  • Чистые функции
  • Неизменяемые (иммутабельные) данные

эти свойства делают код более предсказуемым, а программы - более надежными

Чистые функции:
  • являются детерминированными
  • не обладают побочными эффектами
Вычисление чистых функций
  • можно кешировать
  • переупорядочивать и откладывать их выполнение (поговорим на 3-м занятии)
  • передавать в другие потоки (поговорим на 5-м занятии)

Сложности изменяемых данных

object UserRepository {
  private val users: ListBuffer[String] = ListBuffer()

  def checkPass(user: String, password: String): Boolean = ???
  
  def login(user: String, password: String): Unit = {
    if (checkPass(user, password)) {
      users += user
    } else {
      throw new BadPasswordException(user)
    }
  }

  def getUsers(): ListBuffer[String] = users
} 
Проблема:

// oops!

UserRepository.getUsers().append("hacker")
					
аналогичная проблема с данными которые передают в наши функции
В Java такие проблемы обычно решают "защитными" копиями:

def getUsers(): Seq[String] = users.toVector
					
В Scala это работает лучше, так как попытки модифицировать не компилируются
Но можно сразу работать с неизменяемыми данными:

object UserRepository {
  private var users: Vector[String] = Vector.empty

  def login(user: String, password: String): Unit = {
    if (checkPasword(user, password)) {
      users = users :+ user
    } else {
      throw new BadPasswordException(user)
    }
  }

  def getUsers(): Seq[String] = users
}
					
эффективно ли это?

Персистентные структуры данных

Операции создают новую версию; старая остается полностью "рабочей"

Обе версии разделяют общие элементы данных, насколько это возможно

Классический связных список: List[T]

Структура стала популярной благодаря языку Lisp

Используется в Ocaml, Haskell, Erlang и др.

Хороша для обучения, не слишком эффективна на практике

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

List - алгебраический тип с двумя вариантами:

  • ::(head, tail) - элемент списка, обычно называют "cons"
  • Nil - пустой список

в Scheme используют просто "пару"

например:


val l: List[Int] = 42 :: 69 :: 613 :: Nil
// для удобства есть конструктор List(42, 69, 613)
					

(вычисляется справа налево)

* картинка Yonkeltron at English Wikipedia

Два разных списка разделяют общий "хвост"


val list = 5 :: 4 :: 3 :: 2 :: 1 :: Nil

val list2 = 10 :: list
val list3 = 20 :: list
					

Декомпозиция на голову и хвост – "бесплатная" операция.

Плохой (но популярный) способ:


val l: List[Int] = 42 :: 69 :: 613 :: Nil

if (l.nonEmpty) {
  println(l.head) // 42
  println(l.tail) // List(69, 613)
}		
					

Используем pattern matching:


l match {
  case head :: tail ⇒ // head +: tail
    println(head)
    println(tail)
  case Nil ⇒         // Seq()
    println("empty")
}
					
Работают и более сложные варианты:

l match {
  // через конструктор - точное число элементов
  case Seq(one) ⇒
     s"exactly $one"
  // как минимум один элемент
  case one +: _ ⇒ 
    s"at least one element $one"
  // второй элемент 10; выкинем его и преобразуем в строку
  case head +: 10 +: tail ⇒
    (head +: tail).mkString
}
					

Декомпозицию можно использовать для итерирования по элементам списка.

Сумма элементов списка


def sum(list: List[Int]): Int = {
  list match {
    case Nil ⇒ 
      0
    case head :: tail ⇒
      head + sum(tail)
  }
}
					

сборщик мусора собирает список прямо в процессе итерации (если на него нет ссылок)

Получение "хвоста" начиная с N-ного элемента:


def drop(list: List[Int], n: Int): List[Int] = {
  if (n > 0 && list.nonEmpty) {
    drop(list.tail, n - 1)
  } else {
    list
  }
}
					
Тут рекурсию можно заменить на цикл:

def dropLoop(list: List[Int], n: Int): List[Int] = {
  var these = list
  var count = n
  while (these.nonEmpty && count > 0) {
    these = these.tail
    count -= 1
  }
  these
}
					
Получение элемента по индексу:

// упрощенная версия кода библиотеки Scala 2.12.4
def apply(n: Int): Int = {
  val rest = drop(n)

  if (n < 0 || rest.isEmpty) 
    throw new IndexOutOfBoundsException(n.toString)

  rest.head
}
					

количество операций пропорционально N

Реверс списка:

def reverse(list: List[Int]): List[Int] = {
  def rev(target: List[Int], remains: List[Int]): List[Int] = {
    remains match {
      case head :: tail ⇒
        rev(head :: target, tail)
      case Nil ⇒
        target
    }
  }

  rev(Nil, list)
}

					

(легко можно заменить на цикл)

Добавление в хвост - только с созданием полной копии.


def append(list: List[Int], v: Int): List[Int] = {
  list match {
    case Nil ⇒ 
      v :: Nil
    case head :: tail ⇒
      head :: append(tail, v)
  }
}
					

число операция пропорционально длине списка

Проблемы этого кода:

  • Быстро переполняется стек
  • На цикл заменить нельзя

Как работает этот код?

Список копируется в стек

Стек раскручивается в новый список

Та же логика, но без стека:

def append(list: List[Int], v: Int): List[Int] = {
  (v :: list.reverse).reverse
}
					

Хвостовая рекурсия — частный случай рекурсии, при котором любой рекурсивный вызов является последней операцией перед возвратом из функции.

Хвостовая рекурсия автоматически заменяется на цикл компилятором (в границах одного файла!)


@tailrec // оптимизация или ошибка
def drop(list: List[Int], n: Int): List[Int] = {
  if (n > 0 && list.nonEmpty) {
    drop(list.tail, n - 1)
  } else {
    list
  }
}
					

filter

Одна из "классических" функций: оставляет только элементы, для которых верно условие фильтрации.


// метод List[A] 					
def filter(pred: A => Boolean): List[A]
					

Пример использования - только четные числа:


val l: List[Int] = List.fill(10)(Random.nextInt(10))

l.filter(_ % 2 == 0)					
					

эквивалентно


l.filter(x ⇒ x % 2 == 0)					
					
Простая реализация:

def filter(list: List[Int], f: Int ⇒ Boolean): List[Int] = {
  list match {
    case head +: tail if f(head) ⇒ head +: filter(tail, f)
    case _ +: tail               ⇒ filter(tail, f)
    case Nil                     ⇒ Nil
  }
}

filter(list, _ % 2 == 0)					
					

рекурсия не хвостовая, стек переполняется

Плохая идея


def filter(list: List[Int], f: Int ⇒ Boolean): List[Int] = {
  def rec(list: List[Int], acc: List[Int]): List[Int] = {
    list match {
      case head +: tail if f(head) ⇒ rec(tail, acc :+ head)
      case _ +: tail               ⇒ rec(tail, acc)
      case Nil                     ⇒ acc
    }
  }

  rec(list, Nil)
}
					

Рекурсия хвостовая, но количество операций - пропорционально N*N

Время выполнения:

  • 100 элементов ~ 1 ms
  • 1000 элементов ~ 2 ms
  • 10000 элементов ~ 93 ms
  • 100000 элементов ~ 11 секунд
  • 200000 элементов ~ 51 секунда

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

Но в стандартной библиотеке Scala (и многих других) код оптимизирован.

List в библиотеке Scala - не совсем immutable:


final case class ::[B](override val head: B, 
                       private[scala] var tl: List[B]) 
		       extends List[B]
					

Однако, для пользовательского кода все гарантии сохранены.

List позволено менять классу ListBuffer - "конструктору" списка.

В ListBuffer хранится приватный список, в который можно добавлять в конец.

Списки, полученные из ListBuffer никогда не меняются.

def filter(list: List[Int])(f: Int ⇒ Boolean): List[Int] = {
  val builder = ListBuffer[Int]()
  var current = list
  
  while (current.nonEmpty) {
    if (f(current.head)) {
      builder += current.head
    }
    
    current = current.tail
  }
  
  builder.toList // после список уже никогда не изменится
} 

map - применяет функцию ко всем элементам списка


list.map(x ⇒ x * x)					
					

можно указать готовую функцию:


list.map(Math.sqrt) // для List[Double]
					

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


val list = List(1, 2, 3)

list.map(x ⇒ List.fill(2)(x))
// List[List[Int]] = List(List(1, 1), List(2, 2), List(3, 3))
					

flatten - превращаем список списков в плоский:


val list = List(1, 2, 3)

list.map(x ⇒ List.fill(2)(x)).flatten
// List(1, 1, 2, 2, 3, 3)
					

flatMap - комбинация map и flatten


list.flatMap(x ⇒ List.fill(2)(x))
					

Как превратить List[Option[Int]] в List[Int]?

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


val list = List(Some(1), None, Some(3))

list.filter(_.isDefined).map(_.get)
					

Option.get - источник ошибок

collect - совмещаем map, filter и pattern matching


val list = List(Some(1), None, Some(3))

list collect {
  // определяем только интересные паттерны
  case Some(v) ⇒ v
}
					

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


def foldLeft[B](z: B)(op: (B, A) => B): B
					

z - начальное значение

Сумма элементов списка:


list.foldLeft(0)(_ + _)
					

отдельно функция:


def op(acc: Int, v: Int) = acc + v

list.foldLeft(0)(op)
					
Применяется foldLeft вот так:

op(0, list(0))

op(op(0, list(0)), list(1))

op(op(op(0, list(0)), list(1)), list(2))

op(op(op(op(0, list(0)), list(1)), list(2)), list(3))

...
					
Пример: убираем повторяющиеся подряд элементы:

list.foldLeft(List.empty[Int]) { (acc, v) ⇒
  if (acc.headOption.contains(v)) {
    acc
  } else {
    v :: acc
  }
}.reverse					
					

Пример: функция count


val list = List.fill(1000)(Random.nextInt(100))

list.count(_ > 50)
					

Реализация через filter:


def count(list: List[Int], f: Int ⇒ Boolean): Int = 
  list.filter(f).length
					

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

Через foldLeft:


def count(list: List[Int], f: Int ⇒ Boolean): Int = {
  list.foldLeft(0) { (acc, v) ⇒
    if (f(v)) {
      acc + 1
    } else {
      acc
    }
  }
}
					

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

Неэффективность List:
  • Большая нагрузка на сборщик мусора
  • Большой overhead по памяти (в два раза больше массива)
  • Многие операции над списком O(N)

На 3-м занятии поговорим о других структурах данных

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

Домашнее задание (1/3):

Элементы списка произвольной длины сдигаем влево на N позиций, например:


val list = List('a', 'b', 'c', 'd', 'e', 'f', 
                'g', 'h', 'i', 'j', 'k'))

rotate(3, list)
// List('d', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'a', 'b', 'c')

rotate(-2, list)
// List('j', 'k', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i')
					

Разрешено использовать только декомпозицию списка; сборку через ::; fold и reverse

Домашнее задание (2/3):

Реализуем две стандартные функции:


// проверяем существование элемента, удовлетворяющего условию
def exists(list: List[Int], f: Int ⇒ Boolean): Boolean = ???

// проверяет что все элементы удовлетворяют условию
def forall(list: List[Int], f: Int ⇒ Boolean): Boolean = ???
					

"Short-circuit evaluation" - вычисляем пока не получен результат.

Одну функцию реализуем хвостовой рекурсией, вторую - циклом.

Домашнее задание (3/3):

Проверка корректности решения Судоку - головоломки с числами.

поле представлено списком списков


val sudoku: List[List[Int]] = List(
  List(1, 2, 3, 4, 5, 6, 7, 8, 9),
  List(4, 5, 6, 7, 8, 9, 1, 2, 3),
  List(7, 8, 9, 1, 2, 3, 4, 5, 6),
  List(2, 3, 4, 5, 6, 7, 8, 9, 1),
  List(5, 6, 7, 8, 9, 1, 2, 3, 4),
  List(8, 9, 1, 2, 3, 4, 5, 6, 7),
  List(3, 4, 5, 6, 7, 8, 9, 1, 2),
  List(6, 7, 8, 9, 1, 2, 3, 4, 5),
  List(9, 1, 2, 3, 4, 5, 6, 7, 8)
)
					

Можно пользоваться любыми функциями библиотеки.

Не забываем про тесты!

NonEmptyList*

дополнительная часть, если успеем

Специальный тип, реализованный в библиотеках Cats, Scalaz и др.


  // код библиотеки Cats
  final case class NonEmptyList[+A](head: A, tail: List[A]) 
    extends Product with Serializable 
					

Специальная головная ячейка списка

Вопрос: чем это отличается от обычного списка?


  final case class NonEmptyList[+A](head: A, tail: List[A]) 
    extends Product with Serializable 
					

Функции которые увеличивают или не меняют длину возвращают NonEmptyList

Остальные возвращают List

Пример:

import cats.data.NonEmptyList

val list: NonEmptyList[Int] = NonEmptyList.of(1, 2, 3, 4)

val list2: NonEmptyList[Int] = list.map(_ * 2)

val list3: List[Int] = list.tail
					

зачем?

Например делаем обработчик данных веб-форм на сайте

Простой вариант - функция


def process(request: Request): Result
					

При ошибках - бросаем исключение

Некорректный ввод - не исключение


def process(request: Request): Either[ValidationError, Result]
					

Много полей - много разных ошибок


def process(request: Request): 
  Either[List[ValidationError], Result]
						

Что делать если обработчик вернул Left[Nil]?


def process(request: Request): 
  Either[NonEmptyList[ValidationError], Result]
						

Избегаем ошибки при помощи более точного типа

Неизменяемая очередь: устройство и производительность*

дополнительная часть, если успеем

У очереди две стандартные операции:

  • Добавить элемент в конец очереди
  • Извлечь первый элемент из очереди

Очередь не изменяемая, помещаем в неё элемент:


import scala.collection.immutable.Queue

var queue = Queue[String]()

queue = queue.enqueue("first")
					

Обрабатываем элементы


val (next, nextQueueValue) = queue.dequeue
// process(next) - вариант 1
queue = nextQueueValue
// process(next) - вариант 2					
					

Вопрос: в чем разница?

Как это работает? List не подходит

Два приватных списка:

  • in - для добавления
  • out - для извлечения

// переформатированный код библиотеки Scala 2.12.4
def dequeue: (A, Queue[A]) = out match {
  case Nil if !in.isEmpty => 
    val rev = in.reverse 
    (rev.head, new Queue(Nil, rev.tail))
  case x :: xs => 
    (x, new Queue(in, xs))
  case _ => 
    throw new NoSuchElementException("dequeue on empty queue")
}
					

Проблемы immutable Queue:

  • Производительность ниже чем у mutable
  • Иногда плохое время отклика

Операции map/filter/flatMap у Option*

дополнительная часть, если успеем

Option напоминает коллекцию, которая или пустая, или содержит один элемент


val v: Option[Int] = Some(11)

v.filter(_ > 5) // Some(11)
v.map(_ * 2) // Some(22)
					

val users = List(1 -> "Petya", 11 -> "Vasya", 23 -> "Ivan")
v.flatMap(id ⇒ users.find(_._1 == id)).map(_._2)
// Some("Vasya")
					

С следующий раз поговорим о хеш-таблицах


val usersMap = Map(1 -> "Petya", 11 -> "Vasya", 23 -> "Ivan")
v.flatMap(usersMap.get)