12 Correct Posture for Using Functions

12 Correct Posture for Using Functions #

In the previous articles, we have covered all the collection data types provided by Go itself and also introduced a few types from the standard library’s container package.

In almost all mainstream programming languages, collection data types are the most commonly used and important ones. I hope that through our discussions, you can take your understanding and utilization of these data types to the next level.

Starting from today, I will begin introducing the knowledge you must understand when doing modular programming in Go. This includes several important data types and some techniques for modular programming. First, we need to understand functions and function types in Go.


Introduction: Functions are First-Class Citizens #

In Go, functions are first-class citizens, and function types are also first-class data types. What does this mean?

Simply put, this means that functions can not only be used to encapsulate code, separate functionality, and decouple logic, but they can also act as regular values and be passed between other functions, assigned to variables, used for type checking and conversion, and so on, just like values of slices and maps.

And the deeper meaning behind this is: function values can become independent logical components (or functional modules) that can be easily propagated.

For function types, they are important tools for templating a set of inputs and outputs. They are lighter and more flexible than interface types, and their values can be easily replaced. For example, in the demo26.go file, I wrote the following code:

package main

import "fmt"

type Printer func(contents string) (n int, err error)

func printToStd(contents string) (bytesNum int, err error) {
    return fmt.Println(contents)
}

func main() {
    var p Printer
    p = printToStd
    p("something")
}

Here, I first declared a function type named Printer.

Notice the syntax here: the func keyword is on the right side of the type declaration, which tells us that this is a function type declaration.

On the right side of func is the parameter list and result list of this function type. The parameter list must be wrapped in parentheses, and as long as the result list has only one result declaration and it is not named, we can omit the parentheses around it.

The way of writing the function signature is consistent with function declaration. The only difference is that what comes immediately after the parameter list is not the function name, but the keyword func. Here, we have simply swapped the positions of the function name and func.

The function signature is actually a general term for the parameter list and result list of a function. It defines the characteristics that can be used to distinguish different functions, and it also defines how we interact with the function.

Note that the names of the parameters and results are not part of the function signature, and even for result declarations, they can be omitted.

As long as the element order and types in the parameter list and result list of two functions are the same, we can say that they are the same functions, or in other words, they implement the same function type.

Strictly speaking, the function name is not part of the function signature either. It is just an identifier that needs to be provided when calling the function.

By looking at the code in the main function below, we can confirm the relationship between these two. I successfully assigned the printToStd function to the variable p of type Printer, and we successfully called it.

In summary, “functions are first-class citizens” is an important feature of functional programming. Go supports functional programming at the language level. The following questions are related to this.

Today’s question is: How do we write higher-order functions? Let’s talk about what is a higher-order function. Simply put, a higher-order function satisfies the following two conditions:

1. Accepts other functions as parameters; - 2. Returns other functions as results.

As long as it satisfies either of these characteristics, we can say that the function is a higher-order function. Higher-order functions are an important concept and feature in functional programming.

The specific problem is that I want to implement addition, subtraction, multiplication, and division operations between two integers by writing a function called calculate, but I want the two integers and the specific operation to be provided by the caller of the function. So, how should such a function be written?

Typical Answer

First, let’s declare a function type called operate with two int type parameters and one int type result.

type operate func(x, y int) int

Then, we write the signature part of the calculate function. This function should not only take two int type parameters, but also an operate type parameter.

The function should have two results: one is of type int, representing the actual operation result, and the other should be of type error because if the operate type parameter is nil, an error should be returned directly.

By the way, function types are reference types, and their values can be nil, and the zero value of this type happens to be nil.

func calculate(x int, y int, op operate) (int, error) {
	if op == nil {
		return 0, errors.New("invalid operation")
	}
	return op(x, y), nil
}

Implementing the calculate function is fairly straightforward. We need to check the parameters with a guard statement first. If the operate type parameter op is nil, return 0 and an error type value that represents the specific error.

A guard statement is used to check the legality of crucial preconditions and immediately terminate the execution of the current code block if the check fails. In Go language, an if statement is often used as a guard statement.

If the check is successful, then call op and pass those two operands to it, and finally return the result returned by op and nil, indicating that no errors occurred.

Problem Analysis

Actually, once you understand the meaning behind the phrase “functions are first-class citizens,” this question becomes simple. As I mentioned earlier, I hope you have a clear understanding. In the previous example, I demonstrated one of the points, which is assigning a function as a normal value to a variable.

In this question, I am actually asking how to achieve another point, which is to pass functions between other functions.

In the answer, one of the parameters of the calculate function is of type operate, which is a function type. When calling the calculate function, we need to pass a function value of type operate. How should this function value be written?

As long as its signature matches the signature of the operate type and is implemented properly. We can declare a function first, just like the previous example, and then assign it to a variable, or we can directly write an anonymous function that implements the operate type.

op := func(x, y int) int {
	return x + y
}

The calculate function is a higher-order function. However, we said that higher-order functions have two characteristics, but this function only demonstrates one characteristic, which is accepting other functions as parameters.

Then, what about the other characteristic, which is returning other functions as results? How does that work? You can take a look at the calculateFunc function type and the genCalculator function declared in the demo27.go file. Among them, the only result type of the genCalculator function is calculateFunc.

Here is the code using them.

x, y = 56, 78
add := genCalculator(op)
result, err = add(x, y)
package main

import (
	"errors"
	"fmt"
)

type calculateFunc func(int, int) (int, error)
type operate func(x int, y int) int

func main() {
	add := genCalculator(func(x int, y int) int {
		return x + y
	})

	subtract := genCalculator(func(x int, y int) int {
		return x - y
	})

	multiply := genCalculator(func(x int, y int) int {
		return x * y
	})

	result, err := calculate(add, 1, 2)
	fmt.Printf("The result: %d (error: %v)\n", result, err)

	result, err = calculate(subtract, 3, 2)
	fmt.Printf("The result: %d (error: %v)\n", result, err)

	result, err = calculate(multiply, 4, 5)
	fmt.Printf("The result: %d (error: %v)\n", result, err)
}

func calculate(op operate, x int, y int) (int, error) {
	return op(x, y), nil
}

func genCalculator(op operate) calculateFunc {
	return func(x int, y int) (int, error) {
		if op == nil {
			return 0, errors.New("invalid operation")
		}
		return op(x, y), nil
	}
}

通过使用genCalculator函数,我们可以动态地生成不同运算类型的闭包函数,然后使用calculate函数来执行相应的运算。

示例中,我们生成了addsubtractmultiply三个闭包函数,并分别传入calculate函数进行计算。

这样一来,我们就可以方便地根据需要生成不同类型的运算函数,实现了灵活的运算逻辑。

func main() {
    array1 := [3]string{"a", "b", "c"}
    fmt.Printf("The array: %v\n", array1)
    array2 := modifyArray(array1)
    fmt.Printf("The modified array: %v\n", array2)
    fmt.Printf("The original array: %v\n", array1)
}

func modifyArray(a [3]string) [3]string {
    a[1] = "x"
    return a
}

What will this command source code file (demo28.go) output when executed? This is a common question I ask.

In the main function, I declare an array array1, and then pass it to the modify function. The modify function makes a slight modification to the parameter value and returns it as the result value. After obtaining this result, the main function prints it (array2) as well as the original array array1. The key question is, will the original array change due to the modification of the parameter value by the modify function?

The answer is: the original array will not change. Why is that? The reason is that all parameter values passed to a function are copied. The function does not use the original value of the parameter, but rather a copy of it.

Since an array is a value type, each copy will copy the array itself and all its element values. What I modify in the modify function is just a copy of the original array, and it will not affect the original array in any way.

Note that for reference types, such as slices, maps, and channels, copying their values as shown above will only copy the types themselves, not the underlying data they reference. In other words, this is a shallow copy, not a deep copy.

Taking a slice value as an example, when making such a copy, only the pointer to one of the underlying array’s elements, as well as its length and capacity values, are copied. The underlying array itself is not copied.

It is also important to note that even if we pass a value type as a parameter, if one of the elements in this parameter value is a reference type, we still need to be careful.

For example:

complexArray1 := [3][]string{
    []string{"d", "e", "f"},
    []string{"g", "h", "i"},
    []string{"j", "k", "l"},
}

The variable complexArray1 is of type [3][]string, which means that although it is an array, each of its elements is a slice. If a value like this is passed to a function, will modifications to the parameter value in the function affect complexArray1 itself? I think this can be left as today’s reflection question.

Summary

Today we mainly focused on the usage techniques of functions. In Go, functions are first-class citizens. They can be declared independently, passed around as regular values, and assigned to variables. Furthermore, we can declare anonymous functions within other functions and assign them directly to variables.

You need to remember how Go identifies a function, as the function signature plays a crucial role in this.

Functions are the main embodiment of functional programming in Go. We can write higher-order functions by “passing functions to functions” and “returning functions from functions”. We can also use higher-order functions to implement closures and dynamically generate part of the program logic.

In the end, we mentioned a cautionary note about function parameters, which is important and may affect the stability and security of the program.

A related principle is: Do not expose the details of your program to the outside world, and try not to let external changes affect your program. You can think about how this principle can be applied here.

Reflection Questions

Today, I have left two reflection questions for you.

  1. If complexArray1 is passed to a function, will modifications to the parameter value in that function affect its original value?
  2. Since the function actually receives copies of the parameter values, will the result value returned by the function be copied as well?

Click here to view the accompanying detailed code for the Go language column.