Страничка курса: https://maxcom.github.io/scala-course-2022/
В функциональном программировании предпочитают
эти свойства делают код более предсказуемым, а программы - более надежными
Сложности изменяемых данных
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 интерфейс коллекция изменяемый
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] - алгебраический тип с двумя вариантами:
в 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)
}
(легко можно заменить на цикл)
Одна из "классических" функций: оставляет только элементы, для которых верно условие фильтрации.
// метод 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
Время выполнения:
Классическое решение - сборка списка в обратном порядке и 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)(_ + _)
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
}
}
}
еще можно реализовать рекурсивным обходом списка
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()
Специальный тип, реализованный в
библиотеке 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]
Избегаем ошибки при помощи более точного типа
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")
Напоминаю: