Hacker News new | ask | show | jobs
by svieira 267 days ago
I wonder if this will wind up being a category of problems and the solution is a separate "system" set of effects (effectively `({user}, {system})`) or if this one-off extension is all that will be needed.

Either way, extremely well explained both in motivate and implementation!

3 comments

The other major use case that leaps to mind is "observability"; I want to be able to poke a metric without the function becoming impure, since it is not uncommon for something deep down the call stack to poke a metric and I don't want it to propagate up the stack. You can also make a case for logging that isn't just debug logging. Propagating up a new effect just because some deep function needs to push a log is not very friendly or useful.

Perhaps there is two or three more, but I do think this is a finite set that we can write a language around and just sort of consider them "ambient effects". While metrics and logging are nominally impure, in that they are certainly mutations, if you can't read the logs or the metrics without an effect, you still retain the really important aspect of purity, which is that the pure code can't cause a change that is observable by that or other code, with the very specific exception of the logging stream and metrics.

I wouldn't be quite ready to put all my chips on this, but I think "the inability to create changes that can be witnessed" is actually the true goal, not "the inability to create changes" with no qualifications. Pure code already necessarily creates changes in a system, the key is that while the CPU registers may change and other parts of the system may mutate, the code can't witness those changes and conditionalize future execution on it. All effects-based systems have already agreed that there are things that are mutations in something real in the physical world they aren't going to consider effects, adding a couple more categories is not going from 0 to 1 but 12 to 15. It's not a strict purity question but a cost/benefits question.

It occurs to me as I type this that a really ambitious language with a strong enough type system might even be able to turn this into a completely safe proposition, allowing users to declare effects with some sort of very safe "sink" associated with them that the type system checks can not escape out into the rest of the code in any visible way and constraining the visibility somewhere that is contained in the conventional effects system. All these things I'm talking about here and in the blog post are taking the form of values that simply disappear into the ether from the point of view of the creating function. I'd like the language to make it easy to query a function for which ambient effects it uses (as a development-time operation, not a run-time one), and I think that would clean up most of the rest of the practical problems.

Are you saying, essentially, that if we split the "I" and the "O" in "IO", then ignore the the "O" in the way described in the article, the resulting program would be "safe-enough" ?
Hmm, at least in Haskell terms that would not be sufficient, if we define "IO" by the IO type, because some types of O, such as setting the value of IORefs, is definitely something that could be witnessed elsewhere depending on the flow of the IORef in question.

But I think in concept that maybe that's pretty close to the concept. Input from IO is arguably the definition of impure that we care about. But not all forms of Output are necessarily radioactive waste for purity. We might be able in practice to give ourselves a bit more wiggle room on that side without breaking all the benefits of purity, and gain substantial practical utility for a very, very small loss in theory. With some careful thought it's even possible the theory loss could be either "minimized or eliminated" or "strongly characterized and constrained". I don't know enough about the language in question to know what is in it, but a language that had first-class messaging of some sort ought to be able to define a form of output that is "you can send this message to this target any time you want without breaking purity", and the the code for the thing receiving the message could still itself be constrained by the effects system. (You could conceivably go so far as to insist that the resulting "exception handlers" rigidly form a tree that grounds out into code that uses none of these implicit handlers, though my gut says that will probably end up being more trouble than it is worth.)

This is basically what my effect system, Bluefin, achieves with its Stream type. "Stream" is an "output channel" that by itself is completely pure. Then, at the point of handling, you can choose to remove the effect in a pure way, by interpreting it in a way that doesn't use any effects, or by using IO, from which you then cannot escape.

https://hackage-content.haskell.org/package/bluefin-0.0.17.1...

100% well said! Exactly how to handle log/print/metrics (as results/returns from functions) has been on my mental back burner for years.

It's proper (but goofy) to pass in a `logger` object to every function, but the practicality of plumbing a "logger" everywhere is disgusting. There's a ton of value to be able to "capture" what was logged by a function, but as you mentioned with "metrics", the invasiveness of plumbing a metrics object "through" your code is really gross.

Maybe a straw man syntax like:

   this.__classHooks__.logger
      = new AbstractLogger(...)
   this.__classHooks__.metrics
      = new AbstractMetrics(...)
...like a puzzle piece, if the container (parent object) wants to "attach" to the exported interface, it can kindof dependency injection, otherwise logs and metrics might fly off into the void.
> I wonder if this will wind up being a category of problems and the solution is a separate "system" set of effects (effectively `({user}, {system})`) or if this one-off extension is all that will be needed.

It already is, kinda. In my practice very often you have global singleton values that are either defined as static variables, or passed as arguments to nearly all functions in a module. Since implicit presence of `Debug` effect is already a compilation parameter, it could be generalized to support any sets of implicit effects. Thus you might design a module that has implicit Logger and Database effects in all its functions.

> Thus you might design a module that has implicit Logger and Database effects in all its functions.

Logging does seem like a very similar case to debugging, only that you expect to leave it on in production. On the other hand an implicit Database effect kind of defeat the point of an effect system.

I think the key is that Debug and Logger effects don't really affect the rest of the code - if you remove all debug/log statements, the only thing that changes is the debug/log output (and slightly faster execution probably).

> Logging does seem like a very similar case to debugging, only that you expect to leave it on in production

In a lot of logging systems, debug is one of the common levels of logging. I'm not even convinced that the term "debug" is unambiguous to clearly refer to something that's not a subset logging. Presumably the difference is intended to mean things printed that are unconditionally going to stdout rather than into some system that might change the output location and filter/annotate things, but I have to imagine that it might just make more sense not to have separate models for them at all

I was referring to the specific Debug effect described in the post, which only works in debugging builds. This means that the production build cannot have unexpected output from a function with no declared effect.
I understand. My point was that if you did want to extend further to have an effect for logging, it seems like having fully separate abstractions for them rather than finding a way to express them both as part of the same abstraction would make sense.
Singleton pattern isn't really used in functional-effect systems, dependency injection is generally used instead.
I think the solution is to not have side effects in pure functions really.

At some point you're composing your pure functions and _have_ to call them by some effectful function (otherwise they're never executed or you're doing computations that aren't consumed by anything).

The only sane alternative is to have a debug effectful variant where you turn off these checks. But then why would `stdout` debugging fine, and not say writing to a different stream or file?