Skip to content

Instantly share code, notes, and snippets.

@erikrozendaal
Last active December 13, 2015 23:19
Show Gist options
  • Save erikrozendaal/4990502 to your computer and use it in GitHub Desktop.
Save erikrozendaal/4990502 to your computer and use it in GitHub Desktop.
Type driven development - an example Use a recent version of `sbt` to load the example into the scala console: $ sbt console scala> import timeseries._ scala> println(accountTotals)
name := "timeseries"
scalaVersion := "2.10.0"
libraryDependencies ++= Seq(
"joda-time" % "joda-time" % "2.1",
"org.joda" % "joda-convert" % "1.3")
import org.joda.time.LocalDate
import scala.collection.breakOut
import scala.collection.immutable.{ SortedMap, SortedSet }
package object timeseries {
case class Money(value: BigDecimal) {
def +(that: Money) = Money(value + that.value)
}
object Money {
def apply(value: Int): Money = apply(value: BigDecimal)
}
trait Monoid[T] {
def zero: T
def plus(a: T, b: T): T
}
object Monoid {
// Easily get access to an implicit monoid instance for T by
// writing `Option[T]`.
def apply[T](implicit monoid: Monoid[T]) = monoid
implicit val IntAdditionMonoid = new Monoid[Int] {
def zero = 0
def plus(a: Int, b: Int) = a + b
}
implicit val BigDecimalAdditionMonoid = new Monoid[BigDecimal] {
def zero = 0
def plus(a: BigDecimal, b: BigDecimal) = a + b
}
implicit val StringMonoid = new Monoid[String] {
def zero = ""
def plus(a: String, b: String) = a ++ b
}
implicit def ListMonoid[T] = new Monoid[List[T]] {
def zero = List.empty
def plus(a: List[T], b: List[T]) = a ++ b
}
}
type TimeSeries[T, V] = SortedMap[T, V]
def combineTimeSeries[T: Ordering, V: Monoid](a: TimeSeries[T, V], b: TimeSeries[T, V]): TimeSeries[T, V] = {
val timestamps = a.keySet union b.keySet
timestamps.map { timestamp =>
timestamp -> Monoid[V].plus(
a.to(timestamp).lastOption.map(_._2).getOrElse(Monoid[V].zero),
b.to(timestamp).lastOption.map(_._2).getOrElse(Monoid[V].zero))
}(breakOut)
}
val a = SortedMap(1 -> "a", 3 -> "c", 4 -> "d")
val b = SortedMap(2 -> "B", 4 -> "D")
val combined = combineTimeSeries(a, b)
assert(combined == SortedMap(1 -> "a", 2 -> "aB", 3 -> "cB", 4 -> "dD"))
// By defining an Ordering instance for LocalDate and a Monoid
// instance for Money we can reuse the addTotals function. Notice
// that these instances can even be defined for types you do not
// own!
implicit val LocalDateOrdering: Ordering[LocalDate] = Ordering.by(date => (date.getYear, date.getMonthOfYear, date.getDayOfMonth))
implicit val MoneyMonoid = new Monoid[Money] {
def zero = Money(0)
def plus(a: Money, b: Money) = a + b
}
val accountA = SortedMap(
new LocalDate(2013, 1, 1) -> Money(100),
new LocalDate(2013, 1, 5) -> Money(140),
new LocalDate(2013, 1, 19) -> Money(70)
)
val accountB = SortedMap(
new LocalDate(2013, 1, 1) -> Money(120),
new LocalDate(2013, 1, 13) -> Money(40),
new LocalDate(2013, 1, 19) -> Money(20)
)
val accountTotals = combineTimeSeries(accountA, accountB)
assert(accountTotals == SortedMap(
new LocalDate(2013, 1, 1) -> Money(220),
new LocalDate(2013, 1, 5) -> Money(260),
new LocalDate(2013, 1, 13) -> Money(180),
new LocalDate(2013, 1, 19) -> Money(90)
))
// But we can also combine the latest sensor information using the
// List monoid!
val temperatureSensor = SortedMap(10 -> List("T: 18C"), 16 -> List("T: 19C"))
val humiditySensor = SortedMap(6 -> List("RH: 74%"), 22 -> List("RH: 89%"))
val combinedSensors = combineTimeSeries(temperatureSensor, humiditySensor)
assert(combinedSensors == SortedMap(
6 -> List("RH: 74%"),
10 -> List("T: 18C", "RH: 74%"),
16 -> List("T: 19C", "RH: 74%"),
22 -> List("T: 19C", "RH: 89%")
))
// We can make the notation for combining monoids a bit nicer by
// defining our own operator |+|, a literal for zero, and a way to
// default a missing Option value to the monoid's zero.
def mzero[T: Monoid] = Monoid[T].zero
implicit class MonoidOps[T: Monoid](value: T) {
def |+|(that: T) = Monoid[T].plus(value, that)
}
implicit class MonoidOptionOps[T: Monoid](value: Option[T]) {
def orZero = value.getOrElse(mzero[T])
}
assert(combinedSensors == (temperatureSensor |+| humiditySensor))
assert("" == mzero[String])
assert("" == (None: Option[String]).orZero)
assert(4 == (Some(4): Option[Int]).orZero)
// But timeseries have monoids too, using our addTotals for the plus
// implementation!
implicit def TimeSeriesMonoid[T: Ordering, V: Monoid] = new Monoid[TimeSeries[T, V]] {
def zero = SortedMap.empty
def plus(a: TimeSeries[T, V], b: TimeSeries[T, V]) = combineTimeSeries(a, b)
}
// We can also combine many monoid values, instead of just two.
def foldMonoid[T: Monoid](list: List[T]): T = list.fold(Monoid[T].zero)(Monoid[T].plus)
// So now we can combine multiple bank accounts, even with different
// currencies, since maps that have values that have monoids are
// monoids too.
implicit def MapMonoid[K, V: Monoid] = new Monoid[Map[K, V]] {
def zero = Map.empty
def plus(a: Map[K, V], b: Map[K, V]) = (a.keySet union b.keySet).map { key =>
key -> (a.get(key).orZero |+| b.get(key).orZero)
}(breakOut)
}
val swissAccount = SortedMap(
new LocalDate(2013, 2, 5) -> Map("CHF" -> Money(40)),
new LocalDate(2013, 2, 8) -> Map("CHF" -> Money(70))
)
val euroAccount = SortedMap(
new LocalDate(2013, 2, 6) -> Map("EUR" -> Money(30))
)
val checkingAccount = SortedMap(
new LocalDate(2013, 2, 3) -> Map("USD" -> Money(100)),
new LocalDate(2013, 2, 7) -> Map("USD" -> Money(50)),
new LocalDate(2013, 2, 9) -> Map("USD" -> Money(60))
)
val savingsAccount = SortedMap(
new LocalDate(2013, 2, 5) -> Map("USD" -> Money(200)),
new LocalDate(2013, 2, 9) -> Map("USD" -> Money(205))
)
val allAccounts = foldMonoid(List(swissAccount, euroAccount, checkingAccount, savingsAccount))
assert(allAccounts == SortedMap(
new LocalDate(2013, 2, 3) -> Map("USD" -> Money(100)),
new LocalDate(2013, 2, 5) -> Map("CHF" -> Money(40), "USD" -> Money(300)),
new LocalDate(2013, 2, 6) -> Map("CHF" -> Money(40), "EUR" -> Money(30), "USD" -> Money(300)),
new LocalDate(2013, 2, 7) -> Map("CHF" -> Money(40), "EUR" -> Money(30), "USD" -> Money(250)),
new LocalDate(2013, 2, 8) -> Map("CHF" -> Money(70), "EUR" -> Money(30), "USD" -> Money(250)),
new LocalDate(2013, 2, 9) -> Map("CHF" -> Money(70), "EUR" -> Money(30), "USD" -> Money(265))
))
// Provide a wrapper that uses our problem domain's terminology.
type BankAccountBalances = TimeSeries[LocalDate, Money]
def addTotals(a: BankAccountBalances,
b: BankAccountBalances)
: BankAccountBalances = TimeSeriesMonoid[LocalDate, Money].plus(a, b)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment