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

Compiler: add implicit block arguments (_1, _2, etc.) #9216

Closed

Conversation

asterite
Copy link
Member

@asterite asterite commented May 1, 2020

Note: this PR exists because I wanted to see how hard was it to implement it. It's not decided yet whether this is something that we want... but I enjoy coding these things :-) . Also with this people can try it out themselves.

This is exactly like in Ruby. I chose _1, _2, etc. instead of &1, &2, etc. because & means the start of a block or a block arguments:

foo &.bar
foo &block_argument

so it would be a bit confusing to reuse & to also mean "block argument":

foo { bar &1 } # is &1 a block given to bar?

I also think _1 is visually less noisy than &1. And maybe _1 is a bit easier to type (no need to move the right hand to the middle of the keyboard).

Finally, it's the same as in Ruby so Rubyists will feel familiar with this syntax.

Examples

[1, 2, 3].each { puts _1 }
# Output:
# 1
# 2
# 3

files = ["foo.cr", "bar.cr"]
files.map { File.exists?(_1) }

# same as:
files.map { |file| File.exists?(file) }

%w(World Computer).each { puts "Hello #{_1}!" }
# Output:
# Hello World
# Hello Computer

Hash(String, Array(Int32)).new { _1[_2] = [0] }

# same as:
Hash(String, Array(Int32)).new { |h, k| h[k] = [0] }

Do I like it?

I think for cases like the File.exists? above where it's obvious that each block argument will be a file, and the block is pretty small, that it actually improves readability.

Also with try when you need to do something other than invoke a method on the yielded object:

file = # a nilable string
file.try { File.exists?(_1) }

# instead of:
file.try { |file| File.exists?(file) } # So many files! So confusing!

# or:
file.try { |the_file| File.exists?(the_file) }

Note how the two latter ways of writing it introduce a lot of noise (the block syntax, the variable name). The first way is pretty "zen" in my mind.

I also think it finishes generalizing the block syntax. Right now we have the explicit block syntax:

[1, 2, 3].map { |x| x.to_s }

We have the &. shorthand for when you want to invoke a method on the first and only block argument:

[1, 2, 3].map &.to_s

But no syntax exists for the general case when you want to do something with the block arguments without needing to explicitly mention them. Other languages have it (Kotlin, just for the first argument), Ruby now has _1 and _2, and Elixir has the general &(exp(&1, &2)) expansion syntax.

Breaking change

This is a tiny breaking change because if you had a variable named _1, well, now it's no longer a variable. But I think those cases shouldn't exist... I hope! And fixing those occurrences is easy.

@paulcsmith
Copy link
Contributor

I love it. And agree on the usefulness for shorter code and for try is really nice. That’s primarily what I’d use it for. Easier to write and read in these cases

@waj
Copy link
Member

waj commented May 2, 2020

This is similar in Elixir also: https://elixir-lang.org/getting-started/modules-and-functions.html

I don’t find it confusing using &1 because the concepts are related.

I love this feature. You know it because we talked several times about it 😉. But in any case I would leave the feature and discussion post 1.0

@jzakiya
Copy link

jzakiya commented May 2, 2020

I could/would definitely use this right now, as it would make allot of my code shorter and easier to read.

@oprypin
Copy link
Member

oprypin commented May 2, 2020

Note: "you" here doesn't mean the author, just anyone


Ah yes. "Pass a block that accepts no arguments and gets the 2nd from the 1st."

That's how one reads this code. Easy discount on WTFs/minute. And don't try to tell me you'll get used to it. Well, even if you will, the vast majority won't. But I don't believe you will either. This will be a mental interrupt every single time.

Because you couldn't be bothered to declare the args, every time someone reads that code will have to stumble on it. Do we deserve that? Is it OK to keep going in the direction where code can be read only by its author even if it's not logically convoluted?

I personally wouldn't want that, and I definitely don't trust people to not abuse it (i.e. put it into any expression > 20 chars, where the distance between "cool, it accepts no args" and "wait, what's first?" is already too much) -- sadly Crystal would be particularly exposed to this compared to other languages due to the association with Ruby where there's just no common sense of restraint on one-liners.

@bew
Copy link
Contributor

bew commented May 2, 2020

What's the semantic for nested blocks and block short syntax?

files.each { Log.debug { _1 } }

That won't work, and it'll log nothing, or the arg to Log.debug, or fail at compile time if nothing is given to Log.debug' block (I don't remember).

But what about:

files.each { Log.debug &.entry(_1) }

Using the dsl syntax demoed in another log pr iirc, will that log each file in files ?

@asterite
Copy link
Member Author

asterite commented May 2, 2020

@bew it aways applies to the closest surrounding block

I do agree with @oprypin that this is not needed and that it might be negative to introduce the feature.

@jhass
Copy link
Member

jhass commented May 2, 2020

Yes, I think we should rather think about how to improve the pass a method as block syntax. Maybe we can allow something like this just in a block shorthand? I think this can solve @oprypin's arguments somewhat, first of all enforcing a short distance and and second it doesn't make you mentally enter into block parse mode by introducing a new "short hand block" parse mode.

Not a syntax proposal, just trying to show that the examples that motivate this can be equally solved with that:

# instead of
[1, 2, 3].each { puts _1 }

# allow
[1, 2, 3].each &:puts
[1, 2, 3].each &:puts(_1)

files = ["foo.cr", "bar.cr"]

# instead of
files.map { File.exists?(_1) }

# allow
files.map &:File.exists?
files.map &:File.exists?(_1)

# instead of
%w(World Computer).each { puts "Hello #{_1}!" }

# allow
%w(World Computer).each &puts("Hello #{_1}!")

# instead of
Hash(String, Array(Int32)).new { _1[_2] = [0] }

# allow
Hash(String, Array(Int32)).new  &.[_2]=([0]) 

Some more from the forum post. Not arguing that the proposal makes them strictly better than the current expanded form, just to demonstrate the problem space that can be attacked by it.

# instead of
[1, 2, 3].map { _1 + 3 }

# allow
[1, 2, 3].map &.+(3)

# instead of
(1..9).each_slice(3).map { _1 + _2 + _3 }

# allow
(1..9).each_slice(3).map &.+(_2 + _3) # of course that should just be &.sum

#  instead of
[{name: 'foo'}, {name: 'bar'}].map { _1[:name] }

# allow
[{name: 'foo'}, {name: 'bar'}].map &.[:name]

user = User.first?

# instead of
user.try { ChargeCard.call(prince_in-cents: 500, user: _1 }

# allow
user.try &:ChargeCard.call(prince_in-cents: 500, user: _1)

# instead  of
password.value.try do
  copy_and_encrypt_password _1, to: encrypted_password
end

# allow
password.value.try &:copy_and_encrypt_password(to: encrypted_password)

# above is going to far probably and should be 
password.value.try &:copy_and_encrypt_password(_1, to: encrypted_password)

So to formalize:

  • Extend &. expressions to allow to refer the arguments as _1, _2, _3, ....
  • If the call in the &. expression has no explicit argument list, pass it the remaining arguments (second, third, fourth...).
  • Introduce &: Where &. expands to a call on the first block argument, &: simply skips that part of the expansion.
  • &: expressions support to refer to the arguments as _1, _2, _3, ... as well.
  • For &:, if the call has no explicit argument list, pass the arguments (first, second, third...)

So much for the base. Possible extensions

  • Don't require the number of block arguments to match the number of positional arguments exactly. That is if the the method takes fewer positional arguments than there's block arguments, ignore the extra block arguments.
  • If the call only has explicit named arguments, pass the block arguments (for &. starting with the second, for &: starting with the first) to the method as positional arguments as above.

Please let's focus on the semantics of this proposal first. Any signs chosen here are explicitly not part of the proposal and just used for demonstration purposes! I would prefer to keep the discussion focused on semantics and in case those semantics would be a feature beneficial to the language, we can then discuss the best syntax for this.

@Sija
Copy link
Contributor

Sija commented May 2, 2020

I feel this feature takes us back to the cryptic Perl-land with all those magic $1 variables and such.

What I'd be reeeeally excited about though, is Elixir-like syntax allowing me to do thing like {"foo.txt" => "bar"}.each(&->File.write(&1, &2)) - which I was missing from the day I've discovered Elixir.

@oprypin
Copy link
Member

oprypin commented May 2, 2020

{"foo.txt" => "bar"}.each(&->File.write(&1, &2))

// note that you can do this already:
{"foo.txt" => "bar"}.each(&->File.write(String, String))

But yes, that, or #9197, seems fine to me, as these don't exhibit the biggest problem I see with this proposal, which is that the syntax for a block with 0 args is reused for a block with any (not immediately visible) number of args.

@Sija
Copy link
Contributor

Sija commented May 2, 2020

@oprypin true indeed, what I've missed was the &1.foo(&2) syntax.

@oprypin
Copy link
Member

oprypin commented May 2, 2020

Well, that's exactly what this current proposal gives you, then 😄 ❓

@asterite
Copy link
Member Author

asterite commented May 2, 2020

@Sija it seems this PR is what you want but with a different syntax?

@paulcsmith
Copy link
Contributor

Ah yes. "Pass a block that accepts no arguments and gets the 2nd from the 1st."

That's how one reads this code. Easy discount on WTFs/minute. And don't try to tell me you'll get used to it. Well, even if you will, the vast majority won't. But I don't believe you will either. This will be a mental interrupt every single time.

@oprypin I get what you are saying here. BUT, I personally think this is easier to read in the examples @asterite posted. Elixir has some similar syntax and I don't see it abused much and is generally only used for short method lines. Let's be honest too, people use short meaningless variable names with blocks already.

For example:

Benchmark.ips do |x|
  x.report("short sleep") { sleep 0.01 }
  x.report("shorter sleep") { sleep 0.001 }
end

What is x? Does anyone know or care? Probably not. I think it is very common when yielding blocks that the name is not particularly important.

With all that said, if this is not added it is fine. It is just syntactc sugar that I think has proven to be valuable in places like Elixir and is generally not abused, but I get the argument against it 👍

@asterite
Copy link
Member Author

asterite commented May 2, 2020

Also know that I first implemented this with &1, & 2, etc., so that syntax is definitely possible.

What @jhass suggests, or doing it almost like how elixir does it, by surrounding the block with &(...) might also be a better idea so that you know the replacement is a bit more scoped.

@paulcsmith
Copy link
Contributor

I like the idea of wrapping it so it is scoped! And I personally think &1 is easier to understand since it seems like a block related thing. _1 looks like an unused variable to me. But that could just be me :)

@oprypin
Copy link
Member

oprypin commented May 2, 2020

Elixir has some similar syntax and I don't see it abused much

OK but see the last part of my message.

people use short meaningless variable names with blocks already.

Yes but at least you see upfront how many of the variables there are (as opposed to seeming like there are none)

@oprypin
Copy link
Member

oprypin commented May 2, 2020

Yea maybe this proposal could go somewhere if it doesn't reuse the syntax for block with 0 args to mean block with any number of args.

@paulcsmith
Copy link
Contributor

@oprypin You mean this?

sadly Crystal would be particularly exposed to this compared to other languages due to the association with Ruby where there's just no common sense of restraint on one-liners.

Most elixir programmers came from Ruby so if anything this shows that it can work. especially if scoped with &()

@rafaelfess
Copy link

I like the idea of wrapping it so it is scoped! And I personally think &1 is easier to understand since it seems like a block related thing. _1 looks like an unused variable to me. But that could just be me :)

For me, _1 also seems like an unused variable.

@RX14
Copy link
Contributor

RX14 commented May 2, 2020

Most code is read many (10x) more times than it's written. Often by you. At 2am. Six months later.

For this reason, I'm a big fan of naming things even if it makes typing it a bit longer.

@paulcsmith
Copy link
Contributor

Most code is read many (10x) more times than it's written. Often by you. At 2am. Six months later.

For this reason, I'm a big fan of naming things even if it makes typing it a bit longer

I generally agree with well named methods, argument, etc. But some cases there is no better name. Like in the try example or the benchmark example. IMO the &1 is just as easy or easier to read in those cases. Just wondering if you felt the same about those examples to or if those looked readable?

e.g.

title.value.try { slugify(&1) }

@pannous
Copy link

pannous commented Jun 16, 2020

For one argument blocks add the keyword ‘it’

[1, 2, 3].each { print it }

(maybe in addition to _1 &1 $1 or #1)

Update: never mind I just saw the other pull request. great work, ‘it’ should be merged ;)

@asterite asterite closed this Jul 26, 2020
@asterite asterite deleted the numbered-block-arguments branch July 26, 2020 14:47
@Fryguy Fryguy mentioned this pull request Nov 24, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.