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

План

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

Почему List?

Две причины, первая:
  • Классическая структура, придуманная в языке Lisp
  • Есть во всех ФП языках, например в OCaml, Haskell, F#. И еще в Erlang.
  • База для более сложных структур.
Вторая. Мой личный top-3 проблем производительности Scala кода.
  1. Построчная работа с БД.
  2. Отладочное журналирование, даже выключенное.
  3. Неаккуратная работа с коллекциями.

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

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

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

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

Чистые функции:
  • являются детерминированными
  • не обладают побочными эффектами
Вычисление чистых функций
  • можно кешировать
  • переупорядочивать и откладывать их выполнение (поговорим на 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]

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

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

  • ::(head: T, tail: List[T]) - элемент списка, обычно называют "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
}
					

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

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


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

// упрощенная версия кода библиотеки 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 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
}
					
Реверс списка:

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

					

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

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 (и многих других) код оптимизирован.

Перерыв 5 минут

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 // после список уже никогда не изменится
} 

Builder - абстракция для "конструкторов" неизменяемых типов


val builder = Vector.newBuilder[Int]
builder += 1
builder.result()
					

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.flatMap(List.fill(2))
					

Как превратить List[Option[Int]] в List[Int]?
(без flatten)

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


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
}
					

for тоже так может:


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

for {
  Some(v) <- list
} yield 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
    }
  }
}
					

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

Домашнее задание

Множество на основе бинарных деревьев поиска.

wikipedia

  • immutable
  • моделируем алгебраическим типом
  • операция вставки (без балансировки)
  • операция проверки наличия элемента
  • операция foreach (в любом порядке)

trait SimpleTreeSet {
  def +(v: Int): SimpleTreeSet
  def contains(v: Int): Boolean
  def foreach(f: Int => Unit): Unit
}
					

Как выкладывать решение

  • Только git (gitlab/github по выбору, выбор не меняем)
  • Начинаем с пустого коммита (git commit --allow-empty)
  • От пустого коммита создаем ветку "reviewed", не забываем push
  • Готовое решение в merge request с master на reviewed
  • Меня назначьте assignee, ссылку в почту

Семинар

Исследование производительности коллекций
при помощи JMH.

Стартовый код в репозитории
(каталог code/seminar2).

Заранее откройте его в IDEA. Запустите командой

sbt "jmh:run -f 0 -wi 1 -i 1"

Напоминаю:

NonEmptyList*

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

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


  // код библиотеки Cats
  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")
					

Напоминаю: