Skip to content

LPTK/Boilerless

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Boilerless: Beautiful Syntax for Sealed Class Hierarchies

Introduction

Boilerless is a small utility that lets you write class hierarchies with a lightweight syntax closer to how you define data types in other functional languages. It has special support for Generalized Algebraic Data Types (GADT) and enums-like hierarchies.

Following is a short example showing how to write an EitherOrBoth data type. The precise rules used to expand it are explained further below.

@enum class EitherOrBoth[+A,+B] {
  def fold[T](f: A => T, g: B => T)(m: (T,T) => T): T
  
  // Cases:
  class First [A](value: A)     { fold(f,g)(m) = f(value)         }
  class Second[B](value: B)     { fold(f,g)(m) = g(value)         }
  class Both[_](fst: A, snd: B) { fold(f,g)(m) = m(f(fst),g(snd)) }
}

Boilerless is based on macro annotations, which will expand at compile time into proper Scala code. The code above will generate the equivalent of:

sealed abstract class EitherOrBoth[+A, +B] {
  def fold[T](f: A => T, g: B => T)(m: (T,T) => T): T
}
object EitherOrBoth {
  // Cases:
  case class First[+A](value: A) extends EitherOrBoth[A, Nothing] {
    private[this] type B = Nothing
    override def fold[T](f: A => T, g: B => T)(m: (T,T) => T): T = f(value)
  }
  case class Second[+B](value: B) extends EitherOrBoth[Nothing, B] {
    private[this] type A = Nothing
    override def fold[T](f: A => T, g: B => T)(m: (T,T) => T): T = g(value)
  }
  case class Both[+A, +B](fst: A, snd: B) extends EitherOrBoth[A, B] {
    override def fold[T](f: A => T, g: B => T)(m: (T,T) => T): T = m(f(fst),g(snd))
  }
}

Note: Macro annotations are not officially supported in Scala. Syntax highlighting may be broken in some IDE's. However, Boilerless offers alternatives to circumvent these problems.

Functionalities

Enumerations

Type and term parameters can be passed from case classes to the parent class implicitly using the lightweight _[..types](...args) syntax inside the body of the case class. Moreover, if that is the very first expression in the body and there are no types to pass, the _ can be ommitted. Therefore, one can write:

@enum class State(entryName: String) {
   object Alabama {"AL"}
   object Alaska  {"AK"}
   
   object California { _("CA") }  // explicit initialization syntax
   
   // and so on and so forth.
}

Boilerless has special support for enumeratum. By only changing @enum to @enumeratum in the code above, the parent class is made to extend enumeratum.EnumEntry, the case classes to extend enumeratum.Enum[State], and a val values = findValues field is added to the companion object:

@enumeratum class State(entryName: String) {
   object Alabama {"AL"}
   object Alaska {"AK"}
   // and so on and so forth.
}
assert(State.withName("AL") == State.Alabama)

You can see the code generated by the definitions above here.

Type Parameters Forwarding

As shown in the EitherOrBoth example above, if there is no explicit extends Parent[..](...) clause nor _[..](...) initialization call, type parameters named the same as type parameters of the parent class are forwarded automatically. Bounds and variance annotations for these parameters do not need to be repeated, as they are copied from the parent class. Parent type parameters not mentioned in the case class are passed to the parent class as the lower bound if the parameter is covariant, the upper bound if it is contravariant, and an existential otherwise.

One can also import all parent parameters with syntax [_, ..], i.e., first parameter named underscore _, possibly followed by more parameters.

Additionally, a private type is created in each case class for all parent type parameters it does not mention, so that it can refer to it nonetheless (see EitherOrBoth in cases First and Second).

Nested Hierarchies

Nested hierarchies are naturally supported, as macro annotations expand from the outermost to the innermost definition. The following:

@enum class Level0(x: Int) {
  class Sub0(){0}
  class Sub1(x: Int){x}
  @enum class Level1(x: Int) { _(x)
    class SubSub0{1}
    class SubSub1(y: Int){y}
  }
}

... generates:

sealed abstract class Level0(x: Int)
object Level0 {
  case class Sub0() extends Level0(0)
  case class Sub1(x: Int) extends Level0(x)
  @enum case class Level1(x: Int) extends Level0(x) {
    class SubSub0 {1}
    class SubSub1(y: Int) {y}
  }
}

... which in turn generates:

sealed abstract class Level0(x: Int)
object Level0 {
  case class Sub0() extends Level0(0)
  case class Sub1(x: Int) extends Level0(x)
  sealed abstract class Level1(x: Int) extends Level0(x)
  object Level1 {
    case class SubSub0() extends Level1(1)
    case class SubSub1(y: Int) extends Level1(y)
  }
}

Using Boilerless

Boilerless has only been made to work on Scala 2.11 yet. More work is needed to port it to other versions.

To use Boilerless, enable the macro-paradise plugin and add the library dependency:

resolvers += Resolver.sonatypeRepo("snapshots")

addCompilerPlugin("org.scalamacros" % "paradise" % paradiseVersion cross CrossVersion.full)

libraryDependencies += "com.github.lptk" %% "boilerless" % boilerlessVersion

Where paradiseVersion is the version of Macro Paradise (for example "2.1.0") and boilerlessVersion is the version of Boilerless (for example "0.1-SNAPSHOT"). See this project for an example.

IDE Integration and File-Generation Approach

Some IDE's like Eclipse seem to support Boilerless remarkably well – most type errors point to the right thing, and jump-to-definition is often approximately right.

Other IDE's like IntelliJ do not even try to understand macros. To mitigate some of the IDE problems, you can make the companion object of the @enum class extend the class, so the IDE will at least see the case classes.

Boilerless also provides an @enumInFile(fileName, package) macro that, instead of expanding into the class trees, will write the result to a new Scala file [1]. The new file will be placed in $folderName/ClassName.scala, its package will be $package, and imports found at macro call site will be placed at the top.

For example see this tests file, which contains:

@enumInFile("core/src/test/scala/boilerless/gen", "boilerless.gen")
class Opt[+T] {  class Som[T](value: T);  object Non  }

The generated code can be found here.

Arguments folderName and package should be string literals. It is advised to set folderName to a folder belonging to a subproject that depends on the project containing the @enum class, and not the same project. This way, whenever you change the @enum class, it will re-expand first, writing the result in the file located in the dependent project, and that file will then be compiled as part of the dependent project.

Note: You may still have to compile twice, unless you use a special configuration or command to explicitly ask sbt to compile the project containing the templates first, like sbt templates-project/compile main-project/run.

If you do not want to use macro annotations, a def macro version is also available as genEnum(folderName, package)(){""" code """}.

This functionality has only been tested with sbt 0.13.8 and Scala 2.11.8. It is known not to work in IntelliJ (but a mere warning will be raised and the macro failure will not stop compilation).

[1] Something macros are not supposed to do, but is very useful.

Summary

Here is a summary of Boilerless' functionalities:

  • Make outer class sealed abstract and remove potential final and case modifiers.

  • Make inner classes and objects final case and move them to the companion object.

  • Make inner classes extend outer class implicitly.

  • Forward type parameters with their bounds and variance if none are specified explicitly.

  • Pass type and term parameters to the parent class if specified with the _[..](...) syntax.

  • Create private aliases to the arguments passed for the outer class' type parameters, if they are not also inner class parameters.

  • Convert expressions of the form f(...args) = body found in inner class bodies to definitions of the corresponding abstract methods or values found in the outer class.

Custom Options

Several options can be passed to @enum in order to customize its behavior.

  • 'Unseal prevents making the @enum class sealed.

  • 'NotInterested removes warning like "this class could be an object!".

  • 'Debug enables debugging output, and allows to see what is generated by the macro expansion.

For example: @enum('Unseal, 'Debug) class Enum { ... }.

Annotate with @ignore a member definition to leave it untouched by Boilerless.

In addition, arbitrary classes, methods and objects (even outside of @enum hierarchies) may be modified after the fact with:

  • @notCase to cancel a case modifier

  • @open to cancel a final or sealed modifier

  • @concrete to cancel an abstract modifier

Known Limitations

Syntax-Driven

Boilerless is completely syntax-driven, as it operate before type-checking and name resolution. As a consequence, if you extend the parent class explicitly with extends Base[..](...), it is important to do so with the bare parent name (so Boilerless can detect it), and not something like extends my.package.Base[..](...).

IDE support

Some IDE's like IntelliJ will likely not understand Boilerless' syntax and semantics, so it may be good to turn inspections off for the specific definition files. See also this to circumvent the problem.

About

Beautiful Syntax for Sealed Class Hierarchies

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages