Return Value

This section describes the return values of Lua functions. First introduce the case of fixed number return values, and then introduce the case of variable number.

Similar to the parameter characteristics in the previous section involving parameters and arguments, there are two places involved in realizing the return value of a function:

  • The called function generates a return value before exiting. This is done with the return statement in Lua. Correspondingly, the Return bytecode needs to be added.

  • The caller reads and processes the return value. This part of the functionality is implemented in Call bytecode. Previous Call bytecode just called the function without processing the return value.

Just as the arguments are passed by the stack, the return values are also passed by the stack.

We first introduce the return statement and Return bytecode that the function generates the return value.

Return Bytecode

Between the called function and the caller, the return values are passed using the stack. The called function generate return values and loads them on the stack, and then notifies the caller of the return values' positions on the stack, and the caller reads the return values from the stack.

Functions in Lua language support multiple return values. If the positions of these return values on the stack are discontinuous, it is difficult to inform the caller of the specific return value. Therefore, all return values are required to be arranged continuously on the stack, so that the caller can be informed by the starting index on the stack and the number of return values. To do this, all return values need to be loaded onto the top of the stack in turn. Like the following example:

local function foo()
     local x, y = 1, 2
     return x, "Yes", g1+g2
end

The stack layout before the function returns is as follows:

|       |
+-------+
|  foo  | The caller loads `foo` onto the stack
+=======+ <--base
|   x   | 0 \
+-------+    + local variables
|   y   | 1 /
+-------+
|   x   | 2 \
+-------+    |
| "yes" | 3   + return value
+-------+    |
| g1+g2 | 4 /
+-------+
|  g2   | 5<-- temporary variables
+-------+
|       |

The numbers 0~5 on the right side of the stack are relative addresses. Among them, 2~4 is the position of the return values on the stack, then the information to be returned by this function is (2, 3), where 2 is the starting position of the return value on the stack, and 3 is the return values number. It can be seen that the newly added bytecode Return needs to be associated with 2 parameters.

In addition to the above-mentioned general cases, there are two special cases, that is, the cases where the number of return values is 0 and 1.

First of all, for the case where the number of return values is 0, that is, the return statement with no return value, although the Return bytecode can also be used to return (0, 0), but for clarity, we add bytecode Return0 without associate parameter for this case.

Secondly, for the case where the number of return values is 1, it can be optimized during syntax analysis. In the case of the above multiple return values, it is mandatory to load all the return values onto the stack sequentially for the sake of continuity and to be able to notify the caller of the position of the return value. And if there is only one return value, continuity is not required, so for local variables that are already on the stack, there is no need to load them on the stack again. Of course, other types of return values (such as global variables, constants, table indexes, etc.) still need to be loaded. Like the following example:

local function foo()
     local x, y = 1, 2
     return x
end

The stack layout before the function returns is as follows:

|       |
+-------+
|  foo  | The caller loads foo onto the stack
+=======+ <--base
|   x   | 0 \    <-----return values
+-------+    + local variables
|   y   | 1 /
+-------+
|       |

There is only one return value x, and it is a local variable, which is already on the stack, and it is enough to return (0, 1), without loading it to the top of the stack again.

In summary, the newly added two bytecodes are defined as follows:

pub enum ByteCode {
     Return0,
     Return(u8, u8),

The parsing process of the return statement is as follows:

  • for no return value, generate Return0 bytecode;
  • For a single return value, on-demand loaded onto the stack and generate Return(?, 1) bytecode;
  • For multiple return values, force to be loaded onto the stack sequentially and generate Return(?, ?) bytecode.

Syntax Analysis of return statement

The parsing process of the return statement is summarized above, and now the syntax analysis begins. The BNF definition of the return statement is as follows:

retstat ::= return [explist][';']

In addition to optional multiple return value expressions, there can be 1 optional ;. In addition, there is another rule, that is, the end token of a block must be followed by the return statement, such as end, else, etc. This statement is relatively simple, but there are more details. The code is first listed below:

     fn ret_stat(&mut self) {
         let code = match self. lex. peek() {
             // return;
             Token::SemiColon => {
                 self. lex. next();
                 ByteCode::Return0 // no return value
             }

             // return
             t if is_block_end(t) => {
                 ByteCode::Return0 // no return value
             }

             _ => { // has return values
                 let mut iret = self.sp;

                 // Read the list of expressions. Only the last one is kept and ExpDesc
                 // is returned, while the previous ones are loaded onto the stack.
                 // Return value: `nexp` is the number of previously loaded expressions,
                 // and `last_exp` is the last expression.
                 let (nexp, last_exp) = self.explist();

                 // check optional ';'
                 if self.lex.peek() == &Token::SemiColon {
                     self. lex. next();
                 }
                 // check block end
                 if !is_block_end(self.lex.peek()) {
                     panic!("'end' expected");
                 }

                 if nexp == 0 {
                     // single return value, loaded *on demand*
                     iret = self.discharge_any(last_exp);
                 } else {
                     // Multiple return values, other return values have been loaded to
                     // the top of the stack in turn, now we need to put the last
                     // Expressions are also *forced* to be loaded on top of the stack,
                     // after other return values
                     self.discharge(self.sp, last_exp);
                 }

                 ByteCode::Return(iret as u8, nexp as u8 + 1)
             }
         };
         self.fp.byte_codes.push(code);
     }

Because the processing of single and multiple return values is different, when reading the return value list, keep the last expression and not directly load it on the stack. At this point, the modified explist() function in the previous section comes in handy again. If there is only the last expression, that is, nexp == 0, then it is a single expression, and it is loaded on the stack as needed; otherwise, it is the case of multiple return values, and other return values have been loaded to the stack in turn At the top, it is necessary to force the last expression to be loaded on the top of the stack, behind other return values.

To review, in the above code, the discharge_any() method in the case of a single return value is on-demand loading, that is, it does not process expressions already on the stack (local variables or temporary variables, etc.); and The discharge() method in the case of multiple return values is forced to load.

Return Bytecode Execution

After completing the syntax analysis, the next step is to introduce the execution of the Return bytecode by the virtual machine. Two things need to be done:

  • To exit from the execution of the current function execute(), use the return statement of Rust;

  • The most intuitive way to tell the caller the position of the return value is to return the two parameters associated with Return bytecode: the starting position and number of return values on the stack. However, the starting position here needs to be converted from a relative position to an absolute position. code show as below:

     ByteCode::Return(iret, nret) => {
         return (self. base + iret as usize, nret as usize);
     }

This is a bit long-winded, and there are 2 problems:

  • The prototype of the Rust function type in Lua (such as the print function) is fn (&mut ExeState) -> i32, and there is only 1 return value i32, which represents the number of Rust function return values. If the Lua function type returns 2 values, the return information of these two types of functions is inconsistent, which is inconvenient to handle later.

  • Later in this section, a variable number of return values of Lua functions will be supported, and the specific number of return values needs to be calculated according to the execution situation.

So it is also changed here to only return the number of Lua function return values, but not returning the starting position. For this reason, possible temporary variables on the stack need to be cleaned up to ensure that the return value is at the top of the stack. In this way, the caller can determine the position of the return value only according to the number of return values. Also using the above example:

|       |
+-------+
|  foo  | The caller loads foo onto the stack
+=======+ <--base
|   x   | 0 \
+-------+    + local variables
|   y   | 1 /
+-------+
|   x   | 2 \
+-------+    |
| "yes" | 3   + return values
+-------+    |
| g1+g2 | 4 /
+-------+
|       | <--clean up the temporary variable `g2`

In this example, after clearing the temporary variable g2 at the top of the stack, only return 3 to the calling function, and the calling function can read the 3 values at the top of the stack as the return value.

So why do we need to associate 2 parameters in the Return bytecode? In addition to the number of return values, but also the starting position of the return value? This is because it is difficult to determine whether there are temporary variables on the top of the stack during execution during the syntax analysis phase (such as g2 in the above example), and even if it can be determined, there is nothing to do with these temporary variables (unless a bytecode is added to clean up the temporary variables ). Therefore, the return value cannot be expressed only by the number. In the virtual machine execution stage, since possible temporary variables can be cleaned up, there is no need to return to the starting address without the interference of temporary variables.

In summary, the execution code of Return bytecode is as follows:

     ByteCode::Return(iret, nret) => {
         // convert relative address to absolute address
         let iret = self.base + iret as usize;

         // clean up temporary variables to ensure that `nret`
         // at the top of the stack is the return value
         self.stack.truncate(iret + nret as usize);

         return nret as usize;
     }
     ByteCode::Return0 => {
         return 0;
     }

Correspondingly, the entry function execute() executed by the virtual machine also needs to modify the prototype, change it to return a usize value:

     pub fn execute(&mut self, proto: &FuncProto) -> usize {

Bytecode Traversal and Function Exit

Now that the execute() function is mentioned, let's talk about the traversal and exit of the bytecode sequence.

At the beginning, this project only supported sequential execution, using Rust Vec's iterator:

     for code in proto.byte_codes.iter() {
         match *code {

Later, after the jump statement is supported, it is necessary to traverse manually, and judge whether to exit by whether the pc exceeds the bytecode sequence:

     let mut pc = 0;
     while pc < proto.byte_codes.len() {
         match proto.byte_codes[pc] {

Lua's return statement is now supported, and the execution of the corresponding Return bytecode will exit the execute() function. If all Lua functions eventually contain the Return bytecode, there is no need to check whether the pc has exceeded the bytecode sequence to determine whether to exit. In this way, the original while loop in the execute() function can be changed to a loop loop, reducing a conditional judgment:

     let mut pc = 0;
     loop {
         match proto.byte_codes[pc] {
             ByteCode::Return0 => { // Return or Return0 bytecode, exit function
                 return 0;
             }

To do this, we append the Return0 bytecode at the end of all Lua functions:

fn chunk(lex: &mut Lex<impl Read>, end_token: Token) -> FuncProto {
     let mut proto = ParseProto::new(lex);
     assert_eq!(proto.block(), end_token);
     if let Some(goto) = proto. gotos. first() {
         panic!("goto {} no destination", &goto.name);
     }

     // All Lua functions end with `Return0` bytecode
     proto.fp.byte_codes.push(ByteCode::Return0);

     proto.fp
}

So far, the function of exiting the function and generating a return value is completed. Next, introduce the second part: the caller reads the return value.

Read Return Values: Position

After the called function returns through the return statement, the virtual machine execution sequence returns back to the Call bytecode of the outer calling function, where the return values are read and processed. How to handle the return values? It depends on the different application scenarios where the function call is made. Because the Lua function supports multiple return values, and the specific number of return values cannot be determined during the syntax analysis stage, similar to the variable parameters expression ... in the previous section, the processing of the function return values is simlar with the variable parameter and also includes 3 scenarios:

  1. When used as the last argument of a function call, the last value of a return statement, or the last array member of a table construction, read all return values. For example, the following example:

    print("hello: ", foo(1, 2)) -- last argument
    local t = {1, 2, foo()} -- last list member
    return a+b, foo() -- the last return value
    
  2. When used as the last expression after the equal sign = of a local variable definition statement or an assignment statement, the number of return values will be expanded or reduced as required. For example, the following example:

    local x, y = foo() -- take the first 2 actual parameters and assign them to x and y respectively
    t.k, t.j = a, foo() -- take the first actual parameter and assign it to t.j
    
  3. Other places only represent the first actual parameter passed in. For example, the following example:

    local x, y = foo(), b -- not the last expression, just take the first argument and assign it to x
    t.k, t.j = foo(), b -- not the last expression, just take the first argument and assign it to t.k
    if foo() then -- conditional judgment
       t[foo()] = foo() + f -- table index, and binary operands
    end
    

In addition, there is another scenario:

  1. For a single function call statement, the return values are ignored at this time. For example, the following example:

    print("no results")
    foo(1, 2, 3)
    

The fourth scenario does not need to deal with the return values, so ignore it for now. In the previous three scenarios, it is necessary to move the return values from the top of the stack to the position of the function entry. For example, for the print("hello", sqr(3, 4)) statement, the stack layout before calling the sqr() function is shown in the left figure below:

|       |        |       |                |       |
+-------+        +-------+                +-------+
| print |        | print |                | print |
+-------+        +-------+                +-------+
|"hello"|        |"hello"|                |"hello"|
+-------+        +-------+                +-------+
|  sqr  |        |  sqr  |              / |   9   | <--original sqr entry position
+-------+        +-------+ <--base   /-+  +-------+
|   3   |        |   3   |           |  \ |   16  |
+-------+        +-------+           |    +-------+
|   4   |        |   4   |           |    |       |
+-------+        +-------+           |
|       |        |   9   | \         |
                 +-------+  +return--/
                 |   16  | / values
                 +-------+
                 |       |

In the left picture, the print function is at the top of the stack, followed by the parameter "hello" string constant and the sqr() function, and then the two parameters of the sqr() function: 3 and 4. The important point here is that in the syntax analysis stage, the arguments of the function are generated by explist() bytecodes, which are loaded onto the stack in turn, so the sqr() function must be located in the print() function's argument location. Then, the return value of the sqr() function should be moved to the position of the sqr() function as the argument of the print() function, as shown in the rightmost figure in the above figure.

Therefore, the above three stack layout diagrams are summarized as follows:

  • The picture on the left is the state before the sqr() function call;

  • The picture in the middle is after the sqr() function is called, that is, the state after the Return bytecode introduced in the previous part of this section is executed;

  • The picture on the right is the expected state after calling the sqr() function, that is, the return value of the sqr() function is used as the return value of the print() function.

Therefore, what we need to do is to change the stack layout from the middle picture to the right picture, so in the processing flow of Call bytecode, move the return value from the top of the stack to the position of the function entry, which is the last line in the following code :

     ByteCode::Call(func, narg_plus) => {
         self.base += func as usize + 1;
         match &self.stack[self.base - 1] {
             Value::LuaFunction(f) => {
                 // The processing of parameters is omitted here.

                 // Call the function, `nret` is the number of return values at the top of the stack
                 let nret = self. execute(&f);

                 // Delete the stack values from the function entry to the
                 // starting position of the return values, so the return
                 // values are moved to the function entry position.
                 self.stack.drain(self.base+func as usize .. self.stack.len()-nret);
             }

Here, the return values are not directly moved to the function entry position, but the stack data from the function entry to the start position of the return value is cleared through the Vec::drain() method to realize the return value in place. This is also done to clean up the stack space occupied by the called function at the same time, so as to release resources in time.

Read Return Value: Number

The above describes where to put the return values, now let's deal with the number of return values. This is also the same as the variable parameter expression in the previous section. According to the above four scenarios, it is also divided into four types:

  1. All return values;
  2. Fixed the first N return values;
  3. The first return value;
  4. No return value is required.

Similar to VarArgs bytecode, Call bytecode also needs to add a parameter to indicate how many return values are needed:

pub enum ByteCode {
     Call(u8, u8, u8) // Add the third associated parameter, indicating how many return values are required

But there is a difference here, that is the number of parameters associated with VarArgs, and the value 0 means all variable arguments. The fourth scenario is added here for the function call, which does not need a return value, that is, a return value of 0 is required, so the new associated parameters of the Call bytecode cannot be represented by 0 as a special value for all return values. This is like the scene in the previous section Number of parameters, that is, there are already 0 parameters, so it cannot be simply used 0 is a special value. There are two solutions to this problem:

  • Refer to the processing method of the number of parameters in the previous section, use 0 to represent all return values, and change the case of fixed N return values to N+1 and encode them into the Call bytecode. This is also the scheme adopted by Lua's official implementation;

  • Take the "no need to return value" in the fourth scenario as "ignore the return value", that is, there is no need to process the return value, or it doesn't matter how to process the return value. Then in this scenario, we can fill in any number for this associated parameter. Here we choose to fill in 0.

We choose the latter option. That is to say, the value 0 has two meanings:

  • All return values are required;
  • No return value is required.

Although the meanings of these two scenarios are different, the processing method is the same when the virtual machine is executed, and the return value is not processed. In other words, all return values (if any) will be placed at the function entry.

If the value of this parameter is not 0, it corresponds to the second and third scenarios above, that is, the situation where the first N and the first return values need to be fixedIn this case, you need to deal with:

  • If the actual return value is less than the expected demand, then nil needs to be added;
  • Otherwise, no processing is required. The extra return value is considered as a temporary variable on the stack and has no effect.

Next, add this nil filling process in the process of executing Call bytecode in the virtual machine:

     ByteCode::Call(func, narg_plus, want_nret) => {
         self.base += func as usize + 1;
         match &self.stack[self.base - 1] {
             Value::LuaFunction(f) => {
                 let nret = self. execute(&f);
                 self.stack.drain(self.base+func as usize .. self.stack.len()-nret);

                 // Fill nil as needed
                 // If want_nret==0, there is no need to process it, and it will not enter the if{} branch.
                 let want_nret = want_nret as usize;
                 if nret < want_nret {
                     self.fill_stack(nret, want_nret - nret);
                 }
             }

At this point, the virtual machine execution part of Call bytecode is completed.

Syntax Analysis of Scenarios

In previous chapters, we always introduce syntax analysis to generate bytecode first, and then introduce the virtual machine to execute the bytecode. But this time is different. The above only introduces the virtual machine execution of Call bytecode in different scenarios; it does not introduce syntax analysis, that is, how to generate Call bytecode in each scenario. Make it up now.

The first and second scenarios above are exactly the same as the corresponding scenario of variable parameter expressions, so there is no need to do these statements here to modify, we only need to add ExpDesc::Call expressions in discharge_expand() and discharge_expand_want(). The code of discharge_expand() is listed below, and ``discharge_expand_want()` is similar, so it is omitted here.

     fn discharge_expand(&mut self, desc: ExpDesc) -> bool {
         let code = match desc {
             ExpDesc::Call(ifunc, narg_plus) => { // Add function call expression
                 ByteCode::Call(ifunc as u8, narg_plus as u8, 0)
             }
             ExpDesc::VarArgs => {
                 ByteCode::VarArgs(self.sp as u8, 0)
             }
             _ => {
                 self.discharge(self.sp, desc);
                 return false
             }
         };
         self.fp.byte_codes.push(code);
         true
     }

In Lua, when the number of values cannot be determined during the syntax analysis stage, there are only variable arguments and function calls. So these two functions are now complete. If there are other similar statements, we can also add statements to this function without modifying the specific application scenario.

Next, look at the third scenario, which only takes the first return value. The same as the variable arguments statement in the previous section, the loading of the ExpDesc::Call expression is also completed in the discharge() function. Unlike the variable arguments statement, the first associated parameter of the VarArgs bytecode generated by the variable arguments is the target address, and the three parameters associated with the Call bytecode here have no target address of. It is introduced above that when the virtual machine is executed, the return value is placed at the entry address of the function, but the discharge() function is to load the value of the expression to the specified address. Therefore, the loading of the ExpDesc::Call expression may require 2 bytecodes: first generate the Call bytecode to call the function and put the return value at the function entry position, and then generate the Move bytecode to put the first A return value is assigned to the target address. code show as below:

     fn discharge(&mut self, dst: usize, desc: ExpDesc) {
         let code = match desc {
             ExpDesc::Call(ifunc, narg_plus) => {
                 // Generate Call, keep only 1 return value, and put it in ifunc position
                 self.fp.byte_codes.push(ByteCode::Call(ifunc as u8, narg_plus as u8, 1));

                 // Generate Move, copy return value from ifunc to dst position
                 self.fp.byte_codes.push(ByteCode::Move(dst as u8, ifunc as u8));
             }

For example, the following sample code:

local x, y
x = foo()

Its stack layout is as follows:

|       |          |       |          |       |
+-------+          +-------+          +-------+
|   x   |          |   x   |    /---->|   x   |
+-------+          +-------+    |     +-------+
|   y   |          |   y   |    |     |   y   |
+-------+          +-------+    |     +-------+
|  foo  |    /---->|  100  |----/     |       |
+-------+    |     +-------+    Move bytecode assigns the return value to the target address
:       :    |     |       |
+-------+    |
|  100  |----/ `Call` bytecode moves the returns value 100 to `foo` position
+-------+
|       |
  • The picture on the left is the stack layout before the foo() function returns, assuming 100 at the top of the stack is the return value of the function;
  • The picture in the middle shows that after the Call bytecode is executed, the return value is moved to the function entry position, which is the function completed above in this section;
  • The figure on the right is the Move bytecode assigning the return value to the target address, that is, the local variable x.

It can be seen that 2 bytecodes are generated in this scenario, and the return value is also moved 2 times. There is room for optimization here. The reason why 2 bytecodes are needed is because the Call bytecode has no parameters associated with the target address, so it cannot be directly assigned. The reason why there is no associated target address parameter is because the Call bytecode has already stuffed 3 parameters, and there is no space to stuff it into the target address.

Once the problem is identified, the optimization solution becomes obvious. Since only one return value is always required in this scenario, the third associated parameter (the number of required return values) in Call bytecode is meaningless. So you can add a bytecode dedicated to this scenario, delete the third parameter in the Call bytecode, and make room for the parameter of the target address. For this, we add CallSet bytecode:

pub enum ByteCode {
     Call(u8, u8, u8), // Associated parameters: function entry, number of arguments, number of expected return values
     CallSet(u8, u8, u8), // Associated parameters: target address, function entry, number of arguments

In this way, in the discharge() function, the function call statement only needs one bytecode:

     fn discharge(&mut self, dst: usize, desc: ExpDesc) {
         let code = match desc {
             ExpDesc::Call(ifunc, narg) => {
                 ByteCode::CallSet(dst as u8, ifunc as u8, narg as u8)
             }

The virtual machine execution of CallSet bytecode is as follows:

     ByteCode::CallSet(dst, func, narg) => {
         // Call functions
         let nret = self. call_function(func, narg);

         if nret == 0 { // no return value, set nil
             self. set_stack(dst, Value::Nil);
         } else {
             // use swap() to avoid clone()
             let iret = self.stack.len() - nret as usize;
             self.stack.swap(self.base+dst as usize, iret);
         }

         // Clean up the stack space occupied by the function call
         self.stack.truncate(self.base + func as usize + 1);
     }

The call_function() method in the above is a function that extracts the execution flow of Call bytecode. After calling the function, if there is no return value, set the target address to nil, otherwise assign the first return value to the target address. The last line cleans up the stack space occupied by function calls, and there are 2 cases in cleaning:

  • If the target address is a local variable, then the cleanup location is from the function entry;
  • If the target address is a temporary variable, set the target address of the function return value as the function entry position in discharge_any(), so the cleaning position starts from one position behind the function entry.

In summary, always start cleaning from a position behind the function entry position, which can satisfy the above two conditions. Only in the case of local variables, one more function entry will be reserved.

Variable Number of Return Values

The syntax analysis and virtual machine execution of the return value are introduced above, but one place is still missing. Among the three application scenarios of variable parameters listed in the previous section, the first scenario includes three statements: table construction, function argument, and function return value. At that time, only the first two statements were introduced. Now that the return value statement is supported, the last statement is added.

This section above introduces the syntax analysis of the return statement, but at that time, all expressions of the return value were loaded onto the stack in sequence, that is, only a fixed number of return values was supported. When the last expression of the function return value statement is a variable parameter or a function call statement, then all variable parameters or all return values of the function when the virtual machine is executed will be used as the return value of this function, that is to say, the number of return values cannot be determined during the parsing phase, that is, a variable number of return values.

Variable number of return values, syntax analysis can refer to the previous section table construction or function arguments, that is, use the modified explist() function , special treatment is given to the last expression. The specific code is omitted here.

What needs to be explained is how to represent "variable number" in bytecode. In this section, two new return value-related bytecodes are added, Return0 and Return. Among them, Return0 is used when there is no return value, so the parameter of the number of return values associated in Return bytecode will not be 0, then 0 can be used as a special value to indicate variable number.

Summary of Variable Number Statements and Scenario

Here is a summary of statements and scenarios related to variable quantities. Statements that directly result in variable numbers include:

  • Variable argument statement ..., there are 3 application scenarios;
  • Function call statement, in addition to the 3 application scenarios of variable parameters, there is also a scenario of ignoring the return value.

Among the several application scenarios of these two statements, the first scenario is to take all the actual parameters or return values when the virtual machine is executed. This scenario includes 3 statements:

  • Table construction, corresponding to SetList bytecode;
  • Function arguments, corresponding to Call/CallSet bytecode;
  • The return value of the function corresponds to the Return bytecode of the called function and the Call/CallSet bytecode of the calling function.

In the above bytecodes, in order to represent the state of "actually all expressions when the virtual machine is executed", 0 is used as a special value, among which:

  • The second parameter of Call/CallSet bytecode represents the number of actual parameters. Because the function call originally supports no parameters, in order to use 0 as a special value, we have to correcte the number with adding by 1 for fixed number case, that is, N fixed parameters are encoded into N+1 in the bytecode;

  • The third parameter of Call/CallSet bytecode represents the number of expected return values. The function call also supports the situation that the return value is not required, but we understand "no need" as "ignore", then it is no problem to read all the return values, so 0 can be used as a special value;

  • The second parameter of SetList and Return bytecodes both represent the number. However, when these two bytecodes are used for fixed numbers, no expressions are supported, so 0 can be directly used as a special value.

In addition, it needs to be emphasized again that when 0 is used to represent a special value in the above bytecode, the number of specific expressions is calculated from the top of the stack, which must ensure that there is no temporary variable on the top of the stack, so the virtual machine must explicitly clean up temporary variables when executing variable parameter and function call statements.

Summary

This section begins by introducing fixed number return values. The called function puts the return value on the top of the stack through the Return/Return0 bytecode, and then the calling function reads the return value in the Call/CallSet bytecode.

The variable number of return values was introduced later, which is similar to the variable parameters in the previous section.