JadenSimon / proposal-defer

MIT License
2 stars 0 forks source link

ECMAScript defer Statement

We propose the introduction of a new control-flow statement into ECMAScript: defer. This statement executes code unconditionally at the end of the current lexical scope in a last-in-first-out (LIFO) manner.

defer console.log('world!');
console.log('hello, ');
// hello,
// world!

Motivations

The primary motivation for defer is to simplify state management. Managing the lifecycle of transient state (e.g. flags and counters) and system resources (e.g. file handles, network connections) can be tricky, especially when ensuring cleanup occurs consistently across different state and/or different control flows like exceptions, early returns, or loops. defer provides a straightforward, minimal syntax that guarantees code execution at the right moment, reducing friction, boilerplate, and opportunities for user-error.

Why defer instead of XYZ?

Inspirations

defer has already been implemented and widely used in several popular languages:

The behavior in this proposal aligns nicely with Swift/Zig (LIFO, supports blocks) and is only slightly different compared to Go's function-scoped defer. This proposal adapts defer to fit naturally into ECMAScript while handling the quirks of the language.

Behavior

Grammar

DeferStatement[Await]:
    defer DeferBlockOrExpressionStatement[?Await]

DeferBlockOrExpressionStatement[Await]:
    DeferBlock[?Await]
    DeferExpressionStatement[?Await]

DeferBlock[Await]:
    { DeferBlockBody[?Await] }

DeferBlockBody[Await]:
    StatementList[~Yield, ?Await, ~Return] opt 

DeferExpressionStatement[Await]:
   [~Await] [lookahead ∉ { (, BinaryOperator }] ExpressionStatement[~Yield, ~Await]
   [+Await] ExpressionStatement[~Yield, +Await]

DeferBlock is similar to a class static block except that await is conditionally allowed.

Valid Syntax

Synchronous Defer

defer listener.dispose();
defer console.log('finally!');

defer {
    const data = finalizeData();
    logger().log('category', data);
}

// LIFO ordering
function foo() {
    defer console.log('3');
    defer {
        console.log('1');
        console.log('2');
    }
}

// Output:
// 1
// 2
// 3

// Nested functions
function outer() {
    defer console.log('outer');
    function inner() {
        defer console.log('inner');
    }
    inner();
}

// Output:
// inner
// outer

// Loops
for (let i = 0; i < 3; i++) {
    defer console.log(i);
    console.log('looped');
}

// Output:
// looped
// 0
// looped
// 1
// looped
// 2

// `yield` is _not_ a scope exit
function* gen() {
    defer console.log('done');
    yield 1;
    yield 2;
    yield 3;
}

for (const x of gen()) console.log(x);

// Output:
// 1
// 2
// 3
// done

// `throw` is allowed in blocks, this may "suppress" already thrown exceptions (same as the `using` proposal)
defer {
  throw new Error('uh oh!');
}

// Exceptions can be caught by a containing scope
try {
    defer { throw new Error('uh oh!'); }
} catch (e) {
    console.log(e.message) // uh oh!
}

// Try statements do not change the behavior of `defer` regardless 
// of whether it's in a `try`/`catch`/`finally` block. 

Asynchronous Defer

A defer statement is asynchronous if it contains an await expression. The behavior must be the same as-if the expression or block were inlined at the scope exit. For example, if an await doesn't occur until halfway into a block, the preceding statements must be executed in the same microtask as the previously executed statement.

defer await file.close();

defer await Promise.all([
  resource1.dispose(),
  resource2.dispose(),
]);

defer {
    await resource1.dispose();
    // ... do something else
    await resource2.dispose();
}

// These two `defer` statements clarify the "same microtask" behavior.
// The calls for `first` and `second` must occur in the same microtask 
// because there are no "intermediate" `await` expressions.

defer {
    console.log('second');
    await asyncWork();
    console.log('third');
}

defer console.log('first');

// We can wait for certain things on scope exit while letting other things happen as we move on
// The same could be achieved with a separate async function declaration
defer {
    const task = await myPendingTask;
    if (shouldCancel(task)) {
        console.log('cancelling task without waiting');
        task.cancel().catch(e => console.error('failed to cancel task', e));
    }
}

Invalid Syntax

Situations introduced by defer that lead to SyntaxError:

// We cannot return anything because `defer` statements are always executed after normal terminating control flow (`return`, `throw`, etc.)
// The same applies for `break`, `continue`, and `yield`
defer return foo;
defer { return foo; }

// `defer await` is not allowed in a non-async function or the top-level of a Script
function foo() {
    defer await x();
}

// No import declarations
defer {
    import foo from 'bar'
}

// Cannot appear as the sole statement of an iteration statement
for (const x of arr) defer x.dispose();

let i = 0;
while (i < 3) defer console.log(i++); 

// This is the same behavior as `while (true) const x = 0`
// While technically possible to support, doing so creates a confusing situation, does 
// `defer` execute per-iteration or in the outer scope, that also lacks practical utility.
//
// Omitting the `defer` keyword results in the same net behavior without the ambiguity.

// The following are not allowed because they are both confusing and have no practical value
// Standalone `let`/`const` variable statements:
defer const x = 1;
defer let x = 1;

// Standalone function expressions and arrow functions
defer function fn() {};
defer () => {};

Other situations which lead to SyntaxError (it's the same behavior as try):

// All cases below would result in `SyntaxError: Unexpected identifier 'y'` in V8

// As an expression
const x = defer y;

// In a class body
class MyClass {
    defer y; 
}

Oddities

The flexibility of defer means it can be used in "interesting" ways that may not always have clear behavior for some readers. While the behavior in the spec is clear and consistent with the rest of ECMAScript, the novelty can leave some room for interpretation to readers. Many of these cases will likely be rarely used in practice but can still exist. defer itself does not create these oddities per-se, but rather adds a new mechanism to make them more apparent. This section disambiguates these cases.

Switch Statements

defer statements accumulate in the switch statement block and will execute when leaving said block (via break, return, reaching the end of the block, etc.)

A defer in a case clause that is otherwise not in a separate scope adds to the containing switch block.

switch ('defer-me') {
    case 'defer-me': {
        defer console.log('one'); 
    }
    case 'defer-me': defer console.log('three');
    default: console.log('default');
    case 'defer-me': 
        defer console.log('two');
        break;
    case 'defer-me': defer console.log('last');
}
console.log('outside switch')

// Output:
// one
// default
// two
// three
// outside switch

This behavior is consistent with variable declarations in case clauses:

switch ('foo') {
    case 'foo':
        let x = 1
    case 'foo': x = 2
    case 'foo': {
        let x = 'purple'
    }
    default: console.log(x) // 2
}

Diverging from this existing behavior is not worth it given the likely niche usefulness of defer within switch statements. Advanced ECMAScript users are the most likely to run into this situation, and they're likely to be familiar with the existing behavior.

Parenthesized Expressions

defer followed by a ParenthesizedExpression is interpretted as a CallExpression

// Attempt to call `defer` with the result of `console.log`
defer (console.log(''));

// Normal `defer` statements
defer void (console.log(''));
defer { (console.log('')); }

This is because defer () is already perfectly valid syntax; we cannot break backwards compatibility.

Binary Expressions

TODO: does the grammar define "binary operator" anywhere?

defer followed by a binary operator is parsed as a binary expression for backwards compatibility.

defer + 1; // Adds 1 to the variable `defer`

Potential Usage Concerns

This section addreses common usage concerns from adding defer to the language

LIFO Evaluation

defer statements are evaluated in an order opposite of their appearance in source code. For someone first learning about defer, this can seem unnatural:

defer console.log('one');
defer console.log('two');

// Output:
// two
// one

However, this seemingly unnatural behavior quickly becomes an asset, not a liability:

const r1 = new Resource();
defer r1.dispose();

const r2 = new Resource(r1);
defer r2.dispose();

The LIFO behavior ensures that dependent state is always unwound before its dependencies while still providing a natural ordering of statements. This code would have safety flaws without LIFO:

const r1 = new Resource();
const r2 = new Resource(r1); // `r1` would not be disposed if we fail here!
defer r2.dispose(); // We must dispose of `r2` before `r1`
defer r1.dispose();

In practice, the LIFO behavior generally goes unnoticed by users, minimizing cognitive overhead.

Nested Situations

In all cases, defer only affects the immediate containing scope. So regardless of the depth of nesting, the behavior of each individual scope is well-defined. From there, you can determine the flow of the entire program by chaining together the behavior of each scope.

function bar(i) {
    defer console.log(`defer bar ${i}`);
    console.log(`bar ${i}`);
}

function foo() {
    defer bar(1)
    defer bar(2)
    console.log('foo')

    return () => {
        defer console.log('two');
        console.log('one')
    }
}

foo()()

// Output:
// foo
// bar 2
// defer bar 2
// bar 1
// defer bar 1
// one
// two

While there is some amount of added cognitive overhead from defer, many will find it easier to understand than the equivalent try/finally structure:

function bar(i) {
    try {
        console.log(`bar ${i}`);
    } finally {
        console.log(`defer bar ${i}`);
    }
}

function foo() {
    try {
        console.log('foo');

        return () => {
            try {
                console.log('one')
            } finally {
                console.log('two')
            }
        }
    } finally {
        try {
            bar(2)
        } finally {
            bar(1)
        }
    }
}

Performance Concerns

defer is well-suited for code generation. Conceptually, each statement can be treated as an arrow function with no parameters, pushed onto a scope-isolated execution stack that is consumed on scope exit. Static analysis works well here because defer statements are never conditionally added to this stack.

For a given scope, we can construct a linked-list of defer statements where each node points to the previous statement (likely during parsing). The first defer statement in a nested scope can point to the last encountered statement (if any) in containing scopes. This means you only need a pointer to the most recently encountered defer statement. Upon scope exit, we unwind using this pointer by iterating and executing the statements.

We can use this information to generate machine code directly by understanding that, for a given "exit" point (return, break, continue, throw, block end), the defer statement pointer will always be the same.