Stupidly Obscure Programming in a Troubled Time
Since obsessively underlining passages in a tattered copy of Goodbye to Berlin hasn't proven to be the uplifting diversion I was hoping for, I resolved to bash my head against some really complicated scala code that I'm not qualified to write and that nobody is asking for either.
So that this exercise in self-abuse could pass for a reasonable use of my time - but not too reasonable, since that would be a giveaway - I decided to link it to an existing, longstanding obsession of mine: paradigms and constructs for concurrency.
Warning of what's to come:
- Concurrent data retrieval in Haskell with
Haxl
and in Scala withFetch
, observing thatHaxl
benefits from extensions to thedo
construct that we don't have with Scala'sfor
. - Tedious construction of a Scala macro that purports to extend
for
in the same fashion. Many sad lessons are learned along the way. - Demonstration of success in this endeavor, with some rueful acknowledgments.
- An unexpected rant about programming that uses types with names from category theory in combination with blocks of expressions involving left-arrows.
Waiting for guff
A couple of years ago, I offered to the the ungrateful internet a numbing fugue of mumblings that purported to explain how parallelism and batching were achieved in the Haxl package for Haskell. For no obvious reason, nearly all my code was in Clojure, which made no sense in the context of Haskell, but paid some dividends in the end, because the modern[ish] miracle of homoiconicity, plus a nice interface to quasar fibers, facilitated an alternate implementation based on asynchronicity and no monadic guff whatsoever.
Two years is a long time to wait for monadic guff, but good things - well, things anyway, come to those who wait.
Buried within the verbiage1 of the my previous post were a few pieces of information that will be useful today.
First, the Haxl is a Monad, allowing data retrieval operations to be composed
in do notation. In this contrived example, we retrieve the BFF of each member of
some grp
, and then we produce a list of the full names of each of the BFFs.
runHaxl env $ do
ids <- getIds grp
mapM getBffName
where
getBffName id = do
bffId <- getBff id
fn <- getFirstName bffId
ln <- getLastName bffId
fn ++ " " ++ ln
The cool part is that, assuming proper implementations of getBff
, getFirstName
and getLastName
, the queries to assemble this information will be batched into
two queries that
- retrieve the
bffId
s for everyone in the group - retrieve the first and last names for all the
bffId
s so obtained.
It does this by not performing the queries as soon as they're made, but (loosely) by building up a tree of which queries depend on the results of which other ones, accumulating as many queries as it can do without those unknown dependencies and executing them in a batch, then repeating, using the all available information based on previous queries.
There's a bit of special magic necessary to thwart the usual ordering described
in nested fmap
and >>=
. In fact, the Haskell compiler has been
rejiggered to take advantage
of the fact that
Haxl
is an applicative functor- Neither the
getLastName
norgetFirstName
call depends on the other
and automatically translate the 2nd do
block to
getBffName id = do
bffId <- getBff id
(fn,ln) <- (,) $ (getFirstName bffId) <*> (getLastname bffId)
fn ++ " " ++ ln
where fn
and ln
are now retrieved simultaneously.
Without this magic, there would have been three queries, one to get the bffId
s,
and then one each to get the first and last names. With the magic, the last two
queries are combined. So magic gets us from 9:3 to 9:2.
Go fetch
In Scala, there's a library similar to Haxl, called Fetch,
which (again, assuming that proper data sources have been written), can
batch together what look like queries for individual items. Just accept for now
that the various getXXX
methods return Fetch
instances.
getIds(grp).traverse {id =>
for(bffId <- getBff(id)
fn <- getFirstName(bffId)
ln <- getLastName bffId)
yield s"$fn $ln"
}
Unfortunately, there is no special applicative magic in Scala's for
comprehension, so if we wanted to get the job done in two batches, we'd
have to do the applicative transform ourselves...
Sort of. Scala is a language that, depending on your perspective, either
provides sophisticated support for multi-argument functions, or fails
to recognize that multi-argument functions are actually curried
single-argument functions, so "applicative" in Scala is sort of a
qualitative equivalent: Not support per se for a <*>
combinator
def starcyclops[F[_], A, B](f: F[A => B]): F[A] => F[B]
but rather support for what you would want to accomplish by chaining such
things together, that is, apply a function of N parameters to N arguments
of type F[_]
, e.g.
def mapN[F[_], T1, ... Tn, R](f: (T1,T2 ... Tn) => R))
(F[T1],F[T2] ... F[Tn]): F[R]
In addition to the mapN
, there are various syntactical conveniences. For
example, it's very common to want mapN(TupleN.apply _)(f1, f2, ... fN)
, so
CATS provides syntactic sugar to express this as (f1, f2, ... fN).tupled
.
Thus, the "applicative transform" that we must perform manually thus looks like this:
getIds(grp).traverse {id =>
for(bffId <- getBff(id)
(fn,ln) <- (getFirstName(bffId), getLastName bffId).tupled
yield s"$fn $ln"
}
In the next section, we'll try to verify that Fetch actually delivers the expected concurrency.
Quick demonstration of concurrency with Fetch
Closely following the examples in the fetch documentation, I wrote some fake data sources that pretend to divine best-friends-for-life and names, but actually just look them up in a static dictionary. All data sources extend a trait defined in the Fetch library,
trait DataSource[I, A] {
def name: String
def fetch[F[_] : ConcurrentEffect : Par](id: I): F[Option[A]]
def batch[F[_] : ConcurrentEffect : Par](ids: NonEmptyList[I]): F[Map[I, A]]
}
where the fetch
method fetches one value, and batch
batches up several at once. Note that,
while the trait is defined in the Fetch library, it nowhere refers to other Fetch
types. The implicit ConcurrentEffect
is just something with a runCancelable
method,
which does what it sounds like it does, and Par
has a parallel
method, whose
return value gives us access to various Applicative
methods. So what we're doing
is guaranteeing that fetch
and batch
will be used with constructs that can
be run in parallel. Those classes come from the Cats
library, rather than Fetch.
The boilerplate for my fake data sources can be condensed into a sub-trait,
trait DictSource[A,B] extends DataSource[A,B] {
protected val dict: Map[A,B]
final override def fetch[F[_] : ConcurrentEffect : Par](a: A) = logReturn(dict.get(a), s"One $name")
final override def batch[F[_] : ConcurrentEffect : Par](as: NonEmptyList[A]) =
logReturn(dict.filterKeys(as.toList.contains), s"${as.size} x $name")
private def logReturn[F[_] : ConcurrentEffect : Par, A](result: A, msg: String) = {
val id = Thread.currentThread.getId
Sync[F].delay(println(s"Requesting [tid=$id] $msg")) >>
Sync[F].delay(Thread.sleep(100)) >>
Sync[F].delay(println(s"Receiving [tid=$id] $msg")) >>
Sync[F].pure(result)
}
}
that looks for results in its dict
ionary, and, conveniently, documents the
data requests on stdout so we can see what's happening and when. Other
than thinking up some amusing names, implementing the three data sources
is trivial.
type PersonId = Int
type LastName = String
type FirstName = String
type BFF = PersonId
implicit object BFFSource extends DictSource[PersonId, BFF] {
override val name = "Best Friends Forever"
override val dict = Map(1 → 2, 2 → 3, 3 → 1)
}
implicit object FirstNameSource extends DictSource[PersonId, FirstName] {
override val name = "First Name"
override val dict = Map(1 → "Biff", 2 → "Heinz", 3 → "Tree")
}
implicit object LastNameSource extends DictSource[PersonId, LastName] {
override val name = "Last Name"
override val dict = Map(1 → "Loman", 2 → "Doofenshmirtz", 3 → "Trunks")
}
At this point, something actually called "Fetch" enters the picture,
via the following methods that will produce
Fetch
instances that use the defined data sources, given a particular
concurrency construct F
.
def getBFF[F[_]: ConcurrentEffect : Par](id: PersonId): Fetch[F,BFF] =
Fetch(id, BFFSource)
def getFirstName[F[_]: ConcurrentEffect : Par](id: PersonId) = [F, FirstName] =
Fetch(id, FirstNameSource)
def getLastName[F[_]: ConcurrentEffect : Par](id: PersonId): Fetch[F, LastName] =
Fetch(id, LastNameSource)
Three concerns are nicely separated and can be implemented independently:
- The
DataSource
, which in real life would actually go to some database and retrieve information, singly or in batches. - The
ConcurrentEffect
/Par
construct for running the data retrieval routines. - The
Fetch
logic to gather up queries to be run in batches.
This decoupling comes at the cost of quite a bit of type complexity, however.
In an earlier
version of the library, Fetch
had one type parameter - specifying the type of thing
being fetched, and, since the properties of Fetch
did not depend on the F
we didn't need to carry around the implicit guarantees that F
has ConcurrentEffect
and Par
instances. (Presumably that ugliness will disappear with implicit function
types in Scala 3.)
Now we'll assemble a query to get the full name of the BFF of one person:
def getBFF[F[_]: ConcurrentEffect : Par](id: PersonId): Fetch[F, String] = for {
bff ← getBFF(id)
fn ← getFirstName(bff)
ln ← getLastName(bff)
} yield s"$fn $ln"
and another that traverse this to get three BFFs,
def getBFFs[F[_]: ConcurrentEffect : Par]: Fetch[F,List[String]] = List(1, 2, 3).traverse(getBFF[F])
run the query,
println(Fetch.run[IO](getBFFs).unsafeRunSync())
and see what kind of parallelism we get, as indicated by the println
s in
the contrived logReturn
method:
First try:
Requesting [tid=15] 3 x BFF Receiving [tid=15] 3 x BFF Requesting [tid=13] 3 x Last Name Receiving [tid=13] 3 x Last Name Requesting [tid=14] 3 x First Name Receiving [tid=14] 3 x First Name List(Heinz Doofenshmirtz, Tree Trunks, Biff Loman)
This is about as expected - though it is a bit sad that none of the BFFships are mutual. While we logically queried for 9 items - fully interleaved as requests for BFF, LastName, FirstName, BFF, LastName, FirstName, etc. - our queries ran in only 3 batches, with like queries grouped together. Also, as expected, no advantage was taken of the fact that the two name queries were theoretically independent.
If we manually parallelize the two independent name queries:
def getBFF2[F[_]: ConcurrentEffect : Par](id: PersonId): Fetch[F, String] = for {
bff ← getBFF(id)
(fn,ln) ← (getFirstName(bff), getLastName(bff)).tupled
} yield s"$fn $ln"
we see a subtly different execution:
Requesting [tid=15] 3 x BFF Receiving [tid=15] 3 x BFF Requesting [tid=13] 3 x Last Name Requesting [tid=14] 3 x First Name Receiving [tid=13] 3 x Last Name Receiving [tid=14] 3 x First Name List(Heinz Doofenshmirtz, Tree Trunks, Biff Loman)
Note how the grouping has changed. Instead of fully interleaved Requesting/Receiving messages, both first and last name requests go out simultaneously. We get the desired reduction from 9 batches down to two.
But I don't want to do anything manually
Why should important people like us be forced to transform our code manually?
Less frivolously, since the whole purpose libraries like Haxl
and Fetch
is
to detect operations that could occur concurrently and batch them automatically,
it's annoying to have to keep track of the limits to their detection abilities.
It would be cool if Scala's for
could do something similar to Haskell's do
,
detecting when the monads are in fact parallelizable and independent, and
tupling them together.
One reason this is difficult to accomplish in Scala is that at the time that
for
desugaring occurs, the compiler has not yet performed type resolution,
so it has no way of knowing whether the rhs of the left-arrow has applicative
powers: for
comprehensions simply get textually converted into nested flatMap
and map
calls, e.g.
for(i <- Some(1); j <- Some(2); k <- Some(3)) yield i+j+k'
becomes
Some(1).flatMap(((i) => Some(2).flatMap(((j) => Some(3).map(((k) => i.$plus(j).$plus(k)))))))
irrespective of the abilities of the Option
class. In fact, we could stick in
arbitrary monad poseurs, and the parser couldn't care less.
for(i <- Harry(1); j <- Dick(2); k <- Jane(3)) yield i+j+k'
Harry(1).flatMap(((i) => Dick(2).flatMap(((j) => Jane(3).map(((k) => i.$plus(j).$plus(k)))))))
Obviously the compiler will eventually complain, just not yet.
If we want to get messy with compiler internals and fetch (j,k)
at once, we're going
to have to do so after the typer has run, which means we'll be working with with
code that has already been converted into nested maps.
Writing a macro to do this will be awkward,
but not impossible, especially if set our sights relatively low.
For example, I'll assume the simplest possible for
constructs - no guards, no =
assignments. Additionally, the grouping will only work for independent terms that happen
to be adjacent; there will be no re-ordering of the code.
Also, I'm going to keep testing to an absolute minimum, guaranteeing that the macro will work under practically no other circumstances, as is fitting, given that the whole purpose of this work is self-abuse.
Under the Macroscope
Scala macros are fun and easy, in the sense that it's incredibly easy to make obscure mistakes and fun to laugh at yourself afterwards for making them. Also, they're difficult to debug, so when you do manage to fix a problem, you feel very proud of yourself.
The goal is to be able to write
def getBFF[F[_]: ConcurrentEffect : Par](id: PersonId): Fetch[F, String] =
LiftFetchTuples {
for {
bff ← getBFF(id)
fn ← getFirstName(bff)
ln ← getLastName(bff)
} yield s"$fn $ln"
}
where LiftFetchTuples.apply
is actually a macro that teases apart the map-nest
passed to it as an argument, and converts it into the version with
(fn,ln) ← (getFirstName(bff), getLastName(bff)).tupled
in it.
I'm going to omit the most boring details (only the most boring details) , so if you want those, take a look at the actual code on github.
We start with the standard macro boilerplate,
def apply[M[_], T](expr: Fetch[M,T])
(implicit semigroupal: Semigroupal[Fetch[M, ?]],
invariant: Invariant[Fetch[M,?]]): Fetch[M,T] =
macro liftTuplesImpl[M, T]
made more complicated than usual by the presence of implicit parameters. We want
a guarantee before the macro is even invoked that
the Fetch
being passed to us is amenable to fancy tupling, which
essentially means that there's a Semigroupal
typeclass for it. You might wonder why
we need guarantees beyond knowing that the expression is in fact a Fetch
: It turns
out that Fetch
isn't always semigroupal, just as it doesn't always have
a ConcurrentEffect
- the implicit conversions that make it so
depend on implicits of the specific type parameters.
As is the custom in these parts, heavy lifting occurs in a method ending in Impl
.
It's not required to have a silly ending that rhymes with "pimple," but if you give your
macro a nice name, then other people will be jealous.
def liftTuplesImpl[M[_], T](c: Context)(expr: c.Tree)( semigroupal: c.Tree, invariant: c.Tree) = ???
Notice that the keyword implicit
no longer shows up. The evidence expressions are passed
to us as Abstract Syntax Tree
s, just like any other argument.
Overlapping recursion
The expression tree we're passed represents nested application of map and flatMap. Aggressively cleaned up, the abstract syntax tree will look like
Apply(TypeApply(Select(vx, flatMapName, typeParams),
Function(args, PossiblyNestedMap))
where
Apply(fun, args)
representsfun(args)
TypeApply(fun, tparams)
representsfun[tparams]
Function(arg :: Nil, body)
representsarg => body
PossiblyNestedMap
represents the entire expression, recursively.
The first 3 are predefined members of Trees
. We'll have to write an extractor
for the last.
The quasiquote string context allows one to express the AST more like the code itself, but it has some limitations, but I couldn't figure out how to use it for recursive extraction. I was able to use it for tree construction in places, as you'll see below.
Here's a full-blown extractor that looks for such patterns, which I'll annotate
line by line. The unapply
method
matches application of vx.flatMap[T]
object PossiblyNestedMap {
def unapply(tree: Tree): Option[Tree] = tree match {
case Apply(TypeApply(Select(vx, flatMapName), outerMethTypeParam),
to a closure
Function(vArgDef :: Nil,
whose body is another PosiblyNestedMap
- that is, unapply
will be called
recursively on the body,
PossiblyNestedMap(
and we pick apart whatever got returned from that recursive call
into the inner wx.mapOrFlatMap[T]
,
Apply(TypeApply(Select(wx, innerMeth), innerMethTypeParam),
applied to an inner closure whose expr
body we for now don't care about
Function(wArgDef :: Nil, expr) :: Nil))) :: Nil)
but may itself have once been a PossiblyNestedMap
- meaning our recursive call to unapply
would have called itself again. Note that the innerMeth
may be a map
or a flatMap
depending on where we are in the for
expression.
Our basic plan is to re-express this as one (vx,wx).tupled.flatMap( ... )
and return the applicative expression from the extractor. Assuming we've
written the extractor, the main job of our macro will be to transform the tree recursively,
replacing every instance
of an appropriate PossiblyNestedMap
with its applicative equivalent.
class TupleLiftingTransformer extends Transformer {
override def transform(tree: Tree): Tree = tree match {
case PossiblyNestedMap(xformed) ⇒ xformed
case _ ⇒ super.transform(tree)
}
}
(new TupleLiftingTransformer).transform(expr)
In our extractor, by the way, there'll be a second, more boring case, for an non-nested map application. We still need to transform it recursively in case there's something interesting lurking in sub-expressions, but that's all we have to do. Finally, there's the most common case, where no map of any sort is found.
case Apply(TypeApply(Select(wm, comb), _), Function(_ :: Nil, _) :: Nil) ⇒
Some(super.transform(tree))
case _ => None
Cats have claws
You might expect that vx
and wx
would represent expressions of type Fetch
,
but you would be wrong. You see, Fetch
doesn't actually have a flatMap
method, but can be converted implicitly into a FlatMap.Ops
, which does.
If we examine vx
in the debugger, we might (well, I did) see something like this
cats.syntax.`package`.all.toFlatMapOps[[A]Fetch[F,A], Author]
(... our getWhatever call ...)
(fetch.`package`.fetchM[F](evidence$47))
The implicit toFlatMapOps
method requires evidence that its argument is in the FlatMap
typeclass. FlatMap
is implemented by Monad
, an instance of which the implicit
fetchM
method can provide, as long as it has evidence that the F
type parameter
is itself a Monad
, which was in turn provided by the implicit parameter of
LiftFetchTuples.apply
, to which scala assigned the symbol evidence$47
.
So there's a hell of a lot of implicit evidence being called forth so that Fetch
can avoid implementing its own flatMap
.
Our immediate problem is to retrieve the actual Fetch
from the FlatMap.Ops
it was converted to.
Fortunately, these Ops
classes all have a .self
method that returns the
original object. So we'll assign
val vm = q"$vx.self"
val wm = q"$wx.self"
here using the convenient quasiquote string context.
Having extracted our get
calls, we now need to make sure that they're independent;
i.e. the argument to vm
's closure should not be used anywhere within wm
. This
is as easy as searching for the v
symbol in the w
closure. If we find the
symbol, we give up, printout out an informative message and carrying on with
remaining transformation of the tree:
if(wm.find(vArgDef.symbol == _.symbol).isDefined)
c.info(vUsed.get.pos, s"Not lifting, because ${vValDef.symbol} is used on rhs", true)
Some(super.transform(tree))
}
If we pass this test, it should be fine to create a new tupled Fetch
. Since
.tupled
is just going to trigger another implicit search, it's cleaner to
call the tuple2
function it will end up calling, explicitly passing in its implicit
evidence, which we have from the arguments of our macro:
val newQual = q"_root_.cats.Semigroupal.tuple2($vm, $wm)($semigroupal, $invariant)"
When we flatMap
over this new monad, it's closure will take a Tuple2[V,W]
,
where V
and W
were the types taken by the two original closures, Here, we extract
the types of the original closure arguments, create the new tuple type, and synthesize
a new argument with a guaranteed unique name:
val vt = oldClosure.vparams.head.tpt.tpe
val wt = oldInnerClosure.vparams.head.tpt.tpe
val tupleOfVWtt: Tree = tq"($vt,$wt)"
val vwArgName = internal.reificationSupport.freshTermName("x$")
val vwArgDef = ValDef(Modifiers(Flag.SYNTHETIC | Flag.PARAM), vwArgName, tupleOfVWtt, EmptyTree)
The body of the new closure still refers to the old v
and w
, which we must
populate with values from the tuple.
val newBody = Block(
c.internal.valDef(vValDef.symbol, q"$vwArgName._1"),
c.internal.valDef(wValDef.symbol, q"$vwArgName._2"),
transform(body)
val newClosure = Function(vwArgDef :: Nil, newBody)
Altogether, the new closure we build is going to look a bit like
x$123: (V, W) => {
val v = x$123._1
val w = x$123._2
originalBodyOfYieldExpression(v, w) // more or less
}
Finally putting together the full map/flatMap over the new tupled Fetch
with the new
closure, (This is one of the places where quasiquotes annoyingly didn't work.)
val ret = Apply(TypeApply(Select(newQual, innerMeth), innerMethTypeParam), newClosure :: Nil)
OK, not finally
We're not really done. First we have to typecheck our new expression,
val rett = c.typecheck(ret)
and even after we do that, there's still a bit of a mess to clean up. One big pitfall
of scala macros is that, in addition to the implicit "ownership" of AST elements that are
components of other AST elements, every symbol we use has to be explicitly owned by another
symbol. For example, all val
s declared directly in a function body have symbols
whose .owner
is the symbol of the containing Function
. The tree of symbol ownership
must exactly parallel the tree of AST elements. Even the slightest mistake will lead
to obscure errors during the delambdafy phase, much later in the compilation
Symbol ownership gets assigned
automatically for us during initial typing, but when we muck up the tree as I've just
done, correct ownership must sometimes be established manually. In this case, we want
to make sure
1. that the new closure has the same owner as the old outer closure, and
2. that the new v
and w
vals belong to the new closure, instead of
the old outer and inner closures respectively.
To change the owner of the new closure, we need first to get its symbol; as that symbol was assigned just now when we typechecked, we need to extract it from the typed expression:
val Apply(_, newClosureTyped :: Nil) = rett
c.internal.setOwner(newClosureTyped.symbol, oldClosure.symbol.owner)
Now we ensure that the the two old parameters belong to the new closure,
c.internal.changeOwner(rett, vValDef.symbol.owner, newClosureTyped.symbol)
c.internal.changeOwner(rett, wValDef.symbol.owner, newClosureTyped.symbol)
c.info(tree.pos, s"Lifting to $rett", true)
Some(rett)
It's alive!
The LiftFetchTuples
'd code compiles, with a few new messages that the macro generated:
[info] /Users/pnf/dev/scala-playground/core/src/main/scala/fetchy/Fetchy.scala:202:25: Not lifting, because value bff is used on rhs [info] fn ← getFirstName(bff) [info] ^ [info] /Users/pnf/dev/scala-playground/core/src/main/scala/fetchy/Fetchy.scala:202:10: Lifting to cats.syntax.`package`.all.toFunctorOps[[A]fetch.Fetch[F,A], (fetchy.TestBFF.FirstName, fetchy.TestBFF.LastName)](cats.Semigroupal.tuple2[[A]fetch.Fetch[F,A], fetchy.TestBFF.FirstName, fetchy.TestBFF.LastName](cats.syntax.`package`.all.toFlatMapOps[[A]fetch.Fetch[F,A], fetchy.TestBFF.FirstName](TestBFF.this.getFirstName[F](bff)(evidence$54, evidence$55))(fetch.`package`.fetchM[F](evidence$54)).self, cats.syntax.`package`.all.toFunctorOps[[A]fetch.Fetch[F,A], fetchy.TestBFF.LastName](TestBFF.this.getLastName[F](bff)(evidence$54, evidence$55))(fetch.`package`.fetchM[F](evidence$54)).self)(fetch.`package`.fetchM[F](evidence$54), fetch.`package`.fetchM[F](evidence$54)))(fetch.`package`.fetchM[F](evidence$54)).map[String](((x$13: (fetchy.TestBFF.FirstName, fetchy.TestBFF.LastName)) => { [info] val fn: fetchy.TestBFF.FirstName = x$13._1; [info] val ln: fetchy.TestBFF.LastName = x$13._2; [info] scala.StringContext.apply("", " ", "").s(fn, ln) [info] })) [info] fn ← getFirstName(bff)
The "Not lifting" message is straightforward: we need to know bff
before we can
call getFirstName
, so bff
and fn
cannot be tupled.
What we can initially tell from the second message and the daunting scroll bar is that we apparently generated a huge amount of code. The full insanity is more easily appreciated by adding some line-feeds and shortening a few FQCN prefixes. One thing that's clear immediately is that the vast majority of the code was actually inserted by scala implicit searches:
toFunctorOps[[A]Fetch[F,A], (FirstName, LastName)]
(
Semigroupal.tuple2[[A]Fetch[F,A], FirstName, LastName] // <-- LOOK HERE SECOND
(
toFlatMapOps[[A]Fetch[F,A], FirstName]
(
getFirstName[F](bff)(ev$54, ev$55) // <-- LOOK HERE FIRST
)
(fetchM[F](ev$54)).self,
toFunctorOps[[A]Fetch[F,A], LastName]
(
getLastName[F](bff)(ev$54, ev$55)
)
(fetchM[F](ev$54)).self
)
(
fetchM[F](ev$54), fetchM[F](ev$54)
)
)
(
fetch.`package`.fetchM[F](ev$54)
)
.map[String](((x$13: (FirstName, LastName)) => {
val fn: FirstName = x$13._1;
val ln: LastName = x$13._2;
scala.StringContext.apply("", " ", "").s(fn, ln)
}))
At the LOOK HERE FIRST
comment, you can see the original
getFirstName[F](bff)(ev$54, ev$55)
, with its ConcurrentEffect
and Par
evidence.
It was implicitly wrapped in a call to toFlatMapOps
, which was
implicitly provided with monadic evidence (fetchM[F](ev$54))
, which
itself relied on the ConcurrentEffect
evidence. Now we undo that
wrapping with .self
.
At LOOK HERE SECOND
, you see the two Fetch
queries passed to
Semigroupal.tuple2
, the result of which must itself be wrapped
toFunctorOps
in order to call .map
on it.
At the bottom, you see the new combined closure, which takes a tuple
and extracts the original fn
and ln
to use in the string expression.
But the really exciting part is that, when we run it, we see the same batch grouping as if we had performed the tupling manually!
Requesting [tid=15] 3 x BFF Receiving [tid=15] 3 x BFF Requesting [tid=13] 3 x Last Name Requesting [tid=14] 3 x First Name Receiving [tid=13] 3 x Last Name Receiving [tid=14] 3 x First Name List(Heinz Doofenshmirtz, Tree Trunks, Biff Loman)
What have we learned today?
If you've made it this far, I hope you've learned that not all self-deprecation should be taken ironically. I warned you that this post constituted self-abuse, and that it would be stupidly obscure. Maybe you learned something about Fetch (having filtered out by ignorant misrepresentations), or maybe you are now more than ever convinced that you either do or do not want to write scala macros - self-knowledge that can be useful in life and work.
I haven't learned anything. I never learn. I did, however come away with a bit of a distaste for writing Haskell-style programs in Scala.
The way Cats implements typeclasses through implicit evidence and conversions
makes sense - or can be made sense of - but it seems really complicated. When
encountering a .map
, the first assumption of many programmers will be that this
method was implemented in the class or some trait of its target. By this point,
we're all used to the standard typeclass pattern, where a Foo
is wrapped with
an implicit class SpecialFooOps(foo: Foo)
, but the process can get
byzantine when, say Foo
has a type parameter, Foo[T]
, and SpecialFooOps can only
be instantiated with implicit evidence of Bar[T]
, and the implicit def
providing
that evidence in turn relies on some further evidence of a Wiz[T]
.
This would be less of a problem if IDEs were capable of following a long chain of implicit dependencies, but, at this point at least, IntelliJ is not, and the only reliable way I found to untangle the implicitness was to set break points in the debugger, then to paste the observed values into an editor and format them. What's worse, IntelliJ is as likely to fail to locate an implicit parameter - hurling jagged red lighting down upon screens of innocent source - as to smilingly approve the worst delinquencies, abandoning you to the tender mercies of scalac typer errors.
And for what?
These typing complications are, unfortunately, most evident when for
constructs
are involved. For example, the reason I wrote,
def getBFFs[F[_]: ConcurrentEffect : Par] = List(1, 2, 3).traverse(getBFF[F])
breaking the for
construct into its own method,
was not actually to emphasize composability (which I guess it sort of does),
but so I wouldn't have to look at this:
Clearly, one can't expect an IDE to intuit the arbitrary manipulations of my macro, but this example is practically straight out of Fetch documentation.
I maintain that the element most central to broad feasibility of what
Martin Odersky calls "categorical programming" is the for
or do
construct,
by which an involution of closures presents itself in procedural guise.
Beyond dispute, when the illusion works, the code looks beautiful.
It is also claimed that, when the beautiful code compiles, it is probably correct,
because otherwise typechecking would have failed. I'm dubious of that point
but prepared for argument's sake to accept its truth given a sufficiently clever
typechecker. However, with an insufficiently clever typechecker - one whose
inferencing abilities require frequent nudges, one that disagrees with its multiple
personalities across the tool-chain - you are forced to mentally re-ravel the tidy stack
of left-arrows into the underlying mess of combinators to begin to guess what went wrong.
Of course I enjoy that sort of thing. Or anyway I find it conveniently distracting sometimes.
I close with my 2nd or 3rd favorite quotation from Oscar Wilde:
Jack. Is that clever?
Algernon. It is perfectly phrased! and quite as true as any observation in civilised life should be.
Jack. I am sick to death of cleverness. Everybody is clever nowadays. You can’t go anywhere without meeting clever people. The thing has become an absolute public nuisance. I wish to goodness we had a few fools left.
-
A word that is frowned upon my employer as ostensibly meaningless. It's not. I use it here in the sense of the manifestation of verbosity, excessive use of many words were few - perhaps none - would have sufficed.] ↩
Comments
comments powered by Disqus