~icefox/garnet#71: 
Implicits design

Passing around explicit module implementations as args is both really powerful, and a bit of a pain in the ass. So what if we have an implicit parameter mechanism?

References:

Open questions:

  • Is this a terrible idea? Zig gets by without it, afaict.
  • Should it be limited to modules/interfaces, or general purpose for any type of value? Well interfaces are just normal value, so the point is moot.
Status
REPORTED
Submitter
~icefox
Assigned to
No-one
Submitted
7 months ago
Updated
7 months ago
Labels
T-DESIGN T-LATER

~icefox 7 months ago

Huh! I just had a thonk.

I've heard context-y stuff described as "rediscovering that dynamic scoping exists". What if you did literally that and instead of making context stuff be "hidden" function args, you just had a stack of values that was basically unconnected from the lexical scoping of function frames, and have regular ass functions to add and fetch data from it?

So some function could do something like dynamic_scope_set("allocator", ArenaAlloc) and some other function down the call chain could do dynamic_scope_get("allocator") and, uh, this is all strongly typed somehow idk how something like rust's Any type is less than ideal but would work . And then the functions between them don't have to thread any otherwise-irrelevant information around via function args.

This initially seems a lot simpler to actually use, but I can think of some downsides.

First, with implicit args being threaded from function to function, if something changes and you want to find out where it changed you can see it in a backtrace or such... though your dynamic scope stack could save the address of which function set which value (and probably will have to, to do cleanup properly) so you do have debug information available.

Second, you stop being able to verify that if foo() calls bar(), and bar() needs an implicit value set, you can't always prove that foo() or something called before it sets it. You can enumerate/infer which implicits a function sets and reads, with some language support, and then it becomes a function signature again. Which is probably the way to go, since everything converges that way. Might be useful to have this signature be listed/inferred separately from normal function args though? separating the commonly-used and rarely-used things seems like it could be nice. the downside of that is if you have a function foo() calling bar() calling baz(), and baz() wants access to an implicit parameter for a memory allocator or something that is set in foo(), then bar() has to pass it along even if it doesn't care about it. So these things are duals of each other: either you have entirely comptime lookup and resolution or entirely runtime lookup, and both have failure modes the other covers.

So what if you had both? something like the dynamic_scope_set() API could be used (probably with a slight performance malus) in the places where a programmer controls foo() and baz() but not bar(), in the presumably-rare cases that the implicit args fail. That could be useful. But maybe we're now getting too fancy with a system that should probably not be commonly used. (iiuc Zig just has every data structure that uses an allocator contain a pointer to its allocator, which is probably Kinda Important anyway to not mix up allocators when realloc'ing or freeing memory)

Anyway, that brings us to the third problem, performance. If the dynamic_scope_*() functions do what is essentially a dynamically-typed search of a stack from the top down, then it's gonna be slow. there's ways to design around that though, for example instead of one stack containing some kind of tagged pointers to values, you could have a struct-of-arrays where each type of implicit value gets its own stack, and then getting the most recent one is just loading the top of the correct stack. The tradeoff there is that freeing a dynamic scope frame when a function exits then would usually involve looking at multiple stacks. You could have some shortcut info where each function has a struct that lists which dynamic stacks it pushes items to, and maybe sneak these values into a function's call frame somehow, but that'd get complicated and adds more indirection. There's multiple ways of slicing it, but it's gonna be slower than just passing hidden function params.

Sooooooooo uh. Head full many thoughts .

You can 100% make a dynamic-scope context lib entirely in normal code, it'll just be slow compared to having compiler support, or clunky 'cause it won't be able to infer what types actually get put into it. May be useful as a proof of concept. We can probably find such things on crates.io with some digging, but it'll also be interesting to see if there's any major projects out there that use them for anything. And it sounds like it should only really be used in places where perf doesn't matter much and you can't use implicit args 'cause of stupid API's that don't expect them.

~icefox 7 months ago*

From some of the snarkier answers in https://stackoverflow.com/questions/10375633/understanding-implicit-in-scala, looks like Scala (2) lets you make both implicit arguments to functions, and also implicit conversion functions that are automatically called when it thinks a conversion is needed. Let's heckin' not do that second one.

~icefox 7 months ago

From wukong:

u could pull an odin and have the implicits only set for a certain number of known ids

odin has a context system but its only for stuff like allocators, logging, and rng. this has the benefit of allowing u to hook into third party code with ur own allocation or logging solutions, but the downsides (imo) r 1. u first have to use the context system which not everyone is gonna use and 2. its a fixed API so u cant get the dynamism ala kotlin context parameters or scala contextual parameters

https://odin-lang.org/docs/overview/#implicit-context-system https://github.com/odin-lang/Odin/blob/master/base/runtime/core.odin#L434-L446

~akavel 7 months ago*

Dynamic scoping sounds kinda scary to me, but I admit I never programmed with it, never did any Lisp; that said, could it be there's kinda a reason it's not used in modern programming languages? 🤔

With that said, two things the posts above make me think of:

~akavel 7 months ago

Aaalso, again not exactly a full answer, but Nim has some syntax sugar via a using keyword: https://nim-lang.org/docs/manual.html#statements-and-expressions-using-statement

~icefox 7 months ago*

could it be there's kinda a reason it's not used in modern programming languages?

There is, it was the only real option for scoping in a lot of early Lisp's an it suuuuuuuuucked. (I think Emacs Lisp still uses it?) That said it's used in bits and pieces in a lot of languages... printing out a backtrace is basically walking down the dynamic scope stack. So using it for things like passing around logging state makes sense to me. Other language features that operate essentially by inspecting dynamic scope (usually implemented by the call stack, one way or another): exception handlers, destructors, etc.

_ENV in Lua is a cogent association, since as far as I can tell that's basically how it implements scoping.

That said, if we have destructors, we can implement contexts pretty nicely in userland code, just as (locked) mutable globals for particular types. In my mind those are used for different things than implicit modules or such are though, and require some noticeable amount of setup beforehand.

...I had an idea for using this to set scoped panic modes inside functions, so you can call with_unwind(|| thing()) and if thing() panicked then it would unwind back to that point and maybe provide some metadata. Soooooo I just reinvented exceptions. As one does.

Register here or Log in to comment, or comment via email.