godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.15k stars 97 forks source link

Make lambdas automatically return value of the last expression #5925

Open KoBeWi opened 1 year ago

KoBeWi commented 1 year ago

Describe the project you are working on

Various GDScript projects

Describe the problem or limitation you are having in your project

GDScript 2.0 introduced lambdas and various useful methods like filter() or any(). These two go really well together, except for one thing. Consider this code:

print([1,2,3].any(func(v): v > 1))

It should return true if any of the values are greater than 1. Result? false Here's a valid code:

print([1,2,3].any(func(v): return v > 1))

idk I find the first example very intuitive (probably coming from other languages) and lack of return caught me a few times already. Worse, there is no warning whatsoever that your code is wrong. The functions just return null implicitly, so it will happily accept it as false.

Describe the feature / enhancement and how it helps to overcome the problem or limitation

Make lambdas return last expression's value. So e.g. func(v): v > 1 implicitly becomes func(v): return v > 1. Lambdas are often simple one-liners and making return implicit would make them even simpler to use.

Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams

When a method is a lamda and has no return, instead of returning null, return the value of last expression instead.

If this enhancement will not be used often, can it be worked around with a few lines of script?

It can be "worked around" by using return, but it's about convenience.

Even if the proposal is rejected, the filter() etc. methods should at least error out when you return wrong value >_> (although that's difficult for map()...)

Is there a reason why this should be core and not an add-on in the asset library?

GDScript is core.

CedNaru commented 1 year ago

As a Kotlin user, I approve. The use is limited for lambdas several lines long, but it's really convenient for readability when you have a lot of one-liner lambdas (and often have to chain them when you deal with collections.)

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

// Use a chain of lambda functions to filter and transform the collection
val result = numbers
  .filter { it % 2 == 0 } // Keep only even numbers
  .map { it * it } // Square each number
  .filter { it > 20 } // Keep only numbers greater than 20
  .map { it * 2 } // Multiply each number by 2
  .sum() // Sum the numbers
AThousandShips commented 1 year ago

I would advocate in favour of an error to not make it confusing, like how should it handle more complex lambdas, should there be a difference between single statement lambdas and more complex ones, what if you have if statements, etc.

But an error at minimum is definitely needed

dalexeev commented 1 year ago

I think it's better to add arrow functions (like in JavaScript) instead of this proposal.

print([1,2,3].any(func(v): return v > 1))
print([1,2,3].any(v => v > 1))
Spartan322 commented 1 year ago

C# also uses => for lambda expressions, making it even more relatable to C#.

YuriSizov commented 1 year ago

Let's not just randomly change the syntax for lambdas, right? The current syntax is intended to fit GDScript's language design. Changing the syntax is not really relevant for this proposal either.

dalexeev commented 1 year ago

Let's not just randomly change the syntax for lambdas, right?

This is not a suggestion to change the lambda syntax, this is a suggestion to add additional syntax. In the languages I know, arrow functions are additional syntax to function expressions, not their replacement.

// Function Declaration
function f(x) {
    return x + 1;
}

// Function Expression
var g = function (x) {
    return x + 1;
};

// Arrow Function
var h = x => x + 1;

As for this proposal, I think it can be confusing and inconsistent if normal functions don't return the value of the last expression. And confusing and non-obvious, if return becomes optional in normal functions. This behavior exists in Rust, but I don't think it's appropriate in GDScript. And besides, if we add arrow functions, then there will be no need for the return omission.

YuriSizov commented 1 year ago

This is not a suggestion to change the lambda syntax, this is a suggestion to add additional syntax. In the languages I know, arrow functions are additional syntax to function expressions, not their replacement.

In GDScript lambas already have dedicated syntax, even if it's a bit verbose and looks a lot like your normal function declaration. This was done for consistency and explicitness. In general, GDScript prefers keywords over more abstract syntax to achieve code clarity for users of different levels (but I will die before I switch from && to and myself :P).

Also note that the fat arrow syntax was suggested in the original proposal, but didn't find overwhelming support.

And besides, if we add arrow functions, then there will be no need for the return omission.

The arrow syntax in, say, JavaScript, allows you to define a multiline, blocked body for a function, just like the full syntax, and you can use return statements in it. So it allows you to have it explicitly or omit it as you wish. Adding a new syntax to GDScript, one that has to relation to any existing syntax and has a specific limited scope even when compared to what inspires it, in that case, would be very strange.

Spartan322 commented 1 year ago

Just gonna point out that in most languages that do the arrow lambda expressions, the multi-line body is functionally a different syntax that uses the same symbol, usually because the lambda would otherwise be verbose, if you want a single line lambda with return without a block the arrow expression is the only way to achieve it in said languages however. There honestly is no reason to add that functionality to GDScript because

var a = func(b):
    one()
    return two()

is so simple and concise already. The only reason I would agree with arrow expressions is specifically in intending for one line functions with an implied return, (like your var sum_of_array = array.aggregate((v1,v2) => v1+v2) assuming that's how you'd expect to write a one line lambda with multiple variables) which fits this proposal a lot more than implying an automatic return on every one line function, it doesn't mess with the natural function behavior and would require less context awareness imo than the original proposal. The assumption I'm thinking however is that it would only apply for this specific proposal.

YuriSizov commented 1 year ago

implying an automatic return on every one line function

It wouldn't apply to every one line function. It would apply to one line lambdas. Just because it works like that for

var a = func(x): x * 2

doesn't mean it's going to work for a class method. Those are different contexts and they are parsed separately, according to vnen's explanations in the proposal. They just happen to use a similar syntax because it was decided to go for func for familiarity, and not to go with, say, lambda like Python.

Therefore, this proposal doesn't need a new syntax.

dalexeev commented 1 year ago

Please note that we have 3 cases:

  1. regular functions;
  2. single line lambdas;
  3. multiline lambdas.

As far as I understand, this proposal applies not only to case 2, but also to case 3. And I find it can be confusing for the user. If you can still guess here that an implicit return is happening:

print([1,2,3].any(func(v): v > 1))

then here the optional return looks like a disadvantage, not an advantage:

var f = func(x, y):
    # ...some lines of code...
    x + y

Therefore, it seems to me that a dedicated solution for one-liner functions (case 2 only) is better than if we mess up an already existing syntax, make it confusing or inconsistent. It matches the picture from the docs.

Picture ![](https://user-images.githubusercontent.com/47700418/207231750-7378fbb3-d02f-4435-a164-105b5fff891e.png)

The full syntax

print([1,2,3].any(func(v): return v > 1))

is already a working solution. The new problem/task is to make the syntax shorter in single line lambdas. That is, it is a matter of purely syntactic sugar. Why do you want to drop return but don't want to drop func? Arrow functions provide the shortest possible syntax, while still being familiar to users from other languages.


Another possible option to omit return is the following syntax:

print([1,2,3].any(func(v) = v > 1))

This option is less confusing and inconsistent as it cannot be used in multiline functions/lambdas. We could even add it to single line regular functions, but I don't think it's compatible with return type hint.

func sum(a, b) = a + b
Spartan322 commented 1 year ago

Another possible option to omit return is the following syntax:

print([1,2,3].any(func(v) = v > 1))

This option is less confusing and inconsistent as it cannot be used in multiline functions/lambdas. We could even add it to single line regular functions, but I don't think it's compatible with return type hint.

func sum(a, b) = a + b

That would be another reason the arrow expression make more sense in my opinion as a solution, (though given the func is functionally a type hint in all but syntax you could probably still type hint the lambda's return but I feel like that be even more confusing to look at too, especially if its being used in a function that takes a Callable, func(v) = v > 1 seems like its trying to assign which feels like it should be an error if it was valid) losing the capacity to type hint when its a useful tool that need not be lost seems like a sub-optimal solution to me, there is then no manner to validate the types being passed, and its not like the type hint should be confused in parsing, if Callables could enforce their types in the syntax perhaps this be unwarranted, but given the only way to protect Callable returns would be by type hints I think that it be unfortunate. (granted probably also useless unless it input types are hinted too, I suppose you could argue just as well that the variables being hinted negates a need for the return type hint, though what about different types for the parameters)

dalexeev commented 1 year ago

func(v) = v > 1 seems like its trying to assign which feels like it should be an error if it was valid

I think, yes. But with a space it looks a little better: func (v) = v > 1 (I just followed the style from OP).

In single line functions, the return type can usually be predicted if the argument types are known and the return expression is type-safe.

YuriSizov commented 1 year ago

The full syntax

print([1,2,3].any(func(v): return v > 1)) is already a working solution. The new problem/task is to make the syntax shorter in single line lambdas. That is, it is a matter of purely syntactic sugar. Why do you want to drop return but don't want to drop func?

That's a fair question and makes me think that we shouldn't go with this proposal. Like I have mentioned before, GDScript prefers keywords and explicitness, verbosity over compactness. So dropping return, in some cases, or creating a completely new arbitrary syntax doesn't fit the design of our little language.

I'm going to retract my support for this proposal.

KoBeWi commented 1 year ago

As far as I understand, this proposal applies not only to case 2, but also to case 3.

I would be fine if only one-liners had implicit return. My main problem is that when using such lambdas, the return is easy to forget and there is no indication other than your code not working as expected (and the reason is not obvious). As I said, providing an error, when there is no return value while it should, would be fine too.

Spartan322 commented 1 year ago

That's a fair question and makes me think that we shouldn't go with this proposal. Like I have mentioned before, GDScript prefers keywords and explicitness, verbosity over compactness. So dropping return, in some cases, or creating a completely new arbitrary syntax doesn't fit the design of our little language.

I'm going to retract my support for this proposal.

To be fair if the preference is to use keywords (though I'm not so sure making a new keyword just to remove return works) then technically could take inspiration from Python again:

add = lambda a,b,c: a + b + c  

I don't like how that looks, most especially would prefer required parenthesis around multiple parameters and optional for single parenthesis in case of type hint support, but this is how Python achieved it.

maximkulkin commented 1 year ago

My 2c: if you're going the route of "everything has a value", then all statements also need to have a value. E.g.

func foo():
  if condition
    expression1
  else:
    expression2

return value is either value of expression1 or expression2 depending on condition. If think it primarily applies to if statements and match statements and less to for and while statements.

vnen commented 1 year ago

I want to point out that this:

print([1,2,3].any(func(v): v > 1))s

will give you a warning (standalone expression). I wish warnings were more prominent in the editor so things like this would be harder to miss.

Another workaround is add a return type:

print([1,2,3].any(func(v) -> bool: v > 1))

Which will give an error since it does not return anything. I do understand that it's more verbose, which is usually not desirable in this situation, but it is a workaround.


I personally don't want to add arrow functions because it would be difficult to parse (the => token appears way too late to easily disambiguate).

Making inline lambdas return the expression automatically is more feasible. It would be ambiguous with function calls:

var x = func(a): call_something(a)

Because calls can have side-effects and may not mean that the return value should be used. Although I don't think returning whatever the called function returned would be much of a concern if you don't expect it to return a value.

Also, even with this I don't think we need to make so "all statements have a value". To use if you can use the ternary operator. For match there's no alternative, but in this case the lambda would have to span multiple lines which would then require the return anyway.

dalexeev commented 1 year ago

It would be ambiguous with function calls:

var x = func(a): call_something(a)

This is a good point. I think GDScript shouldn't have such ambiguous syntax.

I personally don't want to add arrow functions because it would be difficult to parse (the => token appears way too late to easily disambiguate).

Maybe something like this would work (= instead of :)?

Another possible option to omit return is the following syntax:

print([1,2,3].any(func(v) = v > 1))

This option is less confusing and inconsistent as it cannot be used in multiline functions/lambdas. We could even add it to single line regular functions, but I don't think it's compatible with return type hint.

func sum(a, b) = a + b
vnen commented 1 year ago

It would be ambiguous with function calls:

var x = func(a): call_something(a)

This is a good point. I think GDScript shouldn't have such ambiguous syntax.

Note that this is only ambiguous for reading code and if you are not aware of the auto return. If implemented it would be consistent in behavior (it would return the value from the function call) and it would likely be easy to guess from the context.

Maybe something like this would work (= instead of :)?

I think this will be harder to discover. People will only find this after realizing that the return is needed with the regular syntax, likely after having a problem with their code and reporting somewhere. With arrow functions there would be familiarity with other languages to help.

Also, the difference between func(v): v > 1 and func(v) = v > 1 is minimal, many won't notice right away.

maximkulkin commented 1 year ago
var x = func(a): call_something(a)

How is this ambiguous?

Because calls can have side-effects and may not mean that the return value should be used.

Define "should be used". You wrote it that way, it WILL be used. If you were to ignore the return type of the whole lambda anyways, then it does not matter if return value is the return value of an inner function.

Although I don't think returning whatever the called function returned would be much of a concern if you don't expect it to return a value.

Again, what is "if you don't expect it to return a value". Imagine it gets implemented, then EVERYTHING RETURNS A VALUE. It's just that sometimes the value is just null. Expect it or not, it's coming.

Also, even with this I don't think we need to make so "all statements have a value". To use if you can use the ternary operator.

It's not about how can I use a ternary operator. It's about "what to return if the last statement is IF".

For match there's no alternative, but in this case the lambda would have to span multiple lines which would then require the return anyway.

Why make a distinction between single line and multi-line lambdas? Or functions in general sense. You can have implicit returns everywhere. Ruby does that and it works just fine.

Also, the difference between func(v): v > 1 and func(v) = v > 1 is minimal, many won't notice right away.

I would opt for keeping it closer to the way it is right now (func(v): v > 1), but to implement "value of last expression is the return value of a function/lambda". First, it does not break old code. Returns statements at the end of a function (or lambda) are optional. If you need to exit function earlier, you can use explicit return statement:

func max(a, b):
  if a > b:
    return a
  b

items.filter(func(x): x > 5)
vnen commented 1 year ago

Define "should be used". You wrote it that way, it WILL be used. If you were to ignore the return type of the whole lambda antyways, then it does not matter if return value is the return value of an inner function.

The point is when reading code, which might have been written by someone else. So it might not be clear at first glance if the function is returning something that will be used by what called the lambda or if returns nothing because it only has side-effects (and thus what called the lambda isn't, or shouldn't be expecting a return value.

Again, what is "if you don't expect it to return a value". Imagine it gets implemented, then EVERYTHING RETURNS A VALUE. It's just that sometimes the value is just null. Expect it or not, it's coming.

That's technical nitpick. Yes, it will return null. But a function declared void is not meant to have its return value used for anything. You might be undoing the void return type by wrapping into a lambda and getting the null value when not expecting. For example: Array.sort() is void but some people have tried to use its return value not realizing it wouldn't return the array. From the function name alone you can't tell, you also can't tell if it's void when wrapped in a lambda.

Why make a distinction between single line and multi-line lambdas? Or functions in general sense. You can have implicit returns everywhere. Ruby does that and it works just fine.

I'm referring to what has been discussed before in the proposal. One of the ideas is that only single-line lambdas should have implicit return.

I would opt for keeping it closer to the way it is right now (func(v): v > 1), but to implement "value of last expression is the return value of a function/lambda"

This probably should be a different proposal. It goes beyond the problem described in the OP and it brings other questions to the table.