Crushing boilerplate with Scala macros

Marcin Rzeźnicki

mrzeznicki@iterato.rs

Conundrum

Our developers were losing time due to mindless, recurring boilerplate they were forced to write, over and over in each and every project.

 

What Scala, as a language, lacks that makes it susceptible to boilerplate code slipping in?

What did we do?

  • Went through various Scala code-bases we had been writing or maintaining
  • Identified those repetitive patterns
  • Pushed as much of these to a common library that we could reuse throughout existing and future projects

https://github.com/theiterators/kebs

Sources of boilerplate - Slick

object People {
  // BOILERPLATE ALERT!
  implicit val userIdColumnType: BaseColumnType[UserId] = 
    MappedColumnType.base(_.userId, UserId.apply)
  implicit val emailAddressColumnType: BaseColumnType[EmailAddress] = 
    MappedColumnType.base(_.emailAddress, EmailAddress.apply)
  implicit val fullNameColumnType: BaseColumnType[FullName] = 
    MappedColumnType.base(_.fullName, FullName.apply)
  //
}

class People(tag: Tag) extends Table[Person](tag, "people") {
  import People._

  def userId: Rep[UserId] = column[UserId]("user_id")
  def emailAddress: Rep[EmailAddress] = column[EmailAddress]("email_address")
  def fullName: Rep[FullName] = column[FullName]("full_name")
 
  override def * : ProvenShape[Person] =
        (userId, emailAddress, fullName) <> (Person.tupled, Person.unapply)
}

Sources of boilerplate - Slick

Possible solutions - generic wrapper

def valueColumnType[T <: Product : ClassTag, P : BaseColumnType](construct: P => T): 
    BaseColumnType[T] = 
  MappedColumnType.base[T, P](_.productElement(0).asInstanceOf[P], construct)
  • not really type-safe (cast is involved),
  • you cannot restrict types to cover only 1-element case-classes

Sources of boilerplate - Slick

Possible solutions - MappedTo

Some people rightly believe that this solution is bad, because it couples domain with storage too tightly.

Sources of boilerplate - Slick

Possible solutions - Shapeless

import shapeless._

implicit def hList1CoumnType[Head: BaseColumnType] =
  MappedColumnType
    .base[Head :: HNil, Head](l => l.head, head => head :: HNil)
implicit def valueColumnType[T, P](implicit gen: Generic.Aux[T, P],
                                   tag: ClassTag[T],
                                   baseColumnType: BaseColumnType[P]) =
  MappedColumnType
    .base[T, P](t => gen.to(t), p => gen.from(p))


type-safe, but... that’s a lot of code for essentially stating that 1-element tuple is isomorphic to its only element!

Sources of boilerplate - Slick

Possible solutions - Shapeless

case class Name(name: String) extends AnyVal

class OneElement(tag: Tag) extends Table[Name](tag, "MATRYOSHKA") {
  def name = column[String]("name")
  override def * : ProvenShape[Name] = name <> (Name.apply, Name.unapply)
}


type mismatch;
[error] found : slick.lifted.MappedProjection[Name,String]
[error] required: slick.lifted.ProvenShape[Name]
[error] override def * = name <> (Name.apply, Name.unapply)
[error]

Sources of boilerplate - Slick

Possible solutions - Shapeless

Turns out you just unwillingly provided two ambiguous mappings for Name. One — repColumnShape — materialized with help of our catch-all implicit, and the other — mappedProjectionShape — which you almost wrote yourself by providing table mapping *.

So a truly perfect solution could disable automatic derivation if some other derivation already existed.

Is it Scala's fault?

All this redundant code wouldn’t have been necessary if you were able to:

  • treat case-classes as instances of respective ProductN
  • access case-class companion’s apply method in a generic way

kebs-slick

Scala has one powerful escape hatch that lets programmers do things that language doesn’t let them do and it even can circumvent the language rules. You probably heard of it before.

`Tis macros!!

kebs-slick

Macros, among other useful things, can be used to materialize implicits. There is even a special flavor of macros called white-box macros that, when chosen by implicit resolver to provide an implicit, can decide if they materialize it, or bail out and not provide it all. This ability makes them useful to meet our extra condition — disable automatic derivation based on context!

kebs-slick

kebs - slick

object People {
  implicit val userIdColumnType: BaseColumnType[UserId] = 
        MappedColumnType.base(_.userId, UserId.apply)
  implicit val emailAddressColumnType: BaseColumnType[EmailAddress] = 
        MappedColumnType.base(_.emailAddress, EmailAddress.apply)
  implicit val fullNameColumnType: BaseColumnType[FullName] = 
        MappedColumnType.base(_.fullName, FullName.apply)
  implicit val addressLineColumnType: BaseColumnType[AddressLine] = 
        MappedColumnType.base(_.line, AddressLine.apply)
  implicit val postalCodeColumnType: BaseColumnType[PostalCode] = 
        MappedColumnType.base(_.postalCode, PostalCode.apply)
  implicit val cityColumnType: BaseColumnType[City] = 
        MappedColumnType.base(_.city, City.apply)
  implicit val areaColumnType: BaseColumnType[Area] = 
        MappedColumnType.base(_.area, Area.apply)
  implicit val countryColumnType: BaseColumnType[Country] = 
        MappedColumnType.base(_.country, Country.apply)
  implicit val taxIdColumnType: BaseColumnType[TaxId] = 
        MappedColumnType.base(_.taxId, TaxId.apply)
  implicit val bankNameColumnType: BaseColumnType[BankName] = 
        MappedColumnType.base(_.name, BankName.apply)
  implicit val recipientNameColumnType: BaseColumnType[RecipientName] = 
        MappedColumnType.base(_.name, RecipientName.apply)
  implicit val additionalInfoColumnType: BaseColumnType[AdditionalInfo] = 
        MappedColumnType.base(_.content, AdditionalInfo.apply)
  implicit val bankAccountNumberColumnType: BaseColumnType[BankAccountNumber] =
        MappedColumnType.base(_.number, BankAccountNumber.apply)
}

kebs - slick

import pl.iterators.kebs._

kebs-slick

support for

  • slick_pg,
  • enumeratum,
  • using trait instead of import
  • one-column tables

Sources of boilerplate - spray-json

object ThingProtocol extends JsonProtocol {
  def jsonFlatFormat[P, T <: Product](construct: P => T)
                                     (implicit jw: JsonWriter[P], jr: JsonReader[P]): 
        JsonFormat[T] =
    new JsonFormat[T] {
      override def read(json: JsValue): T = construct(jr.read(json))
      override def write(obj: T): JsValue = jw.write(obj.productElement(0).asInstanceOf[P])
    }

  // BOILERPLATE ALERT!!
  implicit val errorJsonFormat = jsonFormat1(Error.apply)
  implicit val thingIdJsonFormat = jsonFlatFormat(ThingId.apply)
  implicit val tagIdJsonFormat = jsonFlatFormat(TagId.apply)
  implicit val thingNameJsonFormat = jsonFlatFormat(ThingName.apply)
  implicit val thingDescriptionJsonFormat = jsonFlatFormat(ThingDescription.apply)
  implicit val locationJsonFormat = jsonFormat2(Location.apply)
  implicit val createThingRequestJsonFormat = jsonFormat5(ThingCreateRequest.apply)
  implicit val thingJsonFormat = jsonFormat6(Thing.apply)
  // 
}

Sources of boilerplate - spray-json

Possible solutions

  • switch to play-json :-)
  • shapeless, but it can get a bit hairy. See this for some excellent code. But still you could not easily mix flat and non-flat formats in one scope

 

 

kebs-spray-json

  • automatically generate RootJsonFormat for any case-class or flat JsonFormat for 1-element case-classes
  • switch between formats depending on context

kebs-spray-json

Because white-box macros can decide whether they materialize an implicit or not, we are able to simultaneously bring two implicits into scope:

 

implicit def jsonFormatN[T <: Product]: RootJsonFormat[T] 
    = macro KebsSprayMacros.materializeRootFormat[T]
implicit def jsonFlatFormat[T <: Product]: JsonFormat[T] 
    = macro KebsSprayMacros.materializeFlatFormat[T]

kebs-spray-json

kebs-spray-json

object ThingProtocol extends JsonProtocol {
  def jsonFlatFormat[P, T <: Product](construct: P => T)
                                     (implicit jw: JsonWriter[P], jr: JsonReader[P]): 
    JsonFormat[T] =
    new JsonFormat[T] {
      override def read(json: JsValue): T = construct(jr.read(json))
      override def write(obj: T): JsValue = jw.write(obj.productElement(0).asInstanceOf[P])
    }

   implicit val errorJsonFormat              = jsonFormat1(Error.apply)
   implicit val thingIdJsonFormat            = jsonFlatFormat(ThingId.apply)
   implicit val tagIdJsonFormat              = jsonFlatFormat(TagId.apply)
   implicit val thingNameJsonFormat          = jsonFlatFormat(ThingName.apply)
   implicit val thingDescriptionJsonFormat   = jsonFlatFormat(ThingDescription.apply)
   implicit val locationJsonFormat           = jsonFormat2(Location.apply)
   implicit val createThingRequestJsonFormat = jsonFormat5(ThingCreateRequest.apply)
   implicit val thingJsonFormat              = jsonFormat6(Thing.apply)
}

kebs-spray-json

object ThingProtocol extends JsonProtocol with KebsSpray

Is it Scala's fault?

One might want better implicit prioritization. The current implementation (implicits defined in a type are preferred over the ones from a subtype) is a one-way street. You can only request the more specific implicit to be prioritized over the less specific one.

Is it Scala's fault?

class A
class B extends A

// THIS ONE WORKS CORRECTLY

trait LowPriorityImplicits {
  implicit val a: A = new A
}
object Implicits extends LowPriorityImplicits {
  implicit val b: A = new B
}

import Implicits._

implicitly[A]



Is it Scala's fault?

// THIS ONE FAILS TO COMPILE

class A
class B extends A

trait LowPriorityImplicits {
  implicit val b: B = new B
}
object Implicits extends LowPriorityImplicits {
  implicit val a: A = new A
}

import Implicits._

implicitly[A]

kebs-spray-json

Tons of other features:

  • snakified formats
  • recursive formats
  • enumeratum
  • @dbronecki added support for case-classes with > 22 fields

I’ll be delighted to hear of your sources of boilerplate

Bad stuff

  • API is not clearly documented
  • API allows you to generate wrong code. It’s not unlike managing memory in C — correct semantics are not enforced and you’re on your own discovering what you did wrong
  • Debugging is a pain

Bad stuff

  • Because macro programming is very fragile in its current state, you need to test it a lot to be sure it does not break a legitimate code.
  • Compilation time will suffer (we found ca. 10% growth in projects with number of serialized DTOs being about 40). That’s especially true if you do a lot of white-box macros.

Bad stuff

  • The nature of white box makes it materialize implicit at every expansion site. Figuratively speaking, it is like ‘pasting’ the generated code whenever it is used. This can cause more allocations and will certainly increase the size of compiled program (this will be mitigated by Scala 2.12 delambdafy allocation scheme)
  • You have to keep up with Scala’s reflect API changes and it can change considerably when dotty comes

Good stuff

  • You do not have to write it!
  • No slow run-time reflection, e.g., conversion to snake_case in JSON format
  • You can do amazing things that bend language rules, e.g., smart JSON formats

Thank you!

PS. kebs is a part of Scala Spree initiative. If you want to hack it please join us at:


Open Source Spree with Scala Center (Functional Tricity #8)

 

    Friday, July 7, 2017

    10:00 AM
    Olivia Business Centre, Olivia FOUR

    aleja Grunwaldzka 472a, Gdansk

kebs

By Marcin Rzeźnicki

kebs

  • 1,267