Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Add invokeable?(...) as short hand for invokeable?.Invoke(...) and add support for any "invoke-able" type #3257

Closed
nietras opened this issue Mar 9, 2020 · 37 comments

Comments

@nietras
Copy link

nietras commented Mar 9, 2020

Proposal: Add invokeable?(...) as short hand for invokeable?.Invoke(...) and add support for any "invoke-able" type

It is proposed to extend and generalize the delegate(...) invocation operator to reduce the amount of repetitive code and support new scenarios for succinct invocation of types following a specific pattern. Such a type is referred to as an "invoke-able" type in this proposal and is any type having any method called Invoke or types made invoke-able via extension methods. Hence, C# should use structural matching for the invokeable(...) and invokeable?(...) invocation operator and not match on whether a type is a delegate.

There are two parts to the proposal:

  • Allow invocation to support the null-propagation operator i.e. action?()
  • Extend invocation () to structurally match any type with one or more Invoke methods available incl. generic methods and extension methods, perhaps even static types.

See Member access operators for existing operators.

In this proposal examples are in C# 8 with nullable enabled.

Motivation

A common and easy pattern to use for logging is to simply use/inject a delegate Action<string>. Allowing this to be null with the meaning that logging is disabled. This is simple to implement and has low coupling. But is also a bit tedious currently in C#:

public void M(Action<string>? log) => log?.Invoke("test");

compared to how this is if this couldn't be null:

public void M(Action<string> log) => log("test");

Proposed Solution

Hence, the first part proposes changing this to allow the null-propagation operator for invocation. It already exists for methods ?. and indexers ?[] and it seems natural to extend that so the following is allowed and is simply a short hand for ?.Invoke(...). With null-propagation being exactly the same. delegate() just emits delegate.Invoke() in IL so this does not change generated code.

public void M(Action<string>? log) => log?("test"); // log?.Invoke()

Other examples:

public int? M(Func<int>? get) => get?(); // get?.Invoke()
public string? M(Func<string>? get) => get?(); // get?.Invoke()

Similar Patterns

Below are examples of existing null-propagation for comparison.

public int? M(int[][][] values) => values?[2]?[1]?[0];
public int? M(Foo? foo) => foo?.Bar?.M();

Extended Solution

Since, the invocation operator () for delegates is simply a short hand for .Invoke(), it is also proposed to extend the invocation operator () for any scenario where a type or instance has any method called Invoke available. Whether it be a member method or an extension method. This helps a lot in patterns that define first class invoke-able types. Hence, the invocation operator should be available as soon as the compiler can find a structural match. Examples are given in the two sub-sections.

In fact, this could also be extended to static classes with static methods called Invoke. One could simply invoke this directly. This is up for discussion and not part of the main proposal here. Example can be seen below.

public static class Foo { public static int Invoke() => 42; }
public int M() => Foo(); // Foo.Invoke();

what benefit would there be to this... don't have any good examples now which is also why this is not part of main proposal.

Member method example

public interface IFunc<TResult> { TResult Invoke(); }
public int M<TFunc>(TFunc compute) where TFunc : IFunc<int> => 
    compute(); // compute.Invoke();
public int? M<TFunc>(TFunc compute) where TFunc : IFunc<int> => 
    compute?(); // compute?.Invoke();

the above example is a pattern I have used a lot for numerical libraries where one need to apply a "functor" to each element in an "array". By defining a value type TFunc one can get method inlining which is cruzial for performance. The desire is code generated specifically for that TFunc. Trading code size for performance. Often this involves unrolling loops and hence a lot of .Invoke( in the code. This is just an example and the main point is this gets more succinct with the invocation operator being available for the types involved. Performance will not change in any way for this proposal. IL is the same.

Extension method example

public struct Foo { public int Test() => 42; }
public static class FooExtensions { 
    public static int Invoke(this Foo foo) => foo.Test(); 
}
public class Bar { 
    public int M(Foo foo) => foo(); // foo.Invoke();
    public int? M(Foo? foo) => foo?(); // foo?.Invoke();
}

Above shows how this would also work for extension methods.

Future

In the future C# might get static delegates, traits and hopefully many other things. By extending the invocation operator and adding null-propagation invocation operator these will make it natural to invoke any type or instance that is invoke-able. Hence, Action<> and Func<> like types get easy to use invocation operator support.

There already is a proposal to extend using to be based on structural matching, which makes a lot of sense in the face of ref-types. cc: @gafter
#1623

Generally, I think it would be preferable to direct C# towards being based more on "patterns" rather than exact type matching, since that creates a direct coupling between runtime types and language. When possible this would make C# more "free" and open to other use cases. Letting new code patterns could emerge.

@jnm2
Copy link
Contributor

jnm2 commented Mar 9, 2020

Overlaps or duplicates #95

@nietras
Copy link
Author

nietras commented Mar 9, 2020

overlaps or duplicates #95

Searched and searched but didn't search for "functor" 🤦‍♂️😅

@Joe4evr
Copy link
Contributor

Joe4evr commented Mar 10, 2020

log?("test")

I have a feeling the parser isn't going to like this, since while the statement is still incomplete, it'll have to guess between "is this a null-safe invoke of an invokable?" and "is this an unfinished conditional expression (condition?(true-branch))?". The IDE experience would be terrible if it's ambiguous all the time.

@iam3yal
Copy link
Contributor

iam3yal commented Mar 10, 2020

We can also go with a slightly different syntax like foo:() and foo:?() to indicate that we make a call to function object as opposed to a method, just an idea.

@CyrusNajmabadi
Copy link
Member

THe former is already legal. i.e. x ? foo : (...) ...

@iam3yal
Copy link
Contributor

iam3yal commented Mar 10, 2020

@CyrusNajmabadi True, maybe something similar, dunno.

@nietras
Copy link
Author

nietras commented Mar 15, 2020

Overlaps or duplicates #95

Well doesn't duplicate 100% so keeping this open, since I think both null-coalescing operator and extending the usage ties together. Which brings me to null-coalescing operator issues.

parser isn't going to like this, since while the statement is still incomplete, it'll have to guess between "is this a null-safe invoke of an invokable?" and "is this an unfinished conditional expression (condition?(true-branch))?". The IDE experience would be terrible if it's ambiguous all the time.

In what cases would this be ambiguous? The type of the condition/expression before ? should be determinable and hence the compiler/intellisense can selectively show what fits this. Not unlike what already happens in existing null-coalescing scenarios. Or when a user write ( after a ?, it has to be determined what was the type of the statement before ? and then suggest based on that, if that is not determinable/easy or whatever, current behavior is to show nothing. So I don't see a major issue here. This can all be handled.

One interesting case would be what happens in the face of implicit conversions. E.g. given a type:

public struct FooBoolean
{
    public bool Value => false;
    public int Invoke() => 42;
    public static implicit operator bool(FooBoolean foo) => foo.Value;
}

this does not work currently if foo is nullable:

    public int? N(FooBoolean? foo) => foo ? (42) : 16;

it fails with the following error:

image

It works fine when foo is not null of course:

    public int? N(FooBoolean foo) => foo ? (42) : 16;

But in neither case is there any intellisense help or suggestions when writing ?(. And if null-coalescing invocation operator is added it should be easy enough to determine whether foo is invokeable and whether foo is implicitly convert-able to a bool. One could then err on the conservative side and not show any completion for ( when an implicit conversion to bool exists.

Hence, overall I don't see an issue here. :)

@gafter
Copy link
Member

gafter commented Mar 15, 2020

The type of the condition/expression before ? should be determinable and hence the compiler/intellisense can selectively show what fits this.

No, types are not known in the parser. Parsing determines the syntactic structure of the expression before its types can be determined.

@nietras
Copy link
Author

nietras commented Mar 15, 2020

No, types are not known in the parser. Parsing determines the syntactic structure of the expression before its types can be determined.

Does that mean that you think parsing ?( as something other than ? () : ; is not possible? Hence, ?( is forever bound to the ternary operator?

@YairHalberstadt
Copy link
Contributor

At the syntax level, foo?(bar)?(bar) : null is ambiguous between foo?.Invoke(bar) ? bar : null and foo ? bar?.Invoke(bar) : null so I think this is genuinely impossible to parse.

@nietras
Copy link
Author

nietras commented Mar 15, 2020

Yes I can see that it is definitely not easy at parser only level :) Alright, feel free to close this PR if null-coalescing invocation operator is deemed impossible. The other part of this proposal seems to be already covered.

@gafter gafter closed this as completed Mar 15, 2020
@AartBluestoke
Copy link
Contributor

@gafter isn't that just an operator precedence issue? we don't complain about many other issues where the syntax has alternate interpretations depending on the implied brackets:
foo?(bar)?(bar) : null would always either equal (foo?(bar))?(bar) : null or foo?((bar)?(bar)) : null depending on the precedence of nullable-invoke vs terniary if. If it is the first, then it is "easy" to parse: if the next ternary location contains a "?" then you previously had an invoke, and that one is the next one to consider, else have a ":" and that first "?" was a terniary.

@YairHalberstadt
Copy link
Contributor

depending on the precedence of nullable-invoke vs terniary if.

I don't think this is true. Precedence tells me, once I've parsed the operators, where do I put the brackets. Here we don't even know how to parse the operators.

@AartBluestoke
Copy link
Contributor

i don't see that it is impossible to parse?

Given

 a is a string of alphanumeric chars (a name-of-a-thing, can't think of the precise language name for this)
 b is the next chunk of text out to the matched bracket (typically 0..n comma separated expressions)
 c is the rest of the expression.

then to parse the following expression:
a?(b)c
if c begins with ":" then the ? was a ternary, and b was an expression, else it is a nullable function call and b was a parameter list.
(some condition, else it was a syntax error)

@YairHalberstadt
Copy link
Contributor

then to parse the following expression:
a?(b)c
if c begins with ":" then the ? was a ternary, and b was an expression, else it is a nullable function call and b was a parameter list.
(some condition, else it was a syntax error

This is valid today and doesn't fit those rules:

condition ? (X).Foo : Bar

@Rekkonnect
Copy link
Contributor

When it comes to the parser, it will definitely increase parsing time when using the ternary operator and the ?( operator, but will not ever have an ambiguity. No finalized invocation expression will ever be directly followed by a :, and in the case that it might appear afterwards, the parser will have already finalized the other expressions, thus never having to resolve a potentially open ternary expression.

My suggestion is to have this opened again, as it will definitely come in handy, especially in the cases of invoking events or event-like delegate memberes, which are quite common in certain applications. Especially given the fact that multicast delegates are null by design if they have no subscribers.

@jnm2
Copy link
Contributor

jnm2 commented Jun 4, 2021

a ? ( b ) ? ( c ) : d could be either:

  • (a?(b)) ? c : d
  • a ? ((b)?(c)) : d

@Rekkonnect
Copy link
Contributor

This is indeed a case that I had not thought of, however I believe it could be specially handled from the compiler. This will always pass the parsing process and the resulting syntax tree will contain one of the two possible scenarios. Then, if the compiler detects this syntax tree, it may yield an error stating the ambiguity of the expression, and asking that the extra parentheses be removed or have more be added to indicate order.

@HaloFour
Copy link
Contributor

HaloFour commented Jun 4, 2021

I think the question would be whether or not it'd be worth that effort given that there already is a way to invoke these methods. With autocomplete the amount of manual typing to call the Invoke method is minimal, probably a single additional keystroke. I personally don't think so.

However, if invocation operators were to become a language feature then maybe it might be worth reconsidering, especially if that operator didn't map to an easily referenced method name.

@Rekkonnect
Copy link
Contributor

However, if invocation operators were to become a language feature then maybe it might be worth reconsidering, especially if that operator didn't map to an easily referenced method name.

Theoretically, the ( operator is an invocation operator, applied on method groups and delegate instances.

@Rekkonnect
Copy link
Contributor

@jnm2 Re:

a ? ( b ) ? ( c ) : d could be either:

  • (a?(b)) ? c : d
  • a ? ((b)?(c)) : d

After further consideration and exploration on the compiler's end, even this expression is not fully ambiguous. It surely is ambiguous at a parsing level, but the binder can resolve the ambiguity easily. The reason is that any experssion preceding the ? symbol must be exactly or convertible to a bool. Invocable symbols (delegate instances, function pointer instances and method groups) meet no such criteria. Therefore, resolution of the exact syntax node will have to occur during binding.

For this to happen, a new type of node would have to be supported, declaring syntax ambiguity resolvable during binding. And in this specific scenario, that node would reflect ambiguity between ?: and ?( expressions. The binder then would need to analyze the bound segmented expressions' types and resolve the ambiguity. The resulting syntax tree though does not have to be updated.

Ideally, the syntax tree should not be updated, exposing the pre-binding ambiguous nodes, allowing warnings to be reported, suggesting explicit clarification of the ordering.

@333fred
Copy link
Member

333fred commented Sep 23, 2021

It surely is ambiguous at a parsing level, but the binder can resolve the ambiguity easily.

C# today does not have ambiguous syntax, and we are not interested in adding anything to the language that would change this.

@Rekkonnect
Copy link
Contributor

Rekkonnect commented Oct 22, 2021

EDIT: the comment below was naively posted right before considering the catastrophic case a ? b ? (c) ? d : e : f : g, ignore until this is also evaluated.

I've given this a bit of thought and discovered a way to discover ambiguities during parsing, without relying on the binder. Ambiguities in this case are only caused in scenarios where ternary operators and nullable invocation operators are mixed before getting to the false expression part of the ternary operation. In other words, DANGER ? DANGER : SAFE, showing that nullable invocation operators cannot be used in the danger zones in the same parenthesized depth level.

As to how the parser recognizes this, unfortunately the only solution is looking ahead up until the end of the last : denoting the false expression part. The algorithm I have in mind is:

  • All steps refer to the current (sub-)expression. If the expression's end is reached, the algorithm skips over to step 5.
  1. Find the next sequence of tokens QP = ? ( (they must be two different tokens, do NOT create a token denoting ?(, this will cause trouble during evaluation)
  2. For each deeper parenthesized depth level, perform this process on the whole parenthesized sub-expression from the beginning, starting at the first node after the (
  3. Count all next QP token sequences before the next : token
  4. Find and count all next : tokens before finding the next QP token sequence
    • If no : tokens are found until the end of the expression, all QP tokens represent nullable invocation operators
    • Otherwise, if the count of QP tokens is greater than the count of : tokens, an ambiguity exists
    • Otherwise, all QP and : tokens represent a ternary operator expression

This algorithm will only have to be executed upon coming across a QP token sequence, and not for every expression. Tokens before the starting sequence are ignored.

Below are examples of this algorithm for the aforementioned ambiguous example, and its unambiguous variants. Execution only begins from the points of discovering the QP token sequence.

== Execution 0
a ? ( b ) ? ( c ) : d
  ^
Step: 1
Go to step 3
Start subexpression parsing

== Execution 1
No QP token sequences were found in the subexpression ( b )
== End execution 1

a ? ( b ) ? ( c ) : d
          ^
Step: 3
Start subexpression parsing

== Execution 2
No QP token sequences were found in the subexpression ( c )
== End execution 2

a ? ( b ) ? ( c ) : d
                  ^
Step: 3
Go to step 4

a ? ( b ) ? ( c ) : d
                     ^ (END)
Step: 4

End of expression reached
QP: 2
Colons: 1
Ambiguity was recognized, report error on the whole expression
== End execution 0
== Execution 0
(a?(b)) ? c : d
  ^
Step: 1
Start subexpression parsing

== Execution 1
No QP token sequences were found in the subexpression (b)
== End execution 1

(a?(b)) ? c : d
      ^
Step: 3

End of expression reached
QP: 1
Colons: 0
QPs represent nullable invocations
== End execution 0
== Execution 0
a ? ((b)?(c)) : d
  ^
Step: 1
Go to step 3
Start subexpression parsing

== Execution 1
a ? ((b)?(c)) : d
        ^
Step: 1
Go to step 3
Start subexpression parsing

== Execution 2
No QP token sequences were found in the subexpression (c)
== End execution 2

End of expression reached
QP: 1
Colons: 0
QPs represent nullable invocations
== End execution 1

a ? ((b)?(c)) : d
              ^
Step: 3
Go to step 4

a ? ((b)?(c)) : d
                 ^ (END)
Step: 4

End of expression reached
QP: 1
Colons: 1
QPs represent ternary operations
== End execution 0

@CyrusNajmabadi
Copy link
Member

@alfasgd the syntax is simply too ambiguous. But necessarily in the sense that a computer could not figure it out, but in the sense that it's too confusing for humans to read/write. I look at the examples and I have no idea what they mean without staring intently for a long time running the parsing algorithm in my head.

That's not desirable for us.

@jnm2
Copy link
Contributor

jnm2 commented Oct 22, 2021

@CyrusNajmabadi However, that's not an argument against doing something that's otherwise valuable. You can always take valuable features and design something horrific like delegate async async(async async);. The majority of the time, this would be a single statement: SomeEvent?(this, EventArgs.Empty); and the merits of that vs ?.Invoke is where I'd expect the discussion to be.

@CyrusNajmabadi
Copy link
Member

However, that's not an argument against doing something that's otherwise valuable.

I disagree, and I don't think the two scenarios are comparable. The async case is the pathological one. I'm saying the normal case for ?( is already confusing imo. It requires potentially unbounded lookahead for my mental parser even for a case with no nesting. That's not a good thing imo.

@jnm2
Copy link
Contributor

jnm2 commented Oct 22, 2021

Ah, gotcha. Even when the entire statement in view is: SomeEvent?(this, EventArgs.Empty);?

@Rekkonnect
Copy link
Contributor

I look at the examples and I have no idea what they mean without staring intently for a long time running the parsing algorithm in my head.

Wait until you hear about the more complex case that I'm still wrapping my head over when it comes to parsing.

That's not desirable for us.

This proposal has been in the heads of the entire community for a while. Its use cases are everywhere, especially in business logic. The feature's syntax cannot be anything other than ?( for the obvious reasons of consistency. Unfortunately, due to the existence of the ternary operator and the ability to nest expressions in parentheses, this parsing ambiguity becomes complex to resolve, though is thankfully feasible under certain constraints, that are known.

The way I see it is, either support this feature, with all the awkward parsing issues, or never bring a small piece of consistency into the language due to those complications.

I'm saying the normal case for ?( is already confusing imo. It requires potentially unbounded lookahead for my mental parser even for a case with no nesting. That's not a good thing imo.

All expressions can get complicated pretty easily. Even this simple expression: M(v = val++) is really confusing, but still allowed. Despite this being legal for compatibility with the rest of the C family, I'd say nobody should ever write code like this.

It's in our hands to not abuse features. The proposal intends to help in simple scenarios where a null-checked invocation could be simplified down to something shorter. ?.Invoke already does half the job with inlined null checking.

And I repeat, nobody should write code that includes monstrous expressions like the above. Even mixing ternaries with null-conditional invocation sounds atrocious. The best advice is to properly parenthesize and space out expressions and symbols. And this applies to everything, especially a feature that introduces potential ambiguous syntax, which will be safely discarded with a compiler error.

@HaloFour
Copy link
Contributor

or never bring a small piece of consistency into the language due to those complications.

Achieving consistency is not a goal of the language, especially at the expense of clarity.

@Rekkonnect
Copy link
Contributor

at the expense of clarity.

Isn't SomeEvent?(args); clear enough? Much like ?[, this behaves in a similar fashion, dare I say the same.

@jnm2
Copy link
Contributor

jnm2 commented Oct 22, 2021

JavaScript recently introduced the optional invocation syntax someFunction?.(someArg) to get around the syntactic ambiguity: https://github.com/tc39/proposal-optional-chaining#faq

@HaloFour
Copy link
Contributor

@alfasgd

IMO, no. You always have to scan ahead to see what the rest of the expression contains, and if the expression is anything more than a trivial invocation that will be challenging. Even if rules can be applied to always disambiguate in the parser that will never help with the human parser.

I just don't find having to include Invoke to be onerous. Autocomplete will do the majority of the typing for you.

@CyrusNajmabadi
Copy link
Member

JavaScript recently introduced the optional invocation syntax someFunction?.(someArg) to get around the syntactic ambiguity: https://github.com/tc39/proposal-optional-chaining#faq

I would be potentially ok with this.

@CyrusNajmabadi
Copy link
Member

The feature's syntax cannot be anything other than ?( for the obvious reasons of consistency.

I disagree with this. The syntax does not need to be ?(. And, if ?( is problematic at the user experience level, i distinctly think it should not be ?(.

Isn't SomeEvent?(args); clear enough?

Nope! :) And that's the core concern. Up through C# 10 i could always read that and know what it meant by checking teh single character after the ?. It was wither ? (null coalesce), . (null-propagate), or [ (null-index). It was simple and easy to grok what was happening just by cursory examination. (of course, that also makes parsing easy, but that's a tangential benefit). With ?( we no longer have that. The language now requires unbounded lookahead. And yes, while that can be done by the parser, it also means needing to do it in my brain when examining code.

Furthermore, it means that when code is incorrect (say i mistype something, or put a : in a incorrect position), it's now much more likely that i'm going to get absolutely crazy messages from teh parser/compiler saying that something is not an invokable delegate :)

@CyrusNajmabadi
Copy link
Member

I just don't find having to include Invoke to be onerous. Autocomplete will do the majority of the typing for you.

I agree with this too. It reads fine, and is easy to write, review, and maintain. If we truly got enough of a belief that this was worth investing into (which the current vote count doesn't seem to imply) then i'd still want a syntax that was easy to parse without my own brain having to do lookahead. So that would mean something like ?.(. This would be trivial as i'd only ever have to look two characters ahead, instead of potentially an unbounded amount of tokens, nodes, and lines.

@Rekkonnect
Copy link
Contributor

I'm seeing the issues ?( creates, and assuming the path of an alternative syntax is taken, I'm not against, however I believe that ?.( should not be reserved for that purpose. This kind of syntax could be reserved for a feature like x?.(condition ? propA : propB), where propA and propB are properties of x.

One alternative option (though ugly) I see is ?:(. It does look sad, though

@CyrusNajmabadi
Copy link
Member

feature like x?.(condition ? propA : propB), where propA and propB are properties of x.

we're pretty opposed to implicit-lookup of identifiers. in all our discussion so far, we've wanted a sigil to indicate that's happening. like x ? .A : .B or Foo(.EnumMember) etc.

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

No branches or pull requests