1

I am learning about Scala case classes, and design patterns. To this end I have created an example below which I think is a fairly likely scenario when working with Json type data. I know libraries exist that do this stuff, but I am doing it manually to learn Scala approaches to solving problems, as using a library won't help me learn.

The main design improvement I want to do is abstracting common code.

Suppose my codebase consists of many case classes, where each case class is serializable:

trait Base {
    def serialize(): String
  }

  trait Animal extends Base
  trait Mammal extends Animal
  trait Reptile extends Animal

  case class Lizard(name: String, tail: Boolean) extends Reptile {
    def serialize(): String = s"""{name: $name, tail: $tail}"""
  }

  case class Cat(name: String, age: Int) extends Mammal {
    def serialize(): String = s"""{name: $name, age: $age}"""
  }

  case class Fish(species: String) extends Animal {
    def serialize(): String = s"""{species: $species}"""
  }

  case class Pets(group_name: String, cat: Option[Cat] = None, lizard: Option[Lizard] = None, fish: Fish) extends Base {
    def serialize(): String = {

      // cat and lizard serialize in a similar fashion
      val cat_object = cat match {
        case Some(c) => s"""cats: ${c.serialize()}"""
        case _ => ""
      }

      val lizard_object = lizard match {
        case Some(d) => s"""lizards: ${d.serialize()}"""
        case _ => ""
      }

      // fish serializes in a different way as it is not an option
      val fish_object = s"""fish: ${fish.serialize()}"""

      s"""{$lizard_object, $cat_object, $fish_object}"""
    }
  }

  val bob = Cat("Bob", 42)
  val jill = Lizard("Jill", true)

  val pets = Pets("my group", Some(bob), Some(jill), Fish("goldfish")).serialize()

  println(pets)
}

Now there is a repetitive pattern here:

  1. In Pets, when I am serializing, I am basically going over each (key, value) pair (except group_name) in the parameter list and doing the following:

    key: value.serialize()

Now I do not know the form of value, it can be an option as in the example. Furthermore, suppose I have many classes like Pets. In that case I would have to manually write many pattern matches on every argument where required, distinguishing between String, Int, Option[String], etc. Would there be a way to abstract out this serializable operation so that if I have many case classes like Pets, I can simply run a single function and get the correct result.

I asked a related question here about getting declared field from cases classes, but it seems that way is not type safe and may create issues later down the line if I add more custom case classes:

https://stackoverflow.com/questions/62662417/how-to-get-case-class-parameter-key-value-pairs

finite_diffidence
  • 893
  • 2
  • 11
  • 20
  • 2
    This isn't a great place to start when learning Scala because you are putting source code symbol names into data values. This involves using *reflection* which is awkward in Scala (or indeed any language). While this is used when processing JSON, there are, as you say, existing libraries that do the work. – Tim Jul 01 '20 at 12:04
  • 3
    What you want to do here involves either runtime or compile time reflection. While neither of these on its own is super hard, learning it without having solid grasp of the rest of the language is a masochism. – Mateusz Kubuszok Jul 01 '20 at 12:15
  • Thanks I will postpone my learning of runtime / compile time reflection. I haven't come across this concept before. – finite_diffidence Jul 01 '20 at 14:13
  • 2
    The TL;DR; answer to this kind of problem is a [**typelcass**](https://tpolecat.github.io/2013/10/12/typeclass.html). – Luis Miguel Mejía Suárez Jul 01 '20 at 16:31
  • 1
    See [What JSON library to use in Scala?](https://stackoverflow.com/questions/8054018/what-json-library-to-use-in-scala). I know your question says you don't want to use one, but I'd still recommend it – user Jul 03 '20 at 19:39

2 Answers2

1

This is a tricky thing to do generically. This code does not always use all the fields in the output (e.g. group_name) and the name of the field does not always match the name in the string (e.g cat vs cats)

However there are some Scala tricks that can make the existing code a bit cleaner:

trait Base {
  def serial: String
}

trait Animal extends Base
trait Mammal extends Animal
trait Reptile extends Animal

case class Lizard(name: String, tail: Boolean) extends Reptile {
  val serial: String = s"""{name: $name, tail: $tail}"""
}

case class Cat(name: String, age: Int) extends Mammal {
  val serial: String = s"""{name: $name, age: $age}"""
}

case class Fish(species: String) extends Animal {
  val serial: String = s"""{species: $species}"""
}

case class Pets(group_name: String, cat: Option[Cat] = None, lizard: Option[Lizard] = None, fish: Fish) extends Base {
  val serial: String = {
    // cat and lizard serialize in a similar fashion
    val cat_object = cat.map("cats: " + _.serial)

    val lizard_object = lizard.map("lizards: " + _.serial)

    // fish serializes in a different way as it is not an option
    val fish_object = Some(s"""fish: ${fish.serial}""")

    List(lizard_object, cat_object, fish_object).flatten.mkString("{", ", ", "}")
  }
}

val bob = Cat("Bob", 42)
val jill = Lizard("Jill", true)

val pets = Pets("my group", Some(bob), Some(jill), Fish("goldfish")).serial

println(pets)

Since the case class is immutable the serialized value does not change, so it makes more sense to make it look like a property called serial.

Option values are best processed inside the Option using map and then extracted at the end. In this case I have used flatten to turn a List[Option[String]] into a List[String].

The mkString method is a good way of formatting lists and avoiding , , in the output if one of the options is empty.

Tim
  • 26,753
  • 2
  • 16
  • 29
  • Cool, I like the mapping over options. Would I be correct in saying this is making use of the option monad property? – finite_diffidence Jul 01 '20 at 14:34
  • 2
    I always get nervous when the word "monad" appears. I see this is as an example of consistency in the Scala library in how it handles collection-like objects like `Option`, `Try`, and `Future`. Methods such as `map`, `flatMap`, and `foreach` operate in "the expected way" on these types as it does for normal collections like `List`. But I believe that `map` (or rather `flatMap`) is one of the characteristic methods of a monad. – Tim Jul 02 '20 at 06:34
1

Here's a generic way to make a limited serialization method for case classes, taking advantage of the fact that they're Products.

def basicJson(a: Any): String = a match {
  case Some(x) => basicJson(x)
  case None    => ""
  case p: Product =>
    (p.productElementNames zip p.productIterator.map(basicJson _))
      .map(t => s"${t._1}: ${t._2}")
      .mkString("{", ", ", "}")
  case _       => a.toString
}

And if you want to serialize Pets without the group name, you could define a single val in Pets that manually serializes all the fields except group_name:

val toJson = s"{cat: ${basicJson(cat)}, lizard: ${basicJson(lizard)}, fish: ${basicJson(fish)}}"

The output of this code

val bob = Cat("Bob", 42)
val jill = Lizard("Jill", true)

val pets = Pets("my group", Some(bob), Some(jill), Fish("goldfish"))

println(pets.toJson)

is this:

{cat: {name: Bob, age: 42}, lizard: {name: Jill, tail: true}, fish: {species: goldfish}}

In Scastie: https://scastie.scala-lang.org/A5slCY65QIKJ2YTNBUPvQA

<script src="https://scastie.scala-lang.org/A5slCY65QIKJ2YTNBUPvQA.js"></script>

Keep in mind that this won't work for anything other than case classes - you'll have to use reflection.

user
  • 7,435
  • 3
  • 14
  • 44