Translated from “A Theory of Modern Go” by Peter Bourgon 2017/06/09
The original link
Conclusion:
Global state has huge side effects — > package-level variables and init functions need to be avoided
Part1
Go is easy to read
The single best attribute of the Go language is that there is almost no magic code. With rare exceptions, direct reading of Go’s source code does not lead to ambiguations such as “definitions,” “dependencies,” and “runtime behavior.” This makes Go more readable and thus easier to maintain, which is the pinnacle of industrial programming.
Part2
Magic is bad
But there are still some ways that magic code gets mixed in. Unfortunately, a very common way is through the use of global state. Package-level global objects can hide state and behavior from external callers. Code that calls these global variables can have unintended side effects that undermine the reader’s ability to understand and build the program in his or her mind.
Functions (including methods, which are slightly different in GO) are basically the only mechanism that GO uses to build abstractions.
Consider the following function definition:
func NewObject(n int) (*Object, error)
Copy the code
Part3 *
By convention, we want functions of the form NewXxx to be type constructors. This function is indeed a constructor, as we see that it returns a pointer to an object and an error. From this we can infer that the constructor may succeed or fail, and if it fails, we will receive an error telling us why.
The constructor takes a single int, which we assume controls the generation of the Object returned by the function. We assume that there are some constraints on the argument int n that will result in an error if the constraints are not met. But since this function takes no other arguments, we expect it to have no side effects other than allocating memory.
We can make these inferences just by reading the signature of the function, which we probably have in mind. Repeating the process recursively from the first line of main is how we read and understand programs.
Suppose this is an implementation of the NewObject function:
func NewObject(n int) (*Object, error) {
row := dbconn.QueryRow("SELECT ... FROM ... WHERE ...")
var id string
iferr := row.Scan(&id); err ! =nil {
logger.Log("during row scan: %v", err)
id = "default"
}
resource, err := pool.Request(n)
iferr ! =nil {
return nil, err
}
return &Object{
id: id,
res: resource,
}, nil
}
Copy the code
This function calls:
1. Package-level global variables database/SQL.conn to query some unspecified databases;
2. Package-level global logger for output of arbitrarily formatted strings to a location;
3. And some type of link pool object at the package level to request some type of resource.
All of these operations have side effects that are completely invisible from the function signature. There is no way for the caller to predict that these things will happen except by reading the function body and jumping to the definitions of all global variables.
Consider another form of the signature function:
func NewObject(db *sql.DB, pool *resource.Pool, n int, logger log.Logger) (*Object, error)
Copy the code
By taking each global dependency as an argument, we enable the reader to know exactly the scope of the function and the behavior that may occur within the function. The caller knows exactly what parameters the function needs and can provide them.
If we were designing a public API for this program, we could do even more effectively.
// RowQueryer models part of a database/sql.DB.
type RowQueryer interface {
QueryRow(string.interface{}) *sql.Row
}
// Requestor models the requesting side of a resource.Pool.
type Requestor interface {
Request(n int) (*resource.Value, error)
}
func NewObject(q RowQueryer, r Requestor, n int, logger log.Logger) (*Object, error) {
// ...
}
Copy the code
By abstracting each concrete object as an interface and capturing only the methods used in the function, we allow the caller to implement it himself. This reduces source-level coupling between packages and enables us to simulate specific dependencies in our tests. If we test the code using specific package-level global variables, we can see how tedious and error-prone this is.
If all of our constructors and other functions explicitly accept their dependencies, then global variables are of no use. Instead, we can construct all database connections, logging, and link pools in the main function so that the component diagram can be clearly drawn and used by future readers.
Furthermore, we can pass these dependencies very explicitly to the components/functions that use them without getting confused about global variables. It is also worth noting that without global variables, there is no need to use the init function, whose sole purpose is to instantiate or change global state at the package level.
Part4
Try to write go without global state
Writing Go programs with almost no global state is not only possible, but very easy. In my experience, programming in this way is no slower or less tedious than using global variables to narrow function definitions.
Conversely, when a function signature reliably and completely describes the scope of a function body, we can do more efficient code reasoning, refactoring, and maintenance. Go Kit has been written in this style since the beginning and has benefited from it.
Part5
Avoid two things
To sum up, we can develop the modern Go theory. According to Dave Cheney, the following guidelines are proposed:
- Avoid package-level variables
- Avoid initializing functions
There are exceptions, of course.