
В этой статье наш ведущий инженер по обработке данных Артём Корсаков разбирает некоторые особенности использования Scala и показывает на шуточных примерах "подводные камни", на которые часто натыкаются разработчики.
Как и во многих других языках программирования, в Scala часто используется декомпозиция. По сути, это разбиение сложного типа данных на более простые части и извлечение из них каких-то данных. В Scala 3 для этого используются трейты, а также сочетание сопоставления с образцом (pattern matching) и кейс-классов (case classes).
Трейты — особый тип классов, определяющий набор методов и полей, доступных для использования в других классах. Также трейты можно рассматривать как шаблоны, которые определяют функциональность, которую вы хотите добавить в свой класс.
Звучит слишком запутанно? Тогда немного пофантазируем!
Представим себя в роли Адама, решившего дать названия всем животным, и начавшего с птиц. Вот мы встречаем одну птицу, затем другую… с точки зрения программирования этот процесс можно обозначить как работу с потоком данных, в котором каждому элементу нужно присвоить наименование, соответствующее его структуре. И тут возникает первая проблема: мы начинаем думать, а что же такое птица?!
Смотрим на сапсана и видим, что он перелетает из пункта А в пункт Б над бездонной пропастью за 10 секунд и никто, кроме птиц, так больше не может. Крокодил, змея, корова — все падают в пропасть!
Отлично, мы нашли отличительный признак! Попробуем выразить его на Scala:
```
trait CanBeBird:
def wings: Int
def flyTime: Int
```
Ура! Фанфары! Мы знаем, что такое птица! Сейчас начнём раздавать имена…
Сапсан, Орёл, Сокол… — какой поток прекрасных птиц!
```
trait CanBeBird:
def wings: Int
def flyTime: Int
println(s"Я могу пролететь над бездонной пропастью за $flyTime секунд")
object Peregrine extends CanBeBird:
def wings: Int = 2
def flyTime: Int = 10
object Eagle extends CanBeBird:
def wings: Int = 2
def flyTime: Int = 15
object Falcon extends CanBeBird:
def wings: Int = 2
def flyTime: Int = 14
val birds = List(Hummingbird, Eagle, Falcon)
// Я могу пролететь над бездонной пропастью за 10 секунд
// Я могу пролететь над бездонной пропастью за 15 секунд
// Я могу пролететь над бездонной пропастью за 14 секунд
```
Пингвин!!!
Рано или поздно в этой очереди появится пингвин! Он точно там будет! Без пингвинов жизнь была слишком беззаботной, а этого ни в коем случае нельзя допустить!
Пингвин точно упадёт в пропасть — других вариантов нет!
Затем на пингвина упадёт страус, вслед за ним — какапо, казуар, такахе и киви (не фрукт, а птица). Вскоре на дне пропасти у нас образуется фарш из исключений, наглядно демонстрирующий, что мы где-то ошиблись.
Но ведь пингвин и остальные падшие твари — тоже птицы?! Да, и что же теперь делать?
Есть две распространённые ошибки, совершаемые в похожей ситуации:
1) Выдать исключение или `null`
```
object Penguin extends CanBeBird:
def wings: Int = 2
def flyTime: Int = throw new IllegalArgumentException("Я упаду в пропасть!")
```
В этом случае Адам как бы перекладывает проблему на будущих потомков, которые потом дальше будут разбираться, что же делать с пингвином и другими птицами, умеющими летать только вертикально вниз. Такой подход приводит к разрастанию клиентского кода, в котором вынуждены перехватывать исключения и как-то их обрабатывать. Автор библиотеки как бы пытается сделать вид, что дочерний элемент обладает всеми свойствами родительского, что они как бы равны, хотя это не так.
2) `Option`
Второй распространённой ошибкой является использование `Option`, что вроде бы выглядит как-то по Scala’вски. Функциональное программирование и все дела!
Но давайте посмотрим на код:
```
trait CanBeBird:
def wings: Int
def flyTime: Option[Int] = None
object Hummingbird extends CanBeBird:
def wings: Int = 2
override def flyTime: Option[Int] = Some(10)
...
object Penguin extends CanBeBird:
def wings: Int = 2
val birds = List(Hummingbird, Eagle, Falcon, Penguin)
birds.foreach { bird =>
bird.flyTime match
case Some(time) =>
println(s"Я могу пролететь над бездонной пропастью за $time секунд")
case _ => println("А я упаду в пропасть!")
}
// Я могу пролететь над бездонной пропастью за 10 секунд
// Я могу пролететь над бездонной пропастью за 15 секунд
// Я могу пролететь над бездонной пропастью за 14 секунд
// А я упаду в пропасть!
```
Проблема решена? Не совсем!
Такой способ приводит к возникновению антипаттерна «God object». Родитель начинает разрастаться до бесконечности и, как правильно заметили в комментарии к онлайн-трансляции Scala-митапа в Музее Криптографии, становится своеобразной википедией. Родитель «может все», тогда как дочерние элементы со временем могут даже не иметь общих методов, хотя наследуются от одного родителя. Дочерние элементы «глушат» родительскую функциональность. И получается, что ключевое слово `extends` используется с точностью до наоборот — родитель наследует от своего дочернего элемента!
На самом деле, здесь Адаму следовало бы выделить более абстрактный интерфейс для птиц, и для всяких нелетающих использовать именно его:
```
trait CanBeBird:
def wings: Int
trait CanBeFlyingBird extends CanBeBird:
def wings: Int
def flyTime: Int
object Hummingbird extends CanBeFlyingBird:
def wings: Int = 2
def flyTime: Int = 20
object Penguin extends CanBeBird:
def wings: Int = 2
val birds: List[CanBeBird] = List(Hummingbird, Penguin)
birds.foreach {
case bird: CanBeFlyingBird =>
println(s"Я могу пролететь над бездонной пропастью за ${bird.flyTime} секунд")
case _ => println("А я упаду в пропасть!")
}
// Я могу пролететь над бездонной пропастью за 20 секунд
// А я упаду в пропасть!
```
В этом случае ответственность также перекладывается на клиента, но это делается явно!
Пользователь сам решает, что ему делать с `птицей-которая-умеет-летать` и `просто-птицей`. Он не получает сюрпризов в виде исключений, null и прочего — всё поведение кода открыто.
Первый раз в абстрактный класс
Выше мы рассмотрели абстрактный интерфейс, а что же такое абстрактные классы?
Abstract class — это класс, который не может быть инициализирован напрямую, а только через его конкретные подклассы. Он может содержать конструкторы и поля, которые должны быть реализованы в его подклассах.
Давайте отвлечёмся от птиц и представим что-нибудь… более абстрактное!
Допустим, что мы работаем с геометрическими фигурами, и у нас есть класс `Shape` с подклассами `Circle` и `Rectangle`.
abstract class Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
Теперь мы можем использовать декомпозицию при работе с объектами этих классов. Например, мы можем вычислить площадь фигуры следующим образом:
def area(shape: Shape): Double = shape match {
case Circle(radius) => math.Pi * radius * radius
case Rectangle(width, height) => width * height
}
Здесь `match` используется для сопоставления с образцом. Если объект `shape` является экземпляром класса `Circle`, то мы извлекаем радиус и используем его для вычисления площади круга. Если же `shape` является экземпляром класса `Rectangle`, то мы извлекаем ширину и длину, а затем используем их для вычисления площади прямоугольника.
С использованием трейтов этот пример выглядел бы следующим образом:
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double)
extends Shape
Главное различие между «trait» и «abstract class» заключается в том, что «trait» может быть добавлен к любому классу, даже если этот класс уже наследует другой класс, тогда как «abstract class» может быть наследован только одним классом.
Если совсем упростить, то «trait» — это набор методов и полей, которым нужно следовать, чтобы создавать определённые классы. В отличие от него, «abstract class» — это класс-шаблон, уже содержащий в себе некоторые общие методы и поля, которые будут использоваться в его подклассах.
О том, что ещё может предложить Scala в плане декомпозиции, а также про «эффект трамплина» эксперты «Криптонита» разбирали на Scala-митапе, прошедшем 20 апреля 2023 года в Музее Криптографии. Переходите по ссылке. Нам нужны ваши лайки, а также шпицы и лаппхунды! :)
