z, ? | toggle help (this) |
space, → | next slide |
shift-space, ← | previous slide |
d | toggle debug mode |
## <ret> | go to slide # |
r | reload slides |
n | toggle notes |
Hello everyone. In this presentation, I will be discussing assignment evaluation order. I will discuss why assignment evaluation order was inconsistent in older versions of Ruby, and how it was fixed in Ruby 3.1 and 3.2.
My name is Jeremy Evans. I am a Ruby committer who focuses on fixing bugs in Ruby.
I am also the author of Polished Ruby Programming, which was published last year. This book is aimed at intermediate Ruby programmers and focuses on teaching principles of Ruby programming, as well as trade-offs to consider when making implementation decisions.
First, what is assignment evaluation order? It is not something that most Ruby programmers have to think about, but all assignment expressions have an order in which the expression is evaluated.
Here is a simple assignment example.
def a = []
def b = 1
a[0] = b
We start off defining a method named a that returns an array.
def a = []
def b = 1
a[0] = b
Then we define a method named b that returns 1.
def a = []
def b = 1
a[0] = b
Then we get to the assignment expression, where we can consider the assignment evaluation order. So how is this assignment expression evaluated?
def a = []
def b = 1
a[0] = b
Note that there are three separate method calls in this assignment.
def a = []
def b = 1
a[0] = b
There is a method call to a, which will return an array.
def a = []
def b = 1
a[0] = b
There is a method call to b, which will return 1.
def a = []
def b = 1
a[0] = b
Finally, there is a call to the element assignment method. It is clear this method must be called last, because it is called on the result of a, with arguments of 0 and the result of b.
def a = []
def b = 1
a[0] = b
However, it is probably not obvious which of these methods is called first, a or b. In this case, Ruby calls a first, then b. So the assignment evaluation order is a, then b, then the element assignment method.
def a = []
def b = 1
a[0] = b
The reason that a is evaluated before b is that Ruby follows the left-to-right evaluation principle, in that a typical expression will evaluate the left part before the right part.
You can see the left-to-right evaluation principle in other cases in Ruby. Consider this code, with the same definitions of a and b.
def a = []
def b = 1
p(a, b)
How does Ruby evaluate this expression?
def a = []
def b = 1
p(a, b)
With the left-to-right evaluation principal, p comes first. However, Ruby cannot evaluate the p method call yet, since the results of a and b are arguments.
def a = []
def b = 1
p(a, b)
So it continues. Ruby comes to a next, so it evaluates a first.
def a = []
def b = 1
p(a, b)
Ruby comes to b next, so it then evaluates b.
def a = []
def b = 1
p(a, b)
Now that it has the results of the method calls to a and b, it can then evaluate the method call to p.
def a = []
def b = 1
p(a, b)
So more precisely, the left-to-right evaluation principle in Ruby is that Ruby will evaluate expressions in a left-to-right order, delaying the evaluation of the current expression if it depends on expressions to the right, until all expressions it depends on have been evaluated.
Unfortunately, this principle of left-to-right evaluation was not implemented consistently for all forms of assignment in older versions of Ruby.
Here is a case where older versions of Ruby did not follow the left-to-right evaluation principle. We have the same method definitions of a and b as before.
def a = []
def b = 1
a[0], a[1] = [b, b]
In this case, the assignment expression uses multiple assignment.
def a = []
def b = 1
a[0], a[1] = [b, b]
Following the principle of left-to-right evaluation, you would assume that Ruby would first evaluate this call to a
def a = []
def b = 1
a[0], a[1] = [b, b]
Then this call to a,
def a = []
def b = 1
a[0], a[1] = [b, b]
Then this call to b,
def a = []
def b = 1
a[0], a[1] = [b, b]
Then this call to b,
def a = []
def b = 1
a[0], a[1] = [b, b]
Then the first element assignment
def a = []
def b = 1
a[0], a[1] = [b, b]
Then the second element assignment.
def a = []
def b = 1
a[0], a[1] = [b, b]
However, that is not what happens. Ruby before 3.1 violated the left-to-right evaluation principle.
def a = []
def b = 1
a[0], a[1] = [b, b]
First it evaluated the left call to b,
def a = []
def b = 1
a[0], a[1] = [b, b]
then it evaluated the right call to b.
def a = []
def b = 1
a[0], a[1] = [b, b]
Then it evaluated the left call to a,
def a = []
def b = 1
a[0], a[1] = [b, b]
then the left element assignment.
def a = []
def b = 1
a[0], a[1] = [b, b]
Then it evaluated the right call to a,
def a = []
def b = 1
a[0], a[1] = [b, b]
and finally the right element assignment.
def a = []
def b = 1
a[0], a[1] = [b, b]
This evaluation order issue for multiple assignment was known for a long time. It was reported and confirmed as a bug before the release of Ruby 1.9.3. Before it was fixed, this issue was one of the oldest still open bugs in Ruby’s bug tracker, with over 10 years between when it was reported and when it was fixed.|The bug was reported in Japanese, so I had to rely on a Google translation of the bug report and initial discussion, but apparently this bug was considered difficult to fix by matz. matz was not wrong. Of all of the bugs I have fixed in Ruby, this was the most challenging bug to fix, and took far more time to fix than any other bug I have worked on.
While not directly related to the multiple assignment evaluation order issue, there was a similar evaluation order issue for constant assignment. Here is an example of that.
def a = Module.new
def b = 1
a::C = b
The code is similar to before, except that the a method returns a module.
def a = Module.new
def b = 1
a::C = b
Here we have the assignment.
def a = Module.new
def b = 1
a::C = b
The left-to-right evaluation principle indicates that we should first evaluate the a method,
def a = Module.new
def b = 1
a::C = b
then evaluate the b method,
def a = Module.new
def b = 1
a::C = b
Then set the constant on the result of the a method.
def a = Module.new
def b = 1
a::C = b
However, what older versions of Ruby do is evaluate b,
def a = Module.new
def b = 1
a::C = b
then evaluate a,
def a = Module.new
def b = 1
a::C = b
then assign the constant on the result of the a method.
def a = Module.new
def b = 1
a::C = b
This constant assignment evaluation order issue was reported in 2019. I ended up fixing this issue shortly after fixing multiple assignment, and thanks to the work I did on fixing multiple assignment, it was much easier to fix constant assignment.|The fix for constant assignment evaluation order was not merged into Ruby until after Ruby 3.1 was released, so Ruby’s assignment evaluation order will not be fully consistent until the release of Ruby 3.2.
I am first going to discuss the issues related to multiple assignment evaluation order, since that is the more complex and challenging case.
It is probably not obvious at first glance why fixing multiple assignment evaluation order would be so challenging. After all, it appears you are just changing the order in which you are doing the evaluation. Surely this is as simple as moving code from one spot to another, right? What could possibly be so hard about that? Unfortunately, it is not so simple as it would first appear.
One of the reasons for this is that Ruby uses a stack-based virtual machine. Ruby compiles your source code into instructions that are executed on the virtual machine. Most of the implementation complexity for left-to-right evaluation comes from having to correctly manage the virtual machine stack.
What do these virtual machine instructions look like? Thankfully, Ruby comes with tools allowing you to easily see the generated instructions. You use the dump option when running your Ruby program, with a value of i, which is short for dumping instructions. This allows us to see how multiple assignment instructions changed between Ruby 3.0 and 3.1.
ruby --dump=i
We will start with a slightly different example. Here we have a multiple assignment expression, which will set the b attribute on a and the first element of c with the values of d and e. When you run Ruby 3.0 with the option to dump instructions,
a.b, c[0] = d, e
You get these instructions output.
a.b, c[0] = d, e
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
As Ruby uses a stack-based virtual machine, in order to understand how the virtual machine instructions work, you also need to consider the virtual machine stack, so here is where we will show the stack. Currently, the stack is empty.
a.b, c[0] = d, e
Stack:
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
As mentioned earlier, Ruby 3.0 evaluates the right hand side of the assignment first. The first expression in the right hand side is the method call to d, which is implemented using these two instructions.
a.b, c[0] = d, e
Stack:
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The first instruction, putself, adds self to the stack. self will be the receiver of the method. We will use a right arrow to show objects added to the stack by the current instruction.
a.b, c[0] = d, e
Stack:
→ self
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next instruction is opt_send_without_block, which is an optimized instruction used for certain method calls.
a.b, c[0] = d, e
Stack:
self
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
From looking at the data associated with the instruction, you can see that this is calling the d method with zero arguments.
a.b, c[0] = d, e
Stack:
self
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
Since the method call passes zero arguments, the virtual machine pops the top entry from the stack, which is the receiver of the method, and then pushes the result of the method, which we will call d. We will use a left arrow to show objects removed by the current instruction. So you can see that this instruction removes self from the stack, and replaces it with d.
a.b, c[0] = d, e
Stack:
← self
→ d
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
Next, Ruby 3.0 has to evaluate the method call to e. This is the same putself and opt_send_without_block instruction combination. This will push self onto the stack, then pop it off the stack when calling the e method, then push the result of the e method onto the stack.
a.b, c[0] = d, e
Stack:
→ e
d
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
It is probably not obvious why we have the next three instructions. The reason for these instructions is because this multiple assignment expression is the last expression being evaluated, so the return value of the assignment expression is needed. The return value is the right hand side of the assignment, which is an array containing the results of d and e.
a.b, c[0] = d, e
Stack:
e
d
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The first instruction, newarray, has a value of two, which tells the virtual machine to pop the top two entries of the stack, and push an array containing the two entries.
a.b, c[0] = d, e
Stack:
← e
← d
→ [d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next instruction, dup, pushes the top entry on the stack onto the stack, so the top two entries on the stack will always be the same after a dup instruction.
a.b, c[0] = d, e
Stack:
→ [d, e]
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next instruction, expandarray, pops the top entry on the stack, which should be an array. In this case, it is given two arguments, two and zero. The first argument of two means it should push the first two elements onto the stack, in reverse order, so the first element in the array is at the top of the stack. So now the stack contains d, e, and the array of d and e, in that order.
a.b, c[0] = d, e
Stack:
← [d, e]
→ d
→ e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
Now Ruby is going to evaluate the method call to a. This is similar to d and e, with the result of a getting pushed onto the stack.
a.b, c[0] = d, e
Stack:
→ a
d
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next four instructions implement the b= method call.
a.b, c[0] = d, e
Stack:
a
d
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The topn instruction is used to push an existing stack entry to the top of the stack. The argument it is given is the offset from the top of the stack, so the argument value of one in this case means to push the second entry on the stack to the top of the stack, which is d in this case.
a.b, c[0] = d, e
Stack:
→ d
a
d
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next instruction is another send instruction. The difference compared to the previous send instructions is that this instruction takes one argument instead of zero arguments. That means instead of popping one entry from the stack for the receiver, it pops two entries from the stack. The top of the stack is the argument to the method, and the second from the top is the receiver of the method. So this calls the b= method on a with the value returned by d. It pushes the result of the b= method onto the stack.
a.b, c[0] = d, e
Stack:
← d
← a
→ b=
d
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next two instructions are both pop instructions, each of which removes the top entry of the stack. We do not need the result of the b= method, and we no longer need the result of d on the stack, so those are popped from the stack.
a.b, c[0] = d, e
Stack:
← b=
← d
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
Next Ruby needs to evaluate the method call to c. This is just like the method calls to d, e, and a, and the return value of c is pushed onto the stack.
a.b, c[0] = d, e
Stack:
→ c
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
Finally, we get to the element assignment method call, setting the first element in c.
a.b, c[0] = d, e
Stack:
c
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The first instruction here is putobject_INT2FIX_0_, which is an instruction for pushing the number 0 onto the stack. This is the first argument to the element assignment method.
a.b, c[0] = d, e
Stack:
→ 0
c
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next instruction is topn, with an argument of 2, which means push the third entry on the stack to the top of the stack. This is going to be the second argument to the element assignment method.
a.b, c[0] = d, e
Stack:
→ e
0
c
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next instruction is opt_aset, which is an optimized instruction for the element assignment method. This method call takes two arguments, so the method call will pop 3 entries from the stack. The top two entries on the stack are the arguments to the method, and the third entry on the stack is the receiver, which is c. The result of calling this method is pushed onto the stack.
a.b, c[0] = d, e
Stack:
← e
← 0
← c
→ []=
e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The next two instructions are both pop instructions. We no longer need the return value of the element assignment method, or the return value of the e method.
a.b, c[0] = d, e
Stack:
← []=
← e
[d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
The final instruction is leave. This is used for return values. Since this is the last expression, the top entry on the stack is popped as the return value. In this case, the return value is the array containing d and e, since the return value for assignment expressions in Ruby is the right hand side of the expression.
a.b, c[0] = d, e
Stack:
← [d, e]
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
So that is how multiple assignment was implemented in Ruby 3.0. I think you really get an appreciation for how much Ruby does for you when you go through each virtual machine instruction.
a.b, c[0] = d, e
Stack:
Ruby 3.0 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 newarray 2
0008 dup
0009 expandarray 2, 0
0012 putself
0013 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0015 topn 1
0017 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0019 pop
0020 pop
0021 putself
0022 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0024 putobject_INT2FIX_0_
0025 topn 2
0027 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>
0029 pop
0030 pop
0031 leave
Here are the instructions when using Ruby 3.1. First, we can see right away that there are more instructions. Many of the instructions are the same, but instructions have been reordered, and there are more instructions that deal with stack manipulation.|On Ruby 3.0, this code resulted in 6 stack management instructions such as dup, pop, and topn. On Ruby 3.1, there are 13 stack management instructions. As you may expect, multiple assignment with attribute and element assignment is slightly slower in Ruby 3.1 than in 3.0 due to this. Such is the cost of correctness.
a.b, c[0] = d, e
Stack:
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Since you now have experience interpreting these instructions, we can pick up the pace a bit. As I discussed, Ruby 3.1 uses the left-to-right evaluation principle for multiple assignment, so it first evaluates the call to a and pushes the return value onto the stack, then evaluates the call to c and pushes the return value onto the stack.
a.b, c[0] = d, e
Stack:
→ c
→ a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Then, before evaluating the right side of the assignment, it evaluates the first argument to the element assignment method, and pushes it onto the stack. If the argument to the element assignment method was a method call, that method call would be evaluated before evaluating the right hand side of the assignment.
a.b, c[0] = d, e
Stack:
→ 0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Next Ruby evaluates the d and e method calls, pushing the results of each onto the stack.
a.b, c[0] = d, e
Stack:
→ e
→ d
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Ruby still uses the same newarray, dup, and expandarray instructions when the result of the multiple assignment needs to be returned. Just as before, this pushes the array for the right hand side of the assignment expression onto the stack, and d and e switching places on the stack, since d will be used first.
a.b, c[0] = d, e
Stack:
← e
← d
→ d
→ e
→ [d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Here are the instructions that Ruby will use to implement the b= method call. We will take this section instruction by instruction.
a.b, c[0] = d, e
Stack:
d
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
The b= method call is called on the the result of a, so we need to push the result of a to the top of the stack, which is done via a topn instruction with argument 5, since a is sixth on the stack.
a.b, c[0] = d, e
Stack:
→ a
d
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Next we have a swap instruction, which swaps the two top stack entries. This is because the next instruction is a method call taking one argument, and we need to have the argument to the method on the top of the stack, and the receiver of the method second from the top.
a.b, c[0] = d, e
Stack:
← a
← d
→ d
→ a
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Then we have the method call to the b= method, which pops the top two entries of the stack, and pushes the return value of the b= method onto the stack.
a.b, c[0] = d, e
Stack:
← d
← a
→ b=
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
We do not need the return value of the b= method, so we pop the value.
a.b, c[0] = d, e
Stack:
← b=
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Next, Ruby needs to evaluate the element assignment, which it does using these instructions. Again, we will go through this section an instruction at a time.
a.b, c[0] = d, e
Stack:
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
First it uses the topn instruction with an argument of 3 to push the receiver of the element assignment method onto the stack.
a.b, c[0] = d, e
Stack:
→ c
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Next, it uses the same topn instruction with an argument of 3 to push the first argument of the element assignment method onto the stack. Even though topn has the same argument, it pushes a different stack entry, because the argument to topn is the offset from the top of the stack, and the previous topn instruction pushed a value onto the stack.
a.b, c[0] = d, e
Stack:
→ 0
c
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Next we have another topn instruction, but this one uses an argument of 2. This pushes the second argument to the element assignment method onto the stack.
a.b, c[0] = d, e
Stack:
→ e
0
c
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
The next instruction calls the element assignment method with two arguments. It pops the two arguments and the receiver from the stack, and pushes the return value onto the stack.
a.b, c[0] = d, e
Stack:
← e
← 0
← c
→ []=
e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
The return value is not needed, so that is popped from the stack. The return value of e is no longer needed, so that is also popped from the stack.
a.b, c[0] = d, e
Stack:
← []=
← e
[d, e]
0
c
a
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
The next instruction we have not seen before. The setn instruction replaces the stack position at the given offset from the top of the stack with the value currently at the top of the stack. In this case, with offset 3, we are replacing the bottom stack entry with the top stack entry. We are doing this so that this assignment expression will have the correct return value, the right hand side of the assignment.
a.b, c[0] = d, e
Stack:
[d, e]
0
c
a → [d, e]
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
We no longer need the top 3 stack entries, so we pop each of them.
a.b, c[0] = d, e
Stack:
← [d, e]
← 0
← c
[d, e]
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
Then we return the value of the multiple assignment expression, the array containing d and e.
a.b, c[0] = d, e
Stack:
← [d, e]
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
While more complex than the Ruby 3.0 instructions, the Ruby 3.1 instructions correctly implement the left-to-right evaluation principle. The challenging part is using the correct stack management instructions, mostly in keeping track of which offsets to use for the topn instructions. As you can see here, at least in the simple case, it is not actually that complex, so why was it so challenging to implement?
a.b, c[0] = d, e
Stack:
Ruby 3.1 Instructions:
0000 putself ( 1)[Li]
0001 opt_send_without_block <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0003 putself
0004 opt_send_without_block <calldata!mid:c, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0006 putobject_INT2FIX_0_
0007 putself
0008 opt_send_without_block <calldata!mid:d, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0010 putself
0011 opt_send_without_block <calldata!mid:e, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0013 newarray 2
0015 dup
0016 expandarray 2, 0
0019 topn 5
0021 swap
0022 opt_send_without_block <calldata!mid:b=, argc:1, ARGS_SIMPLE>
0024 pop
0025 topn 3
0027 topn 3
0029 topn 2
0031 opt_aset <calldata!mid:[]=, argc:2, ARGS_SIMPLE>[CcCr]
0033 pop
0034 pop
0035 setn 3
0037 pop
0038 pop
0039 pop
0040 leave
In order to show you why it was challenging to implement, we will look at the differences in instructions when we modify the code slightly. We will start with the same example code we were using.
a.b, c[0] = d, e
This shows the Ruby 3.0 and 3.1 instructions side-by-side. To make sure everything fits on the screen and to more easily show the differences, this condenses the instruction display slightly.
a.b, c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 5
swap
opt_send_without_block b=, 1
pop
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
What happens to the instructions when adding an argument to the element assignment method call?
a.b, c[0,1] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 5
swap
opt_send_without_block b=, 1
pop
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
Here are the new instructions. Since it may not be obvious what changed, let us look at a diff between these instructions.
a.b, c[0,1] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putobject_INT2FIX_1_
topn 3
opt_send_without_block []=, 3
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putobject_INT2FIX_1_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 6
swap
opt_send_without_block b=, 1
pop
topn 4
topn 4
topn 4
topn 3
opt_send_without_block []=, 3
pop
pop
setn 4
pop
pop
pop
pop
leave
We can first look at the instruction differences in Ruby 3.0. One instruction was added, putobject_INT2FIX_1_, pushing the integer 1 onto the stack. The following topn instruction argument changes from 2 to 3. And the opt_aset instruction changes to opt_send_without_block, with an argument value switching from 2 to 3. All changes are localized, and all other instructions remain the same. We can compare the Ruby 3.0 changes to the Ruby 3.1 changes.
a.b, c[0,1] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
→ putobject_INT2FIX_1_
topn 2 → 3
← opt_aset []=, 2
→ opt_send_without_block []=, 3
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putobject_INT2FIX_1_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 6
swap
opt_send_without_block b=, 1
pop
topn 4
topn 4
topn 4
topn 3
opt_send_without_block []=, 3
pop
pop
setn 4
pop
pop
pop
pop
leave
It is a different situation in Ruby 3.1. The addition of a single argument affects many instructions, with all offset arguments getting modified, and with additional topn and pop instructions. We can tell from this that the offsets to each instruction are based on the number of arguments for each element assignment method on the left hand side.
a.b, c[0,1] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
→ putobject_INT2FIX_1_
topn 2 → 3
← opt_aset []=, 2
→ opt_send_without_block []=, 3
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putobject_INT2FIX_1_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 5 → 6
swap
opt_send_without_block b=, 1
pop
topn 3 → 4
topn 3 → 4
→ topn 4
topn 2 → 3
← opt_aset []=, 2
→ opt_send_without_block []=, 3
pop
pop
setn 3 → 4
pop
pop
pop
→ pop
leave
Instead of adding an argument to the element assignement method, we can see what happens if you make the multiple assignment set an additional local variable at the end.
a.b, c[0], f = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 3, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 3, 0
topn 6
swap
opt_send_without_block b=, 1
pop
topn 4
topn 4
topn 2
opt_aset []=, 2
pop
pop
setlocal_WC_0 f@0
setn 3
pop
pop
pop
leave
On the Ruby 3.0 side, the argument to the expand_array instruction goes from 2 to 3, and there is an additional instruction to set a local variable. All other instructions remain the same.
a.b, c[0], f = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 3, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
→ setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 3, 0
topn 6
swap
opt_send_without_block b=, 1
pop
topn 4
topn 4
topn 2
opt_aset []=, 2
pop
pop
setlocal_WC_0 f@0
setn 3
pop
pop
pop
leave
On the Ruby 3.1 side, you get the same change to expandarray and the instruction to set the local variable. However, three of the topn instructions also have shifted offsets. Some of the offsets shift, but not all of the offsets. So we can tell from this that the offsets are not just based on the number of element assignment method arguments, but also on the total number of attributes or local variables being set by the multiple assignment.
a.b, c[0], f = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 3, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
→ setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 3, 0
topn 5 → 6
swap
opt_send_without_block b=, 1
pop
topn 3 → 4
topn 3 → 4
topn 2
opt_aset []=, 2
pop
pop
→ setlocal_WC_0 f@0
setn 3
pop
pop
pop
leave
What happens if the local variable is added to the beginning instead of the end?
f, a.b, c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 3, 0
setlocal_WC_0 f@0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 3, 0
setlocal_WC_0 f@0
topn 5
swap
opt_send_without_block b=, 1
pop
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
The interesting thing here is the difference in the instructions is the same in both cases. So the offset changes depend on the position of the attribute assignments relative to the local variable assignments.
f, a.b, c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 3, 0
→ setlocal_WC_0 f@0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 3, 0
→ setlocal_WC_0 f@0
topn 5
swap
opt_send_without_block b=, 1
pop
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
You can confirm this by adding the local variable to the middle.
a.b, f, c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 3, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
setlocal_WC_0 f@0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 3, 0
topn 6
swap
opt_send_without_block b=, 1
pop
setlocal_WC_0 f@0
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
Here, only the first topn offset is changed, and not the other offsets. Otherwise, the instruction differences are the same between versions.
a.b, f, c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 3, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
→ setlocal_WC_0 f@0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 3, 0
topn 5 → 6
swap
opt_send_without_block b=, 1
pop
→ setlocal_WC_0 f@0
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
What if instead of adding a local variable, we replace the element assignment with a local variable? We can see that this significantly reduces the number of instructions, but what changes?
a.b, f = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 3
swap
opt_send_without_block b=, 1
pop
setlocal_WC_0 f@0
setn 1
pop
leave
In Ruby 3.0, this results in a bunch of instructions being removed, and a single instruction being added to set the local variable.
a.b, f = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
← putself
← opt_send_without_block c, 0
← putobject_INT2FIX_0_
← topn 2
← opt_aset []=, 2
← pop
← pop
→ setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 3
swap
opt_send_without_block b=, 1
pop
setlocal_WC_0 f@0
setn 1
pop
leave
In Ruby 3.1, it also results in the instructions changing, but it also requires changing the initial topn offset and the setn offset. This shows the offsets depend on not just the number of assignments, number of arguments, and position of assignments, but also the type of assignments.
a.b, f = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
← putself
← opt_send_without_block c, 0
← putobject_INT2FIX_0_
← topn 2
← opt_aset []=, 2
← pop
← pop
→ setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
← putself
← opt_send_without_block c, 0
← putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 5 → 3
swap
opt_send_without_block b=, 1
pop
← topn 3
← topn 3
← topn 2
← opt_aset []=, 2
← pop
← pop
→ setlocal_WC_0 f@0
setn 3 → 1
pop
leave
Unfortunately, multiple assignment in Ruby is more complicated than just assigning to attributes, elements, and local variables. You also need to deal with splats. We can see what the differences are if we splat the second expression being assigned.
a.b, *c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 1, 1
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 1, 1
topn 5
swap
opt_send_without_block b=, 1
pop
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
It turns out that using a splat does not change the offsets. The instruction change is the same in both cases, modifying the arguments to the expandarray instruction.
a.b, *c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 1, 0 → 1
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 1, 0 → 1
topn 5
swap
opt_send_without_block b=, 1
pop
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
If the splat is the first assignment instead of the last, and you are setting post-splat attributes, there are also few differences. In addition to changing the arguments to the expandarray instruction, a second expandarray instruction is added. That is the only instruction change, and it is the same in both Ruby versions.|This gives the impression that splats do not add complexity, and that is deceiving, because the implementation must consider whether there are splats, due to how the compiler interacts with the abstract syntax tree produced by the parser. However, it looks like the net result does not change the instructions.
*a.b, c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 0, 0 → 1
→ expandarray 1, 3
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2 → 0, 0 → 1
→ expandarray 1, 3
topn 5
swap
opt_send_without_block b=, 1
pop
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
In addition to splats, Ruby also supports nested multiple assignment, to any level of nesting. What differences do we see when the first assignment is changed to a nested assignment, with the addition of a local variable?
(a.b, f), c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
setlocal_WC_0 f@0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
expandarray 2, 0
topn 6
swap
opt_send_without_block b=, 1
pop
setlocal_WC_0 f@0
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
This adds two instructions in both Ruby 3.0 and 3.1. The only other change in Ruby 3.1 is the first topn needs an adjusted offset.
(a.b, f), c[0] = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
→ expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
→ setlocal_WC_0 f@0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
expandarray 2, 0
topn 5 → 6
swap
opt_send_without_block b=, 1
pop
→ setlocal_WC_0 f@0
topn 3
topn 3
topn 2
opt_aset []=, 2
pop
pop
setn 3
pop
pop
pop
leave
What about the case where the nested assignment comes in the second assignment, instead of the first assignment?
a.b, (c[0], f) = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
expandarray 2, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 5
swap
opt_send_without_block b=, 1
pop
expandarray 2, 0
topn 4
topn 4
topn 2
opt_aset []=, 2
pop
pop
setlocal_WC_0 f@0
setn 3
pop
pop
pop
leave
In that case, instead of the first topn instruction needing a new offset, the next two topn instructions need a new offset.
a.b, (c[0], f) = d, e
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
→ expandarray 2, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
→ setlocal_WC_0 f@0
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
dup
expandarray 2, 0
topn 5
swap
opt_send_without_block b=, 1
pop
→ expandarray 2, 0
topn 3 → 4
topn 3 → 4
topn 2
opt_aset []=, 2
pop
pop
→ setlocal_WC_0 f@0
setn 3
pop
pop
pop
leave
We can consider one final case, where the results of the multiple assignment are not needed. The Ruby compiler has many optimizations that take into account whether the result of an expression will be used or not. In this case, since the result of the multiple assignment is not used, Ruby should not create an array for the right hand side of the expression.
a.b, c[0] = d, e; nil
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
swap
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
putnil
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
expandarray 2, 0
topn 4
swap
opt_send_without_block b=, 1
pop
topn 2
topn 2
topn 2
opt_aset []=, 2
pop
pop
pop
pop
pop
putnil
leave
On Ruby 3.0 side, the newarray, dup, and expandarray instructions are replaced with a simple swap instruction, so that d is at the top of the stack, instead of e being at the top of the stack. At the very end, a putnil instruction is used so the leave instruction will return nil instead of an array.
a.b, c[0] = d, e; nil
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
← newarray 2
← dup
← expandarray 2, 0
→ swap
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
→ putnil
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
expandarray 2, 0
topn 4
swap
opt_send_without_block b=, 1
pop
topn 2
topn 2
topn 2
opt_aset []=, 2
pop
pop
pop
pop
pop
putnil
leave
As you would expect, on Ruby 3.1 this also causes the offsets to change. It removes the need for the setn instruction, since that was only used to set the return value of the multiple assignment.
a.b, c[0] = d, e; nil
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
← newarray 2
← dup
← expandarray 2, 0
→ swap
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
→ putnil
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
← dup
expandarray 2, 0
topn 5 → 4
swap
opt_send_without_block b=, 1
pop
topn 3 → 2
topn 3 → 2
topn 2
opt_aset []=, 2
pop
pop
← setn 3
pop
pop
pop
→ putnil
leave
One thing to notice about the Ruby 3.1 instructions compared to the Ruby 3.0 instructions is that even when the result of the multiple assignment is not used, Ruby 3.1 will still create an unnecessary array, while Ruby 3.0 will not.|I did not realize this issue when fixing multiple assignment. However, after I discovered it while developing this presentation, I implemented the same optimization used in Ruby 3.0, along with some additional optimizations that apply not just to multiple assignment, but to other cases where this and similar combinations of instructions are used. Those optimizations have been committed and will be in Ruby 3.2.
a.b, c[0] = d, e; nil
Ruby 3.0 Instructions:
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
swap
putself
opt_send_without_block a, 0
topn 1
opt_send_without_block b=, 1
pop
pop
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
topn 2
opt_aset []=, 2
pop
pop
putnil
leave
Ruby 3.1 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
putobject_INT2FIX_0_
putself
opt_send_without_block d, 0
putself
opt_send_without_block e, 0
newarray 2
expandarray 2, 0
topn 4
swap
opt_send_without_block b=, 1
pop
topn 2
topn 2
topn 2
opt_aset []=, 2
pop
pop
pop
pop
pop
putnil
leave
From trying each of these small changes to multiple assignment, and seeing how the instructions are affected, we can sort of piece together what affects the offsets.
We saw that the total number of assignments affects the offsets.
We saw that the type of assignments, such as how many are local variables and how many are attributes, affects the offsets.
We saw that the order of assignments affects the offsets.
We saw that the number of element assignment method arguments affects the offsets.
We saw that the use of nesting affects the offsets.
We saw that whether or not the assignment expression return value is needed affects the offsets.
All of these must be considered when determining the offsets to use for the topn and setn instructions.
Even if you have all of that information, Ruby’s compilation process actually makes using the information challenging.
You start the compilation process using source code. This is the Ruby code that you write.
Ruby uses a parser, which turns your source code
into an abstract syntax tree, also called a parse tree. The abstract syntax tree contains nodes.
You can use the dump option with the value p for parsetree when running ruby, and Ruby will show you the parse tree for the code.
ruby --dump=p
We can take the Ruby code example we have been using,
a.b, c[0] = d, e
and see the parse tree for it. This is the output, though I have simplified it quite a bit. In general, Ruby’s compiler takes the parse tree as input, and it outputs virtual machine instructions. Generating instructions in this case is quite challenging. In general, the compiler recursively processes the parse tree to generate the appropriate instructions.
a.b, c[0] = d, e
# @ NODE_SCOPE
# @ NODE_MASGN
# | @ NODE_LIST
# | | @ NODE_VCALL d
# | | @ NODE_VCALL e
# | @ NODE_LIST
# | | @ NODE_ATTRASGN
# | | | @ NODE_VCALL a
# | | +- b=
# | | @ NODE_ATTRASGN
# | | | @ NODE_VCALL c
# | | +- :[]=
# | | +- @ NODE_LIST
# | | | @ NODE_LIT 0
For example, this is the part of the parse tree related to the first attribute assignment. However, at the point the compiler is processing this part of the parse tree, it does not have the information necessary to produce the correct instructions, since the offsets to use for the topn instructions are not known until the entire multiple assignment node been processed.
a.b, c[0] = d, e
# @ NODE_SCOPE
# @ NODE_MASGN
# | @ NODE_LIST
# | | @ NODE_VCALL d
# | | @ NODE_VCALL e
# | @ NODE_LIST
# | | @ NODE_ATTRASGN
# | | | @ NODE_VCALL a
# | | +- b=
# | | @ NODE_ATTRASGN
# | | | @ NODE_VCALL c
# | | +- :[]=
# | | +- @ NODE_LIST
# | | | @ NODE_LIT 0
To work around this issue, the multiple assignment instruction compiler keeps four separate instruction sequences.
a.b, c[0] = d, e
INIT_ANCHOR(pre);
INIT_ANCHOR(rhs);
INIT_ANCHOR(lhs);
INIT_ANCHOR(post);
One for the evaluating method calls in the left hand side,
a.b, c[0] = d, e
INIT_ANCHOR(pre);
INIT_ANCHOR(rhs);
INIT_ANCHOR(lhs);
INIT_ANCHOR(post);
one for evaluating the right hand side,
a.b, c[0] = d, e
INIT_ANCHOR(pre);
INIT_ANCHOR(rhs);
INIT_ANCHOR(lhs);
INIT_ANCHOR(post);
one for calling assignment methods,
a.b, c[0] = d, e
INIT_ANCHOR(pre);
INIT_ANCHOR(rhs);
INIT_ANCHOR(lhs);
INIT_ANCHOR(post);
and one for cleaning up the stack.
a.b, c[0] = d, e
INIT_ANCHOR(pre);
INIT_ANCHOR(rhs);
INIT_ANCHOR(lhs);
INIT_ANCHOR(post);
While processing the left hand side of the assignment, the compiler creates a linked list of structs containing the information for each of topn instructions.
a.b, c[0] = d, e
struct masgn_lhs_node {
INSN *before_insn;
struct masgn_lhs_node *next;
const NODE *line_node;
int argn;
int num_args;
int lhs_pos;
};
Each struct includes a pointer for where to add the topn instruction once the correct offset has been calculated,
a.b, c[0] = d, e
struct masgn_lhs_node {
INSN *before_insn;
struct masgn_lhs_node *next;
const NODE *line_node;
int argn;
int num_args;
int lhs_pos;
};
as well as information needed to calculate the topn offset argument.
a.b, c[0] = d, e
struct masgn_lhs_node {
INSN *before_insn;
struct masgn_lhs_node *next;
const NODE *line_node;
int argn;
int num_args;
int lhs_pos;
};
After processing all nodes for the multiple assignment,
a.b, c[0] = d, e
while (memo) {
VALUE topn_arg = INT2FIX((state.num_args - memo->argn) + memo->lhs_pos);
for (int i = 0; i < memo->num_args; i++) {
INSERT_BEFORE_INSN1(memo->before_insn, memo->line_node, topn, topn_arg);
}
tmp_memo = memo->next;
free(memo);
memo = tmp_memo;
}
the compiler will iterate over the linked list,
a.b, c[0] = d, e
while (memo) {
VALUE topn_arg = INT2FIX((state.num_args - memo->argn) + memo->lhs_pos);
for (int i = 0; i < memo->num_args; i++) {
INSERT_BEFORE_INSN1(memo->before_insn, memo->line_node, topn, topn_arg);
}
tmp_memo = memo->next;
free(memo);
memo = tmp_memo;
}
calculate the correct offset argument to use for each topn instruction.
a.b, c[0] = d, e
while (memo) {
VALUE topn_arg = INT2FIX((state.num_args - memo->argn) + memo->lhs_pos);
for (int i = 0; i < memo->num_args; i++) {
INSERT_BEFORE_INSN1(memo->before_insn, memo->line_node, topn, topn_arg);
}
tmp_memo = memo->next;
free(memo);
memo = tmp_memo;
}
and then insert the topn instructions at the appropriate places.
a.b, c[0] = d, e
while (memo) {
VALUE topn_arg = INT2FIX((state.num_args - memo->argn) + memo->lhs_pos);
for (int i = 0; i < memo->num_args; i++) {
INSERT_BEFORE_INSN1(memo->before_insn, memo->line_node, topn, topn_arg);
}
tmp_memo = memo->next;
free(memo);
memo = tmp_memo;
}
After inserting the topn instructions,
a.b, c[0] = d, e
ADD_SEQ(ret, pre);
ADD_SEQ(ret, rhs);
ADD_SEQ(ret, lhs);
if (!popped && state.num_args >= 1) {
/* make sure rhs array is returned before popping */
ADD_INSN1(ret, node, setn, INT2FIX(state.num_args));
}
ADD_SEQ(ret, post);
the compiler will merge the four instruction sequences into a single instruction sequence.
a.b, c[0] = d, e
ADD_SEQ(ret, pre);
ADD_SEQ(ret, rhs);
ADD_SEQ(ret, lhs);
if (!popped && state.num_args >= 1) {
/* make sure rhs array is returned before popping */
ADD_INSN1(ret, node, setn, INT2FIX(state.num_args));
}
ADD_SEQ(ret, post);
If the return value of the multiple assignment is needed, it will add the setn instruction to make sure the assignment expression returns the right hand side.
a.b, c[0] = d, e
ADD_SEQ(ret, pre);
ADD_SEQ(ret, rhs);
ADD_SEQ(ret, lhs);
if (!popped && state.num_args >= 1) {
/* make sure rhs array is returned before popping */
ADD_INSN1(ret, node, setn, INT2FIX(state.num_args));
}
ADD_SEQ(ret, post);
So far, we have mostly considered multiple assignment, but as I mentioned earlier, constant assignment did not follow the left-to-right evaluation principle in older versions of Ruby.
We can consider the following simple constant assignment example,
a::B = c
with the related instructions.
a::B = c
Ruby 3.1 Instructions:
putself
opt_send_without_block c, 0
dup
putself
opt_send_without_block a, 0
setconstant :B
leave
Ruby 3.2 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
swap
topn 1
swap
setconstant :B
leave
We can see that in Ruby 3.1, c is evaluated before a, violating the left-to-right evaluation principle.
a::B = c
Ruby 3.1 Instructions:
putself
opt_send_without_block c, 0
dup
putself
opt_send_without_block a, 0
setconstant :B
leave
Ruby 3.2 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
swap
topn 1
swap
setconstant :B
leave
While in Ruby 3.2, a is evaluated before c, following the left-to-right evaluation principle.
a::B = c
Ruby 3.1 Instructions:
putself
opt_send_without_block c, 0
dup
putself
opt_send_without_block a, 0
setconstant :B
leave
Ruby 3.2 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
swap
topn 1
swap
setconstant :B
leave
Just as in the multiple assignment change, Ruby 3.2 requires more stack management instructions. However, for single constant assignment, you do not need to keep track of offsets.
a::B = c
Ruby 3.1 Instructions:
putself
opt_send_without_block c, 0
dup
putself
opt_send_without_block a, 0
setconstant :B
leave
Ruby 3.2 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block c, 0
swap
topn 1
swap
setconstant :B
leave
Ruby supports setting constants during multiple assignment, though you hopefully will never see code that uses it.
a::B, d::E = c
You can even set constants to splat values or in nested multiple assignment. I would only do that if I really disliked the future maintainer, though.
a::B, (f, *d::E) = c
Changing the evaluation order for single constant assignment actually broke compilation of constant assignment inside multiple assignment, so compilation of multiple assignment had to be updated to support left-to-right evaluation for constant assignment.
a::B, d::E = c
Ruby 3.1 Instructions:
putself
opt_send_without_block c, 0
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
setconstant :B
putself
opt_send_without_block d, 0
setconstant :E
leave
Ruby 3.2 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block d, 0
putself
opt_send_without_block c, 0
dup
expandarray 2, 0
topn 4
setconstant :B
topn 2
setconstant :E
setn 2
pop
pop
leave
As you would expect, in Ruby 3.2, multiple assignment to constants follows the left-to-right evaluation principle, evaluting a, then d, then c,
a::B, d::E = c
Ruby 3.1 Instructions:
putself
opt_send_without_block c, 0
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
setconstant :B
putself
opt_send_without_block d, 0
setconstant :E
leave
Ruby 3.2 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block d, 0
putself
opt_send_without_block c, 0
dup
expandarray 2, 0
topn 4
setconstant :B
topn 2
setconstant :E
setn 2
pop
pop
leave
then setting the B constant on a and the E constant on d.
a::B, d::E = c
Ruby 3.1 Instructions:
putself
opt_send_without_block c, 0
dup
expandarray 2, 0
putself
opt_send_without_block a, 0
setconstant :B
putself
opt_send_without_block d, 0
setconstant :E
leave
Ruby 3.2 Instructions:
putself
opt_send_without_block a, 0
putself
opt_send_without_block d, 0
putself
opt_send_without_block c, 0
dup
expandarray 2, 0
topn 4
setconstant :B
topn 2
setconstant :E
setn 2
pop
pop
leave
Here are the lessons I learned related to these bug fixes.
Just because a bug is old, does not mean it cannot be fixed. These assignment evaluation order bugs have both existed since multiple assignment and constant assignment were added to Ruby, and both were known for multiple years before I started work on fixing them.
Be aware that bugs usually only reach old age for a reason. Bugs that are easy to fix are often fixed quickly. The older a bug is, the more likely it is to be difficult to fix.
Even if you expect a bug to be hard to fix, do not let that discourage you. In many cases, the bugs that are the most difficult to fix are the bugs that you will learn the most from fixing. See an old bug as an opportunity to rise to the occasion, and expand your knowledge of Ruby.
Ruby currently has over 75 open bugs in the bug tracker that are over 5 years old, just waiting to you to fix. We look forward to your contibutions!
I hope you had fun learning about how we fixed assignment evaluation order in Ruby 3.1 and 3.2.
If you enjoyed this presentation, and want to read more of my thoughts on Ruby programming, please consider picking up a copy of Polished Ruby Programming.
That concludes my presentation. I would like to thank all of you for listening to me.
A special thank you to Cookpad for sponsoring my travel to RubyKaigi. I think I am out of time, so if you have any questions, please ask me during the break.