Hacker News new | ask | show | jobs
by the_common_man 1516 days ago
> Errors as return values is only acceptable for code that is so performance-sensitive that you aren't allowed to do dynamic memory allocations

Not really. It is acceptable for code whose maintainers value readability and simplicity over everything else. I totally agree that readability and simplicity are quite subjective and this is up to the maintainers.

I don't really know what "conditions+restarts" is but a few articles landed me into LISP which I find totally unreadable. So, can you point me to some "conditions+restarts" code that I can understand/appreciate easily? Any language is fine, I just want to understand the concept better since I am more a C/C++/JS programmer. (FWIW, never written Go, but it's easy to read and understand).

3 comments

It's probably sufficient for this conversation to just understand it as try-catch. A function is invoked; if it "signals" (throws) then control moves to a handler that matches the signal (exception); the handler runs and resolves the situation. Of course, Lisp being Lisp, the system is extended to announcer voice FULL. GENERALITY. but in its simplest form it's basically equivalent to exception throwing.
This is correct (in that the most simplistic case is try-catch).

However, the difference between a try-catch and conditions / restarts is that when one signals a condition (exception), the restart (catch) has a continuation from the condition. This allows you to inject an expression into the location where an exception occurred and "restart" your code from that point.

Whether you do such a thing or not depends on the code, on the type of condition raised, and on what expressions are valid. So you get a lot more flexibility in how errors are handled across the system. But likewise: more complexity in having to make that choice in the first place.

Going farther than this, conditions and restarts are really just a fancy way of packaging delimited continuations. I don't personally know any non-Lisp language that has attempted to package these concepts (maybe Dylan, which is a Lisp-like in its own way but without the syntax?). Going back to the original thought regarding error handling - I think Result<T, Err> type handling is fine and that most languages would be better served by that than having different types of exceptions. Conditions and restarts are powerful but your language has to be very expression focused (i.e. does not use a lot of statements) and it's not really clear that there's been a lot of work on making restarts nice to use. Exceptions in all languages that have them have their own set of associated problems, for what its worth, and it's not as easy to move Lisp features into a non-Lisp as one might believe...

> Going farther than this, conditions and restarts are really just a fancy way of packaging delimited continuations. I don't personally know any non-Lisp language that has attempted to package these concepts (maybe Dylan, which is a Lisp-like in its own way but without the syntax?).

Dylan does have a condition system, but it’s basically a Lisp without the parens, so probably doesn’t count. On the other hand, algebraic effects are another fancy way of packaging delimited continuations, so arguably the research languages Eff[1] and Koka[2] tried. (I don’t think either one explored the connection with condition systems, but I’m not sure.)

> I think Result<T, Err> type handling is fine and that most languages would be better served by that than having different types of exceptions. Conditions and restarts are powerful but your language has to be very expression focused (i.e. does not use a lot of statements) [...]

Huh? I don’t know why you’d say that, if anything I think it’s the Either err t / Result<T, Err> style that is more expression-focused (I mean, it even originates in Haskell :). I wouldn’t even call Common Lisp particularly expression-oriented, honestly, not unless we’re comparing with plain old C and not Rust.

[1] https://www.eff-lang.org/

[2] https://koka-lang.github.io/

> Huh? I don’t know why you’d say that, if anything I think it’s the Either err t / Result<T, Err> style that is more expression-focused (I mean, it even originates in Haskell :). I wouldn’t even call Common Lisp particularly expression-oriented, honestly, not unless we’re comparing with plain old C and not Rust.

I think that's exactly what I mean. The vast majority of languages (including golang, in TFA) use statements for dealing with exceptions. Rust also had try-catch, but has long since removed that syntax.

Anyways, the reason I said it is because it is not clear what to do when one wants to restart a statement. There are plenty of non-expressions that can throw, and usually it's not thought about deeply, but from a language semantics point of view one does need to have an idea of how to engage with it. For example, if you wrote:

    with open('somefile') as f:
        for line in f: 
            # ...
in Python, and had to deal with a restart during `open`, how do you manage this? The naive answer is to just return the continuation at `open`, but the "with" statement may have contextual setup. For example, `open` might be fine during `__init__`, but may have failed in `__enter__`. If you "restart" in `__enter__`, you need to deal with the partial state. Expression-based languages don't really have this issue because the call stack is usually clear (there's no magic under the hood). Similar analogues would be the `using` keyword in C#, or perhaps even lambda-expressions in C++. The abstraction in the code is separated from the execution of the restart, so it gets kind of gross as a language implementer in terms of not having to have very specific places where restarts can and cannot be.

This is a good reason why Rust / Haskell don't package these and just use Either / Result instead. If you have a bunch of types that you didn't write, injecting a restart into any failing code now brings a question of: "Can you safely inject types into a restart for code that you do not have access to?" and the answer is often no. The visibility rules in Rust make this a non-starter, and in Haskell you have a problem of mutability as well. A condition may be triggered at a point where IO could be injected, and so many of the language semantics would be in question. I suspect the type definitions for a restart in any arbitrary location in the code would be pretty hard to write, so maybe this is an open research area in Haskell already, but I doubt it'd be as ergonomic.

> It is acceptable for code whose maintainers value readability and simplicity over everything else.

Errors-as-return values are less readable than conditions, not more - there's literally more visual noise on the screen.

And if you want "simplicity", don't use a computer. Computers are intrinsically complex devices, users desire features with complex implementations, and our job as programmers is to manage complexity, not pretend that it doesn't exist. One of the article's main points is that Go does the latter in lieu of the former, and that's also what errors-as-return-values does.

----------------------------------

The formal name for a condition+restart system appears to be "algebraic effects"[1].

Conditions and restarts are similar to exceptions, with the following changes:

First, conditions are conceptually used for non-error conditions in some cases, like what Python does.

Second, throwing a condition doesn't cause the stack to unwind up to the handler, unlike exceptions.

Third, in addition to throwing conditions, you, uh, wrap ("establish" is the jargon used) code in what are called "restarts", similar to wrapping things in try/catch blocks (but distinct, because with conditions you still have condition handling blocks). Restarts can have names and are non-mutually-exclusive. Conceptually, restarts represent error-recovery strategies, while conditions represent the errors themselves.

Fourth, when a condition is thrown, it propagates upward until it hits either the toplevel (in which case the interactive debugger is launched), or it hits a condition handler - without unwinding the stack. Then, either the human looking at the debugger can pick which restart they want to use, or the logic at the condition handler can do so.

Why is this better than any alternative error-handling mechanism? Because every other error-handling mechanism (1) unwinds the stack (destroying all contextually useful information that isn't explicitly saved by the programmer, and preventing you from restarting a computation in the middle) (2) forces you into a single error-recovery strategy and (3) couples low-level code to high-level code as a result.

In general, low-level code has details about the specific kind of error, context around it, and access to data and control flow that would allow the error to be recovered from (e.g. for a log-processing program, reasonable restarts while parsing a log entry would be (1) skip it (2) retry (3) use an alternative parser and (4) return an empty entry), while high-level code has the application context about why the low-level operation is being performed in the first place and which error-recovery option should be picked.

Conversely, high-level code doesn't have details about what the low-level code was doing at the time of the error, and low-level code doesn't have the high-level context necessary to determine which error recovery strategy is appropriate in this use of the low-level code.

[1] https://en.wikipedia.org/wiki/Effect_system

https://en.wikipedia.org/wiki/Exception_handling#Condition_s...

> Errors-as-return values are less readable than conditions, not more - there's literally more visual noise on the screen.

No.

When you make a function call, and that call can fail, then the happy-path and the sad-path are both things that you need to manage as a caller. Happy-path and sad-path are two equivalent states that both need to be accommodated by the program logic.

Error handling code is not "noise". It is equally important to success-path code.

> No.

Yes. There is literally more visual noise on the screen. This is not up for debate - more pixels are lit on the monitor you are looking at.

> When you make a function call, and that call can fail, then the happy-path and the sad-path are both things that you need to manage as a caller.

False - the direct caller is not responsible for error-handling, in general - some transitive super-caller will be. Errors as return values needlessly generate this visual noise for every caller, when not needed, in addition to introducing aforementioned coupling.

> Error handling code is not "noise". It is equally important to success-path code.

You're misunderstanding my point. I never said that error-handling code is noise - it isn't. What is noise is forcing every single function call between the appropriate error-handling point and the error location to have extra useless junk. When there's an error, you should see exactly two things in your codebase: some stuff at the point where the error is thrown, and some stuff at the point where the error is handled - and, given that the place where the error should be handled is rarely the direct caller, you should see nothing in between.

Error handling is not visual noise. It is equally important to non-error-handling code paths.

The direct caller is _absolutely_ responsible for error handling.

> What is noise is forcing every single function call between the appropriate error-handling point and the error location to have extra useless junk.

No. Falliable operations must be managed by the thing which calls them. Anything else is shadow control flow, which subverts understanding and negatively impacts reliability.

> Error handling is not visual noise.

You clearly did not actually read my previous comment before replying to it. Let me quote it:

"I never said that error-handling code is noise - it isn't. What is noise is forcing every single function call between the appropriate error-handling point and the error location to have extra useless junk. When there's an error, you should see exactly two things in your codebase: some stuff at the point where the error is thrown, and some stuff at the point where the error is handled - and, given that the place where the error should be handled is rarely the direct caller, you should see nothing in between."

Please read this carefully and respond to it.

> The direct caller is _absolutely_ responsible for error handling.

This is objectively false, both on an empirical level, and on a theoretical one.

On the empirical level, it's trivial to find dozens of instances of code on the internet where it's crystal clear that the direct caller of an erroring function is not responsible for error-handling.

Here's one: on line 1471 of emacsclient.c[1], a call to connect() may fail - yet the caller, set_local_socket(), is clearly not responsible for e.g. quitting the application, because only its caller, set_socket()[2], has the contextual information necessary to know that quitting should not happen unless the attempts to open local UNIX domain and network sockets to the Emacs server also fail.

That's it - counter-evidence to your claim. It's straight-up false.

But, let's go and find a few more examples.

Here[3] is a random screenshot of a Python error trace that I found on the internet. You see that bottom frame, listen()? It's calling the erroring function sock.bind(addr). Yet, it's pretty clear that listen() isn't the right place to handle the error - it's in the user's application, "ryu", because again, only that code has the contextual information necessary to determine the correct way to handle the error.

Here[4] is another Python error trace - again, it's pretty clear that the place to handle the erroring getattr() call is not in its direct caller bind() in socket.py, but in the user application in siriServer.py.

Finally, here's some Lisp code. The Hunchentoot web server has a ENSURE-PARSE-INTEGER function[5], which can fail if it receives a non-integer to parse. But, it simply doesn't have the contextual information necessary to handle the error, because it's called by URL-DECODE[6], which is called by FORM-URL-ENCODED-LIST-TO-ALIST[7], which is called by MAYBE-READ-POST-PARAMETERS[8], and that is where the error handling can, should, and must occur.

It's crystal clear - errors are not required to be (or always capable of being) handled at the call site of the erroring function, and the reason for this is simply because context gets lost as you travel down the call stack, so that the point at which an error occurs often simply doesn't have the necessary context to recover from it correctly.

> Falliable operations must be managed by the thing which calls them.

Also false. Look at every one of the code examples I've linked. Go and look at code in general, actually.

> Anything else is shadow control flow, which subverts understanding and negatively impacts reliability.

It sounds like you don't understand exceptions very well. Go and read some code with exceptions - you'll see that the idea is extremely straightforward. Exceptions are very simple - they bubble up through the stack until handled, and that's it. They're far easier to understand than first-class functions, coroutines, monads, or any of another dozen different software engineering concepts that are also being put to extremely good use.

[1] https://github.com/emacs-mirror/emacs/blob/3af9e84ff59811734...

[2] https://github.com/emacs-mirror/emacs/blob/3af9e84ff59811734...

[3] https://i.ytimg.com/vi/CryQPaz8UO0/maxresdefault.jpg

[4] https://serverfault.com/questions/476715/python-socket-error...

[5] https://github.com/edicl/hunchentoot/blob/0023dd3927e5840f1f...

[6] https://github.com/edicl/hunchentoot/blob/0023dd3927e5840f1f...

[7] https://github.com/edicl/hunchentoot/blob/0023dd3927e5840f1f...

[8] https://github.com/edicl/hunchentoot/blob/18d76801150330a579...

> on line 1471 of emacsclient.c[1], a call to connect() may fail - yet the caller, set_local_socket(), is clearly not responsible for e.g. quitting the application, because only its caller, set_socket()[2], has the contextual information necessary to know that quitting should not happen unless the attempts to open local UNIX domain and network sockets to the Emacs server also fail.

Line 1471 describes a failure condition, which is returned to the caller of the encapsulating function, which in this case is is line 1374 set_local_socket. The caller which invokes the function set_local_socket absolutely is responsible for handling that failure condition. The caller is not set_local_socket, the caller is the code which invokes set_local_socket. And if set_local_socket fails, the code which invoked set_local_socket is absolutely responsible for determining what to do. Quitting the application is a decision that only `func main` can choose to do! All other points in the call stack can only bubble the error up to their caller. That's the only rational course of action.

> Exceptions are very simple - they bubble up through the stack until handled . . .

I agree that this is "simple" in one sense. The problem is that this "simplicity" means that there are two mechanisms of call stack control flow. One is the code as it exists "on the page" -- function calls and return statements -- and another is the exception control flow -- everything expressed as throw/catch statements. This is two control flow paths: one visible in the code, and another invisible, or implicit, to the code as written. It should not be controversial to say that removing the concept of exceptions makes control flow easier to understand, to model, to predict, and therefore easier to model the behavior of programs in general.

Maybe this is the fundamental argument here. Plenty of cases I have written code where I am trying to do something over a big set of things, e.g. check a file for hard-coded paths, or send a message to a lot of people; it isn’t weird for those things to fail. Maybe I couldn’t open the file. Maybe the file lacked hard coded paths. Maybe the sender lacked rights to send to that receiver, or maybe the receiver is currently offline. But if most of your code is some complex calculation, say weather simulation?, maybe there is by default just one path.
> I don't really know what "conditions+restarts" is but a few articles landed me into LISP which I find totally unreadable. So, can you point me to some "conditions+restarts" code that I can understand/appreciate easily?

You’ll have to read Lisp, I’m afraid; the best description I know is in the book Practical Common Lisp[1].

(Come on, Lisp syntax is quirky, but it’s not unreadable, and unlike APL or Forth or even Haskell it doesn’t require you to memorize a bunch of semi-meaningless punctuation before you can understand what is going on—it’s pretty wordy usually. I’m not saying you must bring yourself to love writing (f x y) instead of f(x, y), only that adjusting from one to the other should not be particularly hard.)

I mean, I have done a toy Forth implementation, but that is hardly more readable with no experience with the language.

One system that is almost conditions and restarts is 32-bit(!) Win32 SEH, but it is not particularly well-documented and the language bindings usually try rather hard to hide that (though, if you think about it, On Error Resume Next from classic VB is unimplementable on top of bare try/catch).

...

OK, you nerd-sniped me :) Here’s a toy (no subtyping! no introspection! no condition firewall[2]! no tracebacks! no support for native errors! etc.) condition system in Lua (sorry, nested functions in Python are painful):

  -- save as cond.lua
  
  local M = {}
  
  local error, unpack = error, unpack or table.unpack
  local running = coroutine.running
  local stderr = io.stderr
  local exit = os.exit
  local insert, remove = table.insert, table.remove
  
  -- conditions
  
  local handlers = setmetatable({}, {
      __mode = 'k', -- do not retain dead coroutines
      __index = function (self, key) -- no handlers by default
          self[key] = {}; return self[key]
      end,
  })
  
  local function removing(xs, x, ok, ...)
      assert(remove(xs) == x)
      if ok then return ... else error(...) end
  end
  
  -- establish a handler during call
  function M.hcall(h, f, ...)
      local hs = handlers[running()]
      insert(hs, h)
      return removing(hs, h, pcall(f, ...))
  end
  
  -- signal the given condition to currently active handlers
  function M.signal(...)
      local hs = handlers[running()]
      for i = #hs, 1, -1 do hs[i](...) end
  end
  local signal = M.signal
  
  function M.error(...)
      signal(...)
      stderr:write("error: " .. tostring(...) .. "\n")
      exit(1)
  end
  
  function M.warn(...)
      signal(...)
      stderr:write("warning: " .. tostring(...) .. "\n")
  end
  
  -- restarts
  
  -- invoke the given restart
  function M.restart(r, ...)
      local n = select('#', ...); r.n = n
      for i = 1, n do r[i] = select(i, ...) end
      error(r)
  end
  
  local function continue(r, ok, ...)
      if ok then return ok, ... end
      if ... == r then return false, unpack(r, 1, r.n) end
      error(...)
  end
  
  -- establish a restart during call
  function M.rcall(f, ...)
      local r = {}
      return continue(r, pcall(f, r, ...))
  end
  
  return M
Example: DOS-style abort-retry-ignore prompt implemented in the shell with some support in the (mock) I/O system and no support in the application:

  local cond = require 'cond'
  
  -- common condition types (XXX should use proper dynamic variables instead)
  
  local retry, use = nil, nil
  
  -- I/O library
  
  local function _gets()
      if math.random() < 0.5 then cond.error 'lossage' end
      return 'user input'
  end
  
  local function gets()
      local ok, value = cond.rcall(function (_use)
          use = _use
          local ok, value
          repeat ok, value = cond.rcall(function (_retry)
              retry = _retry
              return _gets()
          end) until ok
          return value
      end)
      -- ok or not, we got a value either way
      return value
  end
  
  -- application (knows nothing about errors)
  
  local function app()
      for i = 1, 5 do print(string.format("got: %q", gets())) end
      return "success"
  end
  
  -- shell
  
  local ok, value = cond.rcall(function (abort)
      return cond.hcall(function (err)
          io.stderr:write("I/O error: " .. err .. "\n")
          while true do
              io.stderr:write("[a]bort, [r]etry, [u]se value? ")
              local answer = io.read('*l')
              if answer == 'a' then cond.restart(abort, "aborted") end
              if answer == 'r' then cond.restart(retry) end
              if answer == 'u' then
                  io.stderr:write("value? ")
                  cond.restart(use, io.read('*l'))
              end
          end
      end, app)
  end)
  print(ok, value)
This is not a perfectly accurate semantic model for real condition system, but it should be enough to give a general idea of how these things work and what the advantage over bare unwinding mechanisms like try / throw or Lua’s pcall / error is.

[1] https://gigamonkeys.com/book/beyond-exception-handling-condi...

[2] https://www.nhplace.com/kent/Papers/Condition-Handling-2001....