Hi all,

This is going to be long, but I've tried to organise my thoughts clearly and as succinctly as possible.

On 26/04/13 18:06, Patrick Walton wrote:
So here are my ideas. They are very worse-is-better at this point.

* Failing to perform a basic I/O operation should result in a call to the `io_error` condition (default behavior: fail task) and should set an internal flag saying that this stream is dead.

Agreed.


Stream constructors like `open` should return a dead stream.

This would be a relatively ugly approach, to my way of thinking. Why should a dead stream be returned at all, if the code to create it failed? Why should I be able to call write() on something that could not be created?


Operations like read or write on dead streams should be no-ops.

I think this should cause a task failure (maybe even a hard program assertion failure), rather than doing nothing, BUT, it should be almost impossible to cause, because nothing should return a bad stream, and code that "breaks" a stream should be forced to deal with it or be unwound, rather than simply continuing.


* Stream readers such as `read_uint` should be duplicated into two methods for convenience: `read_uint` and `try_read_uint`. The former just delegates to the latter with `.get()`.

This is pretty bad, imho -- enough to spur me into writing long emails with alternatives ;)

That system would cause a massive development burden for library writers/users, create much larger APIs, create too many similar functions, etc. It would mean code duplication, more bugs, more attack vectors, more confusion, and so on. I've used APIs like this, and they're not fun. Nor is working on a code base that uses them inconsistently.


For instance, why should a try_read_uint exist, if you can:

   // assign a handler for read failures
   { read(); }
   // remove the handler

AND have that handler code implemented just once, in a library?


The only problems I can see are that:

1. Handlers for x() might be slow compared to a if try_x() statement.
2. The "do condition::cond.trap(handlerCode) actualcode" syntax is
   pretty unwieldy, and I'm not sure I like it being right at the top
   of the do block, potentially a long way from the code it affects.

I think both of those should be addressed at a language/core level, rather than duplicating lots of functions in every module/lib. I tend to think, if a few developers can do something once, to save tens or hundreds or thousands of users doing the same thing, all in their own slightly different ways, with their own bugs, time loss, incompatibilities, etc., then it should be done right the first time.


So, FWIW, since aspirational examples were mentioned, I would aim for something more like this:

   try {

        handle io::conditions::diskFull with io::handlers::purgeTempFiles;

        let fp = io::open(somePath, io::mode::write);
        fp.write(someBytes);

   } catch (cond : &io::Condition) {

        io::println(fmt!(
            "Some non-disk-full error (%?) occured, OR we couldn't
   purge disk space."
            "I don't know how to handle this. Rethrowing.",
   cond
   ));
        raise cond;

   } finally {

        // Restores the default handler state before we changed it above.
        // it might restore core::throwHandler as the handler, for example,
        // which just throws up the stack and unwinds, like other
   language's
        // exceptions would.
        //
        // NOTE:  To reduce boilerplate, this should probably be
   implicit in
        //             exiting the try block, rather than requiring a
   manual finally
        // block entry.

        handle io::cond::diskFull with super;

   }


In other words, I'm imagining a language where:

 * You have Conditions, built by ConditionFactories. The casual user
   doesn't need to care, he just uses the API and handles its Conditions.

 * ConditionFactories interface with the call stack to allow building a
   stack of Handlers for the Condition.

 * Conditions are ALSO usable just like exceptions: they're /throwable/
   & /catchable/, by naming the specific class you want to catch, or by
   naming a base class/trait.

 * The only real difference between catching a thrown Condition and
   handling one would be that:

     o In-place Condition handling is done with a closure or a function
       call
     o Thrown Conditions first use a special in-place Handler that
       unwinds the stack, passing the condition up the stack to a catch
       block, or, if no more catch blocks exist, to terminate() (or
       Rust's equivalent).

 * If you don't set a Handler, you get the default behaviour defined in
   that Condition class, or, if that is not set, the language/library
   default for /any/ Condition class, which is probably throwing, with
   the *option* of catching.

 * While a Condition can be caught by catch() {}, handled by a
   function, or handled by a closure, all three methods take a
   compatible argument: a Condition trait, or a variation.  This allows
   code sharing, aids familiarity, etc.

 * Condition handling code ends by calling one of:

       cond.retry()

           To attempt to retry the code that raised the condition.  I
           confess, I've little knowledge of how this works in Rust,
           but I imagine it's something like a guarded try block, with
           potential to become like STM.

       raise cond

           To throw the condition to a catch () block further up the stack

 * The Condition trait would support retrieving the stack frame, the
   file/line which the condition occurred at, etc., through methods.

 * In the case of catch blocks, some of that information would have
   been built up during the stack unwind, before the information was lost.

 * Conditions passed to in-place code where no stack unwinding is
   needed, would LOOK the same to the user, but they'd be FASTER, because:

     o The handling code would (interchangeably) be closures or functions.
     o That code would ideally be inlined whenever advantageous.
     o The Condition object would not be built up ahead of time, since
       it might not be needed, and all the information would be there
       in the current and previous stack frames anyway. There's no need
       to package all that information unless the Condition is thrown.


*_Variations_*_
_

 * Some kind of tracking of previously set handlers is needed. This
   could be as simple as setting tempory variables.  However, it could
   be useful to allow a full stack of handlers, on a per-call-stack
   basis, so that raise cond in inline handlers calls the previous
   handler, until it reaches the throw handler, and then catch blocks
   take over.  Using raise cond in those rethrows cond up the stack.

 * As a less invasive proposal, to avoid adding the handle keyword,
   this sort of syntax could be used:

   try {

       io::cond::diskFull.push_handler(io::handlers::purgeTempFiles)
       // some code

   } finally {

       io::cond::diskFull.pop_handler();// again, this could be
       implicit if pushes happen in the try block

   }

   This is closer to Rust's existing condition handling (if I've been
   looking at the latest version).  However, I think it's less elegant,
   even if it does save keywords.  IMHO, error handling is SUCH a big,
   frequent thing in languages, that it deserves a few keywords of its own.


The full concept above would require only two new keywords: "handle", and "catch", along with some simple new statements. I'm not qualified to implement it in a compiler, but I believe it would be minimal effort, over the basic throwable exception model. With the alternative syntax, only "catch" would be new, and optional.

To me, the extra keywords/work seem like a small price, for what we'd gain in clarity, elegance, and flexibility. We'd have the best of two popular error handling models, to choose from at will, with a nice syntax, too.


--
Lee

_______________________________________________
Rust-dev mailing list
Rust-dev@mozilla.org
https://mail.mozilla.org/listinfo/rust-dev

Reply via email to