Hacker News new | ask | show | jobs
by FeepingCreature 1546 days ago
So this is basically Eternalism[1]? Instead of writing a program that reads inputs, experiences a sequence of state mutations and takes actions, you generate the graph of every possible state and path between states annotated with inputs on the edges and outputs on the vertices, and let the runtime pick the actual path through it. And this works because you're generating it lazily, but that's just an implementation detail. And the program in itself isn't ever in any particular state, because the program is the description of every possible state.

[1] https://en.wikipedia.org/wiki/Eternalism_(philosophy_of_time...

3 comments

I didn't know there was a name for this concept in general. This is exactly how I think about writing code in functional programming languages. And as far as I can tell, algebraic effects are an extreme case of this, where your program is essentially a big graph of decision points in response to as-yet-unseen inputs.
I like to explain functional programs like this.

Functional programs don't _do_ anything, they are a list of commands some external interpreter (what you call the runtime, but doesn't have to be a runtime) will execute or compile.

Functional programs only contain descriptions of the various commands, but it is an external interpreter executing the commands.

Functional programs return "recipes".

"recipes" are picked up by some executor that will turn them in dishes (the side effect).

If all of this seems generic allow me some typescript.

```

// we can declare a a function that takes no argument and return a value IO.

type IO<A> = () => A

declare const program: () => void; // thus can be rewritten as

declare const program: IO<void> // this is the PURE function I can reason about

declare const execute: IO<void> => void // this is the interpreter that will execute the commands and have side effects

```

Now, let's declare a side effectful functions:

```

const log: (s: string) => IO<void>

// desugarized: (s: string) => () => void `` `

Notice how the signature is similar to the standard console.log: (s: string) => void

except that it's lazy. Laziness is one of the most convenient ways to express "commands" rather than side effects in a language like JavaScript, but there are also alternatives.

Now we can have a pure program that logs to the console some string.

`log("foo")` does not "execute" any console.log, it still needs to be executed:

```

const program: IO<void> = log("foo");

// program() will actually print "foo" in stdout

```

Note: we could've encoded IO in different ways, e.g. with a struct/interface rather than a function and had the interpreter actually reason about the side effect on its own.

Now, the only missing part is: how do I compose such IO functions together? Well, there are various ways but the most common ones are applicative functors and monads. I am not going to delve deeper in this comment on those topics because it would take long but I hope I have transmitted my point:

in functional programs you return programs (I like to think about them as recipes, recipes don't DO anything), those programs may compose effectful commands, the actual execution of the commands is shoved inside an external interpreter.

This is quite obvious in some languages like Haskell, it's less obvious in others.

This I can understand.

But why would someone want to do this? It feels like mental overhead. So what advantages are there to a system like this?

“Have you tried turning off and on again?” is advice to perform a total state reset, which works because the system’s state is inconsistent but its sources of truth are not.

You never have to reset state if you do not have it in the first place. You get other bugs still, but you don't have the most common class of bugs.

But, you need some state. You just want to discourage it. Some of it can come from laziness, but with difficulty. (Chris Okasaki’s Purely Functional Data Structures uses it to de-amortize the bound on a FIFO queue.) Other things, like ring buffers, are harder to argue.

So you want to be able to express an array of IORefs, say, for your ring buffer. But the people who use it become aware that it is a stateful construct and must be used that way.

Read “what color is your function?” for a counterpoint, of course.

You do it if you're using a language where there's a rule that there are only functions. For example there's no concept of doing one thing followed by another. The only way you can arrange that is to have two functions and compose them: g(f(x)). So now you know that the code in g is executed after the code in f. Monadic composition is basically about doing that while having code that looks like it would in a regular language that has statements and sequential execution. There are a few other reasons, such as you can create your own control flow as-code, lifting, etc. But the primary driver is: because you can't write useful programs in a pure functional language without this trick.
Referential transparency, as the original post says. For example, in a functional language you're always allowed to transform

  a = f()
  b = f()
  c = g(a, b)
into

  a = f()
  c = g(a, a)
because f() is guaranteed not to have side effects. (If it does return side effects through the IO monad, instead of actually performing the side effects, g receives two data structures that encode what IO operations to perform.) In an imperative/procedural language, f() may have side effects, and if it does the two programs are not equivalent.

This may (not) make programs easier to reason about.

You can reason and compose pure effectful programs leveraging referential transparency.
If your brain can encompass the thought, yes, it is arguably the best and most correct way to think of a Haskell program. This is why Haskell programmers can claim with a straight face that "main" is actually a pure value of type IO and the language has no impurity, and there is an important sense in which this is completely true and not the cop out some people think it is in the slightest.

The laziness allows the pure language constructs like "if" to be lifted up into the IO value. That is, if you embed an if statement into one of the (pure) functions used in an IO value somewhere, only the branch that the "if" would take is ever evaluated. However, technically speaking, you can also look at the branches happening for all inputs; if you input a true or a false from the user, you can look at that as a true branch, a false branch, and some additional error branches, even if the code doesn't look like it. A strict language would not permit this. (Though even in a strict language, real-world useful IO values end up with enough function calls in them that a strict language wouldn't manifest the full tree either.)

If you input a full Int32, you can look at that as defining 2^32 possible branches in the IO value, and the execution will take one of them. In reality, the program does not do 2^32 different things, and a better view of what the program is doing is the more conventional one for almost every purpose. (Although that view will still pass through a lot of possible states!) But in terms of understanding how the IO value is "pure", this view is momentarily helpful.

The only operation in a Haskell program that violates this is unsafePerformIO, which really does penetrate this abstraction and work like a "normal" program. Otherwise, no matter how much Haskell code you write, technically all you're doing is making a bigger and more complicated pure IO value, which defines how to have an effect on the world but does not itself have an effect on the world. Executing the program is what finally puts the two together.

To put it another way, Haskell has a completely clear separation between being a program and executing a program. If I say to someone, "pick up that glass, fill it with water, and water this plant", that statement is itself a "pure value". The execution of that statement is where things get impure. Some languages do not have this clear separation, most notably Perl but the dynamic languages in general don't. Many others do, as having a compilation step all but forces this sort of separation, they just don't think of it that way and there can be leaks here and there, and features that may blur the line deliberately. Haskell does have a very clear separation, and in that separation, with laziness, it is almost like IO is just one big macro language for putting together programs, in some sense beyond what even Lisp would dream of.

And in another sense it's just a funny way to write conventional programs with really weird pretensions, and there's a certain value to that point of view too, which is that when you're done blissing out on the hippie math juice, when you actually sit down and write code in Haskell you're doing much the same thing you do in any other language and treat IO just like any other source code. But, as with the story of enlightenment... "first it was a mountain, then it was not a mountain, then it was a mountain again"... where you end up on this journey is not quite the same as where you started.