9

In Ruby, calling a lambda with the wrong number of arguments results in an ArgumentError:

l = lambda { |a, b| p a: a, b: b }
l.call(1, 2) 
# {:a=>1, :b=>2}

l.call(1)
# ArgumentError: wrong number of arguments (given 1, expected 2)

Passing an array instead doesn't work either: (because an array is just a single object, right?)

l.call([3, 4])
# ArgumentError: wrong number of arguments (given 1, expected 2)

Unless I use a splat (*) to convert the array to an argument list, but I didn't.

But ... if I call the lambda implicitly via yield, something unexpected happens:

def yield_to
  yield(1, 2)
  yield([3, 4])
end

yield_to(&l)
# {:a=>1, :b=>2}
# {:a=>3, :b=>4}   <- array as argument list!?

What's even more confusing, a lambda derived via Method#to_proc does work as expected:

def m(a, b)
  p a: a, b: b
end

yield_to(&method(:m))
# {:a=>1, :b=>2}
# ArgumentError: wrong number of arguments (given 1, expected 2)

What's going on here?

Stefan
  • 109,145
  • 14
  • 143
  • 218
  • `&` does more than just call `#to_proc` so your last example isn't that fair. But I think the key here is that `yield` doesn't invoke `#call`, it executes a *"bare bones"* block. While the `#call` methods checks the arguments and then executes the block. – ndnenkov Jan 26 '17 at 09:48
  • It looks like `yield` uses the equivalent of `call(arg)` or `call(*args)` depending on the expected number of parameters. It's hard to find the corresponding documentation, though. – Eric Duminil Jan 26 '17 at 09:52
  • @ndn I get the same result if I retrieve the proc via `prc = method(:m).to_proc` and call `yield_to(&prc)`. `prc` is a lambda with two required arguments, just like `l`. – Stefan Jan 26 '17 at 09:53
  • @EricDuminil then the last example should not raise an exception, either. – Stefan Jan 26 '17 at 09:54
  • 1
    Related but IMO not duplicate: http://stackoverflow.com/questions/23945533/why-do-ruby-procs-blocks-with-splat-arguments-behave-differently-than-methods-an – dcorking Jan 26 '17 at 10:03
  • You should write that as an answer and accept it instead. It's nice to have resolution. :) – ndnenkov Jan 26 '17 at 13:42

4 Answers4

4

I'm answering my own question here, because this is a known bug:

https://bugs.ruby-lang.org/issues/12705

And it was fixed in Ruby 2.4.1 (thanks @ndn)

Stefan
  • 109,145
  • 14
  • 143
  • 218
3

Obviously implementation spelunking is the way. Firstly why does #call raise exceptions for lambdas with incorrect arguments? Proc::call explicitly checks for lambdas.

Now lets trace yield. It's completely separate from Proc::call. Starts here, then, then, then (VM_BH_FROM_PROC just checks that the thing is indeed a proc), then (due to the goto). I couldn't trace exactly the magic that happens in the second run of the switch, but it only makes sense that it goes through the block_handler_type_iseq case or exits the switch. Point is, invoke_block_from_c_splattable is aware of the lambda status of the supposed proc, but it doesn't use it for anything other than to verify that it is indeed a lambda. It doesn't prevent splatting the arguments.


As for the why portion - I'm not sure it's even intentional, the fork in the logic for procs is just missing. Lambdas are mainly advertised as method-like ¯\_(ツ)_/¯

ndnenkov
  • 35,425
  • 9
  • 72
  • 104
  • _"`invoke_block_from_c_splattable` is aware of the lambda status"_ – it is, but both, `lambda { ... }` and `Method#to_proc` returns procs that are lambas (i.e. have their lambda-flag set). Yet, they are treated differently. – Stefan Jan 26 '17 at 11:00
  • @Stefan, method procs fall in the [`block_handler_type_ifunc`](https://github.com/ruby/ruby/blob/91587f6b63df794db6b4b2ce8f94f78344044ff2/vm.c#L1034) case, which is different from `block_handler_type_proc`? – ndnenkov Jan 26 '17 at 11:03
  • 1
    Here's a missing lambda check: https://github.com/ruby/ruby/blob/v2_4_0/vm_insnhelper.c#L2435 – I don't understand the whole code but that could explain, why lambdas are treated like ordinary procs. – Stefan Jan 26 '17 at 11:26
2

The differences might come from the way to_proc is written: for a simple lambda or proc, it just returns self. For a method, it sets is_from_method flag to true, and this comes into play when the proc is invoked in the VM:

if (proc->is_from_method) {
    return vm_invoke_bmethod(th, proc, self, argc, argv, passed_block_handler);
}
else {
    return vm_invoke_proc(th, proc, self, argc, argv, passed_block_handler);
}

It looks like if the proc was created from a method, the arguments will be checked.

Stefan
  • 109,145
  • 14
  • 143
  • 218
eugen
  • 8,916
  • 11
  • 57
  • 65
  • Unless you show that lambdas are `if_from_method` (which [doesn't seem to be the case](https://github.com/ruby/ruby/blob/91587f6b63df794db6b4b2ce8f94f78344044ff2/vm.c#L804)), this is just a speculation. Though likely in the right direction. – ndnenkov Jan 26 '17 at 10:03
  • @ndn unfortunately, `Proc` doesn't have a `from_method?` method so it cannot be easily shown, but eugen is correct: a proc created via `lamba` doesn't have its `is_from_method` flag set. – Stefan Jan 26 '17 at 10:11
  • 1
    Besides, does anyone know, _why_ it is handled differently? – Stefan Jan 26 '17 at 10:12
  • @Stefan, I think eugen was saying it is set and that is why we get different results (as opposed with other procs). – ndnenkov Jan 26 '17 at 10:21
  • After digging in Ruby's source code for a while, I'm not sure anymore if this really explains it. The mentioned C function `rb_vm_invoke_proc` seems to the one being invoked when calling `Proc#call` and as shown in my examples, `call` works as expected. It's just `yield` that differs. – Stefan Jan 26 '17 at 10:52
0

After looking in the docs, I think it is a bug in Ruby. Proc class docs explicitly say that:

The & argument preserves the tricks if a Proc object is given by & argument.

By that docs are saying that the way of handling params should be preserved even when lambda is passed with & to a method.

Following code

l = lambda { |a, b| p a: a, b: b }

def yield_to(&block)
  yield([3, 4])
  p block.lambda?
end

yield_to(&l)

outputs

{:a=>3, :b=>4}
true

Which is mutually exclusive.

What is interesting, is that this code fails with ArgumentError

l = lambda { |a, b| p a: a, b: b }

def yield_to(&block)
  block.call([3, 4])
  p block.lambda?
end

yield_to(&l)
snovity
  • 488
  • 4
  • 11
  • It does preserve it if you capture it with `&` and use `#call`. I didn't see the docs saying anything about `yield` behaviour explicitly. – ndnenkov Jan 26 '17 at 13:24
  • not sure, but I think it is expected that `yield` and `call` should work the same way with a passed block – snovity Jan 26 '17 at 13:27
  • well if that is the expectation, then yes - it is a bug. I was simply pointing out that the resource you provided doesn't document such expectation. – ndnenkov Jan 26 '17 at 13:28
  • 2
    I've just updated my question, it is a bug and it was already reported. – Stefan Jan 26 '17 at 13:34