Settings

Theme

Exotic Programming Ideas, Part 3: Effect Systems

stephendiehl.com

293 points by psibi 5 years ago · 94 comments

Reader

siraben 5 years ago

I highly recommend Oleg Kiselyov's talk titled "Having an Effect"[0] in which he talks about

- purely functional model of computation and its pitfalls

- Actor model, effectful programming with requests and responses and an implementation in Haskell.

- denotational semantics and combining effects. Once you have a model of your language, what if you want to extend it by adding another effect? It forces you to rewrite the semantics (and thus any interpreter of your language) completely. Taking using the effects-as-requests viewpoint, only the request and handler for an effect needs to be added or changed, and the rest untouched. This is known as "stable denotations".

- really evaluating what it means for an expression to be pure or effectful. Even variable reference should be considered an effect.

- different scoping rules in lambda calculus can be expressed in terms of effects! Creating a dynamic closure is not effectful, though applying it usually is, OTOH, creating a lexical closure is effectful but using it is not.

I think Haskell provides a good example of how a purely functional language can still express effectful computation. through a monadic interface. Though monad transformers have their share of problems when heavily nested (n^2 instances, performance), various effect system libraries are gaining traction.[2] On the bleeding edge of research there's languages like Frank[1] where the effect system is pervasive throughout the language.

[0] https://www.youtube.com/watch?v=GhERMBT7u4w

[1] https://github.com/frank-lang/frank

[2] Implementing free monads from scratch, https://siraben.github.io/2020/02/20/free-monads.html

  • merelydev 5 years ago

    Source: https://www.reddit.com/r/programming/comments/7wbtg/who_is_o...

       - Oleg eats lambdas for breakfast
    
       - The Y combinator fears Oleg
    
       - Oleg knows all the programs that halt on a Turing machine
    
       - Oleg is the guy that Chuck Norris goes to when he has an algorithm complexity question
    
       - Oleg reprograms his own DNA with Scheme macros
    
       - All of Oleg's Haskell programs are well-typed by definition
    
       - Oleg can read C++ compiler error messages.
    
       - Oleg built his house out of Prolog relations
    
       - Oleg speaks machine lanugage
    
       - Oleg once turned a zero into a one.
    
       - Emacs? Vi? Oleg wills files into existance
    
       - Oleg has the Haskell98 Report memorized. In Binary. In UTF-EBCDIC
    
       - Oleg can write unhygienic syntax-rules macros.
    
       - Sometimes Recursion gets confused when Oleg uses it.
    • patrec 5 years ago

      > Oleg can write unhygienic syntax-rules macros.

      Not only can he do it, he even wrote a paper about it: https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.36...

      • siraben 5 years ago

        IMO implementing hygienic macros properly is one of the hardest tasks in programming languages, especially since variable capture can cause subtle bugs.

        • lmilcin 5 years ago

          It is also very fun. That's why you internalize Let over Lambda. It is like discovering Lego Technics after playing Lego for entire life.

          Then it is disappointing when you realize you can't show it at work or people get ideas...

        • patrec 5 years ago

          Well, certainly no one seems to understand how e.g. syntax-case works. But my impression is that macro hygiene in itself is a solution looking for a problem. The key advantage e.g. racket's macro system has over clojure or common lisp is not hygiene but being sufficiently well structured and rich to allow proper tooling. Good error messages with accurate locations >> macro hygiene.

          • deckard1 5 years ago

            > macro hygiene in itself is a solution looking for a problem

            No. Unless you're not familiar with Lisp-1 vs Lisp-2. In Scheme, you would have to GENSYM every variable in addition to every function you call within a macro. Whereas in Common Lisp you just need to GENSYM the variables. That's the real reason Scheme doesn't use DEFMACRO.

            I'm not personally a fan of any hygienic macro system because learning a new language defeats the purpose and elegance of Lisp macros in the first place. But then again, outside of personal projects and academic exercises, no one should be using macros. Messing with fundamental semantics of a language while other developers are working on the same project will certainly make you a ton more enemies than friends. I still have a grudge against the guy that used Ruby's method_missing and I spent an entire day hunting down a method that didn't exist. When I figured it out, I don't think I've ever been so pissed at someone before.

            • patrec 5 years ago

              > No. Unless you're not familiar with Lisp-1 vs Lisp-2.

              Just because "classical" defmacro is mildly awkward to use in Common Lisp and much more so in scheme does not mean that the only viable alternatives are R*RS style hygienic macros. Look at how e.g. clojure does it, it has a simple and perfectly adequate solution (a concise gensym notation, basically). As far as hygiene is concerned. Not so much in terms of generating good error messages.

            • fiddlerwoaroof 5 years ago

              The problem with Ruby’s method missing is mostly that Ruby development generally doesn’t happen in an environment where discovering the existence of that method is easy. Macros are even better and tools like the slime macrostep expander make them relatively easy to deal with.

          • akiselev 5 years ago

            I think in theory they’re orthogonal but in practice the two go together. It’s all fine and dandy when you’ve got a restricted set of battle tested macros from a single library interacting in real world code, but it rapidly breaks down when you’ve got application authors of various skill sets all contributing their own macros because the dare not touch the ones that came before. Macro hygiene provides the equivalent of type level guarantees to metaprogramming scope.

            • patrec 5 years ago

              Is this based on conjecture or personal experience from working on multi-dev projects with both hygienic and unhygienic macro systems?

              I lack such comparative experience but my guess would be that if your org's development approach is such that inexperienced people churn out dodgy macros without more expert review you are screwed and having a hygienic macro system will not save you. I'm very sceptical hygienic macros are at all comparable in utility to what a type system provides for multi-dev projects of any size. These benefits of types at scale are fairly massive and apply to basically any code. In contrast macros should make up a tiny amount (much less than 5% certainly) of code in a non-trivial application code base which means careful review is not a big overhead (and in my experience competent programmers do not introduce a lot of hygiene problems). So to be equivalent, in those instances where you do get a benefit from hygiene, that benefit would need to be absolutely massive compared to the average benefit you get of types.

            • mpweiher 5 years ago

              And maybe Macros aren't the right kind of metaprogramming mechanism...

          • kazinator 5 years ago

            Macro hygiene is a solution to the problem of functions being in the same namespace as variables, together with standard, oft-needed library functions having short names that are easily chosen as variable names.

            • patrec 5 years ago

              Well, that's the standard Common Lisper opinion. I don't buy it though -- I don't get the impression that it's a problem in clojure (which does not have scheme-style hygenic macros, but shares the single namespace for variables and functions) either.

              • kazinator 5 years ago

                The hygiene problem has multiple aspects. One issue is that the user's code can bind identifiers which the macro expansion expects to be predefined.

                In a Lisp-1, we have to worry about this:

                  (let ((list ...))
                    (mac ...)) ;; expansion of mac wants to call (list ...)
                
                Since the macro is not defining anything (its expansion is not introducing a binding for list), the ordinary gensym approach is not applicable.

                THe problem persists into a Lisp-2 like Common Lisp, in this form:

                  (flet ((list (...) ....)) ;; binding in function namespace
                     (mac ...))             ;; wants to use (list ...)
                
                
                Since local functions are rarer than local variables, and extra care can be taken in how hey are named, Lisp-2 mitigates the problem. Common Lisp goes a step further and makes it undefined behavior if code redefines a standard function (lexically or otherwise).

                Hygienic macros solve this aspect of the issue as well. A hygienic (mac ...) can use (list ...) in its expansion. The hygiene mechanism ensures that this calls that list which is lexically visible at the macro definition (probably the global one in the library) even if the target site shadows list with its own definition.

                • patrec 5 years ago

                  Again this is not fundamentally tied to whether functions and variables share a namespace, in clojure they do and yet your example above does not cause a problem. Watch:

                  Let's start out with a function:

                     user=> (defn enlist [x] (if (list? x) x (list x)))
                     #'user/enlist
                     user=> (let [list 1] (enlist list))
                     (1)
                  
                  Create a (pointless) equivalent macro:

                     user=> (defmacro enlist' [x] `(let [y# ~x] (if (list? y#) y# (list y#))))   
                     user=> (let [list 1] (enlist' list))
                     (1)
                  
                  No hygiene and yet it works!

                  How? The secret is that backquote is automatically namespace qualifying:

                      user=> '(list 1 2 3)
                      (list 1 2 3)
                      user=> `(list 1 2 3)
                      (clojure.core/list 1 2 3)
                  
                  In practice this + convenient gensym syntax (also demonstrated above) seems to be enough, just like in practice Common Lisp's approach of separate value and function cells for symbols + prohibition of rebinding function cells of symbols in the standard COMMON-LISP namespace is. Scheme has no cl style namespaces and no prohibition on redefining standard bindings and thus came up with a much more complex macro approach, at the gain of stronger hygiene guarantees (but see article above!) and the cost of an ugly design of considerable complexity that has a completely different sublanguage (non-orthogonally) baked in.

                  As far as purely hygiene is concerned that's IMO not a good trade-off at all; I could rattle off a long list of major usability gripes with both cl and clojure but problems caused by lack of macro hygiene wouldn't be on it.

                  Racket, which is basically several iterations beyond R*RS macros, OTOH is interesting because it's more principled approach gives you something qualitatively different to what you can do with clojure (no read-syntax control; bad error messages) and common lisp (readtables suck and break tooling; bad error messages).

                  • kazinator 5 years ago

                    I'd have to say that a ham-fisted read-time solution which throws important backquote identities under the bus is worse than just coding carefully under the threat of unhygienic macros, and worse than the complexity of hygienic macros.

                    • patrec 5 years ago

                      What concrete problem do you see with it? [edit: BTW you can quite easily implement common-lisp style backquote in clojure as a macro, because just like scheme clojure does not share common lisp's defect of baking unquoting into the readtable dispatch of backquote; i.e. unquotes outside of backquotes are syntactically valid; '~x -> (unquote x)]

    • jhgb 5 years ago

        - Oleg can read C++ compiler error messages.
      
      Oh, come on. You were doing so great until this point.
  • chrisdone 5 years ago

    The recent Unison language uses the effect system described in the Frank language https://www.unisonweb.org/

  • ledauphin 5 years ago

    I'm way out of my depth here, but interested in trying to understand a few of these things, particularly because I've long wondered how functional programmers reason about the "effects" that happen under the hood when dealing with Turing machines which are essentially 100% state.

    Specifically:

    1) do you consider variable reference to be an effect because you're getting a value from memory/disk/network? Or is it effectful simply because you're replacing a name with a value somehow? I'd love to understand the point of view here.

    2) Relatedly, why do you say that creating a lexical closure is effectful (I assume here that we are storing an immutable memory address, or at least a value which represents a way to reliably find another value in the future), but using it (dereferencing that address/value) is not? I can see why you'd say that creating a dynamic closure is not effectful (the code is essentially unattached to anything at the time of creation and can easily be represented by a pure value), but using it is (it now inhabits a scope and will act differently depending on that scope).

    Having typed that out, I wonder if what you mean is that creating the lexical closure in a language with immutable data means that at the time of creating the closure, you are (one way or another) making a copy of that data, which is an input "effect" in a sense. That isn't, of course, how things actually work in most non-functional (e.g. OO) languages - the lexical closure only binds the name, but the value represented by the name could in fact change if the data itself is mutable.

    Anyway, I'm still curious about #1 above. These are the sorts of comments I come to Hacker News to read, so thank you. :)

    • siraben 5 years ago

      > 1) do you consider variable reference to be an effect because you're getting a value from memory/disk/network? Or is it effectful simply because you're replacing a name with a value somehow? I'd love to understand the point of view here.

      To give an example, say you have int* x. When the expression (*x) is used later on, it is the dereference that is effectful. In other words, the expression lacks referential transparency.

      Going further, Oleg says that "an expression is effectful if it sends a message to its context". The example he gives is x+1 being impure, since it sends a request to the context to look up the value of x, whereas (λx. x+1) is pure because λ provides a handler for the request to look up the value of x, and the whole expression can be evaluated without sending anything more to the context.

      > 2) Relatedly, why do you say that creating a lexical closure is effectful (I assume here that we are storing an immutable memory address, or at least a value which represents a way to reliably find another value in the future), but using it (dereferencing that address/value) is not? I can see why you'd say that creating a dynamic closure is not effectful (the code is essentially unattached to anything at the time of creation and can easily be represented by a pure value), but using it is (it now inhabits a scope and will act differently depending on that scope).

      Creating a lexical closure (see 1:22:30 of the video) involves capturing the environment in which the closure was created. As in #1, the effectful part comes from a request to the context, which is collecting the free variables in the body of the lambda and building the closure. OTOH for dynamic closures you can create them without sending any request to the context, but when you apply it you have to request from the context.

      The discussion on the Actor model is very relevant to understand what it means for expressions to send requests to contexts.

  • cultus 5 years ago

    Also see Oleg's "Effects without Monads: Non-determinism -- Back to the Meta-Language"

    https://arxiv.org/abs/1905.06544

  • agumonkey 5 years ago

    Ever seen network protocols modeled in pure FP ?

skybrian 5 years ago

I expect that, as with any other type system extension, the more granular your effects are, the more likely you are to run into a “what color is my function” problem. If you have a public API that declares certain effects, you’re stuck with those unless you break backward compatibility.

In a practical system, when writing a library and especially an abstract interface, you’d want to be careful what you promise and declare effects that you might need (but currently don’t use), just in case you will need them later.

It’s not that easy even to distinguish functions that can fail from those that can’t, if you’re trying to anticipate how a system will evolve. Something that’s in-memory now might change to being done over the network later.

  • AaronFriel 5 years ago

    A properly done effects system with type-level annotation of the "color" of functions helps this, rather than hurts as you might surmise.

    Take for example logging or tracing - we almost always in a modern backend application want an ambient trace or span ID and a log destination. What we don't want is to have to add those as parameters to _every function_.

    So we want to paint these functions with the "logger" and "traced" colors, which respectively allow a function to send messages to an ambient logger via log(message: str) and to get the current trace context via getTraceContext() each with no other arguments.

    The compiler will tell us if we call these functions from an unlogged, untraced function, so at a very high level - say at the RPC request level, we paint them early on. In Haskell we might even be able to do something like

        handleReq req = 
          withTraceSpan (...)
          . withLogging (...)
          $ handleReqInner req
    
    Where handleReqInner requires these "colors".

    Having too many effect handlers is never a problem either - you can always call an untraced function from a traced function. The "trace" effect handler just falls off at that call. Or in other words: you can always call a function whose colors are a subset of the call-site's.

    This is somewhat a foreign concept to people who haven't worked with type systems like this and whose only concept of the color of function is purely binary - either async or not. In a good typed effect system, the compiler will assist you in knowing where you're missing an effect handler, and it will be easy to add those effect handlers sufficiently far from the business logic that you won't have to think about it.

    • skybrian 5 years ago

      You're saying it's not a problem, but let's say you didn't think ahead and want to add tracing later, and there are many intermediate function calls between top level and the place where you want to trace. You still have to change every function signature to add the colors, right?

      • AaronFriel 5 years ago

        In a language and type system where the compiler infers all of the intermediate types, no.

        The way effect systems are typically added to languages without this level of type system is that the effect "fails". Like making a database call in Python without "with use_database(...):" that provides the database context. In those languages and with those effects, typically the default behavior is the effect's methods are always available, but may either be a no-op/return a nullish value or throw an exception.

        As an example, the way tracing works on Node.js is that you have to explicitly declare a span (or use a plugin for a framework which does that for you) and inside that span, you can do something like getSpanId() and it'll return a value. Outside a span, it returns null.

        The worst case scenario is you have a language and framework where it's both difficult to annotate the types and difficult to provide some ambient context. In those cases, you're back to (essentially) passing your trace span, your logger, etc as arguments to every function again. Not ideal.

        • dan-robertson 5 years ago

          In every language I’ve used where types can be inferred, almost everyone writes down the types of their top level functions (or at least the functions that are exposed to clients). I don’t think this is really a good counter argument.

          The only ways I know of to reliably make this kind of change (or just about any nontrivial change) to an interface are increasing version numbers and letting clients suffer or having a monorepo and fixing all the clients yourself

          • AaronFriel 5 years ago

            In Haskell, it's the norm I think to do both: let the compiler infer the type and then add the inferred type to the code. This locks in compatibility.

      • shthrow 5 years ago

        No, think of it like checked exceptions, you only need to handle the effect _at most once_ before it reaches the top (it's up to you to let it keep getting passed up or handle it immediately), otherwise the compiler will automatically infer the effect types in the layers between.

        • skybrian 5 years ago

          Okay, so type inference is the vital feature here.

          It can help in a closed code base. In an open code base, you could inadvertently change a public API, breaking callers that are unknown to you, if the type signature of something public is inferred.

          • shthrow 5 years ago

            Correct, but that is the same for any kind public API though. However that is slowly starting to change with statically typed APIs like GraphQL and Protocol Buffers. You can imagine a public API that is introspected and can tell the client about deprecation and all that.

      • creata 5 years ago

        As Quekid5 said, isn't that the way things should be? Adding tracing is a breaking change, and adding logging inside a deeply nested layer is a breaking change to all the outer layers, too.

      • Quekid5 5 years ago

        Yes, you do... which is as it should be. What else would you expect?

        If a function needs capability X then it must require that capability. Simple as that.

  • slaymaker1907 5 years ago

    Effect systems can actually make this less bad due to functions which are generic over effects. The function color problem is fundamentally because there usually isn't a way to be generic over different colored functions in a performant and ergonomic way.

  • lambda_obrien 5 years ago

    An effects system like this is more about controlling your own code and allowing for switching off implementations easily versus declaring what effects it has. Your declaration of effects on your function is saying, for example, "I need to output some text," and then in the caller of that function you have to do some action to "consume" that effect. For instance, your example might be an effect called "WriteState" and then you could call that function in a small unit test with a thin layer over a Map, in dev you could call it with a local sqlite db, and in prod you'd call it with your postgres or whatever. Each implementation shares a common interface, but does something different with the data. If you were writing a library, you'd give your public API as the base monad of your library, or as IO maybe, or even give a pure API. You should be dealing with the possible failures under that base context and then the user doesn't need to know about the inner failures.

    The effects system effectively acts as an abstraction for some side effect, like an interface, and gets ride of a lot of the boilerplate code needed for mtl or custom transformer stacks.

    Also, in strict typing it's pretty easy to refactor with modern linters and such, it actually makes refactoring an API change delightfully simple, just get rid of the red squiggle lines telling you you types are wrong.

    • skybrian 5 years ago

      Refactoring tools are nice so long as you are in a closed-world environment where you can see all the code and make whatever changes are needed. They don't help nearly as much in an open environment where there are many code owners and not all code is visible to you. When you publish a library, a refactoring tool isn't going to tell you everyone who uses your library, and you don't have permission to change the call sites anyway.

      The only thing for it is to push a new, incompatible version and other people will have to migrate. So you're pushing off the work onto them.

      I don't see how an effects system helps with this much? It might help you better understand how you painted yourself in a corner, but I don't see how it helps you get out of it.

      • Quekid5 5 years ago

        I mean... if you're fixing a broken API, you're going to have to bite the bullet either way -- papering over it isn't going to fix anything... it just hides the problems.

        With a type system which understands effects, at least the compiler can give you very accurate help in fixing call sites.

        • skybrian 5 years ago

          It does help by telling you what's wrong. But in a way, increased precision makes the problem worse.

          Suppose you have have a language with two categories of functions, those that can fail (returning an error) and those that can't.

          It's nice that within the "functions that can fail" category, you don't have to worry about what kind of error it might be. Error propagation can happen in a generic way. Adding a new kind of error doesn't change any API's.

          If instead, you have different kinds of errors and declare them everywhere, you end up with a situation like Java's checked exceptions. The problem is being too precise, which doesn't leave room for changes later.

          Similarly, we could be very generic about effects. Maybe we could just say "this function has effects" and treat them all the same? By not being precise about what the effects might be, we aren't promising too much, so we allow ourselves room for change.

          The downside is that the caller has to assume any effect is possible. This is a fundamental tradeoff between caller and callee convenience. You need to be specific enough for the caller to be able to deal effectively with the effects you declare, but not too specific, or you're painting yourself in a corner.

          • lambda_obrien 5 years ago

            Honestly, in the Haskell world, no one really complains about broken APIs, it's actually pretty common for a library to change things in a breaking way. People tend to either use Nix or stack to deal with versioning or they just plan to refactor when they have to. I personally just started with Haskell, but I've switched a small tool from one streaming library to another and it was super easy even though each had a very different API.

            Also, breaking API changes happen in every language, on every codebase, ask the time. At least with static types and a common linter I can refactor and be a hundred percent sure it works since it compiles, whereas in Python for example I can't be sure unless I add tests and stuff, even then...

            • Chris_Newton 5 years ago

              Honestly, in the Haskell world, no one really complains about broken APIs

              I don’t believe that’s true. The very existence of Stack and Stackage is testament to how significant the compatibility problems had become in the Haskell ecosystem.

              Even if it were true, Haskell world is tiny and (in)famously has the motto “Avoid success at all costs”, so it isn’t necessarily a representative example of practices that would be effective in other contexts.

              The advantage with a type system as explicit as Haskell’s is that at least if there is a breaking change in an interface you depend on, you get quite good information about where the problem is and what you need to do to fix it.

              Also, breaking API changes happen in every language, on every codebase, ask the time.

              Some people take backwards compatibility extremely seriously, and code that depends on their interfaces still works just fine many years after it was written. The pace of development is usually slower when robust standardisation is part of the process, but that isn’t necessarily a bad thing given the stability you get in return.

              At least with static types and a common linter I can refactor and be a hundred percent sure it works since it compiles

              This is a common claim in the Haskell world, but of course it’s not really true. It always reminds me of Knuth’s famous quip, “Beware of bugs in the above code; I have only proved it correct, not tried it.”

            • skybrian 5 years ago

              Yes, there are easier and harder ways to deal with api migrations. But even in Haskell, isn't "Cabal hell" a thing? And the type of the standard prelude's head function is wrong but it can never be changed.

              Dependency migration is the sort of thing that's easy enough in small examples and gets harder as you scale up.

              • Quekid5 5 years ago

                > But even in Haskell, isn't "Cabal hell" a thing?

                No. Just use stack + Stackage and you have a huge set of packages which are mutually compatible.

                (You can also use cabal-install with a Stackage snapshot, I think. Not sure though. Cabal-install has also improved massively and doesn't really do "global" installation of dependencies, so it'll help a lot... but you may still run into difficulties making different versions of your project's dependencies work together.)

              • Chris_Newton 5 years ago

                But even in Haskell, isn't "Cabal hell" a thing?

                Less so than it used to be. Cabal received a big update a few years ago, and the new-style commands now operate more like many other package managers in terms of isolation etc.

              • lambda_obrien 5 years ago

                You should use Haskell and see what it can do, you can change the prelude easily, for instance.

                The standard head function is partial, yes, but that's not a big deal at all.

  • lalaithion 5 years ago

    The "what color is my function" problem is insurmountable in Javascript, because the runtime does not allow you to call an async function from a non-async one. However, most effects aren't like this. If you say "this function needs randomness" then you can create a pure PRNG and call the function with the PRNG providing randomness. If you say "this function needs logging" you can tell it to log to a string and parse/ignore the string. If it throws an exception, you can catch the exception. Haskell even provides `unsafePerformIO`, which lets you run arbitrary computations as if they were pure.

    • skybrian 5 years ago

      There is the specific issue with async functions, but that's only one example of a general problem, what I'm calling "function coloring." Workarounds are often possible, but they are still workarounds and often result in bad code.

      We've been there with Java. An API takes a Runnable. You need to do something that does IO, so you catch the exception... and then what? Log and suppress it? This is how bad code happens. At best you get chained exceptions.

      With Callable, they got smarter and declared it to throw Exception, which means it can only be easily propagated by other methods that declare they throw Exception. So it's a viral declaration, unless you engage in workarounds like exception wrapping.

      You don't need an effect system for this to happen, though. In Go, you don't have the problem with different kinds of errors, but you can still classify functions into two categories: those that may return an error, and those that (apparently) always succeed. If you need to fix the API, you might end up with lots of refactoring [1].

      Mistakes at the API level can be hard to fix without touching lots of API's. This is unsolvable in general. The only way to avoid workarounds (via escape hatches or by mocking things out) is to standardize whatever you can so it works together.

      [1] https://www.dolthub.com/blog/2020-11-16-panics-to-errors/

      • lalaithion 5 years ago

        Nothing prevents having a type system capable of dealing with those problems. Yes, Java and Golang make this difficult, but if you have a language that supports it, there's nothing which prevents writing an API that says "Whatever the effects of the Callable you passed me are, I also perform those effects".

        • skybrian 5 years ago

          Introducing generic types doesn't make the function-coloring problem go away.

          Now, instead of having a single function that has some effects, you have a family of closely-related functions, each with different effects. These functions are incompatible with each other. The function-coloring problem has gotten worse! :)

          (Generic types are still useful though.)

          • lalaithion 5 years ago

            No, it doesn't make it go away, but it makes the "function coloring problem" about as much a problem as the "colors" of functions as determined by their return types. You can't pass a `func () string` to an api accepting a `func () int`.

        • karlicoss 5 years ago

          And indeed, JS Flow detects such async issues pretty well

    • no_wizard 5 years ago

      I think the reactive/rx/observer patter might be an acceptable solution around the what color is my function problem and it tends to be very popular with the functional programming wing of the JS community from what I’ve seen.

      Never understood why the browser didn’t ship a full access to something like an EventEmitter. Since you can dispatch events on window document DOM elements etc seems like being able to subscribe to events in a more arbitrary fashion that worked in parallel to the other events would have been useful and solved callback hell all the same

      • the_benno 5 years ago

        We built an effect system to enforce thread safety in Rx-based android apps a few years ago and published a paper on it at ASE, if you're interested: plv.colorado.edu/benno/ase18.pdf

        the idea should generalize to whatever thread-pool/effect constraints you want to express, but the paper and implementation are focused on keeping UI effects on the android main thread.

  • jiggawatts 5 years ago

    A lot of people are saying that function coloring is essentially an unsolved problem, and this is true. However, coloring of individual functions is not strictly speaking necessary.

    Think of the classic "zlib" style C libraries. Often they can't assume anything about the target platform, even basic stuff like memory allocation or I/O, because there's such a huge variety compilers, standard libraries, and restrictions.

    So just about every such library exports an "API context" struct, full of function pointers. These then allow the caller to pass in implementations for malloc, free, threading, I/O, etc...

    The trick is that these can have default implementations. So the function pointer in the context for allocation can simply point to "malloc()". This can of course be overriden, so the capability is there, but the caller need not do anything by default.

    Features like logging and tracing can be similarly sent through to default implementations that do nothing or call standard APIs in some naive (but sufficient) way.

    • ledauphin 5 years ago

      I think this (allowing some form of default for backwards compatibility) is a underrated point.

      The common modern example being async functions, I would say it's actually quite surprising that JavaScript can't seem to provide the sane default behavior of "block this non-async function until the async function it calls returns a value".

      The reason it can't is simply that it chooses not to provide a runtime with a true threading model - blocking a non-async function means literally blocking the entire runtime. But there's a possible world in which a "colored" (async) function could be used directly in a synchronous function using an escape hatch provided by the runtime, and then async functions would no longer be colored in the same way.

  • jahewson 5 years ago

    I always found IOException in Java to suffer from this problem.

    • codebje 5 years ago

      Java's checked exceptions aren't (usefully) polymorphic. You can't write, for example:

          R process<R, E extends Exception>(ThrowingFunction<Long, R, E> processor) throws E { ... }
      
      You could write "process" to the Exception base class, but it still can't handle a "processor" that doesn't throw at all.

      Checked exceptions a "bolted on" effect that sits outside the usual type system in Java, and their presence on a method signature "colours" it in a way that an effect described inside a type system would not. More of a stain than a tint, if you will.

      Java 8 resolved it by quietly pretending checked exceptions never happened, which works fine until (1) you get unhandled unchecked exceptions in all their glory at runtime, and (2) you want to use one of the Java 7 or earlier methods as a "processor" but, oh no, it throws an exception.

      (It's a deeper colouring than JavaScript's async/await keyword, which is just syntactic sugar around continuations in callbacks and returning promises - you can pass an async "processor" function to a "process" function that has no idea what it's getting and get back the promise you'd expect from it.)

  • The_rationalist 5 years ago

    Arrow Fx manage to be in a way colorless by obscoleting the need of Applicative, monad, etc https://arrow-kt.io/docs/fx/polymorphism/

ianbicking 5 years ago

I've had this idea of "dynamic returns" (akin to dynamic scope) in my head for a while. Reading this, it feels like a dynamically typed companion to effect systems.

The idea of a dynamic return is just to give a formal way to accumulate things during a set of function calls, without having every function to be aware of what might be happening. In Python context managers are often used for this (e.g., contextlib.redirect_stdout to capture stdout), but thinking about it as another kind of return value instead of "capturing" would be an improvement IMHO. (You have to "capture" when hardcoded imperative code later needs to be retrofitted, but as it is retrofitting is all we have.)

But dynamic returns aren't quite like an effect system unless you also create something more-or-less like a transaction or a changeset. We usually think about transactions as simply a way to rollback in case of an error, but as a changeset there's all kinds of interesting auditing and logging and debugging possibilities. E.g., if your effect is writing to stdout, you could rewrite all those changes (e.g., apply a filter, or add a text prefix to each line).

  • andrewflnr 5 years ago

    I don't even see anything essentially type-based in effect systems. You could totally have effect-style APIs in dynamic languages, they just wouldn't be tracked and checked up front. I expect this has already been done a few times in Scheme.

  • marcosdumay 5 years ago

    > E.g., if your effect is writing to stdout, you could rewrite all those changes

    Could you? Once you write something into stdout, as far as your program knows it could already be sent across the world and turned into a set of bank transactions, or missiles fired.

    • a1369209993 5 years ago

      I think what they're proposing is that you'd buffer (not necessarily literally) those writes, then later (also not necessarily literally) pass them through a rewriting function before actually interacting with the operating system / environment.

  • dan-robertson 5 years ago

    Here are some features like that in Common Lisp, I think. Let me be clear about definitions:

    The dynamic extent of (an evaluation of) an expression is the interval of the program’s execution starting when evaluation of the expression begins and ending when control flows out of the expression (ie it returns a value or does some kind of nonlocal transfer of control)

    Something is lexically scoped if it may only be referred to by code which is syntactically inside the expression that creates the scope (eg in lisp, a let ordinarily creates a lexical scope but an anonymous function in the body of the let may continue to refer to the lexically scoped binding outside the dynamic extent of the binding; in JavaScript a var binding is in the lexical scope of body of the function in which it is bound)

    Something is dynamically scoped if it is available for the dynamic extent of whatever creates the scope.

    In Common Lisp most variables are lexically scoped inside the body of the let that defines them. Global variables (or other variables declared special) are dynamically scoped. One can write code like this:

      (defvar x 0) ; x is global so dynamically scoped
    
      (defun f (g) (let ((x 1)) (funcall g))
    
      (let ((x 2))
        (f (lambda () (print x))))
      ; => 1
      (print x)
      ; => 0
    
    If x were not defined as a global the output would be 2. If the language had some kind of block scope which was neither dynamic nor lexical, the output would be 0.

    This dynamic scope is often used for the kind of “dynamic returns” you describe. There are global variables called e.g. standard-output [written with an asterisk on either side but hn just makes it italic] which one may (dynamically) bind to another stream to capture the output.

    Apart from the ordinary control flow out of expressions where they evaluate to something, there are three non local transfer of control constructs:

    - catch/throw are a dynamically scoped way of returning values. They work similarly to exceptions in Java or JavaScript except that instead of catch dispatching on the type of the object which is thrown, it dispatches on the identity of a “catch tag” which is associated with whatever value is thrown, and there is no catch-all construct (so it is relatively hard to interfere with someone else’s catch/throw. This is the Common Lisp construct I would refer to as “dynamic return.” These aren’t used very commonly as the below operators tend to be preferred.

    - block/return-from is lexically scoped but return-from is only valid within the dynamic extent of the corresponding block. Blocks are named by symbols.

    - tagbody/go is much like block/return-from except it is a lexically scoped goto rather than a return. Either could be implemented (potentially less efficiently) in terms of the other.

    There is another operator, unwind-protect, which works like a try–finally block in JavaScript to cause some code to run whenever the dynamic extent of an expression ends.

    Another example of these scoping concepts is in the condition system which handles errors and other conditions (like warnings or “signals”.) Typically this is implemented using a dynamically scoped variable holding the list of handlers which are currently in scope, and another for the restarts which are just functions. When a condition is signalled, the list of handlers is searched for a suitable handler which is a function that is called. This function has access to the lexical scope from where the handler was defined but runs in the dynamic scope from where the condition was signalled. It may choose to invoke one of the dynamically scoped restarts (proposed solutions to the condition, eg retry or abort) which is just a function that will typically transfer control to some place near to where it was defined. This is different from the traditional exception systems where by the time you catch an exception you’ve already unwound a lot of the stack (so it’s hard to resume from an earlier stage now.)

aozgaa 5 years ago

> As far as I can tell no one uses this language [Koka] for anything, however it is downloadable and quite usable to explore these ideas.

I believe the typesetting tool Madoko[1] is implemented in Koka, though in fairness Daan Leijen developed both Koka and Madoko.

[1] https://github.com/koka-lang/madoko

toolslive 5 years ago

Ocaml (almost ?) has it. https://www.janestreet.com/tech-talks/effective-programming/

klodolph 5 years ago

There's some interesting research and ideas here, but it does seem like monads "ate everything for lunch" back in the late 1990s and 2000s when it comes to encoding effects, probably because monads are a bit more ergonomic (which seems like a weird thing to say, given the reputation monads have for being abstract nonsense). So effect systems didn't get as much research as everyone was interested in monads, and now that monads have dried up a bit as a field of research for encoding effects, I'm interested to see what other systems people invent.

  • The_rationalist 5 years ago

    Arrow Fx make monads obscoletes, see the table https://arrow-kt.io/docs/fx/polymorphism/

    • klodolph 5 years ago

      The page you linked to describes monads. It sounds like you’re not describing something that makes monads obsolete, but merely a DSL for Kotlin that makes it easier to use monads. Haskell doesn't need that, because monads are already part of the core syntax of the language.

      • The_rationalist 5 years ago

        You clearly didn't read the table that compare the verbosity and cognitive overhead of the Haskell way vs the fx way

        • klodolph 5 years ago

          > You clearly didn't read…

          Just a word of advice—you’re digging yourself in a hole if you make low-quality, ad-hominem comments like that. Comments of the form “you didn’t read X” are specifically discouraged on HN. Maybe you feel good writing it, but nobody feels good reading it and nobody learns anything.

          I took another look at the page you linked and it still looks like syntactic sugar for Monads. It’s still monads. Monads monads monads. Applicative and Functor are generalizations of Monad.

dustingetz 5 years ago

It's not just about marking regions and checking them, "effect systems are fundamentally about dynamic dispatch (separating effect from effect handler)" Alexis King

smegma2 5 years ago

I was curious about this function:

    fun addRefs(
      a : forall<h> ref<h,int>, 
      b : forall<h> ref<h,int> 
    ) : total ()
    {
      a := 10;
      b := 20;
      return (!a + !b);
    }
Why is it total instead of st<h>? Won't this have a side effect of setting the references?
  • daanx 5 years ago

    Ah, I think Stephen meant to write the following:

      fun add-refs( a : ref<h,int>, b : ref<h,int> ) : st<h> int {
        a := 10
        b := 20
        (!a + !b)
      }
    
    where indeed the effect is `st<h>` as the updates are observable. How the function was written before, the two arguments use a "rank-2" polymorphic type and the heaps are fully abstract -- in that case it would be unobservable but you cannot create such values :-)
The_rationalist 5 years ago

Arrow Fx implement this idea for Kotlin -> https://arrow-kt.io/docs/fx/

transfire 5 years ago

I believe Kitten is another language exploring this area.

PaulHoule 5 years ago

"Non-termination is an Effect"

  • daanx 5 years ago

    (Daan here, creator of [Koka](https://github.com/koka-lang/koka)

    This is an interesting point and comes down to the question -- what is an effect really? I argue that effect types tell you the type signature of the mathematical function that models your program (the denotational semantics). For example, the function

      fun sqr(x : int) : total int { x*x }
    
    has no effect at all. The math function that gives semantics to `sqr` would have a type signature that can be directly derived from the effect type:

      [[int -> total int]]  = Z -> Z
    
    (with Z the set of integers). Now, for a function that raises an exception, it would be:

      [[int -> exn int]] = Z -> (Z + 1)
    
    That is, either an integer (Z), or (+), a unit value (1) if an exception is raised. Similarly, a function that modifies a global heap h, would look like:

      [[int -> st<h> int]] =  (H,Z) -> (H,Z)
    
    that is, it takes an initial heap H (besides the integer) and returns an updated heap with the result.

    Now, non-termination as an effect makes sense: a function like "turing-machine" may diverge, so:

      [[int -> div int]] = Z -> Z_\bot
    
    That is, its mathematical result is the set of integers (Z) together with an injected bottom value that represents non-termination. (note: We don't use "Z + \bot" here since we cannot distinguish if a function is not terminating or not (in contrast to exceptions)).

    In a language like Haskell every value may not terminate or cause an exception -- that is a value `x :: Int` really has type `Int_\bot`, and we cannot replace for example `x*0` with `0` in Haskell.

    Note that in almost all other languages, the semantic function is very complex with a global heap etc, any function `[[int -> int]]` becomes something like `(H,Z_32) -> (H,(Z_32 + 1))_\bot`. This is the essence of why it much harder for compilers and humans to reason about such functions (and why effect types can really help both programmers and compilers to do effective local reasoning)

    • skybrian 5 years ago

      I'm wondering if you've found it useful in practice to distinguish between total and possibly-diverging functions?

      It seems like it's the sort of thing that's useful in something like Agda, where you use the existence of a function (without running it) to prove that its result exists. (The type is inhabited.) Or so I've read; I haven't used it.

      But if you're going to run the program, you typically want to know if a function will return promptly, and a total function could still spin for a million years calculating something, in a way that's indistinguishable in practice from diverging.

      • daanx 5 years ago

        In practice, I have not (yet) found many great use cases for the distinction in Koka. It is nice to have "total" functions, but "pure" (exceptions+divergence) is still a good thing (and what Haskell gives you). And like you say, in practice we can easily have functions that just take a long long time to compute.

        Still, it is a good extra check and I can see more use for the `div` effect for future verification tools where total functions can be used as a predicates (but non-terminating ones cannot).

        • skybrian 5 years ago

          Good to know. At compile or verification time, it seems like you could get the same problem again, though? We want our compiles to finish promptly, and a slow, total predicate could hang.

          Which is to say, a possibly-diverging function seems okay to use even by a verification tool, so long as it finishes quickly in practice. Having some notion of analysis-time-safe predicates seems useful but it doesn't seem to map cleanly to being able to prove termination?

      • opnitro 5 years ago

        I have found total checks in languages like idris helpful, but really only for catching small mistakes that I've made (for example, recursing on the original argument)

        • atennapel 5 years ago

          If your language has equality proofs, for example `a = Int` then, if your language is non-total I can prove anything by divergence. So I can prove `Int = String`. This is still type-safe as long as you don't erase the proof, because you can never use the invalid proof because your program will diverge.

          So if you want to be type-safe but you still want to be able to erase proofs, your proofs have to be total.

  • tomp 5 years ago

    I never understood this one. I mean obviously a kind compiler should warn you against using

        while true { }
    
    and

        while i < 5 { i—-; }
    
    but in general, I don’t find distinction between “this infinite loop terminates” (e.g. an event loop) and “this code obviously terminates but not in the lifetime of this universe” (e.g. computing Ackermann(5)) to be that useful.
    • creata 5 years ago

      It's primarily useful for theorem proving, where a nonterminating argument corresponds to circular or otherwise unfounded reasoning.

      It's also useful because, in practice, we don't accidentally write code that "obviously terminates but not in the lifetime of this universe" as often as we accidentally write nonterminating code, so a lot of mistakes can still be caught by termination checking.

  • SomeHacker44 5 years ago

    ...that cannot be determined by the system (and must be specified by the user).

    • daanx 5 years ago

      Just to add to this: Koka can (obviously :-)) not always determine if a function will terminate or not so it generally adds a `div` effect whenever there is the possibility of infinite recursion.

      However, since most data types in Koka are inductive, any recursion over such inductive data types are still inferred to be always terminating.

      In practice, it looks like about 70% of a typical program can usually be `total`, with 20% being `pure` (which is exceptions + divergence as in Haskell), and the final 10% being arbitrary side effects in the `io` effect.

      • ledauphin 5 years ago

        these numbers are very useful to me even as a Python programmer, as they (somewhat) line up with my intuitions about how much of our programs can be expected to be written as (Python-) pure transforms, vs imperative shells. It's cool to think about a language being able to tell me this directly.

Keyboard Shortcuts

j
Next item
k
Previous item
o / Enter
Open selected item
?
Show this help
Esc
Close modal / clear selection