Is it possible to make a generic function that takes different case classes

Viewed 334

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;
}

1 Answers

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
}
Related