Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

The performance criticism is a common one, but I don't think most people actually know what they're talking about. I certainly don't, since I don't use exceptions, but I've heard many respected engineers say that they needn't be slow (it depends greatly on implementation).

It shouldn't matter much anyway: You usually don't optimize for exceptional cases, especially if they lead to program termination.

And furthermore, you should not make heavy use of exceptions anyway :-). They lead to non-local control flow that is extremely hard to follow. They are nice for short scripts where they print a stack trace and abort the program. But they are a huge maintenance burden in larger programs.



You are correct that the performance of exceptions isn't typically needed because exceptions aren't meant to be thrown that often. They're exceptional.

Railway oriented programming is better suited when errors are much more common in the ringtone and when their handling is a fundamental part of the rules you're encoding.

One friend uses a Railway oriented approach precisely because throwing that many exceptions per second (I don't know the exact number) would severely undermine the performance of their system.


What is an example of exception that happens many times per second? And where it could not be easily avoided to step on the error path at all?


So I'm not talking about exceptions, I'm talking about talking about errors. You can easily generate thousands of errors per second if you're simply validating and routing large streams of data.


It's nice to get concrete and leave the abstract shit talk (of which I've been guilty many times).

So let's focus on that problem you mention and not argue errors vs exceptions terminology. In the case you mention I would clearly make two streams of data. In the simplest case, two append-only dynamically allocated arrays, one for parsing errors and one for parsed results. This is very likely possible to do - unless one needs an exact ordering, in which case it could be still possible to add positions to each of the streams.

It's intuitively clear to me that this approach will be significantly faster than a Result<>/Either based approach. Because control flow is much simpler. There are two consumers, one for the error stream, and one for the parsed stream. There is no switching between the cases on the consuming side, and complexity is much reduced because each part can do one thing and do it well.

You also get the freedom to process the streams independently, e.g. at different points in time, or not at all, etc. It's much simpler - if you try this route once, you will ask yourself afterwards, how have you ever endured this complex algebraic data types stuff.

Yes, you could still add a consumer of those Result<> values that demultiplexes them in two. But nobody does that. Because, why did we introduce Result values in the first place then?

This is just one of the many examples I encounter daily and that demonstrate how advanced type systems and ideas like OOP or FP encourage bad program structure. They are just not good ideas.


> Yes, you could still add a consumer of those Result<> values that demultiplexes them in two. But nobody does that.

What? That's exactly how you consume those values. In pseudocode:

    match result
    when success: ...
    when error: ...


This is not demultiplexing into two different data streams, but handling both clumped together. Exactly what I was speaking of: Avoiding this clumping and processing the cases as separate streams (and independently!) brings huge payoffs.

Just try it; you will realize how much simpler your application gets - so many problems simply go away if you make more homogeneous data streams, and not fewer heterogeneous ones. And so many possibilities magically open up.

Very briefly, here is the difference:

    def process_both_cases(stream):
        for item in stream:
            if case1:
                do_complex_stuff_1(item)
            elif case2:
                do_complex_stuff_2(item)
            else:
                assert(False)
vs

    def process_case1(stream):
        for item in stream:
            do_complex_stuff_1(item)

    def process_case2(stream):
        for item in stream:
            do_complex_stuff_2(item)
I think it's immediately clear how much better the second version is. It's less complicated: The conditionals are not needed. Processing the cases is decoupled.


You... do realize that it's quite easy to make the former out of the later, and that's how every FP parser combinator library works.

Using a combinator to combine two undecorated funtions into one, you don't need to result to two streams, you can significantly simplify your runtime by only needing one. It's been written for you: http://hackage.haskell.org/package/base-4.11.1.0/docs/Contro...

Please consider that the incredible strength of ADTs is that you can just write generic code to combine those things without resorting to the crabtastic pattern matches that you're proposing, AND avoid the ugly implicitly unpredictably ordered world that you're describing.

Heck, we don't even need to resort to combinators for this. It's just you making up a complex case by saying, "What if I hide half of example 2 by handwaving it?" You still are going to have a case1/case2 decomposition in your second version. It's universally worse.


I don't see a pattern match anywhere in my example. And it's not in the slightest "unpredictably ordered" and has much simpler control flow.

> You still are going to have a case1/case2 decomposition in your second version. It's universally worse.

No. There is simply none. The producer makes them as two, and the two are processed independently. There is no problem if you don't make one. If you had ever tried the approach I propose out you'd know the flexibility that comes with it. (I'm currently working on language tooling, and there it is absolutely the case that it's very easy do without ASTs, and process the cases independently).

The only thing I "handwaved away" is the calls:

    s1, s2 = process_data()
    process_case1(s1)
    process_case2(s2)
There is no pattern match there, sorry to disappoint.

Where I need to relate the individual items of s1 and s2 in a common ordering, I've had great success with this approach:

    s1, s2, forks = process_data()
    process_case1(s1)
    process_case2(s2)
    process_forks(forks)
There, "forks" does contain an ADT of pointers into s1 and s2. But it contains nothing more, so it's very minimalistic. The advantage from the first case is still there: Most code profits from the homogeneity of s1 and s2, and from the actual, physical decoupling of the cases, and so much pattern matching just goes away.

(Btw. I'm not interested in fancy FP constructs. You will not convince me that you can process ADTs without pattern matches. You might be able to avoid writing them down in the source code, but I care about what actually happens on the hardware.)

(And actually I think avoiding to write down the pattern match in the source code and only generating it magically with higher level constructs is a bad, bad idea. It has poor readability. And it encourages the mindset "let's just do the worst shit on the actual machine. We can avoid writing it in the source code, and we don't care what happens on the machine and how slow it is. And we're too lazy to think about actual solutions that actually avoid real work".)


> Where I need to relate the individual items of s1 and s2 in a common ordering, I've had great success with this approach:

Sorry I misunderstood your use of the phrase "two streams." In my world multiple streams effectively severs causality between the streams. Or did I? You wrote:

   def process_case1(stream):
        for item in stream:
            do_complex_stuff_1(item)

    def process_case2(stream):
        for item in stream:
            do_complex_stuff_2(item) 
If this genuinely relates to your example above then the only possible consistent ordering without any async/multithreading is to exhaust stream1, then process your errors. If you don't do that and the implication is that the streams won't close on errors but rather continue to emit, then you really can't guarantee any ordering to the processing.

Given this inconsistency, I'm going to continue the post with your intent of causality being preserved and assuming assuming that process_data() offers the next value (or null) and we're calling these in a loop. Because otherwise you have a whole heap of problems and your example doesn't even faintly resemble any subject matter from the post, or what most folks were talking about here. And if you believe that it doesn't destroy causality, you're either writing a lot of code to recombine your streams in lockstep (in which case, splitting is quite the extreme step to avoid matching) or you just don't understand the implications of stream semantics in this case.

> There is no pattern match there, sorry to disappoint.

However you provided a destructuring pattern match in your code!

    s1, s2 = process_data()
Your process data is pattern matched on a generic product there. Sorry to disappoint. The actual type of the value is:

    (Either Null S1Type, Either Null S2Type)
(And I assume it must be an `Either Null a` because we're discussing a case where either case1 or case2 will exist, so there's no valid value for one when the other exists.)

This is sort of why folks who know about ADTs roll their eyes at the sizzling invective about their complexity. You often end up using the same solutions, you just don't name them. It seems like the naming itself is objectionable. You've written about 4 posts I can see here about how much you hate ADTs, but then you're happy for an implicit `Either Null a` on every value, no opting out, and `a,b = f(x)` is fine because the name of the ADT is a punctuation mark, I guess?

As for the first example, Pattern Matches as Case statements are related in that a pattern match is a hyperactive case statement. So when you have one, you have the other to a varying degree.

> (Btw. I'm not interested in fancy FP constructs. You will not convince me that you can process ADTs without pattern matches. You might be able to avoid writing them down in the source code, but I care about what actually happens on the hardware.)

You seem to be making a huge deal out of pointer chasing. However, you're also suggesting a detour through the function contexts of process_case1 and process_case2 just to offer a null in one case.

Your plan seems a bit unfair, while we're on the subject. You're arguing that a pattern match is "heavy overhead", but you're completely ignoring the actual use case of "railway programming": to make a single clean codepath obvious and localize error handling at the end. But the thing you've offered doesn't really do that.

You're processing S1 and S2 (which I'll rename E1 for error), but what happens when I want to compose it? Extending the metaphor, your new code might look like:

    s1, e1 = process_data()
    process_ecase(e1)
    // If you don't put an if( s1 != null ) here you're going
    // to have to do a ton of function calls AND guarantee
    // your handlers check their args for null.
    if( s1 != null ) {
        s2, e2 = process_case(s1)
        process_ecase(e2)
        if( s2 != null ) {
            // ...
        }
    }

You could perhaps change the ordering of these ops (or push the null checks into your code which has a bunch of problems). And most of all, your compiler is totally powerless to help you work out if you have an error or not. You might also instead try to embed the dispatch to the next step in the def blocks of each individual step (an approach that could never work with the stream example, as you need to define your stream network (or at least a global namespace for it) before streaming events through it.

> And it encourages the mindset "let's just do the worst shit on the actual machine.

Okay, your example extended above. The Haskell-style of this combinator pattern (which DOES require all errors have the same base type, let me be transparent about that) is:

    -- Assuming the steps are: a -> Either Error b
    let pipeline = process_data >>= step2 >>= step3  
    pipeline () `catchError` errorHandler
The errorHandler is just a normal function taking your shared error type. Each step is just a function that returns an Either Error or its value. The values can vary so long as the types between steps line up (and the compiler checks this in F#, ML and Haskell).

It's easy to read, too. It's easy to extend, and it's predictable, and it's made from very simple decorated functions. We can even safely make it interact with exceptions, if we need to, without overly complicating the callers.

> And it encourages the mindset "let's just do the worst shit on the actual machine. We can avoid writing it in the source code

What's completely confusing to me about your claim here is that you're doing dynamic dispatches and perhaps excess function calls, demanding your process functions do null checks, you have no clear looping construct, and your compiler has 0 opportunity for any sort of inlining but the most naive, cache busting type. But you're essentially dunking on what becomes BNE statements at the machine code level. At runtime, all the types are erased in the best case or preserved in exactly the same way as any normal object with a dispatch table would. The actual cost is, in most cases, indistinguishable from your if-statement rails.

I actually think you like lots of elements of the FP style, you just seem to dislike the names. Perhaps if you could hold your nose with a clothespin and play through the stink of math-ish names, you might find more you like?


I think you're assuming I'm less experienced than I actually am!

> If this genuinely relates to your example above then the only possible consistent ordering without any async/multithreading is to exhaust stream1, then process your errors. [...]

Unless we know there are dependencies between the processing of s1 and s2 (and we don't; we're talking very abstract here) I'm totally unconcerned about the processing order, and I even stated explicitly that in the simplest case they're just append-only arrays. And the processing of the items in s1 and s2 can be done in any order, but why not just do it in a single thread, first process "data" completely, then "s1", then "s2". In many cases of course, it will be possible to run in 3 parallel threads, where the additional two process s1 and s2 as they are emitted from the first thread. But that's just a small optimization and a lot of complexity and not relevant here.

> Your process data is pattern matched on a generic product there. Sorry to disappoint. The actual type of the value is:

You're purposefully twisting words here. And no, that's not "the type", because I was (obviously) just using straight forward pseudocode whose semantics I assumed would be intuitively clear to everyone. That's not the kind of pattern matching we'd been talking about. In my code s1 and s2 are simply two known variables holding the streams. That's something different than a single variable holding either of two distinct cases. I assume you know that and just want to sell the FP cool-aid or troll me; please refrain from this style of discussion.

> This is sort of why folks who know about ADTs roll their eyes at the sizzling invective about their complexity. You often end up using the same solutions, you just don't name them. It seems like the naming itself is objectionable. You've written about 4 posts I can see here about how much you hate ADTs, but then you're happy for an implicit `Either Null a` on every value, no opting out, and `a,b = f(x)` is fine because the name of the ADT is a punctuation mark, I guess?

There is so much hair-pulling in here and what you say is simply wrong. I was just returning two friggin' variables. And why are you talking about "Null"? That's of no interest. There is no use in my pseudocode for any kind of thing resembling what's called "Null" in any programming language. As a Haskell enthusiast you should know that there is always a canonical stream/array, and that's the empty one. Why are you haunting me with Nulls, that's not fair, I don't want them, and I can do without them. (And just to be clear, I don't do OOP either; I think it's at least as bad as FP)

> What's completely confusing to me about your claim here is that you're doing dynamic dispatches and perhaps excess function calls,

No I'm frigging not. I don't get it why you're talking of dynamic dispatches now. What I have given is some pseudo code of the simplest kind, that easily translates to any procedural programming language, including assembly code. Why do you make it seem like it was something more complicated? You must be deliberately hair-pulling just to win a stupid argument. I'm not doing any "virtual object-oriented blah blah" or any kind of types craziness.

s1 is a stream (or let's just say (again), an "array", to hopefully prevent any misunderstandings) of homogeneous things. Let's just say all items in s1 have type S1 and all items of s2 have type S2. It's the simplest kind of problem.

> -- Assuming the steps are: [..]

Congrats. I learned Haskell once, too. Until at some point it became clear that it didn't bring anything practical to the table and only complicates program designs. This "uniform error handling" idea (actually it seems to be more of a necessity/accident) just makes for bad code. Don't do that. Just do the first processing step, handle errors accordingly. Then do the second step, and handle errors accordingly. The second step in general requires different error handling than the first, (and often the only thing they have in common is that they might "die"), and there is no need and only disadvantages in restricting them to have uniform error handling.

What's wrong with my procedural pseudo code? Fill in the required error handling between the steps as soon as you have a concrete instance for my abstract example.

> dynamic dispatches

Nope

> excess function calls

What's that?

> demanding your process functions do null checks

No, I don't have any "Nulls" of any kind, since my data is simple and normalized.

> no clear looping construct

I used a pseudo-code for loop. Every 1-month beginner to programming has an immediate grasp of that. I never heard anyone complain that was somehow not "clear". It's plain, procedural code. You're trolling me so hard it hurts.

> I actually think you like lots of elements of the FP style, you just seem to dislike the names. Perhaps if you could hold your nose with a clothespin and play through the stink of math-ish names, you might find more you like?

I've been there and I know to use FP-ish constructs when they make sense (very rarely. Much easier to restrict to procedural altogether).


> Unless we know there are data dependencies between s1 and s2 (and we don't; we're talking very abstract here)

The entire discussion is about railway programming for early exit on error handling. So yeah, there are dependencies there.

> You're purposefully twisting words here.

No. I'm pointing out you pattern matched against a generic 2-tuple in your pseudocode. There's no words to twist, returns in functions on modern hardware are atomic over 1 value in all but the most obscure languages, and it's not a new phenomenon.

If it's valid to say, "I returned 2 values" it's equally valid to say, "I returned one value and restructured it." They represent the same thing.

> And no, that's not "the type", because I was (obviously) just using straight forward pseudocode whose semantics I assumed would be intuitively clear to everyone.

Both statements can be true. I'm simply writing a type that captures your "obviously true" statements. Therefore, both are obviously true.

> There is so much hair-pulling in here and what you say is simply wrong. I was just returning two friggin' variables.

You're the one consistently lambasting folks about paying attention to what happens at the "bare metal" (ha!) level. I encourage you to take your own medicine here.

> Why do you make it seem like it was something more complicated? You must be deliberately hair-pulling just to win a stupid argument. I'm not doing any "virtual object-oriented blah blah" or any kind of types craziness.

Either you wrote something so vague it can be anything you want (e.g., your magical streams that are also not streams). If we remove the nonsensical (or I guess by your admission, non-sequitur) streams bit then you're left with this outcome. I told you exactly what I'd do in my post. You have to recognize the type of the result of each step as either a success or an error.

You've chosen to pretend none of this is about error handling, which is pretty boring IMO. Error handling is what makes code actually hard to write.

> What's wrong with my procedural code? Fill in the error handling between the steps as soon as you have a concrete instance for my abstract example.

I admit I don't know since it seems to mean exactly what you want it to, and no one else is privy to either the viewpoint nor the problem you're actually discussing. You're just here hurling vague insults about how everything other than your "obvious" pseudocode is nonsense.

> Dude, I used a pseudo-code for loop.

Not in any example I reviewed that didn't completely ignore the actual problem to burn down a straw man about streaming constructs. Also, don't call me dude. I'm not.

> I've been there and I know to use FP-ish constructs when they make sense (very rarely. Much easier to restrict to procedural altogether).

Considering you didn't recognize the product pattern match and objected vigorously to the idea that it might be as such; and tuples something nearly every statically typed FP dialect has? I gotta confess that I don't think you know the field or the techniques very well. Fair enough, many modern FP techniques in use today are less than 25 years old. No shame there. But you should probably investigate a bit more before speaking so confidently on the subject.

You really are just advocating for doing what the F# example given was doing, without all that helpful syntax or compile-time verification of correctness & totality. I dunno why you'd want that. Given that you are accidentally reusing FP pattern matches, they can't be that complicated now can they?


> The entire discussion is about railway programming for early exit on error handling. So yeah, there are dependencies there.

"Early" does not have anything to do with "dependency". You can do a million unrelated things in an order of your choice and abort at the first error. There need not be any dependencies.

> returns in functions on modern hardware are atomic over 1 value in all but the most obscure languages, and it's not a new phenomenon.

This is true and totally unrelated to my point.

> No. I'm pointing out you pattern matched against a generic 2-tuple in your pseudocode.

Again, it's pseudo-code, so no, it need not be a "generic 2-tuple", not even needs to be a "tuple" (whatever that means in a given PL). In a real implementation the types are known precisely (Just like the types of the alternatives in an ADT are known precisely). There is no kind of "matching" (there are no conditionals) whatsoever. s1 and s2 are simply two variables (for example pointers, in C) of precisely known type. And the process_data() function returns two values that get assigned to the variables. It's the simplest kind of code.

The whole point of the discussion was whether to design the output as a single stream of ADT values, or as multiple streams of precisely known values. In this context it's totally clear why my (single) assignment to s1 and s2 is not the "matching" that you need to do on the (many) values of the single stream in the ADT version. Really I don't care how hard you want this assignment to s1 and s2 to be a matching/destructuring of a "tuple"; It doesn't change anything and is totally unrelated to my point.

Sorry to call you "dude". I had already decided to remove that word before reading your answer.


> Again, it's pseudo-code, so no, it need not be a "generic 2-tuple", not even needs to be a "tuple" (whatever that means in a given PL). In a real implementation the types are known precisely (Just like the types of the alternatives in an ADT are known precisely). There is no kind of "matching" whatsoever. s1 and s2 are simply two variables (for example pointers, in C) of precisely known type. It's the simplest kind of code.

That's not different syntactically or even mechanically from pattern matching over a generic 2-tuple. So if you want to do this as a social convention, that's fine. I'm not sure why other people would adhere to such a convention.

> The whole point of the discussion was whether to design the output as a single stream of ADT values, or as multiple streams of precisely known values.

No. The whole point of the talk we're discussing is that a single stream of chained operations that have failure states is difficult to cleanly assemble without ADTs. You've backed away from the error contingency in your argument. To the uncharitable onlooker, this appears to be because ADTs solve the problem elegantly and procedural programming turns into am error prone, one-off pile of if-then-else spaghetti. See also: every complaint about Golang error handling.

Ignoring the Success-or-Failure nature of the talk is to fundamentally ignore the problem it's trying to solve. If you DID have two streams of values that had no contingency, we wouldn't write code this way in any runtime with async primitives.

> Sorry to call you "dude". I had already decided to remove that word before reading your answer.

Thank you.


> The whole point of the talk we're discussing is that a single stream of chained operations that have failure states is difficult to cleanly assemble without ADTs

And I suggested that this is overly complicated and procedural approaches are better. In procedural code we don't "assemble chained operations". We do them one-by-one and handle errors appropriately.

And we can typically arrange the data and control so that error handling is much easier. That was my point.

> one-off pile of if-then-else spaghetti.

ADTs and FP techniques provide only syntactic sugar to avoid this spaghetti in the source code. But due to that they just encourage having the worst mess of control actually happen on the machine.

On the other hand, I demonstrated how to avoid this mess by decomposing data into individually homogeneous bins/streams/arrays/whatever. My example does not have a single if statement (yes, you might argue there are if statements hidden in the for loops). I clearly stated that it's often possible to choose this approach.


> And I suggested that this is overly complicated and procedural approaches are better. In procedural code we don't "assemble chained operations". We do them one-by-one and handle errors appropriately.

Go does this. It's widely and I think rightly criticized for this. It's "easy" to do but not "simple" because it's prone to human error.

> And we can typically arrange the data and control so that error handling is much easier. That was my point.

You can do more work to validate in a separate pass if we're discussing data transforms. It's harder to do this for a series of chained imperative steps. The talk details an example making a series of API queries. It's fantastic to handle a CRUD loop with the ADT method because it lets you write a simple specification and collect errors out in the end, while reserving exception handlers for truly exceptional cases like network faults. F# is incredibly good at this kind of flow.

> ADTs and FP techniques provide only syntactic sugar to avoid this spaghetti in the source code. But due to that they just encourage having the worst mess of control actually happen on the machine.

You've said this multiple times, but it's just not true. The F# code is just a function that calls functions. F#, ML and Haskell compilers all aggressively inline these calls. You pay the exact same costs for validating an error exists in the Golang style, in practice. Indeed, lots of people generate Go from liskov-style templates JUST to get ADTs to make error handling less painful. It's gaining popularity in Swift and Rust as well. Folks there aren't trying to impress people with their FP knowledge, they're cribbing a technique that works and scales well.

> My example does not have a single if statement (yes, you might argue there are if statements hidden in the for loops). I clearly stated that it's often possible to choose this approach.

Your example, by your own admission, doesn't address error handling flows. You need conditional logic to work with operations that don't always succeed. You say, "Structure it so you never have them." I think a lot of FP people would agree with this (in fact, there is a really cool functional pearl about doing exactly this at scale: https://github.com/matt-noonan/gdp-paper/releases/download/j...) but they'd think about it less as a function of assurances and "thinking very hard" and more about using methods to make inconsistent code provably false at compile time.

Very few languages can actually encode formal notions of correctness, right now. Recently languages like Idris, Agda, Coq, and TLA come up as tools for formal correctness, where you can actually have your compiler prove your invariants and their implications.


I'm sorry, I ignored the second part of your question. I was responding to:

> What is an example of exception that happens many times per second?

I made a distinction between exceptions and errors because exceptions are a specific mechanism for propagating errors. I wasn't trying to be pedantic, I was trying to point out that the answer to your first question is more obvious if you subsitute out the word exception for error.

The answer is simply: Because you're probably the one generating the errors and you're going to generate a lot of them.

With regards to the second question:

> And where it could not be easily avoided to step on the error path at all?

I'm not sure how to interpret this question, but I'll throw out an answer anyway. You don't need to take a Railway Oriented Approach. There are other ways. Your reply was an example.

For a streaming scenario, applying a function with the signature ('a -> Result<'b,'e>) to a Stream.map is pretty trivial. It may not be the most performant or scalable, but it's easy to do and easy to reason about. However, one could also easily create two streams as you suggested with a partition type function with the signature: (Stream<'a> -> ('a -> Result<'b,'e>) -> (Stream<'b> * Stream<'e>)

You're free to use the Result<> type as much or as little as it makes sense. How one should best solve the problem depends on the scope of the problem and what you need to do downstream, right?


> I wasn't trying to be pedantic, I was trying to point out...

I agree!

> but it's easy to do and easy to reason about

I agree it's an obvious/intuitive approach to take, just like OOP. However, from a computational and software maintainability standpoint, I think it rarely pays out.

> How one should best solve the problem depends on the scope of the problem and what you need to do downstream

That's right, however ADTs are very rarely occurring naturally in what I do (I've dabbled in quite a few areas).

I've found them to occur naturally in parsers (because languages have forks in their syntaxes), but even there I've had great gains by avoiding ASTs altogether, and making multiple streams, supported by a ADT stream of simple data that does not contain real data but only "joins" the other ones (it contains only a type-flag and a pointer into one of the other streams).

I think ADTs are the right choice whenever the ordering between a set of things with heterogeneous types is very important. If that's not the case, code can be vastly simplified by sorting the data by their types and processing them as separate bins - separately and independently.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: