tc39/proposal-explicit-resource-management

Stage 3 feedback: can we make the number of using decls that need disposal statically knowable?

Opened this issue ยท 11 comments

syg commented

During implementation we realized the following cursed code is possible:

switch (lbl) {
  case 0:
    using x = init();
    break;
}

AFAIK other than this, declarations aren't conditionally evaluated inside a scope. This weirdness means that you have to check at runtime whether a binding actually has a disposable resource. It'd be nice if all using bindings are unconditionally disposed at scope exit.

While I appreciate that other binding forms are allowed in bare switch cases like that and changing this would break symmetry, using declarations already break symmetry elsewhere. Is there a reason to allow this pattern?

AFAIK other than this, declarations aren't conditionally evaluated inside a scope. This weirdness means that you have to check at runtime whether a binding actually has a disposable resource. It'd be nice if all using bindings are unconditionally disposed at scope exit.

How does this differ from other cases where only initialized bindings are disposed? i.e.:

outer: {
  if (y) break outer;
  using x = init();
} // 'x' isn't disposed if 'condition' was true

for (const y of ar) {
  if (y) continue; // possible early continue before `using`
  using x = init();
}

You have to check at runtime whether the resource is disposable when it is initialized, since you also have to capture the @@dispose method at that time as well. If lbl is something other than 0 in your exmaple, or if y is true in my example, init() is not evaluated and thus x is not initialized and the resource and its @@dispose aren't captured. Not to mention if you have:

{
  using x = init1();
  using y = init2();
}

and init1() throws before y is initialized.

syg commented

Good points! You are right, there are many cases that require runtime tracking of disposable resources in using declarations. The last example is particularly compelling.

I mostly retract this complaint on the basis my original argument was wrong.

But I do think there's something to be said about programmer intent. The conditional initializations in your examples seem to say that absent some exceptional cases, the scope intends to dispose all its using bindings. Having using declarations in bare switch cases just seems always like a mistake: the programmer most likely mistakenly thought that each case has its own block scope.

Having using declarations in bare switch cases just seems always like a mistake: the programmer most likely mistakenly thought that each case has its own block scope.

I'd prefer to remain consistent with let and const here, otherwise scoping rules go completely out the window and resource lifetime no longer matches scope:

let x = 0;
switch (x) {
  case 0:
    const a = 1;
    // falls through
  case 1:
    const b = { value: 2 };
    // falls through
  case 2:
    if (x === 0) console.log(a);
    if (x === 0 || x === 1) console.log(b.value);
}

this prints:

1
2

thus I would expect the following to be the same:

let x = 0;
switch (x) {
  case 0:
    const a = 1;
    // falls through
  case 1:
    using b = { value: 2, [Symbol.dispose]() { } };
    // falls through
  case 2:
    if (x === 0) console.log(a);
    if (x === 0 || x === 1) console.log(b.value);
}

Deviating from that would both break developer intuition based on prior experience with let and const and would not align with the lifetime requirements for using.

I also wouldn't necessarily consider such code to be a mistake as switch cases and fall-through can be beneficial to efficient algorithms that use loop unrolling for performance. If using in a switch case seems like code smell, I'd argue that's what lint rules are for. If a linter enforces a no-using-in-bare-switch-case rule, an experienced developer could override that rule as necessary to write an efficient algorithm. If the language enforces that rule, the experienced developer is at a disadvantage as they must also unroll the disposal algorithm.

As it stands, most linters already enforce a no-fallthrough rule that already addresses that case.

syg commented

thus I would expect the following to be the same:

I would still like the second code snippet to throw a SyntaxError. I don't agree with the claim "otherwise scoping rules go completely out the window and resource lifetime no longer matches scope", because I don't feel that developers, even those with deep spec knowledge, have a consistent model for what the scope for lexical declarations inside switch cases is. Having a static error for a problematic case doesn't reach the level of "scoping rules go completely out the window" to me. I don't think using should do something different in switch cases, but prohibited.

Also, using is already special in other syntactic ways, like disallowing destructuring.

I also wouldn't necessarily consider such code to be a mistake as switch cases and fall-through can be beneficial to efficient algorithms that use loop unrolling for performance.

I have my doubts that using in a bare switch case leads to efficient algorithms. Do you have an example (even a toy one) in mind?

Re-thinking #215 (comment), the difference between control flow like continue and break and using in switch cases is that continue and break can still be statically compiled a fully unrolled dispose loop.

For example,

{
  using a = foo();
  if (cond1) break;
  using b = foo();
  if (cond2) break;
}

can still be compiled to avoid the generic dispose loop. In the following pseudocode, assume bindings with a leading . are internal, that gotos exist, and I've also handwaved away how exceptions are done (the range of code that threw is mapped to L2, L1, etc):

.a_disposable_rsrc = undefined;
.b_disposable_rsrc = undefined
come_from_label = undefined;
try {
  const a = foo();
  .a_disposable_rsrc = GetDisposableResource(a);
  if (cond1) goto L1;
  const b = foo();
  .b_disposable_rsrc = GetDisposableResource(b);
  if (cond2) goto L2;
  goto L_All;
} finally {
  L_All:
  L2: Dispose(.b_disposable_rsrc);
  L1: Dispose(.a_disposable_rsrc);
}

AFAICT the only place where we can't unroll the dispose loop is if a using is present in a switch case. It'd still be nice to not have that if there're no real use cases.

I second Shu's point.

In every case except switch, I believe that you can effectively desugar a using statement to something resembling a try-finally.

For example,

for (const y of ar) {
  if (y) continue; // possible early continue before `using`
  using x = init();
  <body>
}

becomes

for (const y of ar) {
  if (y) continue;
  let x = init();
  let x_dispose = GetDisposable(x);
  try {
    <body>
  } finally {
    Dispose(x_dispose);
  }
}

And Shu's example with two using declarations:

{
  using a = foo();
  if (cond1) break;
  using b = foo();
  if (cond2) break;
}

is equivalent to:

let a = foo();
let a_dispose = GetDisposable(a);
try {
  if (cond1) break;
  let b = foo();
  let b_dispose = GetDisposable(b);
  try {
    if (cond2) break;
  } finally {
    dispose(b_dispose);
  }
} finally {
  dispose(a_dispose);
}

The same is not true in a switch statement, because it would effectively require you to jump into the middle of a try block. If I'm not mistaken, this is the only case where disposal scheduling can't be done statically (modulo null/undefined, where disposal is trivial).

Note that the same problem does not occur in cases like this:

switch (lbl) {
  case 0: {
    using x = init();
    ...
    break;
    // disposal happens here
  }
  case 1:
    ...
}

It's specifically bare switch cases that are a problem. I'm strongly in favour of forbidding them.

Why can't

switch (lbl) {
  case 0: {
    using x = init();
    ...
    break;
    // disposal happens here
  }
  case 1:
    ...
}

be desugared into:

switch (lbl) {
  case 0: { try {
    let x = init();
    let x_dispose = GetDisposable(x);
    ...
    break;
    } finally {
    dispose(x_dispose);
    }
  }
  case 1:
    ...
}

?

syg commented

Why can't

You can if the case bodies have their own blocks. We're complaining about bare case bodies.

ahhh, gotcha - because adding a block would conflict with let/const declarations.

To be clear, the case that I would like to prohibit is this one that Ron posted:

let x = 0;
switch (x) {
  case 0:
    const a = 1;
    // falls through
  case 1:
    using b = { value: 2, [Symbol.dispose]() { } };
    // falls through
  case 2:
    if (x === 0) console.log(a);
    if (x === 0 || x === 1) console.log(b.value);
}

in that case, you can't wrap the entire switch statement, and do all the relevant disposals there?

Not easily.

It would mean that while parsing, if you see a using inside a switch, you'd effectively have to go back in time to the beginning of the switch to retroactively insert the new scope.