3 ms·
> A game written in Haskell or Scheme probably has a main loop implemented via recursion, but it's still called a main loop If such a 'main loop' is a single f
by chriswarbo 6y ago
> A game written in Haskell or Scheme probably has a main loop implemented via recursion, but it's still called a main loop
If such a 'main loop' is a single function which (a) dispatches to one of a bunch of 'handlers' then (b) calls itself recursively, I would still call it a 'main-loop anti-pattern'.
More formally, it's likely to be in "defunctionalised" form https://blog.sigplan.org/2019/12/30/defunctionalization-everybody-does-it-nobody-talks-about-it https://blog.sigplan.org/2019/12/30/defunctionalization-ever...
The problem with such 'main loops', whether actual loops or self-recursive functions, is that control flow has to keep doubling-back to this 'central dispatcher'. In the case of actual 'main loops' this is often an unavoidable consequence of lacking tail-call elimination. In languages with tail-call elimination, we're just over-complicating the problem.
> This sounds like a recipe for spaghetti.
The control flow for a 'main loop' program goes like this:
main function fooHandler barHandler
|
+------call-------+
|
+-----return------+
|
+------call---------------------+
|
+-----return--------------------+
|
...
If we have tail-call elimination, the handlers can just call the 'main loop' instead of returning (this is just a CPS transformation), e.g.:
main function fooHandler main function barHandler main function
|
+----call--------+
|
+-----call----------+
|
+------call----+
|
+----call----+
|
...
Even this simple transformation looks less like spaghetti to me. Calls to this 'main function' now have much more context, compared to "top level" calls to itself. Hence we can avoid special-cases and handler-specific checks migrating their way into the 'main function'; even if we want such things, we don't need to pollute the 'main function' itself (or defunctionalised intermediate data) with such cruft; we could just pass such things as extra arguments, from those call-sites which need them. Tugging at this thread, we'll often find that most of the 'main function' could be moved to its callers, and any remaining functionality may be better in a generic 'plumbing' operation like a monadic bind (the latter being a "programmable semicolon" after all ;) ).
> The "main loop" approach is much more conducive to the clean approach of "functional core, imperative shell"
I don't see why. The "functional core, imperative shell" approach is mostly about writing as much as possible using pure functions, then 'lifting' them to the required effectful context (via map, join/>>=, etc.); in contrast to the common approach of writing everything within the required context to begin with (e.g. making everything "IO" just because we need one 'printLn' somewhere).
That doesn't require manually writing a global "main loop". For example I like to use polysemy, where all the business logic can remain pure, and the `main` function just declares how to 'lift' the application to `IO`. Under the hood it uses a free monad, which dispatches to 'handlers'; but (a) that's all automatically managed, and (b) it recurses through calls spread across the application, rather than returning to a self-calling "main function".
- lmm 6y ago> Even this simple transformation looks less like spaghetti to me. Calls to this 'main function' now have much more context, compared to "top level" calls to itself. That's backwards IMO. The first example is structured, hierarchical, organised. The second example is spaghetti because there's no difference between returning and calling, so all you see is a bunch of functions connected to a bunch of other functions. > Hence we can avoid special-cases and handler-specific checks migrating their way into the 'main function'; even if we want such things, we don't need to pollute the 'main function' itself (or defunctionalised intermediate data) with such cruft; It's the opposite of cruft; the whole point of defunctionalising is that you replace invisible control flow with a reified datastructure that you can actually inspect and understand. You can properly separate the concerns of "figure out what state we're in" and "actually execute the things that need to happen now" rather than sprinkling in special-case tests at random points in your execution.