В этой статье наш ведущий инженер по обработке данных Артём Корсаков разбирает некоторые особенности использования 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 года в Музее Криптографии. Переходите по ссылке. Нам нужны ваши лайки, а также шпицы и лаппхунды! :)

Комментарии (2)


  1. shai_hulud
    16.05.2023 12:40

    С птицей надо было разделить на два trait, winged & flying и диссонанс бы ушел


  1. brake
    16.05.2023 12:40
    +2

    Похоже, статья писалась ради размещения ссылки на ютуб. Тема раскрыта слабо, тэг data engineering вообще не в кассу.

    Тема "как мы применяем декомпозицию в работе дейта инженеров" была бы поинтереснее, потому как в пайплайнах не особо развернёшься с этим.