Scala function - методы/функции внутри или вне класса case?

как новичок в Scala - функциональном способе, я немного путаюсь, следует ли мне помещать функции/методы для моего класса case внутри такого класса (а затем использовать такие вещи, как цепочка методов, подсказка IDE) или более функциональный подход для определения функций вне класса case. Рассмотрим оба подхода к очень простой реализации кольцевого буфера:

1/методы внутри класса case

case class RingBuffer[T](index: Int, data: Seq[T]) {
  def shiftLeft: RingBuffer[T] = RingBuffer((index + 1) % data.size, data)
  def shiftRight: RingBuffer[T] = RingBuffer((index + data.size - 1) % data.size, data)
  def update(value: T) = RingBuffer(index, data.updated(index, value))
  def head: T = data(index)
  def length: Int = data.length
}

Используя этот подход, вы можете делать такие вещи, как методы цепочки, и среда IDE сможет в этом случае намекать на методы:

val buffer = RingBuffer(0, Seq(1,2,3,4,5))  // 1,2,3,4,5
buffer.head   // 1
val buffer2 = buffer.shiftLeft.shiftLeft  // 3,4,5,1,2
buffer2.head // 3

2/функции вне класса case

case class RingBuffer[T](index: Int, data: Seq[T])

def shiftLeft[T](rb: RingBuffer[T]): RingBuffer[T] = RingBuffer((rb.index + 1) % rb.data.size, rb.data)
def shiftRight[T](rb: RingBuffer[T]): RingBuffer[T] = RingBuffer((rb.index + rb.data.size - 1) % rb.data.size, rb.data)
def update[T](value: T)(rb: RingBuffer[T]) = RingBuffer(rb.index, rb.data.updated(rb.index, value))
def head[T](rb: RingBuffer[T]): T = rb.data(rb.index)
def length[T](rb: RingBuffer[T]): Int = rb.data.length

Этот подход кажется более функциональным для меня, но я не уверен, насколько он практичен, потому что, например, IDE не сможет намекать на все возможные вызовы методов, как использование методов в предыдущем примере.

val buffer = RingBuffer(0, Seq(1,2,3,4,5))  // 1,2,3,4,5
head(buffer)  // 1
val buffer2 = shiftLeft(shiftLeft(buffer))  // 3,4,5,1,2
head(buffer2) // 3

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

implicit class Piped[A](private val a: A) extends AnyVal {
  def |>[B](f: A => B) = f( a )
}

val buffer2 = buffer |> shiftLeft |> shiftLeft

Не могли бы вы рассказать мне свое собственное мнение об авансе/неудобстве конкретного подхода и о том, какое общее правило, когда использовать какой подход (если есть)?

Большое спасибо.

Ответ 1

В этом конкретном примере первый подход имеет гораздо больше преимуществ, чем второй. Я хотел бы добавить все методы внутри класса case.

Вот пример в ADT, где развязка логики от данных имеет некоторые преимущества:

sealed trait T
case class X(i: Int) extends T
case class Y(y: Boolean) extends T

Теперь вы можете продолжать добавлять логику, не изменяя свои данные.

def foo(t: T) = t match {
   case X(a) => 1
   case Y(b) => 2 
}

Кроме того, вся логика foo() сосредоточена в одном блоке, что позволяет легко увидеть, как она работает на X и Y (по сравнению с X и Y, имеющими собственную версию foo).

В большинстве программ логика изменяется гораздо чаще, чем данные, поэтому этот подход позволяет вам добавить дополнительную логику, не требуя изменения/модификации существующего кода (меньше ошибок, меньше шансов сломать существующий код).

Добавление кода в объект-компаньон

Scala дает большую гибкость в том, как вы добавляете логику в класс с использованием неявных преобразований и концепции классов типов. Вот некоторые основные идеи, заимствованные у ScalaZ. В этом примере данные (класс case) остаются только данными, и вся логика добавляется в объект-компаньон.

// A generic behavior (combining things together)
trait Monoid[A] {
  def zero: A
  def append(a: A, b: A): A
}

// Cool implicit operators of the generic behavior
trait MonoidOps[A] {
    def self: A
    implicit def M: Monoid[A]
    final def ap(other: A) = M.append(self,other)
    final def |+|(other: A) = ap(other)
}

object MonoidOps {
     implicit def toMonoidOps[A](v: A)(implicit ev: Monoid[A]) = new MonoidOps[A] {
       def self = v
       implicit def M: Monoid[A] = ev
    }
}


// A class we want to add the generic behavior 
case class Bar(i: Int)

object Bar {
  implicit val barMonoid = new Monoid[Bar] {
     def zero: Bar = Bar(0)
     def append(a: Bar, b: Bar): Bar = Bar(a.i + b.i)
  }
}

Затем вы можете использовать эти неявные операторы:

import MonoidOps._
Bar(2) |+| Bar(4)  // or Bar(2).ap(Bar(4))
res: Bar = Bar(6)

Или используйте Bar в общих функциях, которые строятся вокруг, скажем, класса типа Monoid.

def merge[A](l: List[A])(implicit m: Monoid[A]) = l.foldLeft(m.zero)(m.append)

merge(List(Bar(2), Bar(4), Bar(2)))
res: Bar = Bar(10)

Ответ 2

Существуют аргументы как против подхода "функции за пределами класса", например https://www.martinfowler.com/bliki/AnemicDomainModel.html и для: например, "Функциональные и реактивные Domain Modeling" Д. Гоша (глава 3). (См. Также https://underscore.io/books/essential-scala/ ch. 4.) По моему опыту, прежний подход предпочтительнее, за некоторыми исключениями. Некоторые из его преимуществ:

  • Легче сосредоточиться только на данных или на поведении, кроме как на их использование в одном классе; и развить их отдельно.
  • Функции в отдельном модуле имеют тенденцию быть более общими
  • Сегментация чистых интерфейсов (ISP): когда клиенту нужны только данные, он не должен подвергаться действию
  • Лучшая композиция. Например,

     case class Interval(lower: Double, upper: Double)
    
     trait IntervalService{ 
    def contained(a: Interval, b: Interval) }
    object IntervalService extends IntervalService
    trait MathService{ //methods}
    

    состоит просто как object MathHelper extends IntervalService with MathService. Это не так просто с классами, богатыми поведением.

Итак, я обычно держу класс case для данных; объект-компаньон для factory и методы проверки; и сервисные модули для другого поведения. Я могу применить несколько методов, облегчающих доступ к данным внутри класса case: def row(i:Int) для класса со таблицей. (На самом деле пример OP похож на этот.)

Есть недостатки: необходимы дополнительные классы/черты; Клиенты могут требовать как экземпляр класса, так и объект службы; определения методов могут вводить в заблуждение: например, в

import IntervalService._
contains(a, b)
a.contains(b)

второй более ясный w.r.t. какой интервал содержит какой.

Иногда объединение данных и методов в классе кажется более естественным (особенно с медиаторами/контроллерами в слое пользовательского интерфейса). Затем я бы определил class Controller(a: A, b: B) методами и частными полями, чтобы отличить его от класса case только для данных.