[go: up one dir, main page]

  1. 18
    Why (pure) functional programming matters video education youtube.com
    1. 15

      My summary by virtue of skimming the talk:

      1. Anything can throw an exception. (Yes, it can but this can be solved through cultural norms such as in Go, without needing pure functional programming)
      2. Trite example of URL comparison doing DNS lookup in Java. (It's not like Haskell and OCaml stdlibs are free of badly designed APIs)
      3. Optionals (Zig has this too, doesn't have anything to do with pure functional programming.)
      4. Equational reasoning
      5. Bunch of vague graphs about cost of change/progress without any concrete examples/case studies
      6. Unsubstantiated claim that AI tools benefit more from local reasoning.

      I would love to see actual case studies grounded in people's real world experience.

      Foundation DB and TigerBeetle are poster boy projects for Deterministic Simulation Testing. The CEO of TigerBeetle states that the static memory allocation strategy would've been much more time-consuming or impossible to use in another language (due to lack of pervasive support for custom allocators)

      What is an equivalent project what would've been impossible without being written in a purely functional language? (Note: the question is about language not about style for a particular piece of code.)

      1. 16

        What is an equivalent project what would've been impossible without being written in a purely functional language?

        Not a project as such, but Haskell’s support for software transactional memory can’t be translated faithfully to an impure language. Haskell’s STM only allows side-effects that can be committed or rolled back transactionally. It takes over the control flow around retries, which it can do because transactional code is pure apart from its effect on the STM.

        1. 2

          Depends on the effect, no? Eg imagine that you're implementing an infrastructure-as-code tool like Terraform. And it has to execute a 'plan' to delete a cloud database and then some networking infrastructure for the database. But it has to do them in a single transaction. So if the second delete fails it has to roll back the database deletion. Is that doable? I don't think so...

          1. 2

            A program can’t use an STM TVar to run terraform or delete a database or launch missiles. STM is just a fancy multi-address CAS.

            1. 2

              Yeah, so ultimately STM is just a fancy way of controlling in-memory mutation. In other languages I can just use an in-memory SQLite database to get the same atomic effects.

              1. 5

                You’re missing the point that STM prevents the programmer from mixing non-transactional effects into a transaction, and that STM takes care of conflicts and retries in a manner that depends on the code being pure. There was a lot of STM research in other languages contemporaneous with Haskell’s STM, but none of them were so elegant in the way they handle conflicts and retries. Which is what I meant by “can’t be translated faithfully”.

          2. 2

            That's a good example of a feature hard to retrofit elsewhere.

            Do you know of any non-trivial production codebases relying on STM in a load-bearing way?

            1. 5

              Most production Haskell code will be relying on STM for shared mutability across threads, it's standard default practice. It's also taught and re-emphasized by the most important educator in Haskell.

              To say that it's load-bearing is probably an understatement. I don't think it's a stretch to say that only toy code in Haskell would not be using STM in some form at least for a long-running, networked service. With that said there are a lot of uneducated Haskell users out there, so the floor here is very low.

              With all of this said I think STM is just about the only thing that justifies pure functional programming and I think functional programming as a whole (beyond the very, very basics of "make functions pure if you can and it doesn't suck") is a massively overrated paradigm that people waste time on for basically no net gain. I've done FP for 10+ years and in hindsight it's been one of the most useless aspects of programming that I can think of.

          3. 16

            Trite example of URL comparison doing DNS lookup in Java. (It's not like Haskell and OCaml stdlibs are free of badly designed APIs)

            My first bug coming out of university was doing a URL comparison in Java.

            Haskell's base library is similarly ancient and has lots of mistakes BUT it definitely does not (because it can not) make unexpected network calls for things like equality comparison. It's totally missing the point to say this is just about badly designed standard libraries.

            1. 8

              Trite example of URL comparison doing DNS lookup in Java. (It's not like Haskell and OCaml stdlibs are free of badly designed APIs)

              I was actually unsure whether to include this or not, as it feels like beating a dead horse by now. Still, I think it shows the utiliy of effect systems / being able to sight-read whether a function is impure or not.

              Optionals (Zig has this too, doesn't have anything to do with pure functional programming.)

              This is a good point, and something I have struggled with myself—there is a certain "aesthetic" which really isn't bound to FP; Rust shares more of this aesthetic with ML than with Clojure, in spite of Clojure being "functional programming" whereas Rust really isn't. I think we need a better name for this.

              Bunch of vague graphs about cost of change/progress without any concrete examples/case studies

              Yeah, this is very much based on anecdata and vibes. However, I think it's not controversial to say that in the presence of unmanaged effects, the complexity of understanding any given function is O(n). We stopped using global variables for a reason, and I think that same reason also applies to unmanaged effects. I do wish that there were more studies on this, but they seem scarce, and those that exist are often plagued by methodological issues (see https://danluu.com/empirical-pl/).

              Unsubstantiated claim that AI tools benefit more from local reasoning.

              I think it's quite uncontroversial to say that if the type system is powerful enough you only need to read the function definition to understand what a function does? Ergo, in order for an LLM to update a function it only needs to load the definition into its context.

              1. 1

                However, I think it's not controversial to say that in the presence of unmanaged effects, the complexity of understanding any given function is O(n)

                What do you mean by this? Are you saying that when someone tries to understand a function in an imperative language codebase, they have to go look at the full call graph but that's not the case for purely functional languages?

                I think it's quite uncontroversial to say that if the type system is powerful enough you only need to read the function definition to understand what a function does? Ergo, in order for an LLM to update a function it only needs to load the definition into its context.

                There's a big difference between this statement and the one you're quoting "Unsubstantiated claim that AI tools benefit more from local reasoning."

                In particular:

                1. LLMs do not necessarily do the minimal thing they theoretically need to do.
                2. In practice, there are multiple orders of magnitude more code for more mainstream languages.

                Your comparison is essentially stating "everything else being equal, local reasoning is better" and sure, that may be the case, but in practice, "everything else being equal" does not hold.

                So the claim as to whether LLM performance is better on purely functional languages needs to be tested against reality. As one data point, I've worked at one company building AI dev tools, and we've seen much better performance on mainstream and simpler languages like TypeScript, Go and Python compared to more niche languages.

                1. 5

                  What do you mean by this? Are you saying that when someone tries to understand a function in an imperative language codebase, they have to go look at the full call graph but that's not the case for purely functional languages?

                  In the limit—yes! This isn’t always true in practice of course, but in general the more properties a type system can capture about a function, the less you need to know about its implementation. A lot of the time you can live with this uncertainty, but consider eg a function that allocates resources that needs to be cleaned up; in that case you either need to be sure that none of its callees can throw exceptions, or slap on a wildcard “try … catch … finally” just to be sure. This is not a purely academic exercise—you run into things like this all the time.

                  As an example of the positive case, take the polymorphic identity function a → a; in a pure functional language, there can only be meaningful implementation of this function! (See https://people.mpi-sws.org/~dreyer/tor/papers/wadler.pdf) When I’m programming I rarely look at docs or implementations of functions, I primarily look at the type signatures.

                  Your comparison is essentially stating "everything else being equal, local reasoning is better" and sure, that may be the case, but in practice, "everything else being equal" does not hold.

                  Fair enough, in November, the year of our Lord 2025, the current models / tools cannot do this out of the box. However, it would be interesting to see if you could eg tweak Claude to be more selective when loading Haskell code, and to use a local Hoogle to find functions with the appropriate signatures.

              2. 8

                What is an equivalent project what would've been impossible without being written in a purely functional language? (Note: the question is about language not about style for a particular piece of code.)

                "Impossible" is a pretty high bar, since most programming styles can be can be written in most programming languages, so most differences for this-or-that project end up being second-order (ergonomics, library compatibility, etc.).

                The only place I can think of where style versus language truly matters is the expressiveness/reasoning tradeoff, i.e. that a language which cannot express X is guaranteed not to do X. The most common example is memory-safety, e.g. Python can't do pointer arithmetic, so Python code shouldn't cause segfaults. We can frame FP styles like this too, e.g. pure FP can't mutate values or trigger side-effects, so code in a pure FP language shouldn't mutate any values or trigger any side-effects.

                We don't need such guarantees when writing code for ourselves, but it's important when dealing with user-supplied code. Examples which come to mind are search queries, regular expressions, arithmetic calculations, etc. but they feel more like limited DSLs rather than "full blown" languages. Maybe Nixpkgs?

                1. 13

                  We don't need such guarantees when writing code for ourselves, but it's important when dealing with user-supplied code.

                  In my own experience, knowing what code cannot do without having to read the implementation is a very nice property to have, especially when dealing with third party code.

                  1. 10

                    That's the real value here. This should be what's mentioned when you campaign for any kind of strictness in software period.

                    Turing complete is Turing complete. You're not going convince someone that functional programs are by nature superior just because you said so.

                    "I don't need functional programming, Rust/Java/TS/C++ already have this..."

                    You also don't need static type checking. A talented python or js developer can create just as robust software as a Rust developer. But we usually work in teams, and it's very hard to maintain this level of consistency and trust. This is the exact same argument in favor of strictness when it comes to fp. For every guarantee, we take a bit of mental load off and can tackle more and more complex problems with more confidence.

                    By the way, they do state all of this in the video, maybe the examples weren't the best, but I still think it's a solid argument.

                    1. 9

                      I am convinced that if all software developers were required to have malpractice insurance (like doctors) then strict programming languages would be much more common in industry.

                    2. 2

                      Sure, but that's partially subjective, and can be achieved/approximated in various ways (e.g. curating dependencies, preferring certain vendors/ecosystems, linting/tooling, etc.). In other words, software engineering.

                      When it comes to "objectively impossible" things, I think it's like the Halting Problem: only applicable when our solution has to work for arbitrary programs (e.g. user-supplied, potentially adversarial).

                      1. 2

                        curating dependencies, preferring certain vendors/ecosystems, linting/tooling

                        Curation is much more work than just looking at a type.

                        Preferring certain vendors/ecosystem isn’t a guarantee.

                        Linting/tooling can’t help you track side-effects when the language doesn’t.

                        1. 2

                          I think it's a spectrum. For example, I've worked with Scala which doesn't make any such guarantees; but I can try sticking to pure FP in my own code, and if I'm going to add a dependency then I can be pretty confident that e.g. a "cats" package (Scala-specific, FP-focused libraries; like lenses, monads, etc.) will maintain that purity more than some Java equivalent (which Scala will also accept).

                          Even in Haskell, the prototypical example of language-enforced pure FP, there's a notion of "safe" packages; which are even more constrained (to prevent unsound things like unsafePerformIO and coercions which could undermine the type system). However, I've rarely seen people considering that flag when choosing dependencies; preferring fuzzier metrics instead, like download counts and update frequency.

                          1. 1

                            It might be worthwhile to add that I've spent almost as much of my professional career coding in Elm, as I've done in other technologies. Elm is more strict than Haskell (no unsafePerformIO), and even interop has to go through a verification layer (ports).

                    3. 5

                      I think nixpkgs/nix expressions are a very good answer to this question. Nixpkgs wouldn't be impossible in a non functional language, but it would be magnitudes harder and make little sense. This is why the "impossible" bar isn't relevant.

                      If something would be significantly easier and more maintainable in a certain paradigm, you should probably use that paradigm.

                    4. 6

                      A pure functional language is much easier to audit for supply-chain attacks since the attack area is vastly reduced. It's much harder to hide some code that steals your credit cards, because such a function would be clearly marked as a side-effect. And well if the dependency has no side-effects, it can't really do much so you don't need to audit it. Well, at least I can't really think of any exploits that scare me (maybe an infinite loop that causes the system to crash?).

                      Also since functions are never redefined or removed, you can do more optimizations to reduce the asset size of the dependencies you pull in. This is quite a big win for web development.

                      1. 5

                        pure functional language is much easier to audit for supply-chain attacks since the attack area is vastly reduced.

                        Are you basing this based on practical experience of auditing codebases or based on theory? Because Haskell code can definitely do this via unsafePerformIO or FFI. Cabal can also run code at build time.

                        Also since functions are never redefined or removed, you can do more optimizations to reduce the asset size of the dependencies you pull in. This is quite a big win for web development

                        In practice, if you look at bundle sizes, there are several JS/TS frameworks with sizes competitive to that of Elm. Additionally, in Wasm usage, the size of the language runtime that needs to be shipped adds substantially to the bundle size, so much so that uptake of Wasm outside of systems languages like C++ and Rust seems to be relatively low.

                        1. 4

                          I'm thinking specifically of elm, which is stricter than Haskell with purity. Side effects can only really happen in functions marked as Cmd or HTML. It is true in Haskell its not as easy due to unsafePerformIO.

                    5. 10

                      IMO, functional programming itself is a red herring.

                      Historically, the FP research community came up with some really good ideas, like algebraic data types and parametric polymorphism. More generally, anything that helps “make illegal states irrepresentable”. But these are achievements of type theory, not of FP itself. And they can be readily applied to imperative languages as well (e.g., Rust).

                      So, what's the essence of FP? It's two things:

                      Using functions as first-class values: This is no different from object-oriented programming. All the usual criticisms of OOP's reliance on dynamically dispatched behavior can be levied at FP as well, and remain just as valid. If anything, FP's culture makes things worse, because FPers love coming up with clever ways to shoot yourself in the foot by messing with the control flow. From least to most harmful:

                      • First-class functions: combinators, recursion schemes, etc.
                      • Custom control operators: first-class continuations, algebraic effects and handlers, etc.
                      • Non-strict evaluation strategies.
                      • Language-building kits: macros, templates, fexprs, etc.

                      Avoiding side effects: In principle, it's a commendable goal to restrict which parts of a program can have effects beyond locally transforming inputs into outputs. In practice, “pure” FP is a heavy-fisted way to achieve this goal. And you lose so much expressiveness that the standard “solution”, i.e., using an IO monad, boils down to embedding an unrestricted imperative language inside a “pure functional” metalanguage [0].

                      A more effective (pun not intended) solution than outright banning effects is to carefully control under which circumstances they may happen. Like how Rust controls mutation so that it doesn't break local reasoning, unless you explicitly request it (with RefCell or Mutex). Again, it's type theory that wins, not FP.


                      [0] Monads were originally invented to give a denotational semantics to effectful, call-by-value languages inside a mathematical universe. For this theoretical purpose they're perfectly fine. However, when you define an IO monad in a library, you're trying to identify the mathematical universe with the programming language you're using. Alas, even “pure” functional languages differ from the mathematical universe in important ways that make the identification dangerous.

                      1. 3

                        I would humbly suggest a third: user-programmable flow-control.

                        When I read a program, I move a mental cursor through it to trace the execution path. Linear top-to-bottom execution is the easiest. Constructs that cause the execution path to become non-linear introduce cognitive load. Composing imperative flow-control constructs is especially bad because it exponentially increases the non-linearity of execution.

                        In FP, changing the flow control is generally achieved by moving code around, and execution remains linear.

                        1. 5

                          Uh, can I have an example? Since from what I understand your comment is essentially saying "imperative programs are more complex because their execution becomes nonlinear the moment you have one of if, for, return, and friends."

                          If that's true, I don't think I can agree with you because:

                          First of all, there are equivalents to these constructs in FP land, such as the venerable case. Ah, but you might say, many times we want to avoid using case by using a combinator or typeclass, which indeed is often idiomatic. But it's like even less clear to trace execution then.

                          Which makes the execution path more explicit?

                          if my_map.contains(x) {
                              my_map.get(x)
                          } else {
                              my_map.insert(x, value);
                              value
                          }
                          

                          vs

                          my_map
                              .entry(x)
                              .or_insert(value)
                          

                          (You can still argue the latter is easier to understand, but that would be because it is less about describing how to get there and more about describing what the final state will look like)

                          Early returns and the like can be achieved using e.g. the exception monad (or Rust's equivalent ?), and loops can be replaced by folds or maps. Outside of goto I have a hard time thinking of something without a direct analogue.

                          But perhaps more importantly, a major point of a functional language is describing what and not how, and in this regard execution is often (deliberately?) obscured. Enter laziness, everyone's favorite footgun in Haskell.

                          I mean seriously, even the small and cute example of

                          fibs = 0 : scanl (+) 1 fibs
                          

                          is extremely confusing to the uninitiated (ask me how I know).

                          Or how about https://chrisdone.com/posts/twitter-problem-loeb/#time-travelling-solution ?

                          I think both of these programs are awesome by the way. But if I had to tell you the order of execution for the latter it would just be a guess.

                          1. 2

                            Composing imperative flow-control constructs is especially bad because it exponentially increases the non-linearity of execution.

                            Since from what I understand your comment is essentially saying "imperative programs are more complex because their execution becomes nonlinear the moment you have one of if, for, return, and friends."

                            Composing is the operative word here. Any single imperative flow-control primitive compares favorably to function-composition on its own; it's when you need to start combining multiple constructs together that FP decisively wins IMO.

                            1. 1

                              Sorry, I appreciate your clarification but I don't think I understand how FP makes tracking the control flow of several primitives easier.

                              I'm guessing that what I'm talking about is orthogonal to your point.

                          2. 2

                            Rather than a third item, I think “user-programmable control flow” is a better name for my first item. First-class functions are the tamest form of user-programmable control flow, which I is why I called them “least harmful”.

                            I can't agree that user-programmable control flow makes the execution path “linear”, though. Any nontrivial algorithm has to have jumps somewhere; the only real question is how crazy such jumps can be. And, with user-programmble control flow, the answer is “very crazy”.

                            Moving past superficial aesthetic preferences (e.g., if/switch vs patern matching, for/while vs. tail recursion, etc.), the only meaningful algorithmic difference is between static jumps (i.e., into a constant address/label) and dynamic jumps (i.e., into an address passed around as a runtime datum):

                            • The usual selection and repetition constructs only involve static jumps.
                            • Calling an ordinary function is a static jump, but calling a virtual method or a function pointer is a dynamic jump. In either case, returning to the caller is a dynamic jump.
                            • Forcing a lazy suspension is a dynamic jump.
                            • Handling effects is a dynamic jump.
                            • And so on.

                            The mathematical techniques for analyzing programs with only static jumps (Hoare logic, Dijkstra's predicate transformer semantics, etc.) have been more or less well understood since the 60's. But analyzing dynamic jumps is a more delicate issue, because you need to track the possible addresses to which the control may jump. In general, you need to look at the whole program at once, which obviously doesn't scale.

                            Almost every language feature that enables user-programmable control flow relies on dynamic jumps. The only exception I can think of are macros. But macros have other complications, e.g., it's hard to check, at the definition site, that a macro will always generate well-typed code. (Essentially the same issue as C++ templates.)

                        2. 7

                          Functional programming absolutely matters, but I really wish talks about FP would give some concrete real-world examples instead of those abstract examples that either about factorials or LaunchMissiles(). How does that relate to actual programming in the real world?

                          1. 14

                            I wish there was more, extensive real-life studies about this, but in lieu of that I will offer my personal anecdata: The day-to-day of working in an FP language is pretty much the same as working in any other programming language. Translate a few lines of a given Java code base to Haskell and the outline of the code would probably look just about the same (with the addition of do here and there for the Haskell code base). The main difference is that whenever I would call a function in e.g. a Python code base, I could never be quite sure what that function was doing, whereas in a functional programming languages I'm a lot more confident about what the potential side effects are.

                            1. 7

                              Although it's not a talk, I like how Scott Wlaschin's Domain Modeling Made Functional uses a real world-ish worked example as a throughline for the book. I personally found it to be effective. As a note, I already had familiarity with DDD and FP/MLs when I approached the book, though, so my novelty-token cost of entry was fairly low; ymmv.