Finally Tagless:
The cake is a lie
Dependency injection
- DI strategies have three components: services, objects and programs
- Objects implement services, and programs use services
- All of them can have disparate implementations
- They also depend on each other in specific ways
DI in Scala
- OO: Compile-time annotation frameworks, runtime annotation frameworks, constructor/parameter injection
- FP: Free structures, finally tagless structures (dual)
- Scala: Cake
- Each has different notions of what an "operation" is, and how to bundle operations into a "service"
Dependencies
- Services provide other services
- Objects depend on services
- Objects use programs that require any of the services they depend on or implement
- Programs should be able to depend on services
- Programs should be able to use other programs that use subsets of the same services
Cakes
- Services are traits full of methods that, to be implemented, *must* side-effect or keep internal state (optionally accompanied by component definition composing multiple services)
- Objects depend on services using self-type annotations
- Object definition amounts to defining a trait inheriting from the implemented services' traits
- The only programs that exist are methods on the concrete cake
Parameters
- Services: a trait full of (probably) side-effecting methods keeping (probably) internal state
- Objects: defining a concrete subclass of the traits describing the services satisfied by the object
- Dependencies are declared using constructor parameters *or* function parameters
- Programs are thus either public members of the class or the functions themselves
Free Structures + Algebras
- Services: initial algebras, a pure-data representation of operations provided by the service
- Objects: interpreters, which can output free programs themselves (horizontal object composition)
- Dependencies: interpreters which output other algebras, to be interpreted yet later (horizontal object composition)
- Programs: free structures over a typeclass for program composition, with coproducts for multiple service dependencies
Benefits of the Free approach
- Explicit programs can be transformed; for example, adding compression to a program that touches a key/value-storage
- Not all programs are naturally expressed as monads: the free construct + algebra approach generalizes to functors, applicatives, monoids and recursion
Downsides of the Free approach
- Explicit programs must be allocated; this typically takes the form of an allocation per service operation and an allocation per composition of the operation
- Programs are deconstructed with pattern matching, which does not scale for large match statements and in Java is capable of cache thrashing
Finally Tagless
- Services: traits with type parameters, methods inside
- Objects: classes/objects implementing traits (classes provide horizontal object composition)
- Programs: functions polymorphic over a type parameter, with implicit parameters for service dependencies, as well as typeclass constraints which provide composition
Concretely
trait Log[F[_]] { self =>
def info(out: String): F[Unit]
def warn(out: String): F[Unit]
}
final class NoisyLog[F[_]](log: Log[F[_]]) {
def info(out: String): F[Unit] = log.warn(out)
def warn(out: String): F[Unit] = log.warn(out)
}
object SilentLog extends Log[Task] {
def info(out: String): Task[Unit] = Task.eval(())
def warn(out: String): Task[Unit] = Task.eval(())
}
object JavaLog extends Log[Task] {
def info(out: String): Task[Unit] = Task.eval(System.out.println(out))
def warn(out: String): Task[Unit] = Task.eval(System.err.println(out))
}
object Main {
def mkLog(silent: Boolean, noisy: Boolean): Log[Task] =
if (silent) SilentLog
else if (noisy) NoisyLog(JavaLog)
else JavaLog
implicit val log = mkConfig(silent = false, noisy = false)
}
Free -> Final Tagless
- Idiom: passing a Free program to a function, which interprets the program in a way unknown to the caller
- Replacement: passing finally tagless actions
// finally tagless action constrained with Constraint
// that uses the service Service
trait Action[Service[_[_]], Constraint[_[_]], A] {
def apply[G[_] : Constraint: Service]: G[A]
}
Good uses of Free, as a special case of finally tagless
- Free provides a means to describe a call tree as data
- Testing that a call tree has a particular shape thus is particularly well suited to Free
- Thus, Free provides a purely functional alternative to mocking libraries
Good uses of Free, as a special case of finally tagless
- Free structures (at least some form of them) are also serializable, and so can be used in producer/consumer arrangements with intermediate queues
Good uses of Free
- Other free structures can offer performance advantages in some situations over the direct finally tagless approach
- Monoids, Monads, Functors can all benefit from their Free varieties
Horizontal object composition
- Horizontal object composition amounts to running a program with several service implementations, "concurrently"
- Note that actual concurrency is not related, but the actions are interleaved into a single program call tree
Horizontal object composition
- Free interpreters can be horizontally composed using products of natural transformations
- Finally tagless objects can be horizontally composed in the same way, using the Cartesian typeclass
- Both of these are useful because they traverse the call tree once while running many interpreters and receiving many values
trait Cartesian[F[_]] {
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
}
def and[F[_], G[_], H[_]]
(first: F ~> G,
second: F ~> H): F ~> Prod[G, H, ?] =
Lambda[F ~> Prod[G, H, ?]].apply(fa => Prod(first(fa), second(fa)))
Title Text
Subtitle
Modular Architecture
Finally Tagless:
By edmundnoble
Finally Tagless:
- 106