Simpler take on builder pattern and context functions

Getting familiar with Scala3, I learned about the concept of context functions. However, looking at their use-case, I got a feeling they could be replaced by conceptually much simpler feature - lambda methods. Lambda methods are anonymous extensions, with implicit this argument. They exist in Kotlin under the name function with receiver. In short:

fun foo(fn: Int.() -> Int) = 1.fn()

foo { increment() } == foo { this.increment() } == 2

Comparison of context functions to lambda methods

Builder pattern:

  class Table:
    val rows = new ArrayBuffer[Row]
    def add(r: Row): Unit = rows += r
    override def toString = rows.mkString("Table(", ", ", ")")

  class Row:
    val cells = new ArrayBuffer[Cell]
    def add(c: Cell): Unit = cells += c
    override def toString = cells.mkString("Row(", ", ", ")")

  case class Cell(elem: String)

  val table =
    table {
      row {
        cell("top left")
        cell("top right")
      }
      row {
        cell("bottom left")
        cell("bottom right")
      }
    }
Context functions
  def table(init: Table ?=> Unit) =
    given t: Table = Table()
    init
    t

  def row(init: Row ?=> Unit)(using t: Table) =
     given r: Row = Row()
     init
     t.add(r)

  def cell(str: String)(using r: Row) =
     r.add(new Cell(str))
Lambda methods (Kotlin)
  fun<T> T.with(fn: T.() -> Unit): T = 
    fn()
    return this
  }

  fun table(init: Table.() -> Unit) =   // look at the type signatures

    Table().with { init() }             // ignore the implementation

  fun Table.row(init: Row.() -> Unit) = // it reads like an ordinary lambda

     add(Row().with { init() })         // no need for `given` or `using`

  fun Row.cell(str: String) =

     add(new Cell(str))
 

Post-conditions

Context functions
object PostConditions:
  opaque type WrappedResult[T] = T

  def result[T](using r: WrappedResult[T]): T = r

  extension [T](x: T)
    def ensuring(condition: WrappedResult[T] ?=> Boolean): T =
      assert(condition(using x))
      x
end PostConditions
import PostConditions.{ensuring, result}

val s = List(1, 2, 3).sum.ensuring(result == 6)
Lambda methods (Kotlin)
val<T> T.result = this
fun<T> T.ensuring(condition: T.() -> Boolean): T {
    assert(condition())
    return this
}
val s = list(1, 2, 3).sum().ensuring{result == 6}

To me, the extension based type signatures look much more understandable and beginner friendly. It is very possible, that contexts are more powerful, but then

  1. The documentation should show examples where more power is needed.
  2. It should be considered, whether the extra power is worth the complexity.

Note: I used kotlin syntax, just because I wanted to discuss the concept itself, before arguing about the particular syntax this could have in Scala.

I understand your point of view. However know that context-functions are ‘just’ giving the same powers to functions to what methods already have. Scala 2 methods can do more than Scala 2 functions and that has been rectified in Scala 3. See also

Context functions are just the same thing for Using Clauses.

Therefore, the use cases for context functions are somewhat the same as the use cases for using clauses. Which can be for example type-level proofs or dependency injection. I can’t say that I fully understand receiver functions or what they are capable of, but I think context functions can indeed do things receiver functions cannot.

Here is some material that dives into the new functionality

That said, I do agree that the examples on the official documentation are not great. Personally I find the tagless example in the paper quite nice. Maybe we can use that one, or ExecutionContext example by Adam Warski?

I am aware of lambda methods, but I would contest that they are simpler than context functions. I think the opposite is true, in fact. Fiddling with this and scope injection in general is IMO very powerful but also dangerous, complex and ad hoc. Context functions are better studied, more principled, and proven sound.

2 Likes

Interesting. Would you care to explain the dangerousness and complexity in more detail (to someone who is not well versed in type theory)? Because on first sight, the context API feels more complex (at least to read and write).

I can imagine that the feature could be tricky to implement correctly. However, I have to admit, that it’s very easy to get used to. In fact, it now feels just as natural and a no-brainer as another great ad-hoc extension - type classes.

I’ve heard complaints that DSL-heavy code in Kotlin is kind of nightmarish to read, because you never know where identifiers come from. Have they been injected? Or are they captured ? Especially in code like the following:

foo {
  bar { x ->
    baz {
      println(f(x))
    }
  }
}

Where does f come from? Was it injected by foo, bar, or baz? Maybe all three inject different types of objects into scopes, so you have to know every field/method defined by every injected type and those of their base types in order to understand the code.

How about x? Is this a capture of the x from the outside, or is it being injected by baz?

Not to mention that println may itself actually refer to an inadvertently injected PrintStream.

This is fundamentally not so different from implicit function types, except that the latter inject much fewer things, and make the whole thing more tractable. You generally know what visible identifiers mean. Things are mostly driven by type, not by name, which means fewer unintentional collisions. Also, code relying on scope injection may completely break if you add a field in a random class whose scope someone had the good idea of injecting in some unrelated code.

Of course, many of these problems also exist in code that abuse import and export features.

7 Likes

I agree. ExecutionContext makes a good example. Here’s a little function I wrote because I test multi-threaded programs all the time:

def withContext[A, Exec](exec: Exec)(code: Exec ?=> Future[A]): A =
   Await.result(code(using exec), Duration.Inf)

I use it as:

withContext(ExecutionContext.global) {
   val f = Future { ... }
   val g = f.map(...)
   g.filter(...)
}

I use even more often a variant that waits for termination of a local thread pool:

def withLocalContext[A, Exec <: ExecutorService](exec: Exec)(code: Exec ?=> Future[A]): A = ...

used as:

withLocalContext(Executors.newCachedThreadPool(8)) { ... }

Without context functions, my Scala 2 setup was more awkward.

2 Likes

It is not a new discussion. Here is my latest totals: Literal Abstractions - #7 by AMatveev
Also,I can say I do not understand statements which say that there are no problems it is ok and scala has all it needs.

I think the main power of OOP is the scope management:

And currently the only way to compose scopes is to create object and classes:

Is the anonymous object is the right choose for scope management. It seems the less evil in some cases for me.
So it is quite difficult choice for me whether I should:

  • Use imports

    It leads to boilerplate code

  • Use global imports

    It has low coupling

  • Use inheritance

    It is quite heavy

I think existence of such choice it is quite bad by itself. And It indicates that the task has no good decision.