scala functional - methods/functions inside or outside case class?

xwinus picture xwinus · Jun 25, 2016 · Viewed 10k times · Source

as a beginner in Scala - functional way, I'm little bit confused about whether should I put functions/methods for my case class inside such class (and then use things like method chaining, IDE hinting) or whether it is more functional approach to define functions outside the case class. Let's consider both approaches on very simple implementation of ring buffer:

1/ methods inside case class

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
}

Using this approach, you can do stuff like methods chaining and IDE will be able to hint methods in such case:

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/ functions outside case class

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

This approach seems more functional to me, but I'm not sure how practical it is, because for example IDE won't be able to hint you all possible method calls as using methods chaining in previous example.

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

Using this approach, the pipe operator functionality can make the above 3rd line more readable:

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

val buffer2 = buffer |> shiftLeft |> shiftLeft

Can you please summarize me your own view of advance/disadvance of particular approach and what's the common rule when to use which approach (if any)?

Thanks a lot.

Answer

marios picture marios · Jun 26, 2016

In this particular example, the first approach has much more benefits than the second one. I would go with adding all the methods inside the case class.

Here is an example on an ADT where decoupling the logic from the data has some benefits:

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

Now you can keep adding logic without needing to change your data.

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

In addition, all the logic of foo() is concentrated in a single block, which makes it easy to see how it operates on X and Y (compared to X and Y having their own version of foo).

In most programs, logic changes much more often than the data, so this approach allows you to add extra logic without ever needing to change/modify existing code (less bugs, less chance of breaking existing code).

Adding code into the companion object

Scala gives a lot of flexibility in how you add logic to a class using implicit conversions and the concept of Type Classes. Here are some basic ideas borrowed from ScalaZ. In this example, the data (case class) remains just data and all the logic is added in the companion object.

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

You can then use these implicit operators:

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

Or use Bar in generic functions build around, say, the Monoid Type Class.

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)