Skip to content
This repository has been archived by the owner on Jan 25, 2022. It is now read-only.

How does Nil affect non optional function calls #2

Closed
xtuc opened this issue Jun 5, 2017 · 29 comments
Closed

How does Nil affect non optional function calls #2

xtuc opened this issue Jun 5, 2017 · 29 comments

Comments

@xtuc
Copy link
Member

xtuc commented Jun 5, 2017

Initial discussion from Babel's Slack

From claudepache/es-optional-chaining:

Technically the semantics are enforced by introducing a special Reference, called Nil, which is propagated without further evaluation through left-hand side expressions (property accesses, method calls, etc.), and which dereferences to undefined (or to /dev/null in write context).

In this example what would be the ouput if b is null?

a?.b()

I expect this to throw but as far as I understand Nil will be propagated and b will not be called.

a?.b?.()

In the latter case we're using the optional chaining syntax so that the function call b() is conditional

What is the difference between the two examples?
What do you think about that?

@davidyaha
Copy link

davidyaha commented Jun 5, 2017

As I see it, this:

a?.b()

Should be exactly like

(a?.b)()

And so, it should throw.

But this:

a?.b?.()

Should behave like so:

typeof (a?.b) === 'function' ? (a?.b)() : null;

And so should just give up on calling the function.

More examples can be found here

@ljharb
Copy link
Member

ljharb commented Jun 5, 2017

Yes, I agree (with the caveat that it's still an open question if the last conditional check is typeof function or == null)

@davidyaha
Copy link

I believe that checking if it's a function gives us not only more useful operator, but also it seems more intuitive as the only thing I meant to write is parentheses for function call.

I mean, without the new optionals I would write

func();

which (on my chrome console) will throw me the error Uncaught TypeError: func is not a function

Then iterating on my code trying to fix the problem I will just change it to:

func?.()

and expect it to not throw me the same error again..

@ljharb
Copy link
Member

ljharb commented Jun 5, 2017

I agree, but it makes ?. not be "always checking for == null" which is a potential consistency issue.

@davidyaha
Copy link

Which brings back the ?() syntax 🎉 😁

But seriously, I think the way of thinking about it is as another operator really which is made of these ?.( characters bunched together.

I think this fits with the change from "null coalescing" to "optional chaining" as the later is more abstract as to what the ?. really does.

@yuchi
Copy link

yuchi commented Jun 5, 2017

Optional chaining is a specific usage of a soaked access, which includes soaked method invocation.
So having both ?. and ?( IMHO is more rigorous and correct.

o?.() is not an optional chain, but a soaked method invocation.

@jridgewell
Copy link
Member

jridgewell commented Jun 6, 2017

I'm going to reference several points here. Let's start with regular member access:

// Member Access
a.b?.c.d

// Rough ES5 
(a.b == null) ? void 0 : a.b.c.d

Here, optionality isn't deep. Only b may be nil, if a or a.b.c is it's got to throw.

// Delete
delete a.b?.c.d

// Rough ES5 
(a.b == null) ? void 0 : delete a.b.c.d

Notice the delete has been moved inside the alternate. Why? Because delete (a.b == null ? void 0 : a.b.c.d never deletes anything.

// Left hand assignment
a.b?.c.d = 42

// Rough ES5 
(a.b == null) ? void 0 : a.b.c.d = 42

Again, we have = 42 moved inside the alternate. Because expressions can't be the left hand side of an assignment.


Call cases:

func?.()

Should throw for consistency if func = null (or it's not declared yet). Doing typeof func == 'function' breaks consistency in two ways:

  1. All other ?.s only check for null
  2. We've added resolvable-ness to the mix without an equivalent to Identifiers.

Now what should deep calls do?

a.b?.c.d()

// Rough ES5
(a.b == null) ? void 0 : a.b.c.d()       // Option 1
((a.b == null) ? void 0 : a.b.c.d)()     // Option 2

I'm in favor of option 1. It matches the the delete and assignment cases from above (call gets moved into the alternate). And it reduces the use of the ugly ?.( optional call syntax and the mess we'd have to transpile it to (context, Function#call usage, lots of "built code").

We can do option 2, but I dislike it. Imagine the ES5 we'd write today to get this same kind of "call this always" (I'm omitting direct translation from option 2 back into IfStatements, because it's just silly):

let func;
if (a.b) {
  func = a.b.c.d;
} else {
  func = noop;
}
func();

To keep this context, it'd be even uglier:

let context;
if (a.b) {
  context = a.b.c;
} else {
  context = { d: noop };
}
context.d();

I just don't image people would really write that. Instead, they'd move the call into the consequent:

if (a.b) {
  a.b.c.d();
}

// Let's write that as an expression:
(a.b == null) ? void 0 : a.b.c.d();

That's option 1 above. 😀

@Jessidhia
Copy link

Looking from a types point-of-view, a?.b.c.d?.() would mean that, not only I accept that a can be null|undefined, but I also accept that, even if a is an object that does have a valid .b.c chain, that a.b.c.d is allowed to be null|undefined. That should not necessarily be the case.

@ljharb
Copy link
Member

ljharb commented Jun 6, 2017

@jridgewell You've laid out the options quite well, I think - thank you.

I can accept that a?.b.c is saying "if a is not nullary, give me a.b.c, else give me a" - I think that follows well. It's basically a shortcut for conditional member access - ie, a ? a.b.c : a.

However, I strongly disagree with option 1; I think a?.b.c() should always be equivalent to (a?.b.c)() - which requires that the invocation be treated separately from the member access.

I would not want to see the proposal advance if a single ?. would put the rest of the chain in some kind of magic mode where invocations, too, are soaked up.

What about a?.b.c().d.e? Does the invocation magically stop the chained soaking, or does it continue? If the former, then I'd expect a?.b.c() to throw when a is nullary, for consistency. The latter seems like way too much magic for me.

@jridgewell
Copy link
Member

I'd see it as:

a?.b.c().d.e

(a == null) ? void 0 : a.b.c().d.e;

I don't understand what "soaking" means, though. If we go back into IfStatements:

if (a != null) {
  a.b.c().d.e;
}

That seems perfectly normal to me (ignoring not doing anything with the expression in the consequent). I don't understand why'd there'd be a throw in that:

if (a == null) {
  throw new Error('why?');
} else {
  a.b.c().d.e
}

I like @Kovensky's example too. In this code, only a might not exist. If it does, it's guaranteed to be of some object type that has a b.c(), and that call returns an object that has d.e.

@ljharb
Copy link
Member

ljharb commented Jun 6, 2017

I do see the logic for your approach.

I think that having "add parens" (which is indistinguishable from "store in a var, and reference the var in the next statement") drastically change the behavior/meaning is very problematic.

@xtuc
Copy link
Member Author

xtuc commented Jun 6, 2017

In the first transformation implementation, in the context of a optional function call I used Function() for the Nil reference.

This avoid an IfStatement and matches more to the spec (IMO) since i'm propagating the Nil reference.

The Nil reference explanation was relevant to me.

@jridgewell
Copy link
Member

jridgewell commented Jun 6, 2017

Oh, I think I get what you're thinking now:

a?.b.c().d.e

// Option 2 interpretation
((a == null) ? void 0 : a.b.c)().d.e

Translating this back into statements:

let c;
if (a != null)
  c = a.b.c;
}
c().d.e

Which again, I don't think people would actually write.


Translating it into an optional call, we get something like:

a?.b.c?.().d.e

// Option 2 interpretation
(((a == null ? void 0 : c = a.b.c) == null) ? void 0 : c().d.e

// statements
let c;
if (a != null) {
  c = a.b.c;
}
if (c != null) {
  c().d.e;
}

Which is ok, I guess. But man, it's gonna be slow (need a Function#call), means c can be null (@Kovensky's comment) , and its now requires two ?.s if there's any optionality in the callee.

Trying to default with this interpretation kinda sucks, too:

// Option 2 interpretation
// With "always" call
(a?.b || { c: () => ({d: { e: "default" } }) }).c().d.e

// With optional call
(a?.b.c?.() || {d: { e: "default" } }).d.e    // because `c == null ? void 0 : c()`
// statements
let c, ret;
if (a != null) {
  c = a.b.c;
}
if (c == null) {
  ret = {d: { e: "default" } };
} else {
  ret = c() || {d: { e: "default" } };
}
ret.d.e

I really dislike these default approaches because it duplicates so much of the chain. The only way out it to use 3 ?.s (and this has to coalesce c's return value):

a?.b.c?.()?.d.e || "default"

// statements
let c, ret;
if (a != null) {
  c = a.b.c;
}
if (c != null) {
  ret = c();
}
if (ret == null) {
  "default"
} else {
  ret.d.e || "default"
}

Instead, option 1's seems much simpler:

// Option 1 interpretation
// With "always" call
a?.b.c().d.e || "default"
// statements
if (a == null) {
  "default"
} else {
  a.b.c().d.e || "default"
}

// With optional call
a?.b.c?.().d.e || "default"
// statements
let c;
if (a != null) {
  c = a.b.c;
}
if (c == null) {
  "default"
} else {
  c().d.e || "default"
}

Sorry if taking over the entire conversation here. I'm really just trying to think through all the possibilities.

@xtuc
Copy link
Member Author

xtuc commented Jun 6, 2017

Yes that's why I used Function() instead of an IfStatement.

b.?().e

Assume b is null, I would transform it into:

((b == null) ? Function : b)().e

Function().e returns undefined.

But the discussion is more about the syntax and the expected behavior, not about how effectively transform a function call.

@ljharb
Copy link
Member

ljharb commented Jun 6, 2017

Calling Function() is observable, fwiw - perhaps not in the spec, but certainly in the transpiler. You'd want babel to convert it to () => {} to avoid the observability.

(Also, I think CSP will error out in invoking the Function constructor)

@rattrayalex
Copy link

rattrayalex commented Jun 7, 2017

I'm curious; what is unexpected about a?.b.c() compiling to a == null ? a : a.b.c(). Is unexpectedness the problem?

In case it's at all helpful, I was not able to find any signs of confusion about this aspect of the feature in CoffeeScript on stackoverflow or github; it might be useful if others could find this (there are plenty of questions about the feature, just none about this aspect).

C# also short-circuits, and I was similarly unable to find signs of confusion/surprise.

Are there any signs of "Option 1" behavior causing problems in the CoffeeScript or C# communities? If not, are there reasons it would cause problems in EcmaScript?

@rattrayalex
Copy link

rattrayalex commented Jun 7, 2017

In terms of a == null ? a : a.b.c() being "magical", it's simply a question of crawling up through both MemberExpressions and CallExpressions. Both node types are very well-understood to be "parts of a chain", among the community and in the parser (eg; babylon's parseExprSubscripts).

@ljharb
Copy link
Member

ljharb commented Jun 7, 2017

@rattrayalex it's that putting arbitrary parens around any part of a chain doesn't change its meaning currently (afaik); this proposal changes that.

@jridgewell
Copy link
Member

Can you explain further? Where would arbitrary parenthesis change the meaning?

@rattrayalex
Copy link

rattrayalex commented Jun 7, 2017

@jridgewell see @xixixao's comments on #3.

I think it's an interesting argument.
Parens can only be added for precedence in a chain if the parens start at the beginning, since anywhere else they'd be a call. But all elements of a chain have the same precedence, so parens couldn't change their order. That changes here, where (x?.y).z should become (x == null ? x : x.y).z. (I don't believe the current implementation does this).

Personally that seems totally fine to me, as parens have always been available in chains but were never useful, but their addition only does what one would expect given their usage elsewhere in the language (and in virtually all programming languages).

But I see where @ljharb is coming from (thanks for the quick, concise explanation!). Hopefully I didn't butcher his reasoning.

@jridgewell
Copy link
Member

jridgewell commented Jun 7, 2017

I don't believe (x.y).z and x.y.z are different in AST (I know they're not in Babel's), so (x?.y).z and x?.y.z are the same. Do other JS ASTs differ?

Checking coffeescript, they definitely treat it differently. But C# follows what I wrote above (x?.y).<=> x?.y.z. And Ruby follows #3's OP, so they're completely on their own.

If you wanted to accomplish precedence like this, wouldn't you have to do it like:

(0, a?.b).c

@ljharb
Copy link
Member

ljharb commented Jun 7, 2017

Another consistency issue is that a.b.c() is always the same as const f = a.b; f.c() - a?.b.c() would not be the same as const f = a?.b; f.c().

@jridgewell
Copy link
Member

That' more #3's discussion, a?.b.c and f = a?.b; f.c are different, too. These two issues are so similar. 😖

@xtuc
Copy link
Member Author

xtuc commented Jun 7, 2017

Should we close this in favor of #3 ?

@jridgewell
Copy link
Member

I think this is a separate discussion that just has to wait for #3. If we decide a?.b.c can throw on .c, then this issue can be closed. Otherwise, we still need to discuss it.

@littledan
Copy link
Member

Personally, I would've expected the chaining/short-circuiting semantics to apply to either both or neither. The midpoint feels odd to me, since it's pretty common to have a chain of property accesses and method calls.

@claudepache
Copy link
Collaborator

To answer the original question. Concerning what is specified on claudepache/es-optional-chaining:

In this example what would be the ouput if b is null?

I presume you wanted to ask “if a.b is null”.

a?.b()

I expect this to throw but as far as I understand Nil will be propagated and b will not be called.

If a itself is not null/undefined, no Nil reference is produced, and it will throw.


Concerning short-circuiting: Often, the distinction between property access and method call is not semantically relevant as in document.body vs. document.querySelector('body'), so that we should treat both cases the same way, whatever the fate of the short-circuiting feature will be.

@claudepache
Copy link
Collaborator

Note also the notion of Nil is a spec artefact and is not how a user should think the feature.

The semantics as I envision is basically:

If the subexpression at the left of ? evaluates to null/undefined, the rest of the current chain consisting of property accesses, method or function invocations, object construction (new), and tagged templates is skipped, and the whole chain evaluates to undefined.

(“Object construction”, and “tagged template” are here because they are at the same level of precedence in the spec, and they may be assimilated to method/function invocations.)

@claudepache
Copy link
Collaborator

claudepache commented Jul 26, 2017

With #20, there will be no more Nil reference, so that the title of the issue will be obsolete.

I'm closing this issue in favour of #10.

As a side note, in a?.(), the check is a != null, not typeof a === "function". Providing a non-null, non-function value is most probably a bug and needs to always fail in order to help debugging. If you disagree, open a new issue with use case.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants