RFC: `&.` within a block

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:

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.

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)
  }
})

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
)
1 Like

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

1 Like

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

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.

2 Likes

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.

3 Likes

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

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.

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

2 Likes

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!

3 Likes

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

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

However, you’re not forced to use &.. You’re always free to choose what’s better for readability in every particular situation. I have a use-case where &. is more preferable. You have case where explicit argument is more preferable.

The whole proposed thing is syntax sugar. We use &. when we know what the argument exactly is and assume that the reader knows it too. Multiple block arguments are usually harder to remember (as well as their exact order), so it’s better to name them explicitly anyway. I don’t like Ruby’s new @1, @2 thing, I think it brings much more confusion to the code rather than improving it.

I think I get what you are saying, but I guess my point is that if we use &<int> it solves your use case and the one I presented at the same time. I agree more than one block var can be confusing, but it can be helpful at times too (each with a key/value pair for example)

So your use_case would be:

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

IMO that is fairly readable. With that said, even if we do what you said and no &<int> I’d be 100% for it. I’ve also needed similar things in my code

2 Likes

Given the example use case above, I think I would prefer the conventional notation:

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

It’s a little bit more verbose, but that added expressiveness is actually a benefit IMO. The alternative (be it &1 or only &) is more confusing. For example, it’s hard to tell on the first look that the first call (#join) actually has a different target than the other two because they’re in a nested block.

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

The amount of code saved is not that much, and the expressive version is also more flexible.

In this particular use case I agree. I actually didn’t realize they were different blocks at first glance. However I think the proposal is still useful. For example, imagine there was just 1 block there

1 Like

Well, the same rule is applicable to with yieldblock is the only indicator that something’s happening. However, we’ve got used to it. In fact, I see my proposal as improvement to with yield, which tries to preserve the API syntax but gives flexibility in block argument explicitness:

API.router do
  &.get "/" {}
  &.post "/" {}
end

I think that a core aspect of nice DSL is that the receiver is implicit.

Although allowing the usage of &. multiple times in the body can reduce 3 chars it’s not very different to me. It also introduces ambiguity in the current language.

foo do
  &.first &.second
end

It is not clear in the above example sends #second in the same context as #first or in the object that can be yielded by #first.


On another note, the index versions remind me too much to De Brüijn and it makes me happy, but I don’t think there is a real benefit. If you need to interact with multiple yielded values the code is probably more complex. If the code is more complex I think is better to let it break when moving it around. If we use names that work everywhere that will not happen.


Finally, why this thread is in Offtopic?! :stuck_out_tongue:

4 Likes