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

Доклад был представлен на Scala Meetup-е в Музее криптографии 20 апреля 2023

Компактная версия выступления

В этой статье разберем, что может предложить Scala 3 в плане декомпозиции.

Что можно сделать с таким trait-ом,

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)    
// res0: Int = 0    
IntCombinator.combineAll(List(1))       
// res1: Int = 1       
IntCombinator.combineAll(List(1, 2, 3))
// res2: Int = 6

Уже неплохо: теперь можно сворачивать числовые коллекции.

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

А ещё переменную empty можно вынести в параметр конструктора:

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.empty)   
// res4: Int = 0   
IntCombinator.combineAll(List(1))       
// res5: Int = 1       
IntCombinator.combineAll(List(1, 2, 3)) 
// res6: Int = 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

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

А ещё параметры конструктора можно задавать и неявно - вот тогда их можно и не указывать при расширении, если, конечно, этот неявный параметр доступен в области видимости.

Например:

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)  
// res8: Int = 0  
IntCombinator.combineAll(List(1))     
// res9: Int = 1     
IntCombinator.combineAll(List(1, 2, 3))
// res10: Int = 6

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

Параметры конструктора инициализируются до инициализации параметров в теле, что позволяет избегать некоторых ошибок.

Рассмотрим пример:

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

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

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

Аналогичный пример с использованием параметра в теле trait-а приводит к некорректной инициализации.

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

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

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

Объяснение почему так происходит можно найти здесь и здесь.

Обновление от 21-04-2023: как правильно подсказали в комментариях к митапу флаг компилятора -Ysafe-init предупреждает о попытке обращения к неинициализированной переменной.

Тот же самый пример с предупреждением компилятора

Расширение нескольких trait-ов

А ещё можно наследоваться от нескольких trait-ов, что делает их более мощным средством декомпозиции.

Расширение нескольких trait-ов называется "смешанной композицией" (mixin composition).

Пример:

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(0)

IntCombinator.combineAll(List.empty)
// res16: Int = 0   
IntCombinator.combineAll(List(1))
// res17: Int = 1      
IntCombinator.combineAll(List(1, 2, 3))
// res18: Int = 6

Если же оба trait-а реализуют метод с идентичной сигнатурой, то в смешанной композиции его нужно будет переопределить:

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.empty)   
// res20: Int = 0   
IntCombinator.combineAll(List(1))       
// res21: Int = 1       
IntCombinator.combineAll(List(1, 2, 3)) 
// res22: Int = 6 
При использовании super.empty будет браться элемент из последнего trait-а в списке наследования. В данном случае - EmptyInt2.

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

Рекомендации по выбору:

Что ещё, кроме основной функциональности могут предложить trait-ы в Scala 3?

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

Trait-ы используются в двух случаях:

Некоторые trait-ы используются преимущественно в первой роли, и обычно их нежелательно видеть в выводимых типах. Примером может служить trait Product, который компилятор добавляет в качестве примеси к каждому case class-у или case object-у.

Или, например, кастомные реализации:

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 x = Set(if true then IntCombinator1 else IntCombinator2)
// x: Set[Combinator[Int] & IntCombine] = ...

Здесь предполагаемый тип x равен Set[Combinator[Int] & IntCombine], тогда как не желательно видеть детали реализации в типе, например, такие как IntCombine.

Безусловно тип можно не выводить, а указывать конкретный, но есть и другой вариант: Scala 3 позволяет помечать 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 x = Set(if true then IntCombinator1 else IntCombinator2)
// x: Set[Combinator[Int]] = ???

Теперь x имеет тип Set[Combinator[Int]]. Общий transparent trait IntCombine не появляется в выводимом типе.

Traitscala.Product, java.io.Serializable и java.lang.Comparable автоматически считаются transparent. Для остальных необходимо указывать этот модификатор.

Как правило, transparent trait — это trait-ы, влияющие на реализацию наследуемых классов, и trait-ы, которые сами по себе обычно не используются как типы. Два примера из стандартной библиотеки коллекций:

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

Поскольку trait-ы разработаны как основное средство декомпозиции, класс, определенный в одном файле, не может быть расширен в другом файле. Чтобы разрешить это, базовый класс должен быть помечен как открытый. Маркировка классов с помощью open - это новая функция Scala 3. Необходимость явно помечать классы как открытые позволяет избежать многих распространенных ошибок. В частности, это требует, чтобы разработчики библиотек явно планировали расширение и, например, документировали классы, помеченные как открытые.

Пример:

// File Combinator.scala
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))

// File IntCombinator.scala
object IntCombinator extends Combinator[Int](0, _ + _)

// Где-то ещё
IntCombinator.combineAll(List.empty)    
// res26: Int = 0    
IntCombinator.combineAll(List(1))      
// res27: Int = 1      
IntCombinator.combineAll(List(1, 2, 3))
// res28: Int = 6

Открытый класс обычно поставляется с некоторой документацией, описывающей внутренние шаблоны вызовов между методами класса, а также хуки, которые можно переопределить. Это называется контрактом расширения класса (extension contract). Он отличается от внешнего контракта (external contract) между классом и его пользователями.

Подробности об open классах.

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

Ок, с наследованием разобрались! Но иногда возникают ситуации, когда предоставлять доступ ко всем публичным элементам родительских trait-ов нежелательно, и поэтому хочется избежать наследования. Что можно сделать?

В этом случае можно:

Во втором случае особенно полезно предложение export, которое определяет псевдонимы для выбранных членов объекта.

Например:

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)
// res30: Int = 0    
IntCombinator.combineAll(List(1))
// res31: Int = 1       
IntCombinator.combineAll(List(1, 2, 3))
// res32: Int = 6

Предложения export особенно полезны при сборе модуля из элементов, изменить которые возможности нет. Например, потому что они публичные и определены во внешней библиотеке.

Подробнее об export

Type class

В Scala самым мощным средством декомпозиции являются type class-ы. Type class — это абстрактный параметризованный тип, который позволяет добавлять новое поведение к любому закрытому типу данных без использования подтипов.

В статье "Type Classes as Objects and Implicits" (2010 г.) обсуждаются основные идеи, лежащие в основе type class-ов в Scala.

Этот стиль программирования полезен во многих случаях, например:

В Scala 3 type class-ы — это просто trait-ы с одним или несколькими параметрами типа.

Например:

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(1))       
// res34: Int = 1       
combineAll(List(1, 2, 3))
// res35: Int = 6

Основное различие между полиморфизмом подтипа и специальным полиморфизмом с type class-ами заключается в том, как реализуется определение type class-а по отношению к типу, на который он действует. В случае type class-а реализация для конкретного типа выражается через определение экземпляра given, предоставляемого как неявный аргумент вместе со значением, на которое он действует. При полиморфизме подтипов реализация смешивается с родительскими элементами класса, и для выполнения полиморфной операции требуется только один терм. Решение type class-ов требует больше усилий для настройки, но более расширяемо: добавление нового интерфейса в класс требует изменения исходного кода этого класса. Напротив, экземпляры type class-ов могут быть определены где угодно.

type class - это не отдельное ключевое слово, это способ. Способ, получивший насколько широкое распространение в Scala, что порой библиотеки поставляются большей частью с type class-ами, описывающими необходимые операции над данными. А реализацию этого поведения под данные предлагается сделать самим пользователям библиотек. type class-ы позволяют рассматривать поведение независимо от данных, исследовать законы, которым удовлетворяет заданное поведение, и на основе них усложнять поведение.

Например, библиотеки Cats и ScalaZ. Пользователь в первую очередь изучает поведение, реализованное в библиотеке. А не данные, к которым это поведение можно применить.

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

Подробнее о type-class-ах:

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

А что на счет коллекций?

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(0)))
// res36: List[Int] = List(0)             
combineAll(List(List(1), List(1, 2, 3, 4, 5)))
// res37: List[Int] = List(1, 1, 2, 3, 4, 5)

Допустим!

Но рано или поздно, когда мы привыкаем к какой-нибудь конструкции:

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

появляется структура, обладающая почти заданным поведением:

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))

1) Выдача исключения:

Одной из самых распространенных ошибок в этом случае является использование исключения или отсутствие реализации:

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 ...

В этом случае происходит как бы перекладывание ответственности за использование такого "комбинатора" на разработчика-клиента. Здесь происходит попытка "сделать вид", что NonEmptyList обладает заданным поведением, хотя это не так. Клиентский код сильно разрастается, потому что пользователи библиотеки вынуждены будут как-то обрабатывать возможные исключения.

Почему не стоит использовать в коде исключения, null или изменяемые переменные описано в книге "Functional Programming in Scala" и в других книгах, статьях, докладах о функциональном программировании.

2) Ещё одним способом "взломать систему" является использование Option, что, вроде как, является "чисто функциональным" и даже выглядит как-то "по-Scala":

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 ...

Но в этом случае получается, что дочерняя структура пытается "заглушить" часть функциональности, которой обладает "родитель".

Одним из самых мощных средств декомпозиции является выделение более абстрактных структур из уже имеющихся. Ключевое слово extends означает "расширение", когда дочерняя структура расширяет родительскую: т.е. обладает в точности тем же поведением, что и родитель, плюс добавляет ещё что-то (расширяет).

Развитие ситуации, когда разработчик пытается "заглушить" расширение, со временем может привести к анти-паттерну "Божественный объект", когда во главе иерархии стоит элемент, "способный делать ВСЕ", а дочерние элементы лишь "забирают" себе необходимое. Некоторые дочерние элементы могут даже не иметь между собой общих методов.

В данном случае рекомендуется определить более общую структуру:

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)

Пользователь явно видит, какую информацию ему необходимо предоставить и не получает "сюрприза" в виде exception.

Заключение

Ответы на вопросы

Хотелось бы более детально ответить на вопросы, прозвучавшие на Meetup-е.

Вопрос 1: Orphan Instances in Scala

Первый вопрос, прозвучавший на докладе.

Потерянный экземпляр (Orphan Instances) - это экземпляр type class-а для класса C и типа T, который не определен ни в модуле, где определен C, ни в модуле, где определен T. Подробное описание можно найти в статье Orphan Instances in Scala или wiki-статье Orphan instance.

В Scala 3 по-прежнему можно определить потерянный экземпляр и получить весь спектр сопутствующих проблем. Пример из статьи в Scastie для Scala 3

И здесь возникает проблема: с одной стороны функциональное программирование говорит о том, что все функции должны быть "чистыми", т.е. вызов одной и той же функции с одними и теми же параметрами несколько раз должен возвращать один и тот же результат. С другой стороны "потерянный экземпляр" позволяет управлять результатом функции через... import!!!

Пример:

trait Semigroup[A]:
  def combine(x: A, y: A): A

object Somewhere1:
  given Semigroup[Int] with
    def combine(x: Int, y: Int): Int = x + y

object Somewhere2:
  given Semigroup[Int] with
    def combine(x: Int, y: Int): Int = x - y

def combine[A: Semigroup](x: A, y: A): A = summon[Semigroup[A]].combine(x, y)

{
  import Somewhere1.given
  combine(1, 2)
} // 3

{
  import Somewhere2.given
  combine(1, 2)
} // -1

Рассмотренный пример в Scastie

Получается все функции, использующие потерянные экземпляры, "нечистые"?! Строго говоря, такого эффекта можно добиться с любым неявным параметром, но "потерянные экземпляры" "плохи" тем, что сложно найти места их объявления.

Scala 3 делает небольшой шаг по пути облегчения поиска "потерянных экземпляров", выделяя импорт неявных экземпляров.

Вопрос 2: Можно ли export использовать в паттерне делегирование?

Да, экспортирование можно использовать в паттерне делегирование, но только в части делегирования методов, потому что все элементы при экспортировании технически становятся методами, включая константы.

Рассмотрим пример:

trait Combinator[A]:
  def combine(a1: A, a2: A): A
  def 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]:
  private val combinator: Combinator[Int] = new:
    val empty: Int = 0
    def combine(a1: Int, a2: Int): Int = a1 + a2

  export combinator.*

IntCombinator.combineAll(List.empty)   
// res39: Int = 0   
IntCombinator.combineAll(List(1))      
// res40: Int = 1      
IntCombinator.combineAll(List(1, 2, 3))
// res41: Int = 6

Здесь объект IntCombinator наследуется от Combinator[Int] и должен реализовать два абстрактных метода. Эти методы реализуются путем экспортирования методов с той же самой сигнатурой из приватного элемента combinator.

Рассмотренный пример в Scastie

Но с абстрактными параметрами делегирование не пройдет, потому что, согласно документации, экспортируемые элементы чисто технически - это методы. Поэтому компилятор выдаст ошибку конфликта имен если необходимо реализовать абстрактную константу, даже если экспортируется реализация константы с той же сигнатурой, потому что она экспортируется как метод.

Пример:

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]:
  private val combinator: Combinator[Int] = new:
    val empty: Int = 0
    def combine(a1: Int, a2: Int): Int = a1 + a2

  export combinator.*
// Compile error:
// error overriding value empty in trait Combinator of type Int; 
// method empty of type => Int needs to be a stable, immutable value

Константа empty при экспорте становится методом empty.

Рассмотренный пример в Scastie

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

trait Monoid[A]:
  def compose(a1: A, a2: A): A
  val empty: A

trait Combinator[A]:
  def combine(a1: A, a2: A): A
  def 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]:
  private val monoid: Monoid[Int] = new:
    val empty: Int = 0
    def compose(a1: Int, a2: Int): Int = a1 + a2

  export monoid.{empty, compose as combine}

IntCombinator.combineAll(List.empty)   
// res43: Int = 0   
IntCombinator.combineAll(List(1))      
// res44: Int = 1      
IntCombinator.combineAll(List(1, 2, 3))
// res45: Int = 6

Здесь IntCombinator-у нужно реализовать методы def empty: Int и def combine(a1: Int, a2: Int): Int, но у него есть только приватный экземпляр monoid. При экспорте элементов приватной переменной, переименовывается метод compose для того, чтобы добиться нужной для Combinator сигнатуры методов. В результате, IntCombinator получает реализацию всех абстрактных членов и ему нет необходимости что-то "доопределять".

Рассмотренный пример в Scastie

Вопрос 3: Можно ли пользоваться методами прозрачных trait-ов?

Да, несмотря на то, что прозрачные trait-ы не выводятся в типе, все их элементы доступны для использования.

Рассмотрим пример:

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

case class Empty(empty: Int)

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 Empty(0), Combinator[Int], IntCombine

val x = IntCombinator
x.productArity // 1

IntCombinator наследуется от case class Empty, который в свою очередь наследуется от scala.Product. Несмотря на то, что Product прозрачный, все его методы доступны. Например, как productArity в коде выше.

Рассмотренный пример в Scastie

Вопрос 4: Какое практическое применение прозрачных trait-ов?

1) В качестве практического применения прозрачных trait-ов может служить использование специального trait-а для логирования, который нежелательно видеть в выходном типе продуктового кода.

2) Также необходимость в использовании прозрачных trait-ов может возникнуть при работе с type class-ами.

Представим, что у нас есть полугруппа, способная "складывать" два объекта произвольного типа:

trait Semigroup[A]:
  def combine(x: A, y: A): A

def combine[A: Semigroup](a1: A, a2: A): A =
  summon[Semigroup[A]].combine(a1, a2)

И есть контейнер, способный хранить значения произвольного типа A. Причем, если в области видимости доступна Semigroup[A] и метод, способный "оборачивать" значения произвольного типа в контейнер, то мы можем реализовать полугруппу для контейнера:

trait Container[A](val a: A)

given [A](using Semigroup[A])(using unit: A => Container[A]): Semigroup[Container[A]] with
  def combine(x: Container[A], y: Container[A]): Container[A] = 
    unit(summon[Semigroup[A]].combine(x.a, y.a))

В отдельном файле объявляется список всех возможных контейнеров в системе, а также метод создания дефолтного контейнера:

sealed trait CanBeContainer

case class DefaultContainer[A](value: A) extends Container[A](value), CanBeContainer
case class OtherContainer[A](value: A) extends Container[A](value), CanBeContainer
given [A]: (A => Container[A]) = a => DefaultContainer(a)

В этом случае при попытке сложить два различных контейнера определенного типа (допустим Int), если в области видимости доступна полугруппа этого типа, выводится ошибка компилятора о том, что не определена полугруппа для Semigroup[(Container[Int] & CanBeContainer)]:

given Semigroup[Int] with
  def combine(x: Int, y: Int): Int = x * y

combine(DefaultContainer[Int](2), OtherContainer[Int](3)).a
// No given instance of type Semigroup[(Container[Int] & CanBeContainer)] 
// was found for an implicit parameter of method combine in object Playground

Рассмотренный пример в Scastie

Но ведь это не так: у нас есть полугруппа для контейнеров, мы умеем их "складывать", а CanBeContainer никаким образом на "сложение" не влияет - это технический trait, созданный только лишь для того, чтобы можно было контролировать список возможных контейнеров. Например, в сопоставлении с шаблоном.

В результате, чтобы "сложить" два контейнера приходится создавать лишние переменные "правильного" по мнению компилятора типа, чтобы получить необходимый результат:

val x: Container[Int] = DefaultContainer[Int](2)
val y: Container[Int] = OtherContainer[Int](3)
combine(x, y).a // 6

Рассмотренный пример в Scastie

Но создавать "технические" переменные не всегда удобно и не всегда возможно.

В этом случае очень выручает возможность объявить технический trait в качестве прозрачного: transparent sealed trait CanBeContainer

Тогда мы сможем "складывать" контейнеры без выделения лишних переменных:

given Semigroup[Int] with
  def combine(x: Int, y: Int): Int = x * y

combine(DefaultContainer[Int](2), OtherContainer[Int](3)).a // 6

Рассмотренный пример в Scastie

Список литературы

Meetup