The grpc-java
library is a good example of a library that utilizes the common builder pattern for creating objects with particular properties:
val sslContext = ???
val nettyChannel : NettyChannel =
NettyChannelBuilder
.forAddress(hostIp, hostPort)
.useTransportSecurity()
.sslContext(sslContext)
.build
Given a library that uses this pattern how can it be wrapt so that a proper functional API can be made available? I would imagine a monad is the appropriate tool to use.
A basic first attempt would look like:
val updateBuilder : (NettyChannelBuilder => Unit) => NettyChannelBuilder => NettyChannelBuilder =
updateFunc => builder => {
updateFunc(builder)
builder
}
val addTransportSecurity : NettyChannelBuilder => Unit =
(_ : NettyChannelBuilder).useTransportSecurity()
val addSslContext : NettyChannelBuilder => Unit =
builder => {
val sslContext = ???
builder sslContext sslContext
}
Although this method is verbose it would at least allow for composition:
val builderPipeline : NettyChannelBuilder => NettyChannelBuilder =
updateBuilder(addTransportSecurity) andThen updateBuilder(addSslContext)
val nettyChannel =
builderPipeline(NettyChannelBuilder.forAddress(hostIp, hostPort)).build
One constraint: no using scalaz
, cats
, or some other 3rd party library. Only scala language "stuff".
Note: grpc is just an example use case, not the primary point of the question...
Thank you in advance for your consideration and response.
If all the methods in the interface of the builder (except maybe build
itself) just mutate the builder instance and return this
, then they can be abstracted as Builder => Unit
functions. This is true for NettyChannelBuilder
, if I'm not mistaken. What you want to do in this case is to combine a bunch of those Builder => Unit
into a single Builder => Unit
, which runs the original ones consecutively.
Here is a direct implementation of this idea for NettyChannelBuilder
:
object Builder {
type Input = NettyChannelBuilder
type Output = ManagedChannel
case class Op(run: Input => Unit) {
def and(next: Op): Op = Op { in =>
this.run(in)
next.run(in)
}
def runOn(in: Input): Output = {
run(in)
in.build()
}
}
// combine several ops into one
def combine(ops: Op*): Op = Op(in => ops.foreach(_.run(in)))
// wrap methods from the builder interface
val addTransportSecurity: Op = Op(_.useTransportSecurity())
def addSslContext(sslContext: SslContext): Op = Op(_.sslContext(sslContext))
}
And you can use it like this:
val builderPipeline: Builder.Op =
Builder.addTransportSecurity and
Builder.addSslContext(???)
builderPipeline runOn NettyChannelBuilder.forAddress("localhost", 80)
It's also possible to use the Reader monad here. Reader monad allows combining two functions Context => A
and A => Context => B
into Context => B
. Of course every function you want to combine here is just Context => Unit
, where the Context
is NettyChannelBuilder
. But the build
method is NettyChannelBuilder => ManagedChannel
, and we can add it into the pipeline with this approach.
Here is an implementation without any third-party libraries:
object MonadicBuilder {
type Context = NettyChannelBuilder
case class Op[Result](run: Context => Result) {
def map[Final](f: Result => Final): Op[Final] =
Op { ctx =>
f(run(ctx))
}
def flatMap[Final](f: Result => Op[Final]): Op[Final] =
Op { ctx =>
f(run(ctx)).run(ctx)
}
}
val addTransportSecurity: Op[Unit] = Op(_.useTransportSecurity())
def addSslContext(sslContext: SslContext): Op[Unit] = Op(_.sslContext(sslContext))
val build: Op[ManagedChannel] = Op(_.build())
}
It's convenient to use it with the for-comprehension syntax:
val pipeline = for {
_ <- MonadicBuilder.addTransportSecurity
sslContext = ???
_ <- MonadicBuilder.addSslContext(sslContext)
result <- MonadicBuilder.build
} yield result
val channel = pipeline run NettyChannelBuilder.forAddress("localhost", 80)
This approach can be useful in more complex scenarios, when some of the methods return other variables, which should be used in later steps. But for NettyChannelBuilder
where most functions are just Context => Unit
, it only adds unnecessary boilerplate in my opinion.
As for other monads, the main purpose of State is to track changes to a reference to an object, and it's useful because that object is normally immutable. For a mutable object Reader works just fine.
Free monad is used in similar scenarios as well, but it adds much more boilerplate, and its usual usage scenario is when you want to build an abstract syntax tree object with some actions/commands and then execute it with different interpreters.
It's quite simple to adapt the previous two approaches to support any builder or mutable class in general. Though without creating separate wrappers for mutating methods, the boilerplate for using it grows quite a bit. For example, with the monadic builder approach:
class GenericBuilder[Context] {
case class Op[Result](run: Context => Result) {
def map[Final](f: Result => Final): Op[Final] =
Op { ctx =>
f(run(ctx))
}
def flatMap[Final](f: Result => Op[Final]): Op[Final] =
Op { ctx =>
f(run(ctx)).run(ctx)
}
}
def apply[Result](run: Context => Result) = Op(run)
def result: Op[Context] = Op(identity)
}
Using it:
class Person {
var name: String = _
var age: Int = _
var jobExperience: Int = _
def getYearsAsAnAdult: Int = (age - 18) max 0
override def toString = s"Person($name, $age, $jobExperience)"
}
val build = new GenericBuilder[Person]
val builder = for {
_ <- build(_.name = "John")
_ <- build(_.age = 36)
adultFor <- build(_.getYearsAsAnAdult)
_ <- build(_.jobExperience = adultFor)
result <- build.result
} yield result
// prints: Person(John, 36, 18)
println(builder.run(new Person))
I know that we said no cats et al.
but I decided to post this up, first, in all honesty as an exercise for myself and second, since in essence these libraries simply aggregate "common" typed functional constructs and patterns.
After all, would you ever consider writing an HTTP server from vanilla Java/Scala or would you grab a battle tested one off the shelf? (sorry for the evangelism)
Regardless, you could replace their heavyweight implementation with a homegrown one of your own, if you really wanted.
I will present below, two schemes that came to mind, the first using the Reader
monad, the second using the State
monad. I personally find the first approach a bit more clunky than the second, but they are both not too pretty on the eye. I guess that a more experienced practitioner could do a better job at it than I.
Before that, I find the following rather interesting: Semicolons vs Monads
The code:
I defined the Java Bean:
public class Bean {
private int x;
private String y;
public Bean(int x, String y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Bean{" +
"x=" + x +
", y='" + y + '\'' +
'}';
}
}
and the builder:
public final class BeanBuilder {
private int x;
private String y;
private BeanBuilder() {
}
public static BeanBuilder aBean() {
return new BeanBuilder();
}
public BeanBuilder withX(int x) {
this.x = x;
return this;
}
public BeanBuilder withY(String y) {
this.y = y;
return this;
}
public Bean build() {
return new Bean(x, y);
}
}
Now for the scala code:
import cats.Id
import cats.data.{Reader, State}
object Boot extends App {
val r: Reader[Unit, Bean] = for {
i <- Reader({ _: Unit => BeanBuilder.aBean() })
n <- Reader({ _: Unit => i.withX(12) })
b <- Reader({ _: Unit => n.build() })
_ <- Reader({ _: Unit => println(b) })
} yield b
private val run: Unit => Id[Bean] = r.run
println("will come before the value of the bean")
run()
val state: State[BeanBuilder, Bean] = for {
_ <- State[BeanBuilder, BeanBuilder]({ b: BeanBuilder => (b, b.withX(13)) })
_ <- State[BeanBuilder, BeanBuilder]({ b: BeanBuilder => (b, b.withY("look at me")) })
bean <- State[BeanBuilder, Bean]({ b: BeanBuilder => (b, b.build()) })
_ <- State.pure(println(bean))
} yield bean
println("will also come before the value of the bean")
state.runA(BeanBuilder.aBean()).value
}
The output, due to the lazy nature of the evaluation of these monads is:
will come before the value of the bean
Bean{x=12, y='null'}
will also come before the value of the bean
Bean{x=13, y='look at me'}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With