New Issue: Supporting initializers that can throw errors

19179, "bradcray", "Supporting initializers that can throw errors", "2022-02-04T00:57:41Z"

It's been Chapel's intention to support initializers that can throw errors, yet we've never implemented them. While reviewing library modules recently, we've found ourselves wanting the feature. This issue is intended to capture that desire.

One of the main challenges that we've discussed in the past is keeping track of where in the chain of field initializations the throw occurs so that those field initializers can be undone, but not the ones that haven't executed yet. For example, given:

proc init() throws {
  this.field1 = x;
  this.field2 = y;
  if (...) then
    throw new Error(...);
  this.field3 = z;
}

the concept would be that if the error was thrown, the initializations of field1 and field2 would need to be undone, but not field3 because it hasn't been initialized yet.

Recently we've been discussing a few simplifying cases that we might consider before tackling the above general case, such as:

  • what if the init() only threw after the explicit or implicit this.complete() had occurred?
  • what if any throwing were to be done in the postinit() method instead? (though that would cause it to apply to all initializer on the type)
    The idea in both cases being that we'd know that all fields had been at least default-initialized, so there'd be no need to keep track of what work needed to be undone.

With a quick check, a week or two ago, I found that if the compiler's error about initailizers not supporting throws is removed that init() can be made to throw; and that with no compiler changes, postinit() can throw. What I didn't realize until today is that (it seems) such errors are always reported as being uncaught, even if the call to new is within a try...catch block. My guess is that the compiler introduces a helper function to invoke the initializer and postinit, and that this function isn't properly propagating the error back to the user code. I.e., that such a helper function ought to be marked throws if either the init or postinit throws (assuming my guess is correct).

I'm also guessing that even though such initializers can throw, they probably don't do any cleanup (e.g., delete the class instance for a class; undo the setting of the fields if anything is needed there).

Thinking about this a bit more, though, I also find myself wrestling with semantic questions, such as:

  • what does it mean to undo a field initialization given that we don't have a general recipe for reversing the consequences of an arbitrary initialization expression?
  • even with the throw occuring after all the fields are initialized, that doesn't seem to necessarily mean that deinit() can be called, since there may be additional semantic computation in the init() or postinit() that is required for the deinit() to be valid? Or, as the class author, is it my job to track such information in the object and write my deinit() to be cognizant of whether or not the initializer completed?

There are almost certainly lessons to be learned here from other languages—I haven't gone looking to try to learn about it yet, though, and mostly wanted to capture my experiences with experimenting with throwing initializers.