Инженер по данным в "Криптоните"

Декомпозиция в Scala 3

https://scalabook.gitflic.space

О чем будет доклад

Основные средства декомпозиции в Scala 3:

  • Параметры конструктора в trait-ах
  • Прозрачные trait-ы
  • Экспортирование элементов
  • Различие trait и abstract class
  • Распространённые ошибки при декомпозиции
trait Combinator[A]:
  def combine(a1: A, a2: A): A
  val empty: A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

Сделай что-нибудь

object IntCombinator extends Combinator[Int]:
  val empty: Int                     = 0
  def combine(a1: Int, a2: Int): Int = a1 + a2

IntCombinator.combineAll(List.empty)    // 0
IntCombinator.combineAll(List(1))       // 1
IntCombinator.combineAll(List(1, 2, 3)) // 6

Реализовать

trait Combinator[A]:
  def combine(a1: A, a2: A): A
  val empty: A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

Параметры конструктора

trait Combinator[A](empty: A):
  def combine(a1: A, a2: A): A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

object IntCombinator extends Combinator[Int](0):
  def combine(a1: Int, a2: Int): Int = a1 + a2

IntCombinator.combineAll(List(1, 2, 3)) // 6
trait Combinator[A](empty: A):
  def combine(a1: A, a2: A): A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

object IntCombinator extends Combinator[Int]:
  def combine(a1: Int, a2: Int): Int = a1 + a2
// missing argument for parameter empty of constructor Combinator 
// in trait Combinator: (empty: Int): Playground.Combinator[Int]
  
trait Combinator[A](empty: A):
  def combine(a1: A, a2: A): A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

trait IntCombinator extends Combinator[Int](0):
  def combine(a1: Int, a2: Int): Int = a1 + a2
// trait IntCombinator may not call constructor of trait Combinator

Fail, fail, fail

trait Combinator[A](using empty: A):
  def combine(a1: A, a2: A): A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

given Int = 0

object IntCombinator extends Combinator[Int]:
  def combine(a1: Int, a2: Int): Int = a1 + a2

IntCombinator.combineAll(List.empty)    // 0
IntCombinator.combineAll(List(1))       // 1
IntCombinator.combineAll(List(1, 2, 3)) // 6

Контекстные параметры

trait Combinator[A](empty: A):
  println(s"Пустое значение - $empty")

object StringCombinator extends Combinator[String]("пусто")

StringCombinator
// Пустое значение - пусто

Параметры конструктора инициализируются до

trait Combinator[A]:
  val empty: A
  println(s"Пустое значение - $empty")

object StringCombinator extends Combinator[String]:
  val empty: String = ""

StringCombinator
// Пустое значение - null

А в чем разница?

А в чем разница?

Смешанная композиция

trait Empty[A](val empty: A)

trait Combinator[A](combine: (A, A) => A):
  val empty: A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

object IntCombinator 
  extends Combinator[Int](_ + _), Empty[Int](0)

IntCombinator.combineAll(List.empty)    // 0
IntCombinator.combineAll(List(1))       // 1
IntCombinator.combineAll(List(1, 2, 3)) // 6

Смешанная композиция

trait EmptyInt1:
  val empty: Int = -1

trait EmptyInt2:
  val empty: Int = 1

trait Combinator[A]:
  def combine(a1: A, a2: A): A
  val empty: A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

object IntCombinator extends Combinator[Int], 
  EmptyInt1, EmptyInt2:
  
  override val empty: Int = 0
  def combine(a1: Int, a2: Int): Int = a1 + a2

IntCombinator.combineAll(List(1, 2, 3)) // 6

Смешанная композиция

Различие trait и abstract class

  • Можно расширить несколько trait-ов
  • Можно расширить не более 1 абстрактного класса
  • Абстрактные классы совместимы с Java

to trait or not to trait?

Для повторного использования в нескольких несвязанных классах - trait

Если поведение не будет использоваться повторно - class

Если необходимо наследовать в коде Java - abstract class

Различие trait и abstract class

trait IntCombine:
  def combine(a1: Int, a2: Int): Int = a1 + a2

trait EmptyInt1:
  val empty: Int = -1

trait EmptyInt2:
  val empty: Int = 1

trait Combinator[A]:
  def combine(a1: A, a2: A): A
  val empty: A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

object IntCombinator1 extends Combinator[Int], EmptyInt1, IntCombine
object IntCombinator2 extends Combinator[Int], EmptyInt2, IntCombine

val condition: Boolean = true
val x = Set(if condition then IntCombinator1 else IntCombinator2)
// Тип х - Set[Combinator[Int] & IntCombine]

Прозрачные trait-ы

transparent trait IntCombine:
  def combine(a1: Int, a2: Int): Int = a1 + a2
transparent trait EmptyInt1:
  val empty: Int = -1
transparent trait EmptyInt2:
  val empty: Int = 1

trait Combinator[A]:
  def combine(a1: A, a2: A): A
  val empty: A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

object IntCombinator1 extends Combinator[Int], EmptyInt1, IntCombine
object IntCombinator2 extends Combinator[Int], EmptyInt2, IntCombine

val condition: Boolean = true
val x = Set(if condition then IntCombinator1 else IntCombinator2)
// Тип х - Set[Combinator[Int]]

Примеры прозрачных trait-ов:

  • Product, Serializable, Comparable
  • IterableOps, StrictOptimizedSeqOps

Прозрачные trait-ы

Открытые классы

  • class-ы нельзя расширять извне
  • только если очень хочется

Открытые классы

open class Combinator[A](empty: A, combine: (A, A) => A):
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

Файл Combinator.scala:

object IntCombinator extends Combinator[Int](0, _ + _)

IntCombinator.combineAll(List.empty)    // 0
IntCombinator.combineAll(List(1))       // 1
IntCombinator.combineAll(List(1, 2, 3)) // 6

Файл IntCombinator.scala:

  • open (case) class не может быть sealed или final
  • trait и abstract class по умолчанию open
  • разница `class` (case class) с `sealed class` (sealed case class) в том, что первый можно расширить, "подавив" предупреждение

Открытые классы

Что кроме наследования?

trait Combinator[A]:
  def combine(a1: A, a2: A): A
  val empty: A
  def combineAll(list: List[A]): A =
    list match
      case Nil    => empty
      case h :: t => combine(h, combineAll(t))

object IntCombinator:
  private val combinator: Combinator[Int] = new:
    val empty: Int                     = 0
    def combine(a1: Int, a2: Int): Int = a1 + a2

  export combinator.combineAll

IntCombinator.combineAll(List.empty)    // 0
IntCombinator.combineAll(List(1))       // 1
IntCombinator.combineAll(List(1, 2, 3)) // 6

Экспортирование элементов

А где же type class?

trait Combinator[A]:
  val empty: A
  def combine(a1: A, a2: A): A

given Combinator[Int] with
  val empty: Int = 0
  def combine(a1: Int, a2: Int): Int = a1 + a2

def combineAll[A](list: List[A])(using c: Combinator[A]): A =
  list match
    case Nil    => c.empty
    case h :: t => c.combine(h, combineAll(t))

combineAll(List.empty)    // 0
combineAll(List(1))       // 1
combineAll(List(1, 2, 3)) // 6

Паттерн type class

  • Отделение поведения от типа
  • Добавление поведения к "недоступным" типам
  • Добавление поведения без изменения исходного класса
  • Определение поведения без типа
  • Добавление более 1 поведения к типу

Пример: cats, scalaZ

Для чего нужны type class-ы?

given Combinator[Int] with
  val empty: Int = 1
  def combine(a1: Int, a2: Int): Int = a1 * a2

combineAll(List(1, 2, 3, 4, 5)) // 120
trait Combinator[A]:
  val empty: A
  def combine(a1: A, a2: A): A

def combineAll[A](list: List[A])(using c: Combinator[A]): A =
  list match
    case Nil    => c.empty
    case h :: t => c.combine(h, combineAll(t))

Combinator для List

combineAll(List(List()))             
combineAll(List(List(1), List(1, 2, 3, 4, 5)))
given list[A]: Combinator[List[A]] with
  val empty: List[A] = List.empty[A]
  def combine(a1: List[A], a2: List[A]): List[A] = 
    a1 ++ a2

combineAll(List(List(1), List(1, 2, 3, 4, 5))) 
// List(1, 1, 2, 3, 4, 5)
case class NonEmptyList[A](head: A, tail: List[A])
given Combinator[NonEmptyList[Int]] with
  val empty: NonEmptyList[Int] = ...
  def combine(
    l1: NonEmptyList[Int], 
    l2: NonEmptyList[Int]): NonEmptyList[Int] =
    NonEmptyList(head = l1.head, 
      tail = l1.tail ++ (l2.head :: l2.tail))
    

Ошибки при декомпозиции

trait Combinator[A]:
  val empty: A
  def combine(a1: A, a2: A): A
given Combinator[NonEmptyList[Int]] with
  val empty: NonEmptyList[Int] = ...
    

Ошибки при декомпозиции

given Combinator[NonEmptyList[Int]] with
  val empty: NonEmptyList[Int] = null
  val empty1: NonEmptyList[Int] = throw new Exception("")
  var empty2: NonEmptyList[Int] = _
  val empty3: NonEmptyList[Int] = ???
  def combine ...

Перекладывание ответственности на клиента

"Родитель" == "Потомок"

Почему так делать не стоит?

trait Combinator[A]:
  val empty: Option[A] = None
  def combine(a1: A, a2: A): A

case class NonEmptyList[A](head: A, tail: List[A])

given Combinator[NonEmptyList[Int]] with
  def combine ...

God object

trait Combinator[A]:
  def combine(a1: A, a2: A): A

trait FullCombinator[A] extends Combinator[A]:
  val empty: A
def combineAll[A](list: List[A], 
    empty: A)(using c: Combinator[A]): A =
  list match
    case Nil    => empty
    case h :: t => c.combine(h, combineAll(t, empty))

def combineAll[A](list: List[A])(using c: FullCombinator[A]): A =
  combineAll(list, c.empty)

Выделение более абстрактной структуры

Запрос "недостающей" информации у клиента

Выделение "родителя"

  • Trait-ы отлично подходят для модуляции компонентов и описания интерфейсов (с обязательными (абстрактными) и предоставляемыми (определенными) службами)
  • отделение данных от операций над ними (по сути выделение поведения в отдельные структуры) позволяет расширять и поддерживать их (почти) независимо друг от друга
  • возможность "отсечь лишнее" (export, transparent) помогает предоставлять разработчикам-клиентам только необходимую функциональность и избегать ошибок
  • при построении архитектуры желательно избегать "ложного наследования": попытки отсечь часть родительской функциональности

Основные выводы

Статья

Слайды

Вопросы?

Декомпозиция в Scala 3

By artemkorsakov

Декомпозиция в Scala 3

  • 121