4

Final Update/Verdict

Much to my surprise, Scala cannot easily solve such a problem without the use of a 3rd party library. Note that the linked duplicate question does not fulfill the request below.


Original Question

I'm part of a team which recently inherited a Scala project and want to make the code more DRY. I have 2 functions that are identical, but take and return a different case class. Is it possible to have one function take either case class?

I'm looking for a solution using standard Scala without the need to install 3rd party libraries.

I've already tried abstracting the function in many ways with no luck (i.e. using generic types, an Either type that accepts both case classes, using asInstanceOf).

Here's a dummy example to illustrate my problem:

trait Bird {
  val avgHeight: Int
}

case class Pigeon(avgHeight: Int) extends Bird
case class Ostrich(avgHeight: Int) extends Bird

def updateHeight(bird: ?): ? = {
  bird.copy(avgHeight = 2)
}

/*
def updateHeight[T <: Bird](bird: T): T = {
  val chosenBird = bird match {
    case _: Pigeon => bird.asInstanceOf[Pigeon]
    case _: Ostrich => bird.asInstanceOf[Ostrich]
  }

  chosenBird.copy(avgHeight = 2)
}
 */

println(updateHeight(Pigeon(1)))
println(updateHeight(Ostrich(1)))


Update

Elaborating based on Mario's answer below. How do I remove the need to duplicate the conditional logic?:

sealed trait Bird {
  val avgHeight: Int
  val avgWidth: Int
  val wingSpan: Int
}

case class Pigeon(avgHeight: Int, avgWidth: Int, wingSpan: Int) extends Bird
case class Ostrich(avgHeight: Int, avgWidth: Int, wingSpan: Int) extends Bird

def updateBird(bird: Bird, height: Int, span: Int): Bird = {
  bird match {
    case p: Pigeon =>
      var newPigeon = p.copy(avgHeight = height)

      if (p.avgWidth.equals(0)) {
        newPigeon = newPigeon.copy(avgWidth = 100)
      }

      if (span > 0) {
        newPigeon = newPigeon.copy(wingSpan = span * 2)
      }

      newPigeon
    case o: Ostrich =>
      var newOstrich = o.copy(avgHeight = height)

      if (o.avgWidth.equals(0)) {
        newOstrich = newOstrich.copy(avgWidth = 100)
      }

      if (span > 0) {
        newOstrich = newOstrich.copy(wingSpan = span * 2)
      }

      newOstrich
  }
}

updateBird(Pigeon(1, 2, 3), 2, 0) // Pigeon(2,2,3)
updateBird(Ostrich(1, 0, 3), 2, 4) // Ostrich(2,100,8)

I'm looking for a truly DRY example where the primary code does not need to be duplicated: Here's an example in Typescript: https://repl.it/repls/PlayfulOverdueQuark

class Bird {
  avgHeight: Number
  avgWidth: Number
  wingSpan: Number

  constructor(avgHeight: Number, avgWidth: Number, wingSpan: Number) {
    this.avgHeight = avgHeight;
    this.avgWidth = avgWidth;
    this.wingSpan = wingSpan;
  }
}

class Pigeon extends Bird {}
class Ostrich extends Bird {}

const updateBird = (bird: Bird, height: Number, span: Number): Bird => {
  const newBird = Object.assign(
    bird,
    Object.create(
      bird instanceof Pigeon 
        ? bird as Pigeon 
        : bird as Ostrich
    )
  );

  // Update logic only happens below and is not 
  // duplicated based on the bird type

  if (bird.avgWidth === 0) {
    // Only update value if X condition is met
    // Condition based on passed in object
    newBird.avgWidth = 100
  }

  newBird.avgHeight = height;

  if (span > 0) {
    // Only update value if X condition is met
    newBird.wingSpan = Number(span) * 2;
  }

  return newBird;
}

Grafluxe
  • 483
  • 4
  • 11
  • 2
    This post is related: https://stackoverflow.com/questions/28745327/case-to-case-inheritance-in-scala The accepted answer uses lenses to address a similar problem – Harald Gliebe Jun 23 '19 at 03:37

1 Answers1

2

Here is an example using shapeless lenses as per Harald Gliebe's and Thilo's suggestion:

import shapeless._

object Hello extends App {
  sealed trait Bird {
    val avgHeight: Int
  }

  case class Pigeon(avgHeight: Int) extends Bird
  case class Ostrich(avgHeight: Int) extends Bird

  implicit val pigeonLens = lens[Pigeon].avgHeight
  implicit val ostrichLens = lens[Ostrich].avgHeight

  def updateHeight[T <: Bird](bird: T, height: Int)(implicit birdLense: Lens[T, Int]): T =
    birdLense.set(bird)(height)

  println(updateHeight(Pigeon(1), 2))
  println(updateHeight(Ostrich(1), 2))
}

which outputs

Pigeon(2)
Ostrich(2)

The linked typescript example is using mutable state to implement updateHeight however case class is an immutable structure. We could achieve similar like so

sealed trait Bird {
  val avgHeight: Int
}

case class Pigeon(avgHeight: Int) extends Bird
case class Ostrich(avgHeight: Int) extends Bird

def updateHeight(bird: Bird, height: Int): Bird =
  bird match {
    case _: Pigeon => Pigeon(height)
    case _: Ostrich => Ostrich(height)
  }

updateHeight(Pigeon(1), 2)
updateHeight(Ostrich(1), 2)

which outputs

res0: Bird = Pigeon(2)
res1: Bird = Ostrich(2)

Note how the compile time type is Bird but the runtime type is specialised Pigeon or Ostrich.

If the question is really about how to mutate an immutable case class, then we can simply use copy to create a new instance with changed height like so

Pigeon(1).copy(avgHeight = 2)
Ostrich(1).copy(avgHeight = 2)

which outputs

res2: Pigeon = Pigeon(2)
res3: Ostrich = Ostrich(2)

However if you would like to use immutable state like in the typescript example then try

class Bird(var avgHeight: Int)
class Pigeon(avgHeight: Int) extends Bird(avgHeight)
class Ostrich(avgHeight: Int) extends Bird(avgHeight)

def updateHeight(bird: Bird, height: Int): Bird = {
  bird.avgHeight = height
  bird
}
Mario Galic
  • 47,285
  • 6
  • 56
  • 98
  • Thanks Mario. My apologizes if I'm mistaken, but doing it this way will not make the code any more DRY; you still have to duplicate all the logic in both HeightUpdaters, no? Keep in mind that the projects actual code does more than a single `copy`. – Grafluxe Jun 21 '19 at 15:45
  • Using another strictly typed language as an example, you'll notice that there is no repetition: https://repl.it/repls/PlayfulOverdueQuark. – Grafluxe Jun 21 '19 at 16:34
  • 1
    `updateHeight` from the Typescript linked example is not pattern matching on `bird` argument, and in fact is not using `bird` argument at all. It simply creates a new `Bird` instance, not a `Pigeon` or `Ostrich` specialisation. The linked example does not seem to be semantically the same as the original question. – Mario Galic Jun 21 '19 at 16:45
  • Please see edited answer, however I am a bit unclear what the exact question is. Perhaps you can edit the question to add the typescript example to help others trying to answer. – Mario Galic Jun 21 '19 at 17:09
  • I improved the typescript example (as you were right that it did not originally match the request) and added an update to the question. – Grafluxe Jun 21 '19 at 17:23
  • I expanded on your codebase to better explain what I'm looking for. – Grafluxe Jun 21 '19 at 18:30