it-roy-ru.com

функционал scala - методы/функции внутри или вне класса 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

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

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

val buffer2 = buffer |> shiftLeft |> shiftLeft

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

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

12
xwinus

В этом конкретном примере первый подход имеет гораздо больше преимуществ, чем второй. Я хотел бы добавить все методы внутри класса 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 Type.

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

Существуют аргументы как против подхода «функции вне класса», например https://www.martinfowler.com/bliki/AnemicDomainModel.html , так и для: например, «Моделирование функционального и реактивного домена» Д. Гоша (гл. 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 case для данных; сопутствующий объект для фабричных и валидационных методов; и сервисные модули для другого поведения. Я могу поместить пару методов, облегчающих доступ к данным внутри класса case: def row(i:Int) для класса с таблицей. (На самом деле пример ОП выглядит примерно так.) 

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

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

вторая более понятна с.р.т. какой интервал содержит какой. 

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

0
Tupolev._