5. Function

function is a “subroutine” that can be called by external code. As a part of the program, the function itself is also a piece of code. A function can have 0 or more parameters and will return a result, which is called the function’s return value.

In Berry, the function is first class value. Therefore, in addition to calling functions, you can also pass functions as values, for example, bind functions to variables, use functions as return values, and so on.

5.1 Basic Information

The use of functions includes two parts: function definition and call. The function definition statement uses the def keyword as the beginning. The function definition is the process of packaging and naming the code of the function body. This process only generates the function structure and does not execute the function. The execution function must use call operator, which is a pair of parentheses. The call operator acts on an expression whose result is a function type. The parameters passed to the function are written in parentheses, and multiple parameters are separated by commas. The result of the calling expression is the return value of the function.

5.1.1 Function Definition

Named Function

named function is a function given a name when it is defined. Its definition statement consists of the following parts: def keywords, function names, lists consisting of 0 to multiple parameters, and function bodies ( function body), multiple parameters in the parameter list are separated by commas, and all parameters are written in a pair of parentheses. We call the parameter when the function is defined as Formal parameters, and the parameter when calling the function as Arguments. The general form of the function definition is:

’def’ name ´(´ arguments ´)´
  block
´end’

The function name name is an identifier; arguments is the formal parameter list; block is the function body. If the function body is an empty statement, the function is called an “empty function”. The function return value statement is contained in the function body. If there is no return statement in block, the function will return nil by default. The function name is actually the variable name of the bound function object. If the name already exists in the current scope, defining the function is equivalent to binding the function object to this variable.

The following example defines a function named add. The function of this function is to sum two numbers and return.

def add(a, b)
    return a + b
end

add The function has two parameters a and b, and the two addends are passed into the function through these parameters for calculation. return The statement returns the result of the calculation.

A function as a class attribute is called a method. This part of the content will be explained in the object-oriented chapter.

Anonymous Function

Unlike named functions, anonymous function has no name, and its definition expression has the form:

´def’ ´(´ arguments ´)´
  block
´end’

It can be seen that compared with named functions, there is no function name in the definition of anonymous functions name. The definition of an anonymous function is essentially an expression, which is called Function literal. In order to use anonymous functions, we can bind the function literal value to a variable:

add = def (a, b)
    return a + b
end

The function of this code is exactly the same as that of the function add in the previous section. An anonymous function can be used to conveniently pass the function value as a literal value. Like other types of literals, function literals are also the smallest unit of expressions. Therefore, between def keywords and end are an indivisible whole.

Call function

Take the add function as an example. To call this function, you need to provide two values, and you can get the sum of the two numbers by calling the function:

res = add(5, 3)
print(res) # 8

We call the called function (the add function in the example) as Called function, and the function that calls the called function (the main function in the example) as Key function. The function call process is as follows: First, the interpreter will (implicitly) initialize the formal parameter list of the called function with the argument list, and at the same time suspend the calling function and save its state, then create an environment for the called function and execute the called function. function.

The function will end its execution when it encounters the return statement and pass the return value to the calling function. The interpreter will destroy the environment of the called function after the called function returns, then restore the environment of the calling function and continue to execute the calling function. The return value of the function is also the result of the function call expression. The following example defines a function square and binds this function to a variable f, and then calls the function square through the variable f. This usage is similar to function pointers in C language.

def square(n)
    return n * n
end
f = square
print(f(5)) # 25

It should be noted that the function object is only bound to these variables (refer to section [section::assign_operator]) and cannot be modified, so reassigning the variable corresponding to the function name will not make the function lose:

f = square
square = nil
print(f(5)) # 25

It can be seen that the function can still be called normally after reassigning square. Only after the function object is no longer bound to any variable will it be lost, and the resources occupied by this type of function object will be recycled by the system.

Forward call

The call of the function must be in the scope of the function variable, so it usually cannot be called before the function is defined. In order to solve this problem, you can use this method to compromise:

var func1
def func2(x)
    return func1(x)
end
def func1(x)
    return x * x
end
print(func2(4)) # 16

In this example, func2 calls func1, but the function func1 is defined after func2. After executing this code, the program will output the correct result 16. This routine uses the mechanism that the function will not be called when the function is defined. Define the variable func1 before defining func2 to ensure that the symbol func1 will not be found during compilation. Then we define the function func1 after func2 so that the function will be used to overwrite the value of the variable func1. When the function func2 is called in the last line print(func2(4)), the variable func1 is already the function we need, so the correct result will be output.

Recursive call

recursive function refers to functions that call themselves directly or indirectly. Recursion refers to a strategy that divides the problem into similar sub-problems and then solves them. Taking factorial as an example, the recursive definition of factorial is 0! = 1, n! = n ⋅ (n−1)!, we can write the recursive function for calculating factorial according to the definition:

def fact(n)
    if n == 0
        return 1
    end
    return n * fact(n-1)
end

Take the factorial of 5 as an example, the process of manually calculating the factorial of 5 is: 5! = 5 × 4 × 3 × 2 × 1 = 120 The result of calling the fact function is also 120:

print(fact(5)) # 120

In order to ensure that the depth of the recursive call is limited (too deep recursion level will exhaust the stack space), the recursive function must have an end condition. fact The if statement in the second line of the function definition is used to detect the end condition, and the recursive process ends when n is calculated as 0. The above factorial formula does not apply to non-integer parameters. Executing an expression like fact(5.1) will cause a stack overflow error due to the inability to end the recursion.

There is another situation Indirect recursion, that is, the function is not called by itself but by another function (directly or indirectly) called by it. Indirect recursion usually requires the use of forward function call techniques. Take the functions is_odd and is_even for calculating odd and even numbers as examples:

var is_odd
def is_even(n)
    if n == 0
        return true
    end
    return is_odd(n-1)
end
def is_odd(n)
    if n == 0
        return false
    end
    return is_even(n-1)
end

These two functions call each other. In order to ensure that this name is in the scope when calling the function is_odd on line 6, the variable is_odd is defined on line 1.

Anonymous function call

If an anonymous function will only be called once, the easiest way is to call it when it is defined, for example:

res = def (a, b) return a + b end (1, 2) # 3

In this routine, we use the call expression directly after the function literal to call the function. This usage is very suitable for functions that will only be called in one place.

You can also bind an anonymous function to a variable and call it:

add = def (a, b) return a + b end
res = add(1, 2) # 3

This usage is similar to the call of a named function, essentially calling the variable bound to the function value. It should be noted that it is more difficult to make recursive calls to anonymous functions, unless you use forward call techniques.

Formal and actual parameters

The function uses actual parameters to initialize the formal parameters when it is called. Under normal circumstances, the actual parameter and the shape parameter are equal and the positions correspond to each other, but Berry also allows the actual parameter to be unequal to the formal parameter: if the actual parameter is more than the formal parameter, the extra actual parameter will be discarded. Less than the formal parameters will initialize the remaining formal parameters to nil.

The process of parameter passing is similar to assignment operation. For nil, boolean and numeric types, parameter passing is by value, while other types are by reference. For the writable pass-by-reference type such as instance, modifying them in the called function will also modify the object in the calling function. The following example demonstrates this feature:

var l = [], i = 0
def func(a, b)
    a.push(1)
    b ='string'
end
func(l, i)
print(l, i) # [1] 0

It can be seen that the value of variable l has changed after calling function func, but the value of variable i has not changed.

Function with variable number of arguments (vararg)

You can define a function to take any arbitrary number of arguments and iterate on them. For example print() takes any number of arguments and prints each of them separated by spaces. You need to define the last argument as a capture-all-arguments using * before its name.

All arguments following the formal arguments are grouped at runtime in a list instance. If no arguments are captured, the list is empty.

Example:

def f(a, b, *c) return size(c) end
f(1,2) # returns 0, c is []
f(1,2,3) # returns 1, c is [3]
f(1,2,3,4) # returns 2, c is [3,4]

Calling a function with dynamic number of arguments

Berry syntax allows only to call with a fixed number of arguments. Use the call(f, [args]) function to pass any arbitrary number or arguments.

You can statically add any number of arguments to call(). If the last argument is a list, it is automatically expanded to discrete arguments.

Example:

def f(a,b) return nil end

call(f,1)        # calls f(1)
call(f,1,2)      # calls f(1,2)
call(f,1,2,3)    # calls f(1,2,3), last arg is ignored by f
call(f,1,[2,3])  # calls f(1,2,3), last arg is ignored by f
call(f,[1,2])    # calls f(1,2)
call(f,[])       # calls f()

You can combine call and vararg. For example let’s create a function that acts like print() but converts all arguments to uppercase.

Full example:

def print_upper(*a)  # take arbitrary number of arguments, args is a list
    import string
    for i:0..size(a)-1
        if type(a[i]) == 'string'
            a[i] = string.toupper(a[i])
        end
    end
    call(print, a)   # call print with all arguments
end

print_upper("a",1,"Foo","Bar")  # prints: A 1 FOO BAR

Functions and local variables

The function body itself is a scope, so the variables defined in the function are all local variables. Unlike directly nested blocks, every time a function is called, space is allocated for local variables. The space for local variables is allocated on the stack, and the allocation information is determined at compile time, so this process is very fast. When multiple levels of scope are nested in a function, the interpreter allocates stack space for the scope nesting chain with the most local variables, rather than the total number of local variables in the function.

return Statement

return The statement is used to return the result of a function, that is, the return value of the function. All functions in Berry have a return value, but you can not use any return statement in the function body. At this time, the interpreter will generate a default return statement to ensure that the function returns. return There are two ways to write sentences:

´return’
´return’ expression

The first way of writing is to write only the return keyword and not the expression to be returned. In this case, the default nil value is returned. The second way of writing is to follow the expression expression after the return keyword, and the value of the expression will be used as the return value of the function. When the program executes to the return statement, the currently running function will end execution and return to the code that called the function to continue running.

When using a separate keyword return as the return statement of a function, it is easy to cause ambiguity. At this time, it is recommended to add a semicolon after return to prevent errors:

def func()
    return;
    x = 1
end

In this example, the x = 1 statement after the return statement will not be executed, so it is redundant. If this kind of redundant code is avoided, the return statement is usually followed by keywords such as end, else or elif. In this case, even if a separate return statement is used, there is no need to worry about ambiguity.

closure

Basic Concepts

As mentioned earlier, functions are the first type of value in Berry. You can define functions anywhere, and you can also pass functions as parameters or return values. When another function is defined in a function, the nested function can access the local variables of any outer function. We call the “local variables of the outer function” used in the function the function Free variable. The generalized free variables also include global variables, but there is no such rule in Berry.Closure is a technique that binds functions to environments. The environment is a mapping that associates each free variable of a function with a value. In terms of implementation, closures associate the function prototype with its own variables. Function prototypes are generated at compile time, and environment is a runtime concept, so closures are also dynamically generated at runtime. Each closure binds the function prototype to the environment when it is generated, for example, in the following example:

def func(i) # The outer function
    def foo() # The inner function (closure)
        print(i)
    end
    foo()
end

The inner function foo is a closure, which has a free variable i, which is a parameter of the outer function func. When the closure foo is generated, its function prototype is bound to the environment containing the free variable i. When the variable foo leaves the scope, the closure will be destroyed. Usually, the inner function will be the return value of the outer function, for example:

def func(i) # The outer function
    return def () # Return a closure (anonymous function)
        print(i)
        i = i + 1
    end
end

The closure returned here is an anonymous function. When the closure is returned by the outer function, the local variables of the outer function will be destroyed, and the closure will not be able to directly access the variables in the original outer function. The system will copy the value of the free variable to the environment when the free variable is destroyed. The life cycle of these free variables is the same as the closure, and can only be accessed by the closure. The returned function or closure will not be executed automatically, so we need to call the closure returned by the function func:

f = func(0)
f()

This code will output 0. If we continue to call the closure f, we will get the output 1, 2, 3… This may not be well understood: variable [2.198 ] Is destroyed after the function func returns, and as a free variable of the closure f, i will be stored in the closure environment, so every time f is called, the value of i will be added to 1 (func function definition line 4).

Use of closures

Closures have many uses. Here are a few common uses:

Lazy evaluation

The closure does not do anything until it is called.

Function private communication

You can let some closures share free variables, which are only visible to these closures, and communicate between functions by changing the values of these free variables. This can avoid the use of external variables.

Generate multiple functions

Sometimes we may need to use multiple functions, these functions may only have different values of some variables. We can implement a function and then use these different variables as function parameters. A better way is to return the closure through a factory function, and use these possibly different variables as free variables of the closure, so that you don’t always have to write those parameters when calling the function, and any number of similar functions can be generated.

Simulate private members

Some languages support the use of private members in objects, but Berry’s class does not support private members. We can use the free variables of closures to simulate private members. This use is not the original intention of designing closures, but nowadays, this “misuse” of closures is very common.

Cache result

If there is a function that is very time-consuming to run, it will take a lot of time to call it every time. We can cache the result of this function, look it up in the cache before calling the function, and return the cached value if found, otherwise call the function and update the cached value. We can use closures to save the cached value so that it will not be exposed to the outer scope, and the cached result will be retained (until the closure is destroyed).

Binding free variables

If multiple closures bind the same free variable, all closures will always share this free variable. E.g:

def func(i) # The outer function
    return [# Return a closure list
        def () # The closure #1
            print("closure 1 log:", i)
            i = i + 1
        end,
        def () # The closure #2
            print("closure 2 log:", i)
            i = i + 1
        end
    ]
end

The function func in this example returns two closures through a list, and these two closures share free variables i. If we call these closures:

f = func(0)
f[0]() # closure 1 log: 0
f[1]() # closure 2 log: 1

As you can see, we updated the free variable i when we called the closure f[0], and this change affected the result of calling the closure f[1]. This is because if a free variable is used by multiple closures, there is only one copy of the free variable, and all closures have a reference to the free variable entity. Therefore, any modification to the free variable is visible to all closures that use the free variable.

Similarly, before the local variables of the outer function are destroyed, modifying the value of the free variable will also affect the closure:

def func()
    i = 0
    def foo()
        print(i)
    end
    i = 1
    return foo
end

In this example, we change the value of the variable i (which is the free variable of the closure foo) from 0 to 1 before the outer function func returns, then we call the closure afterwards The value of the free variable i when the package foo is also 1:

func()() # 1

Create closure in loop

When constructing a closure in the loop body, you may not want the free variables of the closure to change with the loop variables. Let’s first look at an example of creating a closure in a loop while:

def func()
    l = [] i = 0
    while i <= 2
        l.push(def () print(i) end)
        i = i + 1
    end
    return l
end

In this example, we construct a closure in a loop and put this closure in a list. Obviously, when the loop ends, the value of the variable i will be 3, and all the closures in the list l are also references using this variable. If we execute the closure returned by func we will get the same result:

res = func()
res[0]() # 3
res[1]() # 3
res[2]() # 3

If we want each closure to refer to different free variables, we can define another layer of functions, and then bind the current loop variables with the function parameters:

def func()
    l = [] i = 0
    while i <= 2
        l.push(def (n)
            return def () print(n) end
        end (i))
        i = i + 1
    end
    return l
end

To help understand this seemingly incomprehensible code, we focus on the code from lines 4 to 6:

def (n)
    return def ()
        print(n)
    end
end (i)

Here actually defines an anonymous function and calls it immediately. The function of this temporary anonymous function is to bind the value of the loop variable i to its parameter n, and the variable n is also what we need to close The free variables of the package, so that the free variables bound to the closure constructed during each loop are different. Now we will get the desired output:

res = func()
res[0]() # 0
res[1]() # 1
res[2]() # 2

There are some ways to solve the problem of loop variables as free variables. A slightly simpler way is to define a temporary variable in the loop body:

def func()
    l = [] i = 0
    while i <= 2
        temp = i
        l.push(def () print(temp) end)
        i = i + 1
    end
    return l
end

Here temp is a temporary variable. The scope of this variable is in the loop body, so it will be redefined every time it loops. We can also use the for statement to solve the problem:

def func()
    l = []
    for i: 0 .. 2
        l.push(def () print(i) end)
    end
    return l
end

This may be the simplest way. for The iteration variable of the statement will be created in each loop. The principle is similar to the previous method.

Lambda expression

Lambda expression is a special anonymous function. Lambda expression is composed of parameter list and function body, but the form is different from general function:

´/´ args ´->´ expr ´end’

args is the parameter list, the number of parameters can be zero or more, and multiple parameters are separated by commas or spaces (cannot be mixed at the same time); expr is the return expression, the lambda expression will return the expression value. Lambda expressions are suitable for implementing functions with very simple functions. For example, the lambda expression for judging the size of two numbers is:

/ a b -> a < b

This is easier than writing a function of the same function. In some general sorting algorithms, this type of size comparison function may need to be used extensively. Using lambda expressions can simplify the code and improve readability.

Like general functions, lambda expressions can form closures. Lambda expressions are called in the same way as ordinary functions. If you use the immediate calling method similar to anonymous functions:

lambda = / a b -> a < b
result = lambda(1, 2) # normal calling
result = (/ a b -> a < b)(1, 2) # direct calling

Since the function call operator has a higher priority, a pair of parentheses should be added to the lambda expression when making a direct call, so that it will be called as a whole.