Лекция 2.
Работа со списками.

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

План

  1. Почему мы говорим о List?
  2. Функциональный подход и иммутабельность.
  3. Персистентные структуры данных. Устройство List
  4. Рекурсия
  5. filter, map, flatMap и foldLeft.
  6. ListBuffer.
  7. NonEmptyList.
  8. Операции над Option.

Почему List?

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

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

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

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

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

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

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

object UserRepository {
  private val users: Buffer[String] = ArrayBuffer()
  // ??? из стандартной библиотеки
  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(): Buffer[String] = users
} 
Проблема:

// oops!

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

Но это не решает проблему полностью,
и еще в Java интерфейс коллекция изменяемый

Еще такие проблемы решаются "защитными" копиями:

def getUsers(): Seq[String] = users.toVector
					
но это не эффективно
Но можно сразу работать с неизменяемыми данными:

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

  def login(user: String, password: String): Unit = {
    if (checkPasword(user, password)) {
      users = users :+ user // новый Vector
    } else {
      throw new BadPasswordException(user)
    }
  }

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

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

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

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

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

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

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

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

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

например:


val l: List[Int] = 42 :: 69 :: 613 :: Nil
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"only $one"
  // как минимум один элемент
  case element +: _ => 
    s"first $element and more"
  // второй элемент 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
  }
}
					

хвостовая рекусия это как goto :-)

Тут рекурсию можно заменить на цикл:

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
}
					

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

JVM сама это не умеет, к сожалению


@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) // drop не хуже итерации по списку

  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] = {
  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
					

Не собирайте списки через append!

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

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(x => x % 2 == 0)					
					

более компактный синтаксис


l.filter(_ % 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 (и многих других) код оптимизирован. Оптимизацию покажу позже.

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

// List[List[Int]] = List(List(1, 1), List(2, 2), List(3, 3))
					

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


val list = List(1, 2, 3)

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

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


list.flatMap(List.fill(2))
					

flatten и flatMap есть у многих интересных типов

Как превратить 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
					

(про for расскажу на 3-й лекции)

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


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

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

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


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

list.foldLeft(0)(op)
					
компактный синтаксис:

list.foldLeft(0)(_ + _)
					
Применяется 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.empty[Int] вместо Nil так как нужен тип элемента
list.foldLeft(List.empty[Int]) { (acc, v) =>
  // .headOption как .head, но возвращает Option
  // .contains у Option проверяет наличие в нем значения
  if (acc.headOption.contains(v)) {
    acc
  } else {
    // собираем в обратном порядке
    v :: acc
  }
}.reverse

// как избавиться от 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
    }
  }
}
					

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

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

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


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

NonEmptyList

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


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

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

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

def nameOf(v: List[Int]): Option[String] = 
  v.flatMap(id => users.find(_._1 == id)).map(_._2)

// Some("Vasya")
					

Напоминаю: