Почему пингвины падают в пропасть: особенности декомпозиции в Scala

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Источник: https://habr.com/ru/companies/kryptonite/articles/735440/


Интересные статьи

Интересные статьи

Руководители C-level, такие как CEO, COO, CPO, CMO, CTO и т.д., играют большую роль в жизни компаний. Несмотря на высокий уровень ответственности и престижа, который сопутствует этим должностям, сущес...
Микросервисная архитектура – одна из самых полезных архитектур в индустрии программного обеспечения. При правильном подходе она может помочь в создании лучших программных приложений. Из этой статьи вы...
После несерьёзной статьи на серьёзную тему Job Safety Driven Development возникла идея написать о том, как появляются ошибки разработчиков. Вместо этого появилась статья «Почему всё ломается...
Криптовалютам и блокчейну однозначно суждено изменить мировой платежный рынок. При этом надо отметить, что большинство платежных систем оказываются заметно дальновиднее как государства, так и банков п...
В свое время, в далеком уже 2017 году, мы опубликовали статью Что такое Intel Xeon Scalable?, в которой поясняли суть масштабной трансформации серверной линейки процессоров Intel. С тех пор прошло...