RFC: `&.` within a block

#1

Extracted from #7429, which has been closed due to the fact that some of the members of the community can’t stand the fact someone that is having a different point of view (see the irony).

So,

foo do |x|
  x.foo
  x.bar
end

and

foo do
  &.foo
  &.bar
end

Let’s continue arguing and insulting each other the discussion then.

I had extra subjective pros:

  • A developer would not need to think of the argument naming anymore
  • Ampersand is highlighted, which allows the brain to quickly extract the call name (compare x.foo and &.foo in the example above, listen to how your brain reads it)
  • A developer would still be free to choose between explicit block argument and &. whenever they want to
  • It would conform with the existing foo &.bar syntax, as it expands to foo { |x| x.bar }, which is essentially the same
  • It would not affect the with yield syntax

Let’s skip the fact that I could have chosen Go or C instead of Crystal if it was not about subjective perception. And that Crystal would be just a one of meh languages if it was not expressive.

Oh wait, some community members are directly against expressiveness. They imply on the need for the code to be as much explicit as possible. But we have foo &.bar. And we have record. And tons of other macros. And also getter, setter and property. Why not defining def get_foo and def set_foo explicitly? getter is too implicit! Vote for removing the getter macro from the language!

My point is that expressiveness is the amount of code a developer needs to write to achieve the same result preserving the readability. foo &.bar equals to foo { |x| x.bar }, record equals to struct Foo .... We omit repeatable code using some rules.

foo do |x|
  x.foo
  x.bar
end

Is a perfect use-case for omitting the code with a simple rule& = first block argument. It’s easy to remember, it’s easy to parse on the brain-side. Maybe even simpler than record macro.

0 Likes

#2

Please note that I don’t wait for someone to rush implementing the feature immediately. I can live without it. I just want to put it on sometimes list and hope that it gets into the language one day.

0 Likes

#3

This could be cool, but there is a problem :

icr(0.27.0) > [1, 2, 3].map &.to_s
 => ["1", "2", "3"]

You syntax is very close from this one, I think we can take Kotlin convention with it

foo do |x|
  it.foo
  it.bar
end

In Kotlin IDE, it is highlighted, and it’s a pleasure to use.

I don’t think this feature is vital, and I love with ... yield so I would not it ^^

0 Likes

#4

Putting aside our personal visual preferences (I would like omitting &. and making not only the first block argument implicit but method receiver inside a block implicit too)…

What worries me in your proposal is that &.foo now can mean different things. Currently it’s just a shortcut for {|x| x.foo} and that’s it, right?

If I understood your proposal correctly

  • for a block with implicit first argument (we are omitting |x| after do) the &.foo should now mean x.foo (a method call) and not {|x| x.foo} (a block, inside which there is a method call on first argument)
  • if block does not yield any arguments, then it should be an error, and it’s one more thing to worry about: how this error should be different from error when you write &.foo, but not inside block?

Also this syntax

foo do
  &.foo
  &.bar
end

Looks to me a bit similar to

foo \
  &.foo \
  &.bar

Which is the same as foo &.foo &.bar I think. But it currently has a different meaning from what you are proposing.

0 Likes

#5

The idea of re-using Kotlin’s it in some way was already mentioned several times and it doesn’t look like anybody from core team sees any value in it.

Personally I like that with it there is no much need for special syntax &.. Instead of numbers.map &.to_s you use numbers.map { it.to_s } which for one call might be seen as drawback, but OTOH you can use it for multiple calls numbers.map { "#{it.to_s} : #{it.to_s(16)} } - the syntax is the same and it does the same thing as this proposal, only using it instead of &..

Also it is already in use in Spec so I guess it’s a no-go anyway.

If it wasn’t this could possibly work too:

foo do |x|
  x.foo
  x.bar
end

# same as above
foo it
  foo
  bar
end

But it doesn’t look like anybody finds this idea interesting. So :man_shrugging:

0 Likes

#6

@vladfaust What about

Versus

foo { &.foo &.bar }

The later is currently foo { &.foo {|x| x.bar} }. With your proposal if first foo yields an argument shouldn’t it be foo { |x| x.foo; x.bar } instead?

Should multiline do ... end blocks work the same as single line { ... }? :thinking:

0 Likes

#7

These expand to foo { |x| x.bar { |y| y.baz } }:

foo do
  &.bar &.baz
end

foo do
  &.bar(&.baz)
end


foo { &.bar &.baz }
foo { &.bar(&.baz) }

And these – to foo { |x| x.bar; x.baz }:

foo { &.bar; &.baz }

foo do
  &.bar
  &.baz
end

I see no problem here :thinking:

0 Likes

#8

You are right. That was just my some random brainstorm thoughts: “what if code inside multiline and single line blocks will be treated the same?”. But that would break everything.

0 Likes

#9

I totally forgot about itself. With that feature implemented you finally would be able to reference the block argument, not limited by only calling a method directly on it:

json({
  users: @users.map { |user|
    Views::User.new(user, nested: true)
  }
})

Turns into:

json({
  users: @users.map { 
    Views::User.new(&.itself, nested: true)
  }
})
0 Likes

#10

At least allow to have _ or & as block argument names:

posts = Onyx.query(Post
  .select(Post)
  .join(author: true) do |&|
    &.select(:name)
    &.where(name: "John")
  end
)

Or even better:

posts = Onyx.query(Post
  .select(Post)
  .join(author: true) do |_|
    _.select(:name)
    _.where(name: "John")
  end
)
0 Likes

#11

Maybe the _ is not the best choice, since _ is quite common for ignored (unused) arguments in Ruby.

1 Like

#12

News related to several Crystal block arguments discussions:

Ruby 2.7 introduces numbered parameters for blocks https://medium.com/@baweaver/ruby-2-7-numbered-parameters-3f5c06a55fe4

TLDR

[1, 2, 3].map { @1 + 3 }
=> [4, 5, 6]

Not that I like it, but it’s similar to Kotlin’s it: [1, 2, 3].map { it + 3 } and also not limited to the only block argument use case.

Although examples look weird:

(1..9).each_slice(3).map { @1 + @2 + @3 }
=> [6, 15, 24]
[{name: 'foo'}, {name: 'bar'}].map { @1[:name] }
=> ["foo", "bar"]
1 Like

#13

This syntax looks weird. Everyone’s gonna think about instance variables.

$1 etc would be great for this, but it’s already used for match groups.

1 Like

#14

Or &1, because & is related to blocks.

But I agree that at least for me it makes it harder to understand what’s going on.

2 Likes

#15

I agree @ is probably not good as it looks just like an instance variable. I personally like how Elixir did this (similar to Ruby, but with a clearer syntax IMO):

# Expanded way
iex> sum = fn (a, b) -> a + b end
iex> sum.(2, 3)
5

# Can be shortened to
# This also create a function as well, but the important part are the function args
iex> sum = &(&1 + &2) 
iex> sum.(2, 3)
5

How this could work in Crystal

Now sometimes this can make code super hard to read with more than one argument, but it also has the potential to make some code much easier to read/write.

For example:

# Not so great because now you either have shadowed variable or have to come up with a shorthand name like `u`
# You could of course do `User.first?.try` but this illustrates a problem that comes up often.
# Either way, I think it is more of a burden on the developer and future reader
user = User.first?
user.try { |user| ChargeCard.call(price_in_cents: 500, user: user }

# As opposed to the &1 shorthand
user = User.first?
user.try { ChargeCard.call(prince_in-cents: 500, user: &1 }

I love that with this syntax you can pass the object to another method call like this. I do not love & but I also think it is better than the alternatives as it looks different and looks like what it is, syntax sugar. it and itself would make me look for a method definition or local variable and I would never find it. It is also extremely hard to google for.

The proposal

  • Allow adding an integer to & so you can pass the object around as you see fit, and keep the same benefits as shown in the previous example

What do you all think?

3 Likes

#16

Revisiting it once again, I still propose to treat &. in two ways:

  1. As the first block argument (like it is now):
["a", "b"].map &.upcase
# Expands to
["a", "b"].map { |x| x.upcase }
def foo(&block : String, String ->)
  yield "foo", "bar"
end

puts foo &.upcase # FOO
  1. As the first block argument (proposed): it’s not a typo
["a", "b"].map { &.upcase }
# Expands to
["a", "b"].map { |x| x.upcase }
def foo(&block : String, String ->)
  yield "foo", "bar"
end

puts foo { &.upcase } # FOO

I honestly do not understand why you don’t approve it.

Some considerations:

Q: What if is there are multiple block arguments?
A: &. should be the first argument shortcut, others are omitted. If you want multiple args, specify them explicitly. &. would still work in this case:

["a", "b"].map_with_index { &.upcase } # OK
["a", "b"].map_with_index { |s, i| &.upcase * i } # OK

Q: What about nested blocks?
A: In case of nesting, the deeper block takes precedence:

"foo".tap do
  ["a", "b"].map_with_index { &.upcase } # Expands to `s.upcase`
end

"foo".tap do
  ["a", "b"].map_with_index { |s, i| &.upcase } # Still expands to `s.upcase`
end

Q: What is your use-case?
A: Onyx::SQL join query yields a nested query. Instead of this:

posts = Onyx.query(Post
  .join(:author) do |x|
    x.join(:settings) do |y|
      y.select(:foo)
      y.where(active: true)
    end
  end
)

I could write this:

posts = Onyx.query(Post
  .join(:author) do
    &.join(:settings) do
      &.select(:foo)
      &.where(active: true)
    end
  end
)

I’d love to hear @bcardiff’s opinion on this proposal.

P.S: &n syntax is ugly.

0 Likes

#17

The &. can also be a syntax sugar for &1. syntax sugar ;)

1 Like

#18

I think using &<integer> makes more sense as it can be passed as an argument as well. "something".try { call_something(&) } Looks kind of strange to me, but I’d still use it if it were available!

2 Likes

#19

No, in my proposal it would be "something".try { call_something(&.itself) }.

0 Likes

#20

Hmm I think the problem with that is now you it is almost as verbose (and sometimes more verbose) as giving the argument a name. It also had the disadvantage of only allowing a shortcut for 1 arg

user.try { charge_payment(&.itself)
user.try { |u| charge_payment(u) } # actually a bit shorter
0 Likes