score:5

Accepted answer

basic approach

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)

reader monad

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.

generic builder

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))

score:0

a very simple functional approach is having a case class that collects the configuration, and has methods that update their values and pass it along so it can be built at the end:

case class mynettychannel( ip: string, port: int,
                           transportsecurity: boolean,
                           sslcontext: option[sslcontext] ) {
  def foraddress(addrip: string, addrport: int) = copy(ip = addrip, port = addrport)
  def withtransportsecurity                     = copy(transportsecurity = true)
  def withouttransportsecurity                  = copy(transportsecurity = false)
  def withsslcontext(ctx: sslcontext)           = copy(sslcontext = some(ctx))
  def build: nettychannel = {
    /* create the actual instance using the existing builder */
  }
}

object mynettychannel {
  val default = mynettychannel("127.0.0.1", 80, false, none)
}

val nettychannel = mynettychannel.default
    .foraddress(hostip, hostport)
    .withtransportsecurity
    .withsslcontext(ctx)
    .build

a similar approach (without having to create the copying methods in the first place) is to use lenses, for example using the quicklens library:

val nettychannel = mynettychannel.default
  .modify(_.ip)               .setto(hostip)
  .modify(_.port)             .setto(1234)
  .modify(_.transportsecurity).setto(true)
  .modify(_.sslcontext)       .setto(ctx)
  .build

score:2

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'}

Related Query

More Query from same tag