The document discusses using functional programming techniques to solve common programming problems in a type-safe way. It presents examples using data types like WriterT, IndexedStateT, and Codensity to handle time measurement, state machines, and resource management. WriterT allows logging computations to a shared log. IndexedStateT encodes state machine transitions in the type system. Codensity models resource acquisition and release in a way that ensures cleanup. The examples demonstrate how functional patterns can address common tasks in a pure and safe manner.
2. About me
• Freelance software developer
• Doing functional programming, microservices, DevOps
• Co-organize the Underscore Scala meetup
• Hit me up if you want to work together!
Itamar Ravid - @itrvd 2
3. Agenda
• Why I wrote this talk
• Three data types and their usecases
Itamar Ravid - @itrvd 3
6. How do we grow
as functional programmers
Itamar Ravid - @itrvd 6
7. Classic "draw an owl" problem
How do we go from this:
quicksort [] = []
quicksort (p:xs) = (quicksort lesser) ++ [p] ++ (quicksort greater)
where
lesser = filter (< p) xs
greater = filter ( >= p) xs
Itamar Ravid - @itrvd 7
8. Classic "draw an owl" problem
To this:
-- | Fokkinga's postpromorphism
postpro
:: Recursive t
=> (forall b. Base t b -> Base t b) -- natural transformation
-> (a -> Base t a) -- a (Base t)-coalgebra
-> a -- seed
-> t
postpro e g = a where a = embed . fmap (ana (e . project) . a) . g
Itamar Ravid - @itrvd 8
11. Actual agenda
Purely functional and type-safe:
• Time measurement and instrumentation
• State machines
• Resource allocation and release
• Let's go!
Itamar Ravid - @itrvd 11
13. Time measurement and instrumentation
Problem: I have a bunch of computations, and I want to measure
them.
def computeCv(data: Data): IO[Double] =
for {
sample <- computeSample(data)
mean <- computeMean(sample)
variance <- computeVariance(sample)
} yield mean / variance
Itamar Ravid - @itrvd 13
14. Time measurement and instrumentation
Non-solution: manually instrument.
def computeCv(data: Data): IO[Double] =
for {
startTime <- time()
sample <- computeSample(data)
sampleTime <- time()
mean <- computeMean(sample)
meanTime <- time()
variance <- computeVariance(sample)
varTime <- time()
} yield mean / variance
Itamar Ravid - @itrvd 14
16. Time measurement and instrumentation
To start, we define a timed combinator:
def timed[A](fa: IO[A]): IO[(FiniteDuration, A)] =
for {
startTime <- time()
a <- fa
endTime <- time()
} yield ((endTime - startTime).millis, a)
fa is pure, so we can just pass it around.
Itamar Ravid - @itrvd 16
17. Time measurement and instrumentation
We can also name this computation:
def timed[A](fa: IO[A],
name: String): IO[((String, FiniteDuration), A)] =
for {
startTime <- time()
a <- fa
endTime <- time()
} yield (name -> (endTime - startTime).millis, a)
Itamar Ravid - @itrvd 17
18. Time measurement and instrumentation
We can now use our new timed function:
def computeCv(data: Data): IO[Double] =
for {
(sampleTime, sample) <- timed(computeSample(data), "sample")
(meanTime, mean) <- timed(computeMean(sample), "mean")
(varTime, variance) <- timed(computeVariance(sample), "var")
} yield mean / variance
Kinda tedious though!
Itamar Ravid - @itrvd 18
19. Time measurement and instrumentation
To save us from that bookeeping, comes WriterT:
case class WriterT[F[_], Log, Value](
run: F[(Log, Value)]
)
Itamar Ravid - @itrvd 19
20. Time measurement and instrumentation
To save us from that bookeeping, comes WriterT:
case class WriterT[F[_], Log, Value](
run: F[(Log, Value)]
)
If Log: Semigroup- we can merge logs.
Itamar Ravid - @itrvd 20
21. Time measurement and instrumentation
We modify timed to return a WriterT:
type Timing = Map[String, FiniteDuration]
def timed(name: String, fa: IO[A]): WriterT[IO, Timing, A] =
WriterT {
for {
startTime <- time()
a <- fa
endTime <- time()
} yield (Map(name -> (endTime - startTime).millis), a)
}
Itamar Ravid - @itrvd 21
22. Time measurement and instrumentation
And we can call this Timed[A] for nicety:
type Timed[A] = WriterT[IO, Map[String, FiniteDuration], A]
def timed(name: String, fa: IO[A]): Timed[A] =
WriterT {
for {
startTime <- time()
a <- fa
endTime <- time()
} yield (Map(name -> (endTime - startTime).millis), a)
}
Itamar Ravid - @itrvd 22
23. Time measurement and instrumentation
Our original program is only slightly changed:
def computeCv(data: Data): Timed[Double] =
for {
sample <- timed(computeSample(data), "sample")
mean <- timed(computeMean(sample), "mean")
variance <- timed(computeVariance(sample), "var")
} yield mean / variance
Itamar Ravid - @itrvd 23
24. Time measurement and instrumentation
To unpack the timing data, we can define another function:
def logTimings[A](timed: Timed[A]): IO[A] =
for {
(timing, a) <- timed.run
_ <- IO(println(timing))
} yield a
Itamar Ravid - @itrvd 24
25. Time measurement and instrumentation
And use it:
def computeCv(data: Data): IO[Double] =
logTimings {
for {
sample <- timed(computeSample(data), "sample")
mean <- timed(computeMean(sample), "mean")
variance <- timed(computeVariance(sample), "var")
} yield mean / variance
}
Itamar Ravid - @itrvd 25
26. Summary
• You can add infix syntax using implicit class for nicety:
computeSample(data).timed("sample").logTimings,
etc.
• WriterT is a Monad and an Applicative
Itamar Ravid - @itrvd 26
27. Safe state machines
Problem: I want to safely model an authentication protocol:
case class Connection()
trait Authentication {
def connect(): IO[Connection]
def handshake(connection: Connection): IO[Unit]
def sendCreds(connection: Connection): IO[Unit]
def authenticate(connection: Connection): IO[Unit]
}
Itamar Ravid - @itrvd 27
28. Safe state machines
Sure, I can just use the functions:
val api: Authentication
import api._
for {
conn <- connect()
_ <- handshake(conn)
_ <- authenticate(conn)
_ <- sendCreds(conn)
} yield data
Itamar Ravid - @itrvd 28
29. Safe state machines
Sure, I can just use the functions:
val api: Authentication
import api._
for {
conn <- connect()
_ <- handshake(conn)
_ <- authenticate(conn)
_ <- sendCreds(conn)
} yield data
But there's a bug here!
Itamar Ravid - @itrvd 29
30. Safe state machines
Two problems:
1. Nothing enforces the proper call order
2. Carrying the connection around is not ergonomic
Itamar Ravid - @itrvd 30
31. Safe state machines
Static types to the rescue! We will modify Connection:
case class Handshake()
case class Credentials()
case class Auth()
case class Done()
case class Connection[S]()
Itamar Ravid - @itrvd 31
33. Safe state machines
Now we need to carry even more connections around:
val api: Authentication
import api._
for {
connHandshake <- connect()
connCreds <- handshake(connHandshake)
connAuth <- sendCreds(connCreds)
connDone <- authenticate(connAuth)
} yield connDone
Safe, but ugly.
Itamar Ravid - @itrvd 33
34. Safe state machines
FP saves the day! This is IndexedStateT:
case class IndexedStateT[F, SA, SB, A](
run: SA => F[(SB, A)]
)
Itamar Ravid - @itrvd 34
35. Safe state machines
FP saves the day! This is IndexedStateT:
case class IndexedStateT[F, SA, SB, A](
run: SA => F[(SB, A)]
)
For example:
SA = Connection[Handshake], SB = Connection[Credentials]
F = IO, A = Unit
Itamar Ravid - @itrvd 35
36. Safe state machines
val handshake: Connection[Handshake] =>
IO[(Connection[Credentials], Unit)]
Itamar Ravid - @itrvd 36
37. Safe state machines
We can modify our API to use it:
trait Authentication {
def connect: IndexedStateT[IO, Unit, Connection[Handshake], Unit]
def handshake: IndexedStateT[IO, Connection[Handshake], Connection[Credentials], Unit]
def sendCreds: IndexedStateT[IO, Connection[Credentials], Connection[Auth], Unit]
def authenticate: IndexedStateT[IO, Connection[Auth], Connection[Done], Unit]
}
Itamar Ravid - @itrvd 37
38. Safe state machines
And this is how the usage looks like:
val api: Authentication
import api._
val authenticate: IndexedStateT[IO, Unit, Connection[Done], Unit] =
for {
_ <- connect
_ <- handshake
_ <- sendCreds
_ <- authenticate
} yield data
Itamar Ravid - @itrvd 38
39. Safe state machines
The original example, by the way, errors out:
val api: Authentication
import api._
for {
_ <- connect
_ <- handshake
_ <- authenticate
_ <- sendCreds
} yield data
Itamar Ravid - @itrvd 39
40. Safe state machines
cmd6.sc:6: polymorphic expression cannot be instantiated to expected type;
found : [B, SC]IndexedStateT[IO,Connection[Auth],SC,B]
required: IndexedStateT[IO,Connection[Credentials],?,?]
_ <- authenticate
^
Itamar Ravid - @itrvd 40
41. Summary
• IndexedStateT is very cool for type-safe transitions
• It is a Monad and an Applicative when SA = SB
• For non-linear transitions: shapeless.Coproduct and
shapeless.HList
• IxStateT in Haskell is in the indexed-extras package
Itamar Ravid - @itrvd 41
42. Resource allocation and release
Problem: I have many resources allocated in my app.
I need to manage their acquisition and release.
def createKafkaConsumer: IO[KafkaConsumer]
trait KafkaConsumer {
def close: IO[Unit]
}
def createDBConnection: IO[DBConnection]
trait DBConnection {
def close: IO[Unit]
}
Itamar Ravid - @itrvd 42
43. Resource allocation and release
How should my initialization look like?
Itamar Ravid - @itrvd 43
44. Resource allocation and release
How should my initialization look like?
def runApp(): IO[Unit] =
for {
consumer <- createKafkaConsumer
conn <- createDBConnection
_ <- doSomething(consumer, conn)
_ <- conn.close
_ <- consumer.close
} yield ()
Itamar Ravid - @itrvd 44
45. Resource allocation and release
How should my initialization look like?
def runApp(): IO[Unit] =
for {
consumer <- createKafkaConsumer
conn <- createDBConnection
_ <- doSomething(consumer, conn)
_ <- ignoreFailures(conn.close)
_ <- ignoreFailures(consumer.close)
} yield ()
Itamar Ravid - @itrvd 45
46. The smell is strong with you!
Itamar Ravid - @itrvd 46
47. Resource allocation and release
If you know Java, you're probably aware of try-with-
resources:
try (BufferedReader br =
new BufferedReader(new FileReader(path))) {
return br.readLine();
}
This works for any T <: AutoCloseable.
Itamar Ravid - @itrvd 47
48. Resource allocation and release
Our example would look like this:
try (Consumer consumer = new Consumer()) {
try (DBConnection conn = new DBConnection()) {
doSomething(consumer, conn);
}
}
Itamar Ravid - @itrvd 48
49. Resource allocation and release
These are not the abstractions you're looking for!
Itamar Ravid - @itrvd 49
50. Resource allocation and release
IO monads offer us the bracket operation:
object IO {
def bracket[R, A](acquire: IO[R])
(release: R => IO[Unit])
(use: R => IO[A]): IO[A]
}
Itamar Ravid - @itrvd 50
51. Resource allocation and release
But this gets us to the same place:
def createConsumer: IO[Consumer]
def createDBConnection: IO[DBConnection]
bracket(createConsumer)(_.close) { consumer =>
// use consumer
}
Itamar Ravid - @itrvd 51
52. Resource allocation and release
But this gets us to the same place:
def createConsumer: IO[Consumer]
def createDBConnection: IO[DBConnection]
bracket(createConsumer)(_.close) { consumer =>
bracket(createDBConnection)(_.close) { conn =>
// use both
}
}
Itamar Ravid - @itrvd 52
53. Resource allocation and release
There's an interesting data type called Codensity in Scalaz:
abstract class Codensity[F[_], A] {
def apply[B](f: A => F[B]): F[B]
}
Itamar Ravid - @itrvd 53
54. Resource allocation and release
When we set F = IO, A = R, B = A, apply looks like this:
def apply(f: R => IO[A]): IO[A]
Itamar Ravid - @itrvd 54
55. Resource allocation and release
This is remarkably similar to part of bracket!
def bracket[R, A](acquire: IO[R])
(release: R => IO[Unit])
(use: R => IO[A]): IO[A]
def apply (f: R => IO[A]): IO[A]
Remember: type signatures are never a coincidence ;-)
Itamar Ravid - @itrvd 55
56. Resource allocation and release
Let's adapt bracket to fit Codensity:
def resource[R](acquire: IO[R])(release: R => IO[Unit]) =
new Codensity[IO, R] {
def apply[A](use: R => IO[A]): IO[A] =
IO.bracket(acquire)(release)(use)
}
Itamar Ravid - @itrvd 56
57. Resource allocation and release
So now, when we write a resource, we get back a value:
val consumer: Codensity[IO, Consumer] =
resource(createKafkaConsumer)(_.close)
Itamar Ravid - @itrvd 57
58. Resource allocation and release
So now, when we write a resource, we get back a value:
val consumer: Codensity[IO, Consumer] =
resource(createKafkaConsumer)(_.close)
And we can use it:
val things: IO[List[Thing]] =
consumer { c =>
c.consumeSomeThings
}
Itamar Ravid - @itrvd 58
60. Resource allocation and release
It just so happens that Codensity is a Monad.
So I can do this:
val resources: Codensity[IO, (Consumer, DBConnection)] =
for {
consumer <- resource(createKafkaConsumer)(_.close)
dbConn <- resource(createDBConnection)(_.close)
} yield (consumer, dbConn)
Itamar Ravid - @itrvd 60
61. Resource allocation and release
Finally, I can do something with these!
val something: IO[Something] =
resources { case (consumer, conn) =>
doSomething(consumer, dbConn)
}
Finalizers will run in reverse order of acquisition.
Itamar Ravid - @itrvd 61
62. Resource allocation and release
• Codensity is 18 lines of code in total
• I wrote a post on this
• cats-effect includes Resource[F, R]
• In Haskell, you can use ResourceT or Managed
Itamar Ravid - @itrvd 62
63. Summary
• Don't be afraid of the exotic data types!
• Be on the lookout for examples
Itamar Ravid - @itrvd 63