chapel-lang / chapel

a Productive Parallel Programming Language
https://chapel-lang.org
Other
1.77k stars 417 forks source link

Ideas to allow localized error handling? #14104

Open BryantLam opened 4 years ago

BryantLam commented 4 years ago

Usability problem statement for nilability. The actual problem is with localized error handling of exceptions that becomes exacerbated with nilability.

Possibly a non-issue, though it's annoying to me. If you have a function that can throw, but the point at which it throws is limited to essentially one variable assignment, you have to wrap the entire block of (possibly lots of lines of code) in a try-catch even though the failure can only occur at one point near the top.

C++ and Java have the same problem, but because nilability makes it so you can't defer initializing a non-nil-typed variable, the variable declaration has to be within the try-catch instead of outside the try-catch. In C++/Java, you can simply just move the variable declaration out of the try-catch and do the assignment from within like what I would want (I think).

class Map {
  type K
  type V; // Constraint: V must be a class.
  var D: domain(K);
  var map: [D] owned V; // or shared

  proc insert(...): ... { ... }
  proc lookup(k: K): borrowed V throws { /* lookup can fail, so throw MyError */ }
}

proc main() {
  var m = new owned Map(string, MyClass);

  try {
    var v = lookup("string literal");

    /* Lots of lines of source code. Do stuff with v. */
  } catch e: MyError {
    // Handle lookup failure.
  } catch {
    // Handle general failure.
  }
}

Brainstorming: maybe it would be worthwhile to allow deferred initialization? I could then write code like:

  var v: borrowed MyClass; // non-nilable
  try {
    v = lookup("string literal");
  } catch ... {}

  /* Lots of lines of source code to do stuff with `v`. */

Likely an intractable solution given that containers with non-nilables will be difficult to check at compile-time.

Other ideas?

(Of course, this doesn't work if the e.g., lookup function returned a shared and var v is still a borrow, but that becomes a lifetime issue that the user got wrong.)

In any case, it might be a non-issue as something to live with for now.

mppf commented 4 years ago

Other ideas?

The way I would resolve this problem is by creating more functions. You can: a. Move the "Do stuff with v" lines into their own function b. Create a function that encapsulates the lookup + error handling catch clauses you want, and call that like var v = myLookup(something).

mppf commented 4 years ago

you have to wrap the entire block of (possibly lots of lines of code) in a try-catch even though the failure can only occur at one point near the top.

Also, this is only the case in a non-throwing function, or in a setting where you have particular error handling in mind for certain errors, right? I.e., in many cases, simply letting the outer function be throws is going to avoid the issue?

cassella commented 4 years ago

a. Move the "Do stuff with v" lines into their own function b. Create a function that encapsulates the lookup + error handling catch clauses you want, and call that like var v = myLookup(something).

Option b still requires that when the lookup fails, the error handling in myLookup() either halt or be able to return a non-nil v anyway, right? Otherwise the call has to be in a try or try!, or can't be assigned to a non-nilable variable, and ends up looking the same.

Or maybe a different combination of a and b,

c. Encapsulate the lookup and "Do stuff with v" into a new function, and have that function use a try expression to pass errors from lookup back up?

proc DoStuffForX(x) throws {
  var v: borrowed MyClass = try lookup(x);

  /* Do stuff with v */
}

proc Caller() {
     try {
         DoStuffForX(x);
     } catch {
         ...
     }
}

Though now Caller() can't tell if errors it catches came from the lookup() or the /* Do stuff */ code. And/or was the point you wanted the /* Do stuff */ code to not be in a throws region?

(E.g. you don't think any of it throws, and you want the compiler to tell you if you're wrong? (I guess you could put that code into another new non-throwing function.))

My feel is the "create more functions" approaches all seem like they're forcing the programmer to decompose their problem into functions not in the most natural, elegant, or performant manner, but in the manner that will appease nil checking and error handling.

Option a seems the least onerous, but still unpleasant if the code naturally would be something like

proc Caller(a, b, c) {
  var x, y, z = things();

  var v1 = lookup(x, x);
  var aa = doStuff(v1, y);
  var v2 = lookup(aa, z);
  var bb = doStuff(v2, z);
  var v3 = lookup(bb, v1);
  /* do something with v1, v2, v3, a, b, and c */

That is, the "do stuff with v" lines need to repeat this pattern. So if I follow, the aa through "do something" lines would be moved into a new function. But in that function, the same problem arises, and the bb through "do something" lines need to be moved into a further subfunction. And that subfunction needs access to variables from Caller's scope, so they have to be threaded all the way through.

And notionally, the code really just wants to be one function, not three.

How about something like

var vtmp: borrowed MyClass?;
try {
    vtmp = lookup(...);
} catch ... {
    // error handling that halts or returns or sets vtmp some other way;
}

var v: borrowed MyClass = vtmp!;

/* Do stuff with v. */
mppf commented 4 years ago

Option b still requires that when the lookup fails, the error handling in myLookup() either halt or be able to return a non-nil v anyway, right?

I was imagining that it would create a new value return, e.g.

proc myLookup(k: K): borrowed V {
  try {
    return lookup(k);
  } catch e:UnknownKeyError {
    var dummyValue = new owned V();
    store(k, dummyValue);
    return lookup(k);
  }
}

I don't think such logic is appropriate in all cases.

How about something like

This is pretty similar to what I was thinking; just not putting the try/catch into myLookup.