[HN Gopher] Functional Programming Self-Affirmations
___________________________________________________________________
Functional Programming Self-Affirmations
Author : napsterbr
Score : 97 points
Date : 2024-11-26 11:44 UTC (11 hours ago)
(HTM) web link (norikitech.com)
(TXT) w3m dump (norikitech.com)
| falcor84 wrote:
| I'm very disappointed. I was really hoping for something like the
| SRE affirmations - https://youtu.be/ia8Q51ouA_s
| lupire wrote:
| This is fine but it's just a rehash of old well-knowned stuff.
|
| I don't see the value of learning this stuff one random blog post
| at a time.
|
| There are many books and established blogs with long series of
| material to give you an education.
| sensen wrote:
| Do you have an example of the many books and established blogs
| that you can share? A newcomer might not be familiar with where
| to start when attempting to learn more about functional
| programming.
| cosmic_quanta wrote:
| I'm a big fan of Haskell Programming from First Principles.
| That's where more advanced ideas like Monads started
| clicking.
|
| https://haskellbook.com/
| mrkeen wrote:
| Let's keep repeating it until it gets more adoption.
| MeetingsBrowser wrote:
| Known by you, maybe.
|
| None of these "affirmations" are common in any code base I work
| with, unfortunately.
| andrewflnr wrote:
| It's a pretty good collection and summary of ideas I've only
| seen scattered around, before.
| pyrale wrote:
| > This is fine but it's just a rehash of old well-knowned
| stuff.
|
| A significant share of the current dev community wasn't there
| when these principles were first described. Sometimes, bringing
| up old topics once again helps seeing that many people had
| never heard of them yet.
| travisjungroth wrote:
| Could you list the top 5 ideas, with a short summary and a link
| to a well-regarded blog post that goes into more detail for
| each?
| tubthumper8 wrote:
| ~~I can't tell if you're being sarcastic, but that's exactly
| what TFA does. But just to pull it out explicitly:~~
|
| Edit: I think I misunderstood your point. You were asking for
| a similar kind of list as TFA from the other commenter which
| may not necessarily be the *same* 5 ideas
|
| Leaving the rest here in case it helps someone:
|
| 1. Parse, don't validate
|
| https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-
| va...
|
| 2. Make illegal states unrepresentable
|
| https://fsharpforfunandprofit.com/posts/designing-with-
| types...
|
| 3. Errors as values
|
| https://jessewarden.com/2021/04/errors-as-values.html
|
| 4. Functional core, imperative shell
|
| https://www.javiercasas.com/articles/functional-
| programming-...
|
| 5. Smart constructor
|
| https://wiki.haskell.org/index.php?title=Smart_constructors
| travisjungroth wrote:
| Yep, was kinda being sarcastic. IMO these sorts of posts
| are _really_ valuable. They don 't seem valuable when
| you're already familiar with the ideas and have read all
| the posts. But for people who are knew, they can really
| accelerate things.
|
| The comment kinda reminded me of the forum comments that
| will answer questions with "just use Google" and another
| person replies "I found this thread with Google ;(".
| revskill wrote:
| A class is just a function in the category of Javascript.
| agentultra wrote:
| These are great ideas and patterns even if you're not doing
| functional programming.
|
| FP-first/only languages tend to push you in these directions
| because it makes programming with them easier.
|
| In languages where FP is optional, it takes discipline and
| sometimes charisma to follow these
| affirmations/patterns/principles.. but they're worth it IMO.
| greener_grass wrote:
| I'm not convinced that you _can_ follow all of these outside of
| Functional Programming.
|
| How can you "Make illegal states unrepresentable" with mutable
| state and sequences of mutations that cannot be enforced with
| the type system?
|
| How can you do "Errors as values" at a large scale without do-
| notation / monads?
|
| How can you do "Functional core, imperative shell" without the
| ability to create mini DSLs and interpreters in your language?
| tubthumper8 wrote:
| Genuinely curious for all these follow up questions:
|
| Is immutability exclusive to functional programming?
|
| Is the ability to use data/values exclusive to functional
| programming?
|
| Are monads exclusive to functional programming?
|
| For discussions like this, how do we separate "it was done
| first in functional programming but can also be done in
| procedural programming" with "it cannot be followed outside
| of functional programming"?
| greener_grass wrote:
| > Is immutability exclusive to functional programming?
|
| No, but immutable defaults are powerful.
|
| E.g. in JavaScript / Python, the built-in lists and
| dictionarys (which are blessed with special syntax) are
| mutable.
|
| > Is the ability to use data/values exclusive to functional
| programming?
|
| No, but expression-orientation makes this less painful
|
| > Are monads exclusive to functional programming?
|
| You can hack them in by abusing co-routines or perhaps
| async/await in various languages, but it will never be as
| good as something built for this purpose.
|
| Type-inferences, type-classes and do-notation make monads
| workable in practice.
| yazzku wrote:
| You don't need coroutines or async or anything
| complicated to model monads, just functions and data
| structures. Search for "c++ monads" and you'll find a ton
| of examples.
| greener_grass wrote:
| You need the syntax if you want it to actually work well
| in practice.
| solomonb wrote:
| > How can you do "Errors as values" at a large scale without
| do-notation / monads?
|
| You don't need monads for this. You just need the ability to
| encode your error in some term like `Either e a` and ability
| to eliminate those terms. In Rust for example that is the
| `Result` type and you use pattern matching to eliminate those
| terms.
| greener_grass wrote:
| Then you end up with a pyramid of doom. Fine for small
| examples but it doesn't scale up easily.
|
| Rust has special syntax (`?`) for this AFAICT.
| dllthomas wrote:
| `?` and `let ... else` both partially address this, yeah.
| kccqzy wrote:
| That's merely syntax sugar. Haskell doesn't even have
| this sugar and so in a monad you have to bind explicitly,
| and it's fine. It's not a pyramid of doom.
| greener_grass wrote:
| Haskell has do-notation which is a more general kind of
| this syntactic sugar
| kccqzy wrote:
| Yeah that's what I said about bind.
|
| Let's consider a language like C++ where it has none of
| those syntax sugars. The absl::StatusOr is a class with
| the idea that errors should be values. The standard
| library has std::expected since C++23. So where is your
| pyramid of doom?
| solomonb wrote:
| You still don't need monads for any of this. Monads give
| you an ad-hoc polymorphic way of doing monadic actions.
|
| Short circuiting on `Either` is a specific case of this.
| You can define your own EitherBind in any language.
| eitherBind :: Either e a -> (a -> Either e b) -> Either e
| b eitherBind (Left e) _ = Left e
| eitherBind (Right a) f = f a
|
| Now you can bind over Either to your heart's content
| without needing an encoding of Monads in your language.
| do_not_redeem wrote:
| > How can you "Make illegal states unrepresentable" with
| mutable state
|
| By either making the data const, or encapsulating mutable
| state with private fields and public methods
|
| > How can you do "Errors as values"
|
| Go, Rust, Odin, Zig, and many more are imperative languages
| that do exactly this
|
| > How can you do "Functional core, imperative shell"
|
| Write a function that takes the old state and returns the new
| state
| dllthomas wrote:
| > By either marking your data as const, or encapsulating
| mutations with private fields and public methods
|
| That would seem to be making illegal states _unreachable_
| rather than unrepresentable, closer in spirit to "parse,
| don't verify".
| do_not_redeem wrote:
| GP seemed more worried about maintaining invariants in
| the face of mutability, so that's what my answer spoke
| to.
|
| For modeling the data in the first place, just use the
| right combination of sum types, product types, and
| newtypes - that's not specific to functional languages.
| I'm sure GP knew this already without me saying it. Sum
| types may have been a "functional programming" thing a
| few decades ago but they aren't anymore.
| dllthomas wrote:
| > How can you "Make illegal states unrepresentable" with
| mutable state and sequences of mutations that cannot be
| enforced with the type system?
|
| I think you're confusing "make illegal states
| unrepresentable" with "parse, don't verify"? If your type
| cannot _represent_ any invalid states, there 's no way you
| can reach them through mutation.
| matt_kantor wrote:
| The "sequences of mutations" phrasing made me think they
| were talking about stuff like state machines or handles for
| external resources--for example calling
| `databaseConnection.close()` on an already-closed
| connection, which is usually a runtime error (or maybe a
| no-op).
| bunderbunder wrote:
| Maybe not in literally every language, but, to cherry pick
| some examples:
|
| Java (along with many other object-oriented languages) lets
| you create objects that are effectively immutable by
| declaring all fields private and not providing any property
| setters or other methods that would mutate the state.
|
| Errors as values is one of the headline features of both Go
| and Rust, neither of which has do notation and monads.
|
| Functional core, imperative shell is something I first
| learned in C#, but I don't think it was _really_ at
| significantly more of a disadvantage than most other
| languages. The only ones that let you really enforce
| "functional core, imperative shell" with strong language
| support are the pure functional languages. But people working
| in, say, Ocaml or Scala somehow still survive. And they do it
| using the same technique that people working in Java would:
| code review and discipline.
|
| None of this is to say that language-level support to make it
| easier to stick to these principles is not valuable. But
| treating it as if it were a lost cause when you _don 't_ have
| those features in the language is the epitome of making the
| perfect the enemy of the good.
| neonsunset wrote:
| Coincidentally, "functional core, imperative shell" is how
| most companies get to adopt F# from what I was told (and
| had seen). It integrates really well. C# itself is a proper
| multi-paradigm language nowadays, and so is also quite
| successful at employing functional constructs.
| timClicks wrote:
| I consider Rust's Result<T, E> and Option<T> to be monads.
| Is this incorrect?
| bunderbunder wrote:
| Depending on what functions are in there, they are. But
| you can make types that happen to be monads in C, too.
| All you need is a datatype with `return` and `bind`
| functions that follow a certain spec.
|
| What makes Haskell different is that it has a language-
| level concept of a monad that's supported by special
| syntax for manipulating them. (C# does, too, for what
| it's worth.) Without something like that, observing that
| a certain type can be used as a monad is maybe more of a
| fun fact than anything else.
|
| (ETA: for example, many, many languages have list types
| that happen to be monads. But this knowledge probably
| won't change anything about how you use them.)
| louthy wrote:
| As someone who's written a pure functional framework for C#
| [1], I'll bite...
|
| > How can you "Make illegal states unrepresentable" with
| mutable state and sequences of mutations that cannot be
| enforced with the type system?
|
| Firstly, don't use mutable state, write immutable types.
| Secondly, write constructors that reject poorly formed data
| structures. Thirdly, for existing libraries with types that
| are mutable, create a wrapper for the library with functions
| that return an IO/effect monad.
|
| > How can you do "Errors as values" at a large scale without
| do-notation / monads?
|
| Luckily, from a C# PoV, we have LINQ, which is equivalent to
| do-notation. I agree that manual management of monadic flow
| would be hard without something akin to do-notation or LINQ.
|
| You can get quite far with fluent methods, but a general
| monadic-bind is quite hard to chain if you want to carry all
| of the extracted values through to subsequent expressions
| (lots of nesting), so yeah, it would not be ideal in those
| languages. It should be stated that plenty of functional
| languages also don't have do-notation equivalents though.
|
| > How can you do "Functional core, imperative shell" without
| the ability to create mini DSLs and interpreters in your
| language?
|
| I've never really liked the "Functional core, imperative
| shell" thing. I think it's an admission that you're going to
| give up trying to be functional when it gets difficult (i.e.
| interacting with the real world). It is entirely possible to
| be functional all the way through a code-base.
|
| In terms of DSLs: I'm not sure I know any language that can't
| implement a DSL and interpreter. Most people don't realise
| that the Gang of Four Interpreter pattern is isomorphic to
| free-monads, so most imperative languages have the ability to
| do the equivalent of free-monads.
|
| As the GP states, it takes discipline to stick to the
| constraints that a language like Haskell imposes by default.
| Not sure about the charisma part!
|
| I have found that having access to a world-class compiler,
| tooling, and large ecosystem to be more valuable to getting
| shit done than the exact language you choose. So, bringing
| the benefits of the pure-FP world into the place where I can
| get shit done is better than switching to, say Haskell, where
| it's harder to get shit done due to ecosystem limitations.
|
| There's also the get out of jail free card, which allows me
| to do some gnarly high-performance code in an imperative way.
| And, as long as I wrap it up in a function that acts in a
| referentially transparent way, then I can still compose it
| with the rest of my pure code without concern. I just need to
| be a bit more careful when I do that and make sure it's for
| the right reasons (i.e. later stage optimisations). That's
| less easy to do in FP languages.
|
| Again, it's about discipline.
|
| If you want to see how this can look in a mainstream,
| imperative-first, language. I have a few samples in the repo,
| the one I like to share when helping OO-peeps learn about
| monads and monad-transformers is this game of 21/Pontoon [2].
| I suspect most people won't have seen C# look like this!
|
| [1] https://github.com/louthy/language-ext/
|
| [2] https://github.com/louthy/language-
| ext/blob/main/Samples/Car...
| agentultra wrote:
| > Not sure about the charisma part!
|
| Software developed on a team where everyone has different
| values/principles... some times it's not the technical
| discipline that is required, it's convincing folks to adopt
| these patterns and stick with them that's the hard lift. :)
| enugu wrote:
| "Make illegal states unrepresentable" can be done by
| encapsulating the variables inside a single data
| object(struct/class/module) and only exporting constraint
| respecting functions. Also, Algebraic Data Types can be
| present in FP/non-FP languages.
|
| The Result monad can be implemented in any static language
| with generics (just have to write two functions) and in a
| dynamic language this is easy (but return will have to be
| like T.return as there is no implict inference).
|
| I didn't get the relation between FCore/IShell and DSLs, the
| main requirement for FCore is a good immutable library.
| Macros help DSLs though that is orthogonal.
|
| But really, my main point is that OOP vs FP is red herring as
| 3/4 aspects which characterize OOP can be potentially done in
| both OOP and FP, with different syntax. We shouldn't conflate
| the first 3 with the 4th aspect - mutability.
|
| An OOP language with better extension mechanism for classes
| +immutable data structure libraries and a FP language with
| first class modules would converge. (ref: Racket page below
| and comment on Reason/OCaml down the page).
|
| See Racket page on inter-implementability of lambda, class,
| on the unit(ie. a first-class module) page here
| (https://docs.racket-lang.org/guide/unit_versus_module.html).
| Racket has first class 'class' expressions. So, a mixin is a
| regular function.
| jefffoster wrote:
| Mostly functional programming does not work
| (https://queue.acm.org/detail.cfm?id=2611829)
| louthy wrote:
| I have a lot of respect for Erik Meijer and I agree with the
| basic premise of the paper/article. However, I don't fully
| agree with Erik's position.
|
| Let's say this was my program: void Main()
| { PureFunction().Run();
| ImpureFunction(); }
|
| If those functions represent (by some odd coincidence) half
| of your code-base each (half pure, half impure). Then you
| still benefit from the pure functional programming half.
|
| You can always start small and build up something that
| becomes progressively more stable: no code base is _too
| imperative_ to benefit from some pure code. Every block of
| pure code, even if surrounded by impure code, is one block
| you don 't have to worry so much about. Is it fundamentalist
| programming? Of course not. But slowly building out from
| there pays you back each time you expand the scope of the
| pure code.
|
| You won't have solved all of the worlds ills, but you've made
| part of the world's ills better. Any pure function in an
| impure code-base is, by-definition: more robust, easier to
| compose, cacheable, parallelisable, etc. these are real
| benefits, doesn't matter how small you start.
|
| So, the more fundamentalist position of "once one part of
| your code is impure, it all is" doesn't say anything useful.
| And I'm always surprised when Erik pulls that argument out,
| because he's usually extremely pragmatic.
| beastman82 wrote:
| Completely agree with these.
|
| One way to achieve "Make illegal states unrepresentable" is by
| using "refined" types, a.k.a. highly constrained types. There is
| a "refined" library in both Haskell and Scala and the "iron"
| library for Scala 3.
| wesselbindt wrote:
| I do not work in a functional language, but these ideas have
| helped me a lot anyway. The only idea here that I find less
| directly applicable outside purely functional languages is the
| "Errors as values [instead of exceptions]" one.
|
| On the surface, it makes complete sense, the non-locality of
| exceptions make them hard to reason about for the same reasons
| that GOTO is hard to reason about, and representing failure modes
| by values completely eliminates this non-locality. And in purely
| functional languages, that's the end of the story. But in
| imperative languages, we can do something like this:
| def my_effectful_function(): if the_thing_is_bad:
| # do the failure thing raise Exception # or
| return Failure() return Success()
|
| and a client of this function might do something like this:
| def client_function(): ...
| my_effectful_function() ...
|
| and completely ignore the failure case. Now, ignoring the failure
| is possible with both the exception and the failure value, but in
| the case of the failure value, it's much more likely to go
| unnoticed. The exception version is much more in line with the
| "let it crash" philosophy of Erlang and Elixir, and I'm not sure
| if the benefits of locality outweigh those of the "let it crash"
| philosophy.
|
| Have any imperative folks here successfully used the "errors as
| values" idea?
| skirmish wrote:
| Rust does "errors as values" pretty well, see [1]. You can
| manually handle them if you want, or just apply the '?'
| operator to auto-propagate them out of the function. In both
| cases, it is obvious that there was an error handling needed.
|
| [1] https://doc.rust-lang.org/book/ch09-02-recoverable-errors-
| wi...
| wesselbindt wrote:
| I didn't know that! Every time I hear about Rust my opinion
| of it grows brighter. Thanks for sharing this!
| galaxyLogic wrote:
| The way to do functional programming in imperative languages is
| to handle the side-effects as high up in the call-chain as
| possible. That would mean that you return an instance of Error
| from lower-level and decide in some higher caller what to do
| about it.
|
| That as an alternative to throwing the error. This way you get
| the benefit of being able to follow the flow of control from
| each called function back to each caller, as opposed to control
| jumping around wildly because of thrown errors.
|
| In a statically typed imperative language that would need
| support for sum-types, to be able to return either an error or
| a non-error-value. Then you would be less likely to ignore the
| errors by accident because you would always see the return
| value is maybe an error.
|
| Isn't this also basically how Haskell does it, handling side-
| effectful values as high up as as possible which in Haskell
| means moving them into the runtime system above all user-code?
| wesselbindt wrote:
| Right, I understand. But my question is, how do you _ensure_
| a failure value is dealt with by clients? In purely
| functional languages, your clients have no choice, they'll
| have to do something with it. In imperative languages, they
| can just ignore it.
| tubthumper8 wrote:
| In Rust, there's a `#[must_use]` attribute that can be
| applied to types, such as Result, and on functions. This
| triggers if the return value is not used. It's only a
| warning though, but you could imagine a hypothetical
| imperative language making this a hard error
| TeMPOraL wrote:
| Non-locality of exceptions is a _feature_ , not a bug. It's so
| you can focus on the success case, instead of error case, when
| reading your code. It's usually, but not always, what you want.
| "errors as values" is effectively the same thing as exceptions
| anyway, except it's explicit - meaning it's hurting readability
| by default and adding extra work[0]; modern languages go to
| extreme length to try and paper it over with syntactic magic.
|
| Unfortunately, the whole "exceptions vs. expected" issue is
| fundamentally unsolvable for as long as we stick to working on
| common single source of truth plaintext code. Explicit and
| implicit error handling are both useful in different
| circumstances - it's entirely a presentation issue (i.e. how
| code looks to you); our current paradigm forces us to precommit
| to one or the other, which just plain sucks.
|
| --
|
| [0] - See how it works in C++, where you can't easily hide the
| mechanism.
| wesselbindt wrote:
| In general, I find that explicit code is more easily read
| than implicit code. I prefer static over dynamic typing, I
| actually _like_ the explicitness of async/await or the IO
| monad. If something allows me to find out information about
| my current context without having to move up or down the
| stack and reading the code in other functions, I'm pretty
| happy about that something, because reading code is slow and
| tedious. What is it about implicit code that makes you feel
| it's more readable?
| aiono wrote:
| Simple, your compiler/type checker/static analyzer/whatever
| needs to ensure that any value that is not unused gives an
| error. But you need some sort of static analysis for that.
| Without type checking you don't do that of course.
| jandrese wrote:
| > Make illegal states unrepresentable
|
| This is a nice ideal to shoot for, but strict adherence as
| advocated in the article is a short path to algorithmic
| explosions and unusable interfaces on real life systems.
|
| For example, if you have two options that are mutually
| incompatible, this principle says you don't make them booleans,
| but instead a strict enum type populated with only legal
| combinations of the options. A great idea until you have 20
| options to account for and your enum is now 2^16 entries long.
| Then your company opens a branch in a different country with a
| different regulatory framework and the options list grows to 50
| and you code no longer fits on a hard drive.
| MeetingsBrowser wrote:
| The mistake here is having 2^16 valid options.
|
| If you truly do have 2^16 valid and distinct behaviors, it is
| not possible for humans to correctly write 2^16 different code
| paths anyway.
|
| More than likely, the majority of the combinations of your 29
| Boolean flags are invalid. You should strive to have that
| checked by the program, and not only as a note in external
| documentation.
|
| No one is saying int should be turned into an enum.
| joe_the_user wrote:
| The parent is discussing a situation where you have 16
| distinct, binary states many but not all of which are
| mutually compatible. So you can have 16 bit vector of the
| states, 16 binary variables or an enum of valid states - the
| enum would have O(2^16) members because of the distribution
| of valid states but unlike the others, an invalid state would
| not be possible to represent.
| jandrese wrote:
| You only have 20 options, but making that many distinctive
| options is not exactly a stretch. It's not like every single
| set of options is its own code path, most options represents
| a small deviation at one particular part of the code. Most of
| the options aren't mutually exclusive either, only a few
| combinations are illegal.
|
| Imagine a simple shipping system for example. The package
| might be routed via truck, boat, plane, pipeline, foot,
| etc... Probably even a combination of those options. The
| package might be low, medium, or high priority, although high
| priority packages are not allowed to be delivered by boat.
| The package might be small, large, or liquid, but liquids
| can't be delivered by foot. There are 195 different countries
| with even more regulatory regimes to consider, some of which
| may have different customs requirements based on mode of
| transport. Now imagine a problem that is actually
| complicated.
|
| The idea of encoding all of this business logic into the
| datatype is a road to madness IMHO. Especially if the rules
| can change on you and require you to rework your datatypes to
| match. On the small scale this makes a lot of sense and is
| good advice, but strict adherence is impractical.
| MeetingsBrowser wrote:
| You don't need a single type to represent the entire
| program state.
|
| We probably both agree that separate types for shipping
| methods, priorities, size, country makes sense.
|
| The API can be designed to prevent illegal transitions
| between types to arrive at an invalid state. The exact
| implementation depends on the language.
|
| > The idea of encoding all of this business logic into the
| datatype is a road to madness IMHO
|
| The alternative is hoping that every developer remembers
| not to violate any of the implicit and unenforced rules.
|
| If the system is too complicated to be represented in code,
| can a human realistically hold the entire state machine in
| their head as they make changes?
| keybored wrote:
| Where does this exponentional size requirement come from?
| jandrese wrote:
| Imagine you have a program where there are 4 options, W, X,
| Y, Z. Y and Z can't be set at the same time, and X can't be
| set unless W is set. If Y is set then X must be set as well.
|
| How do you represent this in a way that makes it impossible,
| even through programmer error elsewhere in the program, to
| have the flags in an invalid state?
|
| You can create en enum that looks like:
| enum program_state = ( W_X_Y_NZ,
| W_NX_NY_Z, NW_NX_NY_Z, ... and so on
| );
| keybored wrote:
| And the Make Impossible States Unrepresentable crowd
| program like that?
| WorldMaker wrote:
| It is not too bad in languages with discriminated unions.
| It's also not hard to fake discriminated unions in
| languages without them, even if you will miss some of the
| niceties.
|
| Rather than thinking of it as an enum, think of it as a
| list of contructors: class ProgramState
| { bool w, x, y, z;
| ProgramState(x, z) // implies y = true, w = true
| ProgramState(w, z) // cannot set x; implies y = false
| (cannot set y) }
|
| Even if the class needs all four fields, internally to
| represent all the possible combinations of data, there's
| no constructors/setters to work with them independently.
| (Which is also related to why "Make Impossible States
| Unrepresentable" also generally implies _immutable_ , no
| setters at all makes it much easier to make states
| impossible.)
|
| In languages with discriminated unions you might even
| have some natural field sharing depending on how your
| "constructors" are written and the memory expansion isn't
| necessarily "exponential".
| Hackbraten wrote:
| Maybe I'm spoiled by TypeScript but this is how I'd do it
| in a structural typing system: type
| ConstrainedByW = | { w: false, x: false } |
| { w: true, x: bool } type ConstrainedByY =
| | { x: bool, y: false, z: bool } | { x: true, y:
| true, z: false } type ProgramState =
| ConstrainedByW & ConstrainedByY
| jandrese wrote:
| Yes, but certainly you can see how even a toy example
| with just 4 booleans is already getting complicated?
| Hackbraten wrote:
| It's certainly complicated. But not WTF-level
| complicated.
|
| Complexity is both subjective and relative. Allowing
| invalid states in your model is going to complicate
| things, too.
|
| I wouldn't mind having this example in production. It
| will scale just fine with the number of booleans.
| aiono wrote:
| enum program_state { X_W, Y_X_W, Z,
| W, Z_W, Z_X_W, }
|
| You only have these 6 options. And in this case you can
| easily use a SAT solver to generate the cases for you so
| that you don't have to write them by hand.
| crdrost wrote:
| Approximate expansion of the original claim, without direct
| endorsement:
|
| Suppose you have an Order object that needs to track where
| some larger process is in relation to three subtasks. We
| could imagine say that the Inventory department needs to set
| a physical object aside, then the Billing department needs to
| successfully charge for it, then the Shipping department
| needs to retrieve that physical object and ship it.
|
| You start from a description of this as "one Order contains
| three Subtasks" where a Subtask contains the Go-style
| (Optional[Result], Optional[Error]) type. This architecture
| almost fits into a relational database, except that foreign
| key constraints are a bit funky if you shove everything into
| one nullable Result column. But let's just have the Result be
| some random JSON in the Subtasks table and let our relational
| purists weep.
|
| Then you read this advice and you start to see that this
| allows for a lot of illegal states: things could contain both
| a result AND an error, or neither. You eventually decide that
| neither, is an allowed state. These are two boolean flags
| representing only 3 legal states and so they need to be
| factored into an enum: the enum is "Pending | Success[Result]
| | Failure[Error]".
|
| Well, except the problem is a bit more nuanced because the
| pending-states also need to be consistent among the different
| subtasks: there is a dependency graph among them. So you
| should actually have an enum that says:
| Inventory_Pending Inventory_Failure[Error]
| Inventory_OK_Billing_Pending[InventoryData]
| Inventory_OK_Billing_Failure[InventoryData, Error]
| Inventory_OK_Billing_OK_Shipping_Pending[InventoryData,
| BillingData]
| Inventory_OK_Billing_OK_Shipping_Failure[InventoryData,
| BillingData, Error]
| Inventory_OK_Billing_OK_Shipping_OK[InventoryData,
| BillingData, ShippingData]
|
| See, you would have had 3x3x3 = 27 valid states before for
| the Order but we have reduced to only the 7 legal states.
| Yay!
|
| But now consider e.g. the following mutation. On Failure
| cases the executives at our company mandate that we never
| return a failed Order to a Pending status, rather we must
| always create a separate Order. This Order might skip
| inventory and/or billing and those need to be represented
| separately, as Inventory Skipped[OrderID] or
| InventoryAndBillingSkipped[OrderID]. So now our list of
| states following the "no unrepresentable state" logic, should
| really be: [... the 7 above, plus ...]
| Inventory_Skipped_Billing_Pending[OrderID]
| Inventory_Skipped_Billing_Failure[OrderID, Error]
| Inventory_Skipped_Billing_OK_Shipping_Pending[OrderID,
| BillingData]
| Inventory_Skipped_Billing_OK_Shipping_Failure[OrderID,
| BillingData, Error]
| Inventory_Skipped_Billing_OK_Shipping_OK[OrderID,
| BillingData, ShippingData]
| Inventory_And_Billing_Skipped_Shipping_Pending[OrderID]
| Inventory_And_Billing_Skipped_Shipping_Failure[OrderID,
| Error]
| Inventory_And_Billing_Skipped_Shipping_OK[OrderID,
| ShippingData]
|
| Now someone else wants to add remediation actions, but only
| to remediate the exact error in the failure state, so
| _Failure is going to mean "no remediation taken" but we need
| to add some _Remediation with a boolean saying whether that
| process has completed or not. So we add:
| Inventory_Remediation[Error, Bool, Array[RemediationEvent]]
| Inventory_OK_Billing_Remediation[InventoryData, Error, Bool,
| Array[RemediationEvent]]
| Inventory_OK_Billing_OK_Shipping_Remediation[InventoryData,
| BillingData, Error, Bool, Array[RemediationEvent]]
| Inventory_Skipped_Billing_Remediation[OrderID, Error, Bool,
| Array[RemediationEvent]]
| Inventory_Skipped_Billing_OK_Shipping_Remediation[OrderID,
| BillingData, Error, Bool, Array[RemediationEvent]]
| Inventory_And_Billing_Skipped_Shipping_Remediation[OrderID,
| Error, Bool, Array[RemediationEvent]]
|
| We're only up to 21 total states so far which is still
| probably manageable? But these changes _do_ demonstrate
| _exponential growth_ , which is a technical term that means
| that the growth of each step is some small fraction of the
| total growth that has happened up until that point. Because
| everything depends on Inventory (it's at the root of the
| tree), when we add a new state that the Inventory can be in
| (Skipped) we have to add enum cases for all of the other
| states, and we pay a cost proportional to the size of the
| tree. Similarly when everything can have an error (at the
| leaves of the tree), when we add a new uniform requirement
| for errors we have to add new leaves all throughout the tree
| and we pay a cost proportional to the size of the tree.
| (Another thing to notice about the Remediation state is that
| it is a Pending state for another Subtask that could have
| been added to the original Order whenever something moves
| into Failure mode.)
|
| You get something powerful by reducing the 256ish-or-whatever
| states into the 21 legal states; you have a compile-time
| assurance that no bugs in your code have created weird states
| that can propagate their weirdnesses throughout the system.
| But you also have to maintain the 21 legal states all at
| once, instead of maintaining 4 subtasks each having one of 4
| statuses.
| yazzku wrote:
| If you only have 3 states, then yes, that should be an enum,
| not a pair of booleans, because you have 3 states, not 2x2
| independent ones. Making the 4th state unrepresentable removes
| code and error checking. It's also just simple domain modeling.
|
| Your latter example needs context. In what situation have you
| had an enum with 2^16 states? In any case, if you generally
| have a reasonable number of booleans, with some states being
| logically invalid, then you'll need error checking on those
| anyway.
|
| Leaving them as booleans gives you the ability to monkey-patch
| things later in an ad-hoc manner, which is useful when you're
| still in a prototyping phase and the requirements aren't clear
| (and you could argue that this is almost always the case in
| many problem domains; I think that would be valid criticism.)
| But if you've already modeled the domain and you want any
| assurance of correctness, then I don't see why you wouldn't
| enforce the constraints at the type level. Your criticism seems
| to me the usual trade-off between strong static typing and
| dynamic and/or weak monkey-typing.
| joe_the_user wrote:
| This is an excellent illustration. I feel functional
| programming provides great tools and concept for a cohesive
| system (one with consistent requirements, which accomplishes a
| single thing, etc). But many programs you write involve parts
| that aren't very cohesive and trying to make them cohesive is
| far more work than just bolting on a case statement or similar
| things.
|
| Parent currently downvoted without comments. Seems like a sad
| way to respond.
| kccqzy wrote:
| I feel like you might be missing the point. A case statement
| is not to be treated as something you bolt on as a hack. It
| is the right tool for the job the vast majority of the times.
| When you use case statements, you refine and reduce your
| state space. It makes code easier to understand. When you
| combine this with the idea of making illegal states
| unrepresentable, a case statement gives you an exhaustive
| listing of what could happen. Even a lot of Haskell
| programmers, after using things like the `maybe` function to
| eliminate Maybe types and things like `fmap` over Maybe,
| eventually find that using case expressions produces the
| clearest code even though it may be one line longer.
|
| I really hope HN enforces a rule that a downvote must be
| accompanied by a reply.
| kccqzy wrote:
| Nobody says you have to have a _single_ enum type containing
| all the combinations. Chances are, you can use sum types
| (discriminated unions) to factor things nicely if you think
| about them. For example if option B is only relevant when
| option A is set to true, you can have something like
| data OptA = ATrue OptB | AFalse data OptB = BTrue |
| BFalse
|
| There are three valid combinations but no type has three
| alternatives. Nobody in their right mind would write out an
| enum with 2^16 cases. If you do, you are misusing enums and
| it's time to consider other tools in your language.
| joe_the_user wrote:
| _Nobody says you have to have a single enum type containing
| all the combinations._
|
| No, no one would continue up to 2^16 and the code would get
| unmanageable long before that. But it's illustration of the
| problems _starting out_ dealing with the invalid states of
| two variables using an enum because what happens when more
| and more variables arrive? Sure, the standard answer is
| "just refactor" but my experience is no client or boss wants
| to hear "adding this small state is going require a lot of
| change" and a trickle of binary conditions is a very common
| occurrence as is code expanding to handle these (and become
| excessively complicated).
|
| _Chances are, you can use sum types (discriminated unions)
| to factor things nicely if you think about them._
|
| Maybe you have a good chance of combining these binary
| conditions in a good way. But I mention you've substituted a
| hard problem instance (factoring binary conditions) for an
| easy problem instance (checking binary conditions).
| Functional programming has a weird dual personality where on
| the one hand you hear "A functional programmer is always a
| smarty and solve hard problems as a matter of course" but
| also you hear "functional programming would be the dominant
| paradigm if only ... we taught people young so they wouldn't
| have their bad procedural habits"
| BoiledCabbage wrote:
| > and a trickle of binary conditions is a very common
| occurrence as is code expanding to handle these (and become
| excessively complicated).
|
| But you still have to handle this in your code. Wherever
| you have your conditions that handle this, your nest of if
| statements still need to cover all of these invalid
| combinations and ensure your app doesn't silently do the
| wrong thing or just crash (better).
|
| Changing requirements requires changing code. I don't think
| it's a valid argument to say "we shouldn't take that
| approach because as requirements change we'll have to
| change the code". That's essentially software development.
|
| Practically if you don't want to use enums and want another
| option, use a "builder" object. Pass in all of your
| booleans there and have it do you validation when you call
| its build method. It returns a read only configuration that
| the rest of your system can use, and the build method fails
| if an invalid combination of flags are passed in.
|
| Again you force only valid combinations to exist after you
| call "build". And all code relies on the config produced by
| the build method.
| jandrese wrote:
| Imagine a case where you have 4 options. W, X, Y, Z.
|
| Y and Z are mutually exclusive.
|
| X can only be set if W is set.
|
| If Y is set then X must be set.
|
| Going down this road you end up encoding your business logic
| into your datatypes. Which is good to a degree, but makes
| things messy when new options are added or requirements
| change. Imagine a new option U is introduced that is only
| valid when W is unset and Z is set but allows X to be set.
| Your interface becomes very hard to manage with even a small
| amount of complexity added to the options.
| kccqzy wrote:
| This is an instance of inherent complexity. Your domain is
| complex. You either place them into a series of nested if
| statements (which is what majority of programmers do), or
| you place it into the type system. You cannot avoid
| complexity either way. We are merely arguing where this
| complexity belongs. Such complexity is hard to manage in
| either case.
| aiono wrote:
| What is the alternative? And is it really better than
| encoding into the data type?
|
| Only option I can think of is writing unit tests for each
| case, which doesn't seem like too much different.
|
| And without type encoding you never sure that invariant
| always hold. You can always manipulate any options in any
| part of the program. Then after any modification you have
| to perform a "sanity check" if options are in a well
| defined state or not. I don't see how this is better than
| encoding the invariant into the types.
| galaxyLogic wrote:
| True. And what is an "illegal state" anyway? If two options are
| "mutually incompatible" it means that they, as far as we know,
| so far, have never occured at the same time.
|
| But that is just how things are currently. The world may
| change. And it's important to strive for maintainable code that
| can accommodate such change easily.
|
| Expanding company operations to work in a different country is
| an example of this (our) "world changing". States that never
| occur together here, may occur together there. Or in the future
| more generally.
|
| So, making illegal states non-representable is about avoiding
| errors in respect to the system specification. But it does not
| take into account the fact that in the real world
| specifications typically evolve and change even during
| development, and perhaps more so later.
| reval wrote:
| This is technically correct but disingenuous. This reminds of
| the climate change comic where a scientist asks "What if
| climate change is a big hoax and we create a better world god
| nothing?"
| aiono wrote:
| In this example, is it really the case that all 20 are
| dependent into each other so you have to combine all of them
| into a single enum? For the ones that are independent you can
| keep them as booleans and you should.
| enugu wrote:
| FP nerd: The pure core is nice and composable, with the
| imperative shell at the boundary.
|
| State Skeptic: Yes, But! How do you compose the 'pure core +
| impure shell' pieces?
|
| FPN: Obviously, you compose the pure pieces separately. Your app
| can be built using libraries built from libraries.... And, then
| build the imperative shell separately.
|
| My take is that the above solution is not so easy. (atleast to
| me!) (and not easy for both FP and non-FP systems).
|
| Take an example like GUI components. Ideally, you should be able
| to compose several components into a single component
| (culminating in the app) and not have a custom implementation of
| a giant state store which is kept in something like Redux and you
| define the views and modifiers using this store.
|
| Say, you have a bunch of UI components each given as a view
| computed as a function from a value and possible UI events which
| can either modify the value, remain unhandled or configurable as
| either. Ex: dialog box which handles text events but leaves the
| 'OK' submission to the container.
|
| There are atleast two different kinds of composability (cue quote
| in SICP Ch1 by Locke) - aggregation and abstraction. Ex: Having a
| sequence of text inputs in the document(aggregation) and then
| abstracting to a list of distances between cities. This
| abstraction puts constraints on values of the parts, both
| individually(positive number) and across parts(triangle
| inequality). There is also extension/enrichment, the dual of
| abstraction.
|
| This larger abstracted component itself is now a view dependent
| on a value and more abstract events. But, composing recursively
| leads to state being held in multiple layers and computations
| repeated across layers. This is somewhat ameliorated by sharing
| of immutable parts and react like reconciliation. But, you have
| to express your top->down functions incrementally which is not
| trivial.
| yazzku wrote:
| FP is not a silver bullet. GUI is the classic OOP showcase.
|
| > Ideally, you should be able compose them several of them into
| a single app and not have a custom implementation of a giant
| state
|
| If you are suggesting that components store their state, I'm
| not sure about "ideal" there. That works well for small GUI
| applications. In GUI applications of modest size, you do want a
| separate, well-organized and non-redundant data layer you can
| make sense of, at least from my experience. Qt, specifically,
| allows you to do both things.
| enugu wrote:
| This is a digression, but regarding OOP, my somewhat
| provocative view, is that it is not a natural thing, but in
| most languages, it is atleast 4 different concepts 1.
| Encapsulation/Namespace, 2. Polymorphism, 3.
| Extensibility(Inheritance is a special case) 4.Mutability.
|
| These four concepts are forced/complected into a 'class'
| construct, but they need not be.
|
| In particular, FP only varies on 4, but languages like
| ML,Clojure do 1,2,3 even better than OOP languages. Modules
| for encapsulation, Dispatch on first or even all arguments
| for polymorphism and first class modules, ML style, for
| extensibility.
|
| Aside: There was a recent post
| (https://osa1.net/posts/2024-10-09-oop-good.html) (by someone
| who worked on GHC no less), favorably comparing how OOP does
| extensibility to Haskell typeclasses, which are not first
| class, but modules in ML languages can do what he wants and
| in a much more flexible way than inheritance!
|
| There is also the dynamic aspect of orginal OOP - message
| passing instead of method invocation, but this is about
| dynamic vs static rather than OOP vs FP.
|
| What OOP languages _have_ managed to do which static FP hasn
| 't done yet is the amazing live inspectable environments
| which lead to iterable development like we see in Smalltalk.
| The challenge is to do this in a more FP way while being
| modular.
| yazzku wrote:
| Interesting link, thanks.
| enugu wrote:
| This page (https://reasonml.github.io/docs/en/module) is
| useful to see how a FP language can do what he wants.
| Because we have functors, which are functions from a
| group of modules/classes to another module/class, we can
| have Composition, Inheritance(single/multiple), Mixins
| etc.
| enugu wrote:
| To your main point, I wouldn't say exactly that the component
| stores the state. But, rather that every component provides
| an initial value, possible events, and a default event
| handler which is a function from value to value. In effect,
| this is partially 'storing local state', but the above pieces
| can be composed to create a container component.
|
| Note that there is no option really - the app wont be
| reimplementing how a key is handled in a text box. But
| composability means that the same principle should hold not
| just for OS/browser components but also for higher level
| components (A custom map or a tree-view where there are
| restrictions on types and number of nodes - these should also
| have default handling and delegation to upper levels.)
|
| The global store choice makes it harder to have component
| libraries. But, the composable alternative has its problems
| too - redundancy and communication which skips layers (which
| requires 'context' in React).
| beders wrote:
| > But, composing recursively leads to state being held in
| multiple layers and computations repeated across layers.
|
| True, which is why re-frame has a dependency graph and
| subscriptions that avoid re-computation, i.e. the data
| dependencies are outside any view tree.
|
| If data changes, only _active_ nodes (ones that have been
| subscribed to) will re-compute. If nothing changed in a node,
| any dependent nodes will not re-compute.
|
| It's a beauty.
| enugu wrote:
| Doesn't skipping view layers mean that constraints held by
| intermediate layers can be violated?
|
| Say a city stats(location, weather) component is held inside
| a region component which in turn is in charge of a product
| route generating component (which also contains a separate
| 'list of products' component).
|
| You can't update the city coordinates safely from the top as
| the region component enforces that the cities are within a
| maximum distance from each other. The intermediate constraint
| would have to be lifted to the higher level and checked.
|
| Edit: There is also a more basic problem. When your app has
| multiple types of data(product, city), the top level store
| effectively becomes a
| database(https://www.hytradboi.com/2022/your-frontend-needs-
| a-databas...). This means that for every update, you have to
| figure out which views change, and more specifically, which
| rows in a view change. This isn't trivial unless you do
| wholesale updates (which is slow), as effects in a database
| can be non-local. Your views are queries and Queries on
| streaming data is hard.
|
| The whole update logic could become a core part of your
| system modelling which creates an NxM problem (store update,
| registered view -> does view update?). This function requires
| factoring into local functions for efficient implementation
| which is basically the data dependency graph.
| munificent wrote:
| It's hard not to giggle when the conclusion right after "Smart
| constructors" says "Do these ideas belong only in functional
| programming? While they are practiced more there...".
|
| Ah yes, because using constructors to ensure that new objects are
| in a valid state is virtually unheard of in object-oriented
| programming.
| wrenky wrote:
| One thing this article does is assume extreme functional
| mindset, I dont even think OOP enters into the authors mind-
| With that context, I think that statement isn't about object
| constructors but type constructors.
| beders wrote:
| In many (but not all) scenarios "Make illegal states
| unrepresentable" is way too expensive to implement.
|
| Especially when dealing with a fast changing domain, having to
| support different versions of data shapes across long time
| periods: dynamic data definitions are more economic and will
| still provide sufficient runtime protection.
|
| "Errors as values" - what is an error? I see this pattern misused
| often, because not enough thought was put into the definition of
| an error.
|
| "Disk is Full" vs. "data input violates some business rule" are
| two very - very - different things and should be treated
| differently. Exceptions are the right abstraction in the first
| case. It's not in the second case.
|
| "Functional core, imperative shell" - 100% agreement here.
| 7h3kk1d wrote:
| ""Make illegal states unrepresentable" is way too expensive to
| implement."
|
| This has not been my experience. The speed increase in
| development not having to worry about the unrepresentable cases
| have been very valuable. In addition as requirements change
| migrating old data hasn't been a huge concern. For code changes
| refactoring the types helps address new cases as well.
| ffsm8 wrote:
| The difficulty of Making illegal state unrepresentable
| depends entirely on the domain you're working on. And whether
| discarding the occasional invalid transaction is viable.
|
| If you're writing a CMS/wiki software, it's gonna be pretty
| straightforward to do.
|
| If you're working with transactions, trades, contracts etc,
| it's not.
| fuzztester wrote:
| >If you're writing a CMS/wiki software, it's gonna be
| pretty straightforward to do.
|
| >If you're working with transactions, trades, contracts
| etc, it's not.
|
| why not?
|
| what's the difference between those two categories,
| mentioned in your last two sentences, as far as this
| argument about illegal states is concerned? not clear to
| me.
|
| in fact, just a few weeks ago, I saw a video by yaron
| minsky of jane street about ocaml. the title of it might
| have been "why ocaml". I know he has a video by that name.
| just not sure whether that was the one I saw or another one
| by him.
|
| it can easily be googled.
|
| in that video, he talks a fair amount about how jane street
| uses ocaml, including on how they use it (including
| defining types for kinds of business domain data, iirc) to
| make "invalid states unrepresentable" - in fact, iirc, that
| was the title of one of the slides of his talk.
|
| i remember that very well because i was kind of impressed
| by the idea, although i have not checked it out practically
| myself yet.
|
| but I don't know much about this area, so I'm not saying
| that either he or you are wrong. just asking for
| clarification / explanation.
|
| also, trades and transactions seems to be the main area
| that jane street works in.
|
| edited for spelling/wrong autocorrect:
|
| s/one I show/one I saw/
| LudwigNagasena wrote:
| > Errors as values
|
| > To me, this simply makes more sense: isn't it objectively
| better to get a finite and predictable error value from a
| function than an unspecified exception that may or may not happen
| that you still have to guard against?
|
| Whether an error is returned as a value or thrown is orthogonal
| to whether it is finite and predictable. Java has checked
| exceptions. In Swift you also can specify the exceptions that a
| function may throw. How is it any less predictable than return
| values?
|
| Semantically, a thrown exception is simply a return value with
| debug information that gets automatically returned by the caller
| unless specified otherwise. It is simply a very handy way to
| reduce boilerplate. Isn't it objectively better to not write the
| same thing over and over again?
| 7h3kk1d wrote:
| I agree with regards to checked exceptions. Unfortunately Java
| doesn't support any form of polymorphism over thrown exceptions
| so it makes your code much harder to reuse. In languages that
| support polymorphic effects I imagine this is less of a
| concern.
| aiono wrote:
| For "Errors as values", I agree 100% that it's better then
| special values or untracked exceptions but I also think that
| current programming languages lack the features that allow
| encoding errors as values conveniently. Firstly, there is no
| composition of errors. If I use a library for a network call and
| then use another library for a database query, now the possible
| errors should be the union of the errors that can be returned
| from the either of the functions. But most practical languages
| lack the mechanism to do that (except OCaml). One has to define a
| wrapper type just to encode that particular composition. And it
| won't work if I want to handle for example Not Found case but not
| Internal Server Error. I see this is because most statically
| typed languages have nominal typing and not structural typing.
| But it is a necessity for pretty much any software otherwise
| people will just see that tracking errors is too much trouble in
| terms of composition.
| cryptonector wrote:
| > I see this is because most statically typed languages have
| nominal typing and not structural typing.
|
| I don't think that's it. I think what's needed is an
| accumulator method for error interfaces so that you can
| accumulate new errors into existing errors.
| aiono wrote:
| But this way you don't track what you handled in type level
| it's only available in runtime. So you are back to untracked
| errors.
| csours wrote:
| My ideal service layer has a functional core - easy to
| understand, easy to test.
|
| linked from the article:
|
| https://www.javiercasas.com/articles/functional-programming-...
___________________________________________________________________
(page generated 2024-11-26 23:01 UTC)