Last active
December 13, 2015 23:19
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name := "timeseries" | |
scalaVersion := "2.10.0" | |
libraryDependencies ++= Seq( | |
"joda-time" % "joda-time" % "2.1", | |
"org.joda" % "joda-convert" % "1.3") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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